@plinth-music/cli 0.11.0 → 0.12.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.11.0",
7587
+ version: "0.12.0",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -23651,17 +23651,6 @@ function writeMirrorFile(workspaceSlug, entityType, entitySlug, content) {
23651
23651
  throw err;
23652
23652
  }
23653
23653
  }
23654
- function deleteMirrorFile(workspaceSlug, entityType, entitySlug) {
23655
- const target = entityFilePath(workspaceSlug, entityType, entitySlug);
23656
- try {
23657
- unlinkSync(target);
23658
- return true;
23659
- } catch (err) {
23660
- if (err?.code === "ENOENT")
23661
- return false;
23662
- throw err;
23663
- }
23664
- }
23665
23654
  var ARTIST_MEMORY_ANCHOR = "memory.md";
23666
23655
  function artistMemoryFilePath(workspaceSlug, entitySlug) {
23667
23656
  assertSlug("entity slug", entitySlug);
@@ -23746,6 +23735,49 @@ function ensureFilesSubtree(workspaceSlug) {
23746
23735
  const target = filesSubtreeRoot(workspaceSlug);
23747
23736
  return mkdirSync(target, { recursive: true, mode: 448 }) !== undefined;
23748
23737
  }
23738
+ function ensureEntityFolders(workspaceSlug) {
23739
+ const created = [];
23740
+ for (const type of allEntityTypes()) {
23741
+ try {
23742
+ const target = entityFolderRoot(workspaceSlug, type);
23743
+ if (mkdirSync(target, { recursive: true, mode: 448 }) !== undefined) {
23744
+ created.push(type);
23745
+ }
23746
+ } catch {}
23747
+ }
23748
+ return created;
23749
+ }
23750
+ function observeMirrorFolders(workspaceSlug, names) {
23751
+ const seen = [];
23752
+ for (const name of names) {
23753
+ let target;
23754
+ try {
23755
+ target = entityFolderRoot(workspaceSlug, name);
23756
+ } catch {
23757
+ continue;
23758
+ }
23759
+ try {
23760
+ const raw = readdirSync(target);
23761
+ const entries = name === FILES_SUBTREE_DIR ? raw : raw.filter((e) => !e.startsWith("."));
23762
+ seen.push({ name, empty: entries.length === 0 });
23763
+ } catch (err) {
23764
+ if (err?.code === "ENOENT")
23765
+ continue;
23766
+ seen.push({ name, empty: false });
23767
+ }
23768
+ }
23769
+ return seen;
23770
+ }
23771
+ function entityFolderRoot(workspaceSlug, name) {
23772
+ const ws = workspaceMirrorPath(workspaceSlug);
23773
+ const target = resolve(ws, name);
23774
+ const prefix = ws.endsWith(sep) ? ws : ws + sep;
23775
+ if (!target.startsWith(prefix)) {
23776
+ throw new MirrorPathError(`Resolved folder path escapes the workspace mirror`);
23777
+ }
23778
+ assertNoSymlinkedComponent(ws, target);
23779
+ return target;
23780
+ }
23749
23781
  function filesSubtreeRoot(workspaceSlug) {
23750
23782
  const ws = workspaceMirrorPath(workspaceSlug);
23751
23783
  const target = join2(ws, FILES_SUBTREE_DIR);
@@ -24863,11 +24895,20 @@ var WhoamiSchema = exports_external.object({
24863
24895
  workspace_id: exports_external.string().uuid(),
24864
24896
  workspace_slug: exports_external.string().min(1),
24865
24897
  workspace_name: exports_external.string().min(1),
24898
+ role: exports_external.string().min(1).nullable().optional(),
24866
24899
  profile: exports_external.object({
24867
24900
  email: exports_external.string().email(),
24868
- display_name: exports_external.string().nullable().optional()
24901
+ display_name: exports_external.string().nullable().optional(),
24902
+ onboarding_completed_at: exports_external.string().nullable().optional()
24869
24903
  })
24870
24904
  });
24905
+ var MemberPrincipalSchema = exports_external.object({
24906
+ email: exports_external.string().email().max(254),
24907
+ display_name: exports_external.string().min(1).max(200).nullable().optional(),
24908
+ role: exports_external.string().min(1).max(64).nullable().optional(),
24909
+ onboarding_completed_at: exports_external.string().nullable().optional(),
24910
+ fetched_at: exports_external.string().datetime({ offset: true }).optional()
24911
+ });
24871
24912
  var SyncCursorsSchema = exports_external.record(exports_external.string(), exports_external.string().nullable());
24872
24913
  var WorkspaceEntrySchema = exports_external.object({
24873
24914
  workspace_id: exports_external.string().uuid(),
@@ -24875,6 +24916,7 @@ var WorkspaceEntrySchema = exports_external.object({
24875
24916
  api_base_url: exports_external.string().url(),
24876
24917
  last_synced_at: exports_external.string().datetime({ offset: true }).nullable(),
24877
24918
  workspace_name: exports_external.string().min(1).optional(),
24919
+ member: MemberPrincipalSchema.optional(),
24878
24920
  sync_cursors: SyncCursorsSchema.optional()
24879
24921
  });
24880
24922
  var ConfigFileSchema = exports_external.object({
@@ -24978,6 +25020,32 @@ function updateSyncCursor(slug, entityType, cursor) {
24978
25020
  writeAtomic(next);
24979
25021
  return next;
24980
25022
  }
25023
+ function setMemberPrincipal(slug, member) {
25024
+ const existing = loadConfig();
25025
+ if (!existing)
25026
+ return false;
25027
+ const entry = existing.workspaces[slug];
25028
+ if (!entry)
25029
+ return false;
25030
+ const nextEntry = { ...entry, member };
25031
+ writeAtomic({
25032
+ ...existing,
25033
+ workspaces: { ...existing.workspaces, [slug]: nextEntry }
25034
+ });
25035
+ return true;
25036
+ }
25037
+ var MEMBER_REFRESH_AFTER_MS = 24 * 60 * 60 * 1000;
25038
+ function memberNeedsRefresh(entry, now = () => new Date) {
25039
+ const member = entry.member;
25040
+ if (member === undefined)
25041
+ return true;
25042
+ if (member.fetched_at === undefined)
25043
+ return true;
25044
+ const age = now().getTime() - new Date(member.fetched_at).getTime();
25045
+ if (Number.isNaN(age))
25046
+ return true;
25047
+ return age >= MEMBER_REFRESH_AFTER_MS;
25048
+ }
24981
25049
  function updateLastSyncedAt(slug, iso) {
24982
25050
  const existing = loadConfig();
24983
25051
  if (!existing) {
@@ -25684,12 +25752,12 @@ function runDeclare(args, deps = {}) {
25684
25752
  const now = deps.now ?? (() => new Date);
25685
25753
  const declaredPath = args.path?.trim();
25686
25754
  if (!declaredPath) {
25687
- out.error("Nothing to declare. Usage: plinth declare <path> --scratchpad <dir> [--label=<text>]");
25755
+ out.error(`Nothing to declare. Usage: plinth declare "<path>" --scratchpad="<dir>" [--label='<text>']`);
25688
25756
  return { exitCode: 1 };
25689
25757
  }
25690
25758
  const rootArg = args.scratchpad?.trim() || env2[SCRATCHPAD_ENV]?.trim();
25691
25759
  if (!rootArg) {
25692
- out.error(`No scratchpad directory given, so there is nothing to declare into. ` + `Pass --scratchpad <dir> (the scratchpad directory your harness told ` + `you to use), or set ${SCRATCHPAD_ENV}. This is never guessed: a ` + `wrongly guessed root would widen the containment check silently.`);
25760
+ out.error(`No scratchpad directory given, so there is nothing to declare into. ` + `Pass --scratchpad="<dir>" (the scratchpad directory your harness told ` + `you to use), or set ${SCRATCHPAD_ENV}. This is never guessed: a ` + `wrongly guessed root would widen the containment check silently.`);
25693
25761
  return { exitCode: 1 };
25694
25762
  }
25695
25763
  let rootStat;
@@ -25887,6 +25955,86 @@ function renderFreshnessStamp(today, updatedAt) {
25887
25955
  return `This index was last updated on ${bankedDay}, ${age}.`;
25888
25956
  }
25889
25957
  var GROUNDING_BUDGET_BYTES = 20000;
25958
+ var GROUNDING_CATEGORY_ORDER = [
25959
+ "grounding",
25960
+ "identity",
25961
+ "budgetNote",
25962
+ "voice",
25963
+ "rules",
25964
+ "workspaceFacts",
25965
+ "personalFacts"
25966
+ ];
25967
+ var GROUNDING_CATEGORY_LABELS = {
25968
+ grounding: "Plinth's own grounding",
25969
+ identity: "who you are",
25970
+ budgetNote: "the trim notes",
25971
+ voice: "voice pointer",
25972
+ rules: "this workspace's rules",
25973
+ workspaceFacts: "workspace facts",
25974
+ personalFacts: "your own facts"
25975
+ };
25976
+ var MEMBER_EDITABLE_CATEGORIES = [
25977
+ "rules",
25978
+ "workspaceFacts",
25979
+ "personalFacts"
25980
+ ];
25981
+ function segmentBytes(lines, cats) {
25982
+ const out = {
25983
+ grounding: 0,
25984
+ identity: 0,
25985
+ rules: 0,
25986
+ workspaceFacts: 0,
25987
+ personalFacts: 0,
25988
+ voice: 0,
25989
+ budgetNote: 0
25990
+ };
25991
+ lines.forEach((text, i) => {
25992
+ const cat = cats[i];
25993
+ out[cat] += Buffer.byteLength(text) + (i < lines.length - 1 ? 1 : 0);
25994
+ });
25995
+ return out;
25996
+ }
25997
+ var IDENTITY_LINE_MAX_BYTES = 300;
25998
+ function renderMemberIdentity(member, workspaceSlug) {
25999
+ if (member === null)
26000
+ return null;
26001
+ const tail = `. Tasks you create are assigned to this member unless told otherwise.`;
26002
+ const budget = IDENTITY_LINE_MAX_BYTES - Buffer.byteLength(tail);
26003
+ const email3 = oneLine(member.email);
26004
+ const build = (withName, withRole, withZone) => {
26005
+ const who = withName && member.displayName ? `${oneLine(member.displayName)} (${email3})` : email3;
26006
+ const clauses = [`You are working for ${who}`];
26007
+ if (withRole && member.role)
26008
+ clauses.push(`role ${oneLine(member.role)}`);
26009
+ if (workspaceSlug !== null)
26010
+ clauses.push(`in workspace ${workspaceSlug}`);
26011
+ if (withZone && member.timezone)
26012
+ clauses.push(`timezone ${oneLine(member.timezone)}`);
26013
+ return clauses.join(", ");
26014
+ };
26015
+ const candidates = [
26016
+ build(true, true, true),
26017
+ build(true, true, false),
26018
+ build(true, false, false),
26019
+ build(false, false, false)
26020
+ ];
26021
+ if (member.displayName) {
26022
+ const slugClause = workspaceSlug === null ? `` : `, in workspace ${workspaceSlug}`;
26023
+ candidates.push(`You are working for ${oneLine(member.displayName)}${slugClause}`);
26024
+ }
26025
+ const head = candidates.find((c) => Buffer.byteLength(c) <= budget);
26026
+ if (head === undefined)
26027
+ return null;
26028
+ return head + tail;
26029
+ }
26030
+ function oneLine(value) {
26031
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
26032
+ }
26033
+ function shouldOfferOnboarding(rosterIsEmpty, member) {
26034
+ if (member?.onboardingCompletedAt)
26035
+ return false;
26036
+ return rosterIsEmpty;
26037
+ }
25890
26038
  function bankedDate(line) {
25891
26039
  const matches = line.match(/\d{4}-\d{2}-\d{2}/g);
25892
26040
  if (matches === null || matches.length === 0)
@@ -25897,6 +26045,28 @@ function bankedDate(line) {
25897
26045
  latest = m;
25898
26046
  return latest;
25899
26047
  }
26048
+ var POINTER_RE = /\[\[[^\]]+\]\]/g;
26049
+ var TITLE_TRUNCATION_MARK = " …";
26050
+ function factTitle(fact) {
26051
+ const firstLine = fact.split(`
26052
+ `)[0];
26053
+ const match = /[.?!](\s|$)/.exec(firstLine);
26054
+ const sentence = match === null ? firstLine : firstLine.slice(0, match.index + 1);
26055
+ const missing = [...fact.matchAll(POINTER_RE)].map((m) => m[0]).filter((p) => !sentence.includes(p));
26056
+ const unique = [...new Set(missing)];
26057
+ const named = unique.length === 0 ? sentence : `${sentence} ${unique.join(" ")}`;
26058
+ const truncated = named !== fact;
26059
+ return { text: truncated ? `${named}${TITLE_TRUNCATION_MARK}` : named, truncated };
26060
+ }
26061
+ function rankFacts(facts) {
26062
+ return facts.map((line, index) => ({ line, index, date: bankedDate(line) })).sort((a, b) => {
26063
+ const ad = a.date ?? "";
26064
+ const bd = b.date ?? "";
26065
+ if (ad !== bd)
26066
+ return ad < bd ? 1 : -1;
26067
+ return b.index - a.index;
26068
+ }).map(({ index, line }) => ({ index, line }));
26069
+ }
25900
26070
  function groupFacts(body) {
25901
26071
  const facts = [];
25902
26072
  for (const line of body.split(`
@@ -25912,32 +26082,64 @@ ${line}`;
25912
26082
  }
25913
26083
  return facts;
25914
26084
  }
25915
- function selectRecentLines(body, capBytes) {
26085
+ function selectIndexContent(body, capBytes) {
25916
26086
  const lines = groupFacts(body);
25917
26087
  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)
26088
+ return { text: body, titles: "", kept: lines.length, named: 0, total: lines.length };
26089
+ }
26090
+ const ranked = rankFacts(lines);
26091
+ const titleOf = new Map;
26092
+ const info2 = new Map;
26093
+ for (const { index, line } of ranked) {
26094
+ const t = factTitle(line);
26095
+ info2.set(index, t);
26096
+ titleOf.set(index, t.text);
26097
+ }
26098
+ const carried = new Map;
26099
+ const used = () => {
26100
+ let bytes = 0;
26101
+ let bodies = 0;
26102
+ let titles = 0;
26103
+ for (const [index, kind] of carried) {
26104
+ const line = lines[index];
26105
+ if (kind === "body") {
26106
+ bytes += Buffer.byteLength(line);
26107
+ bodies++;
26108
+ } else {
26109
+ bytes += Buffer.byteLength(titleOf.get(index));
26110
+ titles++;
26111
+ }
26112
+ }
26113
+ return bytes + Math.max(0, bodies - 1) + Math.max(0, titles - 1);
26114
+ };
26115
+ for (const { index } of ranked) {
26116
+ if (info2.get(index)?.truncated !== true)
26117
+ continue;
26118
+ carried.set(index, "title");
26119
+ if (used() > capBytes)
26120
+ carried.delete(index);
26121
+ }
26122
+ for (const { index } of ranked) {
26123
+ const was = carried.get(index);
26124
+ carried.set(index, "body");
26125
+ if (used() > capBytes) {
26126
+ if (was === undefined)
26127
+ carried.delete(index);
26128
+ else
26129
+ carried.set(index, was);
25932
26130
  break;
25933
- chosen.push(entry.index);
25934
- used += add;
26131
+ }
25935
26132
  }
25936
- chosen.sort((a, b) => a - b);
26133
+ const inFileOrder = (kind) => [...carried.entries()].filter(([, k]) => k === kind).map(([i]) => i).sort((a, b) => a - b);
26134
+ const bodyIdx = inFileOrder("body");
26135
+ const titleIdx = inFileOrder("title");
25937
26136
  return {
25938
- text: chosen.map((i) => lines[i]).join(`
26137
+ text: bodyIdx.map((i) => lines[i]).join(`
25939
26138
  `),
25940
- kept: chosen.length,
26139
+ titles: titleIdx.map((i) => titleOf.get(i)).join(`
26140
+ `),
26141
+ kept: bodyIdx.length,
26142
+ named: titleIdx.length,
25941
26143
  total: lines.length
25942
26144
  };
25943
26145
  }
@@ -25947,83 +26149,103 @@ function trimmedIndexNote(kept, total, filename) {
25947
26149
  }
25948
26150
  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
26151
  }
25950
- function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemory, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, workspaceMemoryNote, userMemoryNote) {
26152
+ function titlesLeadIn(kept, named, unnamed, total, filename) {
26153
+ const parts = [`${kept} above in full`];
26154
+ parts.push(`${named} below as ${named === 1 ? "its opening" : "their openings"} only`);
26155
+ if (unnamed > 0)
26156
+ parts.push(`${unnamed} not shown at all`);
26157
+ return `Not all of this index fits. It holds ${total} facts: ${parts.join(", ")}. ` + `Every line below ends \`…\` because it is an opening and not the whole ` + `fact. They are all in \`${filename}\` at the mirror root: READ THAT FILE ` + `IN FULL before you answer that no fact covers something.`;
26158
+ }
26159
+ function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemory, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, workspaceMemoryNote, userMemoryNote, member, workspaceTitles = null, userTitles = null) {
25951
26160
  const gateFlag = workspaceSlug === null ? `--workspace="<this workspace>"` : `--workspace ${workspaceSlug}`;
25952
- const block = [
25953
- `Plinth session grounding:`,
25954
- ``,
25955
- `Today's date: ${today}. You have no inherent sense of the current date and ` + `your training has a knowledge cutoff, so treat this injected date as ` + `authoritative for anything time-relative: deadlines, what counts as ` + `"recent" or "upcoming", and the age of any data or of your own knowledge.`,
25956
- ``,
25957
- `Resolve Plinth entities before answering about them. For any answer grounded ` + `in a specific artist, project, task, or other workspace entity, first look ` + `the name up in THIS workspace with the Plinth MCP tools ` + `(\`mcp__plinth__search_entities\`, \`mcp__plinth__list_artists\`, or ` + `\`mcp__plinth__list_projects\`) and ground your answer in what they return. ` + `Do not answer from name recognition alone: a globally famous name may not ` + `exist in this workspace, and an entity here may differ from the public ` + `figure of the same name. If the lookup returns nothing, say so instead of ` + `inventing details.`,
25958
- ``,
25959
- `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends. (Native dry_run support in the Plinth MCP tools ` + `ships separately; until then this is a behavioural rule.)`,
25960
- ``,
25961
- `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`,
25962
- ``,
25963
- `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`, \`list_key_dates\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty.`,
25964
- ``,
25965
- `Read the calendar, and hand a date over rather than writing it yourself. An ` + `artist's dates live on their mapped Google Calendar and reading them is yours: ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs. Writing it is not yours. When a date needs ` + `adding or correcting (a show, a hold, a release, a deadline), say plainly what ` + `the change is, say that it goes in through Plinth's calendar view, and say that ` + `a person makes that change rather than you. Say the reason too, because a date ` + `left looking handled is worse than one openly outstanding: a calendar write ` + `goes out to everyone invited the moment it lands, and it cannot be recalled. ` + `This is not a puzzle to route around. Do not reach for a shell command line to ` + `do it, the Google Workspace one included, and do not go looking for another ` + `tool that writes a calendar. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, the rule is the ` + `same and you are the only thing holding it.`,
25966
- ``,
25967
- `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying and forwarding are not yours, and neither is any shell command line ` + `that does the same thing. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, nothing changes ` + `except that you are the only thing holding it. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`,
25968
- ``,
25969
- `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and \`${VOICE_FILENAME}\` beside it holds your own voice profile, the ` + `sign-off and the habits that belong to one member rather than the team. Read ` + `them before you write and follow them as written, rather than drafting in ` + `your own register and correcting after. Nothing inspects a draft between you ` + `and the person reading it, so the rules in those files are the whole of it.`,
25970
- ``,
25971
- `Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`
25972
- ];
26161
+ const identity = renderMemberIdentity(member, workspaceSlug);
26162
+ const block = [];
26163
+ const cats = [];
26164
+ const add = (cat, ...lines) => {
26165
+ for (const line of lines) {
26166
+ block.push(line);
26167
+ cats.push(cat);
26168
+ }
26169
+ };
26170
+ add("grounding", `Plinth session grounding:`, ``, `Today's date: ${today}. You have no inherent sense of the current date and ` + `your training has a knowledge cutoff, so treat this injected date as ` + `authoritative for anything time-relative: deadlines, what counts as ` + `"recent" or "upcoming", and the age of any data or of your own knowledge.`);
26171
+ if (identity !== null)
26172
+ add("identity", ``, identity);
26173
+ add("grounding", ``, `Resolve Plinth entities before answering about them. For any answer grounded ` + `in a specific artist, project, task, or other workspace entity, first look ` + `the name up in THIS workspace with the Plinth MCP tools ` + `(\`mcp__plinth__search_entities\`, \`mcp__plinth__list_artists\`, or ` + `\`mcp__plinth__list_projects\`) and ground your answer in what they return. ` + `Do not answer from name recognition alone: a globally famous name may not ` + `exist in this workspace, and an entity here may differ from the public ` + `figure of the same name. If the lookup returns nothing, say so instead of ` + `inventing details.`, ``, `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends.`, ``, `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`, ``, `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Reading ` + `dates is yours, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty. When a tool is the only way, ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs.`, ``, `Writing a date is not yours, by any route: not a calendar tool, and not a ` + `shell command line either, the Google Workspace one included. The calendar ` + `tools are refused for you where Plinth has configured this workspace. ` + `Nothing refuses a shell, anywhere, and where Plinth has not configured ` + `this workspace nothing is refused at all: you are the only thing holding ` + `those. The way ` + `through is a person, not a workaround, so when a date needs adding or ` + `correcting say plainly what the change is, say that it goes in through ` + `Plinth's calendar view, and say that a person makes it. Say why, too: a date ` + `left looking handled is worse than one openly outstanding, and a calendar ` + `write goes out to everyone invited the moment it lands and cannot be recalled.`, ``, `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying and forwarding are not yours, by any route: not a mail tool, and ` + `not a shell command line either. The mail tools are refused for you where ` + `Plinth has configured this workspace. Nothing refuses a shell, anywhere, ` + `and where Plinth has not configured this workspace nothing is refused at ` + `all: you are the only thing holding those. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`);
26174
+ if (workspaceRules === null && voiceProfile === null) {
26175
+ add("voice", ``, `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and \`${VOICE_FILENAME}\` beside it holds your own voice profile, the ` + `sign-off and the habits that belong to one member rather than the team. Read ` + `them before you write and follow them as written, rather than drafting in ` + `your own register and correcting after. Nothing inspects a draft between you ` + `and the person reading it, so the rules in those files are the whole of it.`);
26176
+ }
26177
+ add("grounding", ``, `Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`, ``, `The most recent artefact wins on facts, and it is not a judgement call. ` + `Dates, prices, availability, capacities, routings, who is on a thread, ` + `who said what. Do not weigh a newer message against an older banked ` + `line to work out which is more likely right: the newer one is the state ` + `and the older one is history. This holds for any dated artefact, not ` + `only email - call transcripts, meeting notes and executed documents are ` + `all in scope, newest first. Stale context is worse than absent context, ` + `because it reads as current. One carve-out, because it is the failure ` + `this rule would otherwise cause, and it is a mechanical test rather ` + `than a judgement: before promoting the newest message to "the state", ` + `ask whether it COMMITS or PROPOSES. A commit ("we're doing", ` + `"confirmed", "booked", a price, a date someone has agreed to) wins, and ` + `overwrites the old claim. A proposal ("I wonder if", "I'm not saying we ` + `must", or any question put to someone who has not replied) goes in as ` + `an open question, named as unanswered, with who owes the answer. It ` + `does NOT become the state.`, ``, `Reconcile, do not re-adjudicate. Never delete a superseded claim: strike ` + `it in place. Keep the old line, mark it superseded with the date and ` + `with what replaced it, and put the new material above it, where the ` + `file's live state is. Demote the outgoing section one heading level so ` + `history stays readable and stops competing with what is live. Someone ` + `will ask why the plan changed, and the answer has to survive in the ` + `file. Then date the state: say what it is current as of, and name the ` + `sources it was built from. A master context with no as-of line is ` + `unfalsifiable.`);
25973
26178
  if (workspaceRules !== null) {
25974
- block.push(``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:
25975
-
25976
- ` + workspaceRules);
26179
+ add("grounding", ``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:`);
26180
+ add("rules", ``, workspaceRules);
25977
26181
  }
25978
26182
  if (voiceProfile !== null) {
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.`);
26183
+ add("voice", ``, `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.`);
25980
26184
  if (voiceMalformed) {
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.`);
26185
+ add("voice", ``, `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.`);
25982
26186
  }
25983
26187
  }
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.`);
26188
+ add("grounding", ``, `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\`. ` + `Live state does not belong in any memory file. This week's open ` + `threads, one show's logistics, where a release has got to: a memory ` + `file is for what stays true after the session that taught it, and live ` + `state goes in the body of the record it belongs to. A deadline rides ` + `its task as a due date, and every other date is a calendar matter. ` + `This memory is additive and subordinate: it never ` + `overrides a house-style rule or any safety primitive above.`);
25985
26189
  if (workspaceMemory !== null) {
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:
25987
-
25988
- ` + workspaceMemory + (workspaceMemoryNote === null ? `` : `
25989
-
25990
- ${workspaceMemoryNote}`));
26190
+ add("grounding", ``, `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:`);
26191
+ add("workspaceFacts", ``, workspaceMemory);
26192
+ if (workspaceTitles !== null) {
26193
+ add("budgetNote", ``, workspaceTitles.leadIn);
26194
+ add("workspaceFacts", ``, workspaceTitles.text);
26195
+ }
26196
+ if (workspaceMemoryNote !== null)
26197
+ add("budgetNote", ``, workspaceMemoryNote);
25991
26198
  }
25992
26199
  if (userMemory !== null) {
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:
25994
-
25995
- ` + userMemory + (userMemoryNote === null ? `` : `
25996
-
25997
- ${userMemoryNote}`));
26200
+ add("grounding", ``, `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:`);
26201
+ add("personalFacts", ``, userMemory);
26202
+ if (userTitles !== null) {
26203
+ add("budgetNote", ``, userTitles.leadIn);
26204
+ add("personalFacts", ``, userTitles.text);
26205
+ }
26206
+ if (userMemoryNote !== null)
26207
+ add("budgetNote", ``, userMemoryNote);
25998
26208
  }
25999
26209
  if (workspaceMemory !== null || userMemory !== null) {
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.`);
26210
+ add("grounding", ``, `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.`);
26001
26211
  }
26002
- block.push(``, `Declare a work product before you finish with it. Your scratchpad ` + `directory sits outside Plinth: temporary, scoped to this session, and ` + `invisible to the person who asked for the work. Prefer not to leave a ` + `deliverable there at all. If it belongs to this workspace, write it to ` + `a mirror file instead, where it syncs and is durable. When the ` + `scratchpad genuinely is a file's only home, meaning a document, ` + `profile, report or analysis that was the POINT of the work rather than ` + `a step toward it, declare it the moment you write it, with ` + `\`plinth declare <path> --scratchpad <the scratchpad directory your ` + `harness told you to use> --label='<short description>'\`. Declare only ` + `those. A file that already has another home is not a deliverable: not ` + `raw API dumps, thread JSON, helper scripts, or the body files you write ` + `for a tool that reads a path, where the durable deliverable is the ` + `draft the tool creates. Declaring everything you wrote buries the one ` + `that mattered, which is the failure this exists to prevent. ` + `Re-declaring a path replaces the earlier entry, so refine a label ` + `freely. This is about work products, not memory: durable facts still go ` + `to the index above. Like every rule here it is additive and ` + `subordinate, and never relaxes a safety primitive above.`);
26003
- if (rosterIsEmpty) {
26004
- block.push(``, `This workspace has no artists in it yet. OFFER to set it up before you ` + `get into anything else, because an empty roster reported as a summary ` + `reads as a broken product rather than a new one. Say in one or two ` + `lines that ` + `${ONBOARD_COMMAND_INVOCATION} walks them through connecting their ` + `inbox, calendar and files, gets their roster in, and turns the work ` + `they already have running into projects and tasks, then ask whether ` + `to start now. Then STOP AND WAIT for their answer. Do not begin the ` + `interview, do not connect anything, and do not create an artist, a ` + `project or a task until they have said yes. If they say no, or they ` + `asked for something else in the same message, drop it and do the ` + `thing they asked for. Offer once, and never twice in one session. ` + `And a workspace can have no artists on purpose: if they have turned ` + `this down before, or this is not an artist workspace, leave it alone ` + `rather than raising it every time you open one.`);
26212
+ add("grounding", ``, `Declare a work product before you finish with it. Your scratchpad ` + `directory sits outside Plinth: temporary, scoped to this session, and ` + `invisible to the person who asked for the work. Prefer not to leave a ` + `deliverable there at all. If it belongs to this workspace, write it to ` + `a mirror file instead, where it syncs and is durable. When the ` + `scratchpad genuinely is a file's only home, meaning a document, ` + `profile, report or analysis that was the POINT of the work rather than ` + `a step toward it, declare it the moment you write it, with ` + `\`plinth declare "<path>" --scratchpad="<the scratchpad directory ` + `your harness told you to use>" --label='<short description>'\`. ` + `Declare only ` + `those. A file that already has another home is not a deliverable: not ` + `raw API dumps, thread JSON, helper scripts, or the body files you write ` + `for a tool that reads a path, where the durable deliverable is the ` + `draft the tool creates. Declaring everything you wrote buries the one ` + `that mattered, which is the failure this exists to prevent. ` + `Re-declaring a path replaces the earlier entry, so refine a label ` + `freely. This is about work products, not memory: durable facts still go ` + `to the index above. Like every rule here it is additive and ` + `subordinate, and never relaxes a safety primitive above.`);
26213
+ if (shouldOfferOnboarding(rosterIsEmpty, member)) {
26214
+ add("grounding", ``, `This workspace has no artists in it yet. OFFER to set it up before you ` + `get into anything else, because an empty roster reported as a summary ` + `reads as a broken product rather than a new one. Say in one or two ` + `lines that ` + `${ONBOARD_COMMAND_INVOCATION} walks them through connecting their ` + `inbox, calendar and files, gets their roster in, and turns the work ` + `they already have running into projects and tasks, then ask whether ` + `to start now. Then STOP AND WAIT for their answer. Do not begin the ` + `interview, do not connect anything, and do not create an artist, a ` + `project or a task until they have said yes. If they say no, or they ` + `asked for something else in the same message, drop it and do the ` + `thing they asked for. Offer once, and never twice in one session. ` + `And a workspace can have no artists on purpose: if they have turned ` + `this down before, or this is not an artist workspace, leave it alone ` + `rather than raising it every time you open one.`);
26005
26215
  }
26006
- return block.join(`
26007
- `);
26216
+ return { text: block.join(`
26217
+ `), split: segmentBytes(block, cats) };
26008
26218
  }
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);
26219
+ function buildGroundingReport(today, workspaceRules = null, workspaceMemory = null, userMemory = null, freshness = {}, rosterIsEmpty = false, voiceProfile = null, voiceMalformed = false, workspaceSlug = null, member = null) {
26220
+ const compose = (wmBody, umBody, wmNote, umNote, wmTitles = null, umTitles = null) => composeGroundingBlock(today, workspaceRules, wmBody, umBody, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, wmNote, umNote, member, wmTitles, umTitles);
26011
26221
  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)
26222
+ const untrimmed = () => {
26223
+ const all = (body) => {
26224
+ const n = groupFacts(body).length;
26225
+ return { text: body, titles: "", kept: n, named: 0, total: n };
26226
+ };
26227
+ return {
26228
+ block: whole.text,
26229
+ split: whole.split,
26230
+ picks: {
26231
+ wmPick: workspaceMemory === null ? null : all(workspaceMemory),
26232
+ umPick: userMemory === null ? null : all(userMemory)
26233
+ }
26234
+ };
26235
+ };
26236
+ if (Buffer.byteLength(whole.text) <= GROUNDING_BUDGET_BYTES)
26020
26237
  return untrimmed();
26021
26238
  if (workspaceMemory === null && userMemory === null)
26022
26239
  return untrimmed();
26023
26240
  const factCount = (body) => body === null ? 0 : groupFacts(body).length;
26024
26241
  const wmFacts = factCount(workspaceMemory);
26025
26242
  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));
26243
+ const titlesFor = (pick2, filename) => pick2 === null || pick2.named === 0 ? null : {
26244
+ leadIn: titlesLeadIn(pick2.kept, pick2.named, pick2.total - pick2.kept - pick2.named, pick2.total, filename),
26245
+ text: pick2.titles
26246
+ };
26247
+ const noteFor = (pick2, filename) => pick2 === null || pick2.kept === pick2.total ? null : trimmedIndexNote(pick2.kept, pick2.total, filename);
26248
+ const measureFixed = (withNotes2, withTitles) => Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", withNotes2 && workspaceMemory !== null ? trimmedIndexNote(wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME) : null, withNotes2 && userMemory !== null ? trimmedIndexNote(umFacts, umFacts, USER_MEMORY_FILENAME) : null, withTitles && workspaceMemory !== null ? { leadIn: titlesLeadIn(wmFacts, wmFacts, wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME), text: "" } : null, withTitles && userMemory !== null ? { leadIn: titlesLeadIn(umFacts, umFacts, umFacts, umFacts, USER_MEMORY_FILENAME), text: "" } : null).text);
26027
26249
  const wmSize = workspaceMemory === null ? 0 : Buffer.byteLength(workspaceMemory);
26028
26250
  const umSize = userMemory === null ? 0 : Buffer.byteLength(userMemory);
26029
26251
  const allocate = (remaining) => {
@@ -26040,23 +26262,45 @@ function buildGroundingReport(today, workspaceRules = null, workspaceMemory = nu
26040
26262
  umCap = half;
26041
26263
  wmCap = remaining - half;
26042
26264
  }
26043
- const umPick = userMemory === null ? null : selectRecentLines(userMemory, umCap);
26044
- const umUsed = umPick === null ? 0 : Buffer.byteLength(umPick.text);
26265
+ const umPick = userMemory === null ? null : selectIndexContent(userMemory, umCap);
26266
+ const umUsed = umPick === null ? 0 : Buffer.byteLength(umPick.text) + Buffer.byteLength(umPick.titles);
26045
26267
  return {
26046
- wmPick: workspaceMemory === null ? null : selectRecentLines(workspaceMemory, remaining - umUsed),
26268
+ wmPick: workspaceMemory === null ? null : selectIndexContent(workspaceMemory, Math.max(wmCap, remaining - umUsed)),
26047
26269
  umPick
26048
26270
  };
26049
26271
  };
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) }));
26272
+ const build = (picks, withNotes2) => {
26273
+ const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
26274
+ return compose(picks.wmPick === null ? null : picks.wmPick.text, picks.umPick === null ? null : picks.umPick.text, keep(picks.wmPick) ? noteFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME) : null, keep(picks.umPick) ? noteFor(picks.umPick, USER_MEMORY_FILENAME) : null, titlesFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME), titlesFor(picks.umPick, USER_MEMORY_FILENAME));
26275
+ };
26276
+ const refine2 = (seed, withNotes2) => {
26277
+ const asReserve = (section) => section === null ? null : { leadIn: section.leadIn, text: "" };
26278
+ const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
26279
+ const fixed = Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", keep(seed.wmPick) ? noteFor(seed.wmPick, WORKSPACE_MEMORY_FILENAME) : null, keep(seed.umPick) ? noteFor(seed.umPick, USER_MEMORY_FILENAME) : null, asReserve(titlesFor(seed.wmPick, WORKSPACE_MEMORY_FILENAME)), asReserve(titlesFor(seed.umPick, USER_MEMORY_FILENAME))).text);
26280
+ return allocate(Math.max(0, GROUNDING_BUDGET_BYTES - fixed));
26281
+ };
26282
+ const noNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(false, true)));
26283
+ const withNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(true, true)));
26284
+ const candidates = [
26285
+ { picks: withNotes, composed: build(withNotes, true) },
26286
+ { picks: noNotes, composed: build(noNotes, false) },
26287
+ { picks: withNotes, composed: build(withNotes, false) }
26288
+ ].concat([
26289
+ { seed: noNotes, notes: false },
26290
+ { seed: withNotes, notes: true }
26291
+ ].map(({ seed, notes }) => {
26292
+ const picks = refine2(seed, notes);
26293
+ return { picks, composed: build(picks, notes) };
26294
+ })).map((c) => ({
26295
+ ...c,
26296
+ bytes: Buffer.byteLength(c.composed.text),
26297
+ named: (c.picks.wmPick?.kept ?? 0) + (c.picks.wmPick?.named ?? 0) + (c.picks.umPick?.kept ?? 0) + (c.picks.umPick?.named ?? 0),
26298
+ whole: (c.picks.wmPick?.kept ?? 0) + (c.picks.umPick?.kept ?? 0)
26299
+ }));
26057
26300
  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 };
26301
+ const better = (a, b) => b.named !== a.named ? b.named > a.named : b.whole > a.whole;
26302
+ const best = fitting.length > 0 ? fitting.reduce((a, b) => better(a, b) ? b : a) : candidates.reduce((a, b) => b.bytes < a.bytes ? b : a);
26303
+ return { block: best.composed.text, picks: best.picks, split: best.composed.split };
26060
26304
  }
26061
26305
  var buildGroundingReportFor = buildGroundingReport;
26062
26306
  function formatLocalDate(d) {
@@ -26227,7 +26471,8 @@ var WorkspaceStateV2Schema = exports_external.object({
26227
26471
  workspace_memory: WorkspaceMemoryStateSchema.optional(),
26228
26472
  user_memory: UserMemoryStateSchema.optional(),
26229
26473
  user_voice: UserVoiceStateSchema.optional(),
26230
- derived_text_version: exports_external.number().int().nonnegative().optional()
26474
+ derived_text_version: exports_external.number().int().nonnegative().optional(),
26475
+ frontmatter_id_backfill_version: exports_external.number().int().nonnegative().optional()
26231
26476
  });
26232
26477
  var WorkspaceStateV1Schema = exports_external.object({
26233
26478
  version: exports_external.literal(1),
@@ -26408,7 +26653,8 @@ function commitState(before, after) {
26408
26653
  workspace_memory: memory.value,
26409
26654
  user_memory: userMemory.value,
26410
26655
  user_voice: userVoice.value,
26411
- derived_text_version: mergeScalar(fresh.derived_text_version, before.derived_text_version, after.derived_text_version)
26656
+ derived_text_version: mergeScalar(fresh.derived_text_version, before.derived_text_version, after.derived_text_version),
26657
+ frontmatter_id_backfill_version: mergeScalar(fresh.frontmatter_id_backfill_version, before.frontmatter_id_backfill_version, after.frontmatter_id_backfill_version)
26412
26658
  };
26413
26659
  writeState(merged);
26414
26660
  const singletonOverruled = rules.overruled || memory.overruled || userMemory.overruled || userVoice.overruled;
@@ -26531,6 +26777,14 @@ function allFileEntries(state) {
26531
26777
  function fileEntriesEqual(a, b) {
26532
26778
  return a.id === b.id && a.path === b.path && a.content_hash === b.content_hash && a.size_bytes === b.size_bytes && a.mime_type === b.mime_type && a.updated_at === b.updated_at && a.inode === b.inode;
26533
26779
  }
26780
+ function clearFrontmatterIdBackfillMarker(state) {
26781
+ if (state.frontmatter_id_backfill_version === undefined) {
26782
+ return { state, changed: false };
26783
+ }
26784
+ const next = { ...state };
26785
+ delete next.frontmatter_id_backfill_version;
26786
+ return { state: next, changed: true };
26787
+ }
26534
26788
  function listUnsynced(state) {
26535
26789
  return state.unsynced ?? [];
26536
26790
  }
@@ -26674,6 +26928,25 @@ function readUserMemoryFile(slug) {
26674
26928
  function readVoiceProfileFile(slug) {
26675
26929
  return readContextFile(slug, VOICE_FILENAME);
26676
26930
  }
26931
+ function readMemberFromConfig(slug) {
26932
+ const entry = loadConfig()?.workspaces[slug];
26933
+ const member = entry?.member;
26934
+ if (!member)
26935
+ return null;
26936
+ return {
26937
+ email: member.email,
26938
+ displayName: member.display_name ?? null,
26939
+ role: member.role ?? null,
26940
+ onboardingCompletedAt: member.onboarding_completed_at ?? null
26941
+ };
26942
+ }
26943
+ function readMachineTimezone() {
26944
+ try {
26945
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
26946
+ } catch {
26947
+ return null;
26948
+ }
26949
+ }
26677
26950
  function resolveUpdatedAt(raw, record2) {
26678
26951
  if (record2 === null || record2.updated_at === null || raw === null)
26679
26952
  return null;
@@ -26697,6 +26970,8 @@ function runGroundingReport(deps = {}) {
26697
26970
  const readVoiceProfile = deps.readVoiceProfile ?? readVoiceProfileFile;
26698
26971
  const readFreshness = deps.readFreshness ?? readMemoryFreshness;
26699
26972
  const readRosterIsEmpty = deps.readRosterIsEmpty ?? rosterIsEmpty;
26973
+ const readMember = deps.readMember ?? readMemberFromConfig;
26974
+ const readTimezone = deps.readTimezone ?? readMachineTimezone;
26700
26975
  let rawRules = null;
26701
26976
  let rawMemory = null;
26702
26977
  let rawUserMemory = null;
@@ -26707,6 +26982,7 @@ function runGroundingReport(deps = {}) {
26707
26982
  userVoice: null
26708
26983
  };
26709
26984
  let noArtists = false;
26985
+ let member = null;
26710
26986
  const workspaceSlug = isValidWorkspaceSlug(deps.workspaceSlug ?? "") ? deps.workspaceSlug : null;
26711
26987
  if (workspaceSlug) {
26712
26988
  try {
@@ -26739,6 +27015,12 @@ function runGroundingReport(deps = {}) {
26739
27015
  } catch {
26740
27016
  noArtists = false;
26741
27017
  }
27018
+ try {
27019
+ const stored = readMember(workspaceSlug);
27020
+ member = stored === null ? null : { ...stored, timezone: readTimezone() };
27021
+ } catch {
27022
+ member = null;
27023
+ }
26742
27024
  }
26743
27025
  const rules = extractWorkspaceRules(rawRules);
26744
27026
  const memory = extractWorkspaceMemory(rawMemory);
@@ -26747,7 +27029,7 @@ function runGroundingReport(deps = {}) {
26747
27029
  return buildGroundingReportFor(formatLocalDate(now()), rules, memory, userMemory, {
26748
27030
  workspaceMemoryUpdatedAt: resolveUpdatedAt(rawMemory, freshness.workspaceMemory),
26749
27031
  userMemoryUpdatedAt: resolveUpdatedAt(rawUserMemory, freshness.userMemory)
26750
- }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug);
27032
+ }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug, member);
26751
27033
  }
26752
27034
  var groundingCommand = defineCommand({
26753
27035
  meta: {
@@ -26822,6 +27104,21 @@ async function whoami(baseUrl, pat, fetchImpl = fetch) {
26822
27104
  }
26823
27105
  return parsed.data;
26824
27106
  }
27107
+ function memberPrincipalFrom(principal, now = () => new Date) {
27108
+ const member = {
27109
+ email: principal.profile.email,
27110
+ fetched_at: now().toISOString()
27111
+ };
27112
+ if (principal.profile.display_name) {
27113
+ member.display_name = principal.profile.display_name;
27114
+ }
27115
+ if (principal.role)
27116
+ member.role = principal.role;
27117
+ if (principal.profile.onboarding_completed_at) {
27118
+ member.onboarding_completed_at = principal.profile.onboarding_completed_at;
27119
+ }
27120
+ return member;
27121
+ }
26825
27122
  async function syncEntity(args) {
26826
27123
  const { strategy, baseUrl, pat, cursor, limit, fetchImpl = fetch } = args;
26827
27124
  const params = new URLSearchParams;
@@ -27245,7 +27542,8 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}, options2 = {}) {
27245
27542
  user_id: principal.user_id,
27246
27543
  api_base_url: base2,
27247
27544
  last_synced_at: null,
27248
- workspace_name: principal.workspace_name
27545
+ workspace_name: principal.workspace_name,
27546
+ member: memberPrincipalFrom(principal)
27249
27547
  };
27250
27548
  let prior;
27251
27549
  try {
@@ -27853,7 +28151,7 @@ var memoryGateCommand = defineCommand({
27853
28151
  });
27854
28152
 
27855
28153
  // src/lib/context/artist-context.ts
27856
- import { existsSync } from "node:fs";
28154
+ import { existsSync, readFileSync as readFileSync12, unlinkSync as unlinkSync7 } from "node:fs";
27857
28155
 
27858
28156
  // src/lib/context/entity-catalog.ts
27859
28157
  var ENTITY_CATALOG = [
@@ -28010,8 +28308,8 @@ function buildBody(input) {
28010
28308
  lines.push(`- Master context (stable profile + current state) → \`./_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`);
28011
28309
  lines.push(`- Closed/archived, meetings & full lists: \`mcp__plinth__list_projects\` / \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}`);
28012
28310
  lines.push(`- Schema: ${MCP_INTROSPECTION_POINTER}`);
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`);
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`);
28311
+ 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. The session grounding states how to write a fact; follow it rather than a copy`);
28312
+ 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`);
28015
28313
  lines.push("");
28016
28314
  lines.push(USER_BLOCK_BEGIN);
28017
28315
  lines.push(USER_BLOCK_PLACEHOLDER);
@@ -28258,7 +28556,16 @@ var MANAGED_COMMENT = [
28258
28556
  "or in .claude/settings.local.json.",
28259
28557
  "The permissions.deny entries below are managed the same way. They stop an agent",
28260
28558
  "in this workspace sending, replying to or forwarding mail and writing a calendar;",
28261
- "drafting and reading stay allowed. One more stops an agent EDITING voice.md, the",
28559
+ "drafting and reading stay allowed. They match by VERB FAMILY rather than by",
28560
+ "tool name - send_mail, sendEmail, send-mail and outlook_send_message are one",
28561
+ "rule set, not four - and the matcher cannot express an alternation, so each",
28562
+ "spelling needs its own line. That width also catches a few reads whose name",
28563
+ "merely begins with a send verb - getForwardingSettings is the live example.",
28564
+ "You cannot re-open one of these here: a deny beats an allow, including yours,",
28565
+ "and this list is rewritten every sync. Tell us and we will narrow the pattern.",
28566
+ "We took that trade knowingly - a refusal is visible and recoverable, and a",
28567
+ "message that has gone cannot be recalled.",
28568
+ "One more entry stops an agent EDITING voice.md, the",
28262
28569
  "voice profile it is judged against: that file is yours to write and theirs to read.",
28263
28570
  "Your own allow list is untouched, and your own",
28264
28571
  "deny rules are kept and listed first. One exception, stated because it is",
@@ -28269,7 +28576,11 @@ var MANAGED_COMMENT = [
28269
28576
  "since retired is removed here even if you wrote it yourself, because we cannot",
28270
28577
  "tell the two apart. Today that is `Edit(./voice.md)`, which Claude Code itself",
28271
28578
  "suggests - it is superseded by `Edit(//**/voice.md)` below, which is the same",
28272
- "protection at every depth. Put anything you want kept in settings.local.json."
28579
+ "protection at every depth - plus the seven exact-verb rules the verb families",
28580
+ "replaced (`mcp__*__reply`, `mcp__*__create_event` and five more of that shape).",
28581
+ "Every one of those is still covered by a wider rule below, so nothing you were",
28582
+ "protected from yesterday is allowed today. Put anything you want kept in",
28583
+ "settings.local.json."
28273
28584
  ];
28274
28585
  var MANAGED_DENY = [
28275
28586
  "mcp__claude_ai_Gmail__send_message",
@@ -28279,17 +28590,70 @@ var MANAGED_DENY = [
28279
28590
  "mcp__claude_ai_Google_Calendar__update_event",
28280
28591
  "mcp__claude_ai_Google_Calendar__delete_event",
28281
28592
  "mcp__claude_ai_Google_Calendar__respond_to_event",
28593
+ "mcp__*__send*",
28594
+ "mcp__*__*_send*",
28595
+ "mcp__*__*-send*",
28596
+ "mcp__*__*Send*",
28597
+ "mcp__*__reply*",
28598
+ "mcp__*__*_reply*",
28599
+ "mcp__*__*-reply*",
28600
+ "mcp__*__*Reply*",
28601
+ "mcp__*__forward*",
28602
+ "mcp__*__*_forward*",
28603
+ "mcp__*__*-forward*",
28604
+ "mcp__*__*Forward*",
28605
+ "mcp__*__respond*",
28606
+ "mcp__*__*_respond*",
28607
+ "mcp__*__*-respond*",
28608
+ "mcp__*__*Respond*",
28609
+ "mcp__*__rsvp*",
28610
+ "mcp__*__*_rsvp*",
28611
+ "mcp__*__*-rsvp*",
28612
+ "mcp__*__*Rsvp*",
28613
+ "mcp__*__*RSVP*",
28614
+ "mcp__*__create*event*",
28615
+ "mcp__*__*_create*event*",
28616
+ "mcp__*__*-create*event*",
28617
+ "mcp__*__*Create*event*",
28618
+ "mcp__*__create*Event*",
28619
+ "mcp__*__*_create*Event*",
28620
+ "mcp__*__*-create*Event*",
28621
+ "mcp__*__*Create*Event*",
28622
+ "mcp__*__update*event*",
28623
+ "mcp__*__*_update*event*",
28624
+ "mcp__*__*-update*event*",
28625
+ "mcp__*__*Update*event*",
28626
+ "mcp__*__update*Event*",
28627
+ "mcp__*__*_update*Event*",
28628
+ "mcp__*__*-update*Event*",
28629
+ "mcp__*__*Update*Event*",
28630
+ "mcp__*__delete*event*",
28631
+ "mcp__*__*_delete*event*",
28632
+ "mcp__*__*-delete*event*",
28633
+ "mcp__*__*Delete*event*",
28634
+ "mcp__*__delete*Event*",
28635
+ "mcp__*__*_delete*Event*",
28636
+ "mcp__*__*-delete*Event*",
28637
+ "mcp__*__*Delete*Event*",
28638
+ "mcp__*__cancel*event*",
28639
+ "mcp__*__*_cancel*event*",
28640
+ "mcp__*__*-cancel*event*",
28641
+ "mcp__*__*Cancel*event*",
28642
+ "mcp__*__cancel*Event*",
28643
+ "mcp__*__*_cancel*Event*",
28644
+ "mcp__*__*-cancel*Event*",
28645
+ "mcp__*__*Cancel*Event*",
28646
+ "Edit(//**/voice.md)"
28647
+ ];
28648
+ var RETIRED_DENY = [
28649
+ "Edit(./voice.md)",
28282
28650
  "mcp__*__send_message",
28283
28651
  "mcp__*__reply",
28284
28652
  "mcp__*__forward",
28285
28653
  "mcp__*__create_event",
28286
28654
  "mcp__*__update_event",
28287
28655
  "mcp__*__delete_event",
28288
- "mcp__*__respond_to_event",
28289
- "Edit(//**/voice.md)"
28290
- ];
28291
- var RETIRED_DENY = [
28292
- "Edit(./voice.md)"
28656
+ "mcp__*__respond_to_event"
28293
28657
  ];
28294
28658
  var BIN_SENTINEL = "__PLINTH_BIN__";
28295
28659
  var ALL_SENTINEL = "__PLINTH_ALL__";
@@ -28553,6 +28917,7 @@ function ensurePerArtistContext(deps) {
28553
28917
  const resolved = resolvePlinthBinary();
28554
28918
  reportResolution(out, resolved);
28555
28919
  let created = 0;
28920
+ let removedDailyLogs = 0;
28556
28921
  for (const artist of roster) {
28557
28922
  try {
28558
28923
  const entities = entityIndex.get(artist.slug) ?? {
@@ -28571,7 +28936,8 @@ function ensurePerArtistContext(deps) {
28571
28936
  if (regenerateClaudeMd(workspaceSlug, artist.slug, generated, force, out, now)) {
28572
28937
  created++;
28573
28938
  }
28574
- ensureStub(workspaceSlug, `artists/${artist.slug}/${ARTIST_DAILY_LOG_MD}`, dailyLogStub(artist.name));
28939
+ if (retireDailyLog(workspaceSlug, artist.slug))
28940
+ removedDailyLogs++;
28575
28941
  if (ensureSessionHook(workspaceSlug, `artists/${artist.slug}/`, out, resolved) === "skipped") {
28576
28942
  deps.onHookSkipped?.();
28577
28943
  }
@@ -28583,6 +28949,9 @@ function ensurePerArtistContext(deps) {
28583
28949
  if (created > 0) {
28584
28950
  out.info(`Scaffolded per-artist context for ${created} artist${created === 1 ? "" : "s"}.`);
28585
28951
  }
28952
+ if (removedDailyLogs > 0) {
28953
+ out.info(`Removed ${removedDailyLogs} unused daily-log.md file${removedDailyLogs === 1 ? "" : "s"} (never written to; any with your own notes in them were left alone).`);
28954
+ }
28586
28955
  }
28587
28956
  function regenerateClaudeMd(workspaceSlug, artistSlug, generated, force, out, now) {
28588
28957
  const claudeRel = `artists/${artistSlug}/${ARTIST_CLAUDE_MD}`;
@@ -28604,17 +28973,55 @@ function regenerateClaudeMd(workspaceSlug, artistSlug, generated, force, out, no
28604
28973
  }
28605
28974
  return decision.action === "create";
28606
28975
  }
28607
- function ensureStub(workspaceSlug, relativePath, content) {
28608
- if (existsSync(contextFilePath(workspaceSlug, relativePath)))
28609
- return;
28610
- writeContextFile(workspaceSlug, relativePath, content);
28611
- }
28612
- function dailyLogStub(name) {
28613
- return `# ${name} - Daily log
28976
+ var DAILY_LOG_STUB_TEMPLATES = [
28977
+ (name) => `# ${name} - Daily log
28614
28978
 
28615
28979
  ` + `<!-- Your own scratch notes; edit freely, Plinth never overwrites this file. -->
28616
28980
  ` + `<!-- 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. -->
28617
- `;
28981
+ `,
28982
+ (name) => `# ${name} - Daily log
28983
+
28984
+ ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
28985
+ ` + `<!-- Durable facts do not belong here - they go in ./memory.md, which syncs to this artist's Plinth record. -->
28986
+ `,
28987
+ (name) => `# ${name} - Daily log
28988
+
28989
+ ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
28990
+ `,
28991
+ (name) => `# ${name} — Daily log
28992
+
28993
+ ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
28994
+ `
28995
+ ];
28996
+ var DAILY_LOG_H1_RE = /^# (.+) [-—] Daily log\n/;
28997
+ function isPristineDailyLogStub(content) {
28998
+ const m = content.match(DAILY_LOG_H1_RE);
28999
+ if (m === null)
29000
+ return false;
29001
+ const name = m[1];
29002
+ return DAILY_LOG_STUB_TEMPLATES.some((t) => t(name) === content);
29003
+ }
29004
+ function retireDailyLog(workspaceSlug, artistSlug) {
29005
+ let target;
29006
+ try {
29007
+ target = contextFilePath(workspaceSlug, `artists/${artistSlug}/${ARTIST_DAILY_LOG_MD}`);
29008
+ } catch {
29009
+ return false;
29010
+ }
29011
+ let content;
29012
+ try {
29013
+ content = readFileSync12(target, "utf8");
29014
+ } catch {
29015
+ return false;
29016
+ }
29017
+ if (!isPristineDailyLogStub(content))
29018
+ return false;
29019
+ try {
29020
+ unlinkSync7(target);
29021
+ return true;
29022
+ } catch {
29023
+ return false;
29024
+ }
28618
29025
  }
28619
29026
  function backupStamp(d) {
28620
29027
  return d.toISOString().replace(/[-:.]/g, "").replace("Z", "");
@@ -28623,6 +29030,28 @@ function backupStamp(d) {
28623
29030
  // src/lib/context/generate.ts
28624
29031
  import { existsSync as existsSync2 } from "node:fs";
28625
29032
 
29033
+ // src/lib/cli/command-names.ts
29034
+ var PLINTH_COMMAND_NAMES = [
29035
+ "login",
29036
+ "sync",
29037
+ "start",
29038
+ "status",
29039
+ "confirm",
29040
+ "version",
29041
+ "refresh-context",
29042
+ "grounding",
29043
+ "voice-gate",
29044
+ "backlinks",
29045
+ "declare",
29046
+ "review-tier",
29047
+ "session",
29048
+ "memory-gate"
29049
+ ];
29050
+ function isPlinthCommand(token) {
29051
+ const needle = token.trim().toLowerCase();
29052
+ return PLINTH_COMMAND_NAMES.some((name) => name === needle);
29053
+ }
29054
+
28626
29055
  // src/lib/context/skill-commands.ts
28627
29056
  var COMMAND_USER_BLOCK_PLACEHOLDER = "_This workspace's own steps go here: anything specific to your team, your " + "tools or your data. Plinth preserves this block verbatim on every " + "regeneration; everything outside it is overwritten._";
28628
29057
  var OWNERSHIP_NOTE = "<!-- Auto-generated by Plinth into every mirror on every sync. Edit only " + "between the BEGIN/END markers at the foot; everything else is overwritten. " + "Your own commands in `.claude/commands/` are never read or touched by " + "Plinth, only this `plinth/` folder is generated. -->";
@@ -28655,9 +29084,9 @@ function buildStartCommandMd(rosterIsEmpty2) {
28655
29084
  "",
28656
29085
  "## Step 1: use the two memory indexes you were already given",
28657
29086
  "",
28658
- "The SessionStart hook injects **both memory indexes in full**, above: the workspace index (durable facts and conventions that hold for anyone here) and your personal index (facts true of **this member**: their email address, how they work, what they prefer). **Do not re-read `workspace-memory.md` or `user-memory.md`.** They are already in this context window, and re-reading them is the single most expensive thing this command could do for nothing.",
29087
+ "The SessionStart hook injects **both memory indexes**, above: the workspace index (durable facts and conventions that hold for anyone here) and your personal index (facts true of **this member**: how they work, what they prefer). They may arrive trimmed to their newest lines, and the injection says so where it happens. **Do not re-read `workspace-memory.md` or `user-memory.md` wholesale** - they are already in this context window and re-reading them is the single most expensive thing this command could do for nothing - but if the injection says an index was trimmed and you need what is missing, read that file.",
28659
29088
  "",
28660
- "**Use the second one. It is the one that gets skipped**, and it is the one carrying the member's own identity, including the email address that every task assignment needs. An agent that ignores it asks the user for something the workspace already told it.",
29089
+ "**Who you are working for is in the grounding above, not in an index.** The hook renders the member, their email and the workspace from the credentials this machine logged in with, so do not go looking for identity in either memory file and do not ask the user for it. **Use the personal index for what it is actually for**, which is the half that gets skipped: how this member works and what they prefer.",
28661
29090
  "",
28662
29091
  "If neither index appears above, the hook did not run - then, and only then, read the two files at the **mirror root**, the workspace folder holding `CLAUDE.md` (if this session started inside `artists/<slug>/`, that is the parent). Read them silently; do not dump either file into the conversation.",
28663
29092
  "",
@@ -28705,7 +29134,7 @@ function buildStartCommandMd(rosterIsEmpty2) {
28705
29134
  "",
28706
29135
  "## Loading area context",
28707
29136
  "",
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.",
29137
+ "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 is no longer created and nothing writes to any more. Brief on what is live, not on what you opened.",
28709
29138
  "",
28710
29139
  "If nothing resolves, say so plainly and ask. Do not invent an entity.",
28711
29140
  "",
@@ -28835,7 +29264,7 @@ function buildCloseCommandMd(workspaceSlug) {
28835
29264
  "",
28836
29265
  "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.",
28837
29266
  "",
28838
- "## Step 5: summarise, then end the session",
29267
+ "## Step 5: summarise",
28839
29268
  "",
28840
29269
  "**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:",
28841
29270
  "",
@@ -28851,6 +29280,38 @@ function buildCloseCommandMd(workspaceSlug) {
28851
29280
  "",
28852
29281
  "**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.",
28853
29282
  "",
29283
+ "## Step 6: offer to trim what has outgrown its file, and do not trim it",
29284
+ "",
29285
+ "The `plinth status` you ran a moment ago has a row labelled **`Too long`**, with one line per file beneath it. **That row is this step's whole trigger.** If it is not there, **say nothing at all** - no line, no mention, and no note that there was nothing to say. This signal is worth reading precisely because it is rare, and one that fires at every close is one nobody reads.",
29286
+ "",
29287
+ "**Nothing on the `Injection` row above it is a trigger, whatever it says.** That row reports how much of an index rides the session start, in either of two wordings - `Only N of M facts ... fit in the injection`, and `NONE of ... fits in the injection: all N facts are read from the file on demand`. **The second reads like an emergency and is not one.** Most workspaces are partly trimmed most of the time, and the remedy there is shorter lines rather than fewer facts, which that row states itself. Treating either line as the bar turns a rare offer into one that fires at nearly every close, on the member's private index, which is the outcome this step exists to avoid.",
29288
+ "",
29289
+ "If it named one or more, **offer in a single clause and stop there**, then wait for an answer:",
29290
+ "",
29291
+ "> Two artist stores and `voice.md` are past the size they are useful at. Want me to propose a trim?",
29292
+ "",
29293
+ "**Name every file the status named, including the ones you cannot touch.** Some of them you may only report on, and which those are is settled below - but a member who is told about two files out of three has been given a worse answer than one who is told about all three, not a tidier one.",
29294
+ "",
29295
+ "**Running the trim on your own initiative is the failure this step exists to prevent**, and it is the same failure as reporting a sweep nobody asked for. A `no` ends it here, in one line, and the next close offers it again in the same clause without arguing.",
29296
+ "",
29297
+ "**On yes: propose the cut, and write nothing yet.** Take one file at a time and show the member what would change before anything does:",
29298
+ "",
29299
+ "- **Every surviving fact appears either unchanged or beside its rewrite**, so the member is reading a diff rather than trusting a summary of one.",
29300
+ "- **A fact that is now wrong is edited where it stands, not deleted** - the correction and the date it changed are the record. The file's own header comment states its conventions and is the version that stays current; follow it rather than a copy.",
29301
+ "- **Provenance and the absolute date survive on every line you keep.** A fact whose source you drop while shortening it has been damaged, not trimmed.",
29302
+ "- **Nothing banked in the last 30 days is dropped**, measured against the date the session hook injected. Recent facts have not had time to prove whether they were durable, and a trim is not the place to decide.",
29303
+ "- **Merging two facts into one is a rewrite and shows as one**, with both original lines visible in the proposal.",
29304
+ "",
29305
+ "**Then write only what the member confirms**, and only to the files this command can reach:",
29306
+ "",
29307
+ "- `artists/<slug>/memory.md` syncs, and a teammate may be in the same file. **Re-read it at the moment you write**, exactly as Step 4b requires, and change only the lines the proposal named. This is the only file this step ever writes.",
29308
+ `- \`${VOICE_FILENAME}\` is named and nothing else. You are denied editing it, and that is deliberate: the profile a draft is judged against is not the drafter's to rewrite. Say its size, say the member edits it themselves, and propose nothing.`,
29309
+ "- **A memory file the whole workspace shares is not this command's to trim.** `plinth status` names it and says how far over it is; trimming it is a deliberate act by a person who can see who else it belongs to, not a step at the end of somebody's session.",
29310
+ "",
29311
+ "**Anything you write here lands after Step 5 looked at the conflicts list**, so it can be set aside within seconds like any other write. If you wrote a trim, run the Step 5 check once more on what you wrote and report it the same way.",
29312
+ "",
29313
+ "## Step 7: end the session",
29314
+ "",
28854
29315
  "Then, as the **very last step**:",
28855
29316
  "",
28856
29317
  "```sh",
@@ -28881,7 +29342,7 @@ function buildOnboardCommandMd(workspaceSlug) {
28881
29342
  "",
28882
29343
  "**Propose, then stop. Never sweep.** Every step below that creates anything puts the list to the user first and waits for an answer. This is not politeness and it is not a style: the first live run of the opening command on a real workspace created **63 tasks** against an explicit instruction to offer rather than run, and this command creates far more than that one did.",
28883
29344
  "",
28884
- "**And the tools will not save you.** Creating an artist makes you come back with a confirmation token, so the workspace itself blocks an unconfirmed write. **Creating a task or a project does not.** Those go straight through the moment you call them. So on the inbox-review step in particular, the only thing standing between this workspace and a hundred unwanted rows is you doing what this file says.",
29345
+ "**And the tools will not agree to anything for you.** Creating an artist, a task or a project makes you come back with a confirmation token, so the workspace refuses a write you have not repeated. **What that token proves is that you called twice with the same arguments. It does not prove that anybody said yes** - nothing in that exchange asks the user a question. **A contact has no token at all** and is written the moment you call it. Either way, the only thing standing between this workspace and a hundred unwanted rows is you putting the list to them first and waiting for the answer.",
28885
29346
  "",
28886
29347
  "## Step 0: find out where they already are",
28887
29348
  "",
@@ -28936,7 +29397,7 @@ function buildOnboardCommandMd(workspaceSlug) {
28936
29397
  "- **Their name.**",
28937
29398
  "- **Their Spotify link.** Ask for it and take whatever they paste. The bit you need is the ID in the middle of the URL: from `open.spotify.com/artist/<the long string on the end>` the ID is that string. Pass it as `spotify_artist_id`. **If they do not have it to hand, create the artist without it and move on** rather than stalling the run; it can be added later.",
28938
29399
  "",
28939
- "Create each one with `create_artist`. **It will hand you back a confirmation token rather than creating anything**, which is the workspace insisting the user agrees before a row exists. Put it to them, then complete the write. If they say no to one, drop it.",
29400
+ "Create each one with `create_artist`. **It will hand you back a confirmation token rather than creating anything**, so nothing exists until you call again with the same arguments and that token. **The token is not the user agreeing** - it asks you nothing about them - so put the artist to them first, and complete the write only once they have said yes. If they say no to one, drop it.",
28940
29401
  "",
28941
29402
  "**`create_artist` also requires a `slug`, and it is yours to derive rather than theirs to supply.** Do not ask for it: it is a detail of how the workspace stores things, and asking turns a two-question step into a three-question one for no gain.",
28942
29403
  "",
@@ -28962,7 +29423,7 @@ function buildOnboardCommandMd(workspaceSlug) {
28962
29423
  "",
28963
29424
  "**If you cannot name the role, do not propose the person.** An inbox is full of names, and a list of everyone who has ever appeared in a thread is worse than no list at all: it buries the eight people who matter and it makes the user do the sorting you were meant to do. A signature block, a company domain or the thread itself will usually tell you the role. When none of them does, leave the person out.",
28964
29425
  "",
28965
- "Put the ones you are sure of to the user with their role and email, per artist, and write only the ones they confirm with `create_artist_contact`.",
29426
+ "Put the ones you are sure of to the user with their role and email, per artist, and write only the ones they confirm with `create_artist_contact`. **This is the one write in this run with no confirmation step**: it lands the moment you call it, so the user's yes has to come before the call rather than after it.",
28966
29427
  "",
28967
29428
  "**Every contact carries its artist, and the write REFUSES one without it.** `create_artist_contact` needs `artist_id` or `artist_name` and errors with `Either artist_id or artist_name is required` when neither is there, so this is not a field you can leave blank and tidy up later. A person who genuinely spans several artists is therefore a decision rather than an omission: name them to the user, file them under the artist they belong to most, or leave them out and say that you have.",
28968
29429
  "",
@@ -28977,10 +29438,10 @@ function buildOnboardCommandMd(workspaceSlug) {
28977
29438
  "**Show them the whole list before you create any of it**, numbered, and **name the thread each one came from** so every proposal can be traced back to something real. Then gate it:",
28978
29439
  "",
28979
29440
  "- Put it to them with **`AskUserQuestion`**: create all of them, pick through them, or none.",
28980
- "- **If they pick through them, walk the list in batches of four as multi-select questions** (the tool takes four options per question and four questions per ask). Selected means create it. Unselected means drop it.",
29441
+ "- **If they pick through them, walk the list in batches of four as multi-select questions** (the tool takes four options per question and four questions per ask). Selected means create it. Unselected means drop it. **A batch with a single item left cannot be asked that way** - a one-option question is refused by the tool - so put that one to them in plain words instead and take the answer as given.",
28981
29442
  '- **The free-text "Other" choice is how they rewrite one in their own words**, and their wording wins over yours.',
28982
29443
  "",
28983
- "**Create nothing until an answer comes back, and create only what came back.** Creating a task or a project does not ask you to confirm the way creating an artist did, so there is no second chance here and nothing behind you to catch it. **If you find yourself about to create something nobody named, stop.**",
29444
+ "**Create nothing until an answer comes back, and create only what came back.** A task and a project each answer your first call with a preview and a confirmation token, and write nothing at all until you call again with the same arguments plus that token. **The second call is yours to make and not another question for the user** - they have already answered. The token is single-use, expires in five minutes and is refused if any argument changed, so confirm each one as you go rather than previewing the whole batch and coming back to it. **If you find yourself about to create something nobody named, stop**: the token will not stop you, because you are the one holding it.",
28984
29445
  "",
28985
29446
  "**`create_project` requires a `category` and will refuse an invalid one**, so settle it before you start writing rather than guessing partway through a confirmed batch. **Read the exact values off the tool's own schema and pass one of them character for character.** Do not work from a list written down anywhere, including this file: the set changes, a near miss is refused, and the plural forms are not what you would guess.",
28986
29447
  "",
@@ -28988,7 +29449,19 @@ function buildOnboardCommandMd(workspaceSlug) {
28988
29449
  "",
28989
29450
  "**Where you know who owns something, put a real owner and a real due date on it.** Where you do not, leave them empty rather than guessing: a wrong due date on day one teaches them the dates here cannot be trusted.",
28990
29451
  "",
28991
- "## Step 7: tell them what they have, and what they have not",
29452
+ "## Step 7: their voice, in their own words",
29453
+ "",
29454
+ `**One file here is theirs alone: \`${VOICE_FILENAME}\`, at the root of this folder.** It is how they write, and it is read before anything goes out in their name. **You cannot edit it** - the workspace denies that on purpose, so that what an agent is judged against is not something the agent can rewrite. Ask, and let them type.`,
29455
+ "",
29456
+ '**Ask for mechanical rules first, and only four or five of them.** The ones that are either followed or broken with no judgement in between: how they sign off, whether they use dashes, whether a signature block goes on the end, how long an opening paragraph runs, anything they know they always do. Those are worth more than a page about tone, because they can be checked against and "warm but direct" cannot.',
29457
+ "",
29458
+ "**Say where the rest goes: below the rules, not above them.** Examples, past emails and longer notes are useful and they are not the part that has to survive a reader who stops early. A profile is read top to bottom.",
29459
+ "",
29460
+ "**And say plainly that nothing checks a draft automatically.** No gate sits between what gets written and the person who reads it. That file and the agent applying it are the whole of it, which is exactly why the mechanical rules are worth the two minutes now.",
29461
+ "",
29462
+ "**If they would rather skip it, say what it costs in one line** and move on: everything drafted for them will read as the assistant's writing rather than theirs, and they will be correcting the same things by hand each time.",
29463
+ "",
29464
+ "## Step 8: tell them what they have, and what they have not",
28992
29465
  "",
28993
29466
  "**Five or six lines, no file-by-file dump:**",
28994
29467
  "",
@@ -29068,7 +29541,7 @@ function scanStaleUserBlocks(deps) {
29068
29541
  }
29069
29542
  checked++;
29070
29543
  const haystack = block.toLowerCase();
29071
- const tokens = retired.filter((r) => r.commandRel === rel && haystack.includes(r.token.toLowerCase()));
29544
+ const tokens = retired.filter((r) => r.commandRel === rel && haystack.includes(r.token.toLowerCase())).map((r) => ({ ...r, stillACommand: isPlinthCommand(r.token) }));
29072
29545
  if (tokens.length > 0) {
29073
29546
  stale.push({ commandRel: rel, invocation: invocationFor(rel), tokens });
29074
29547
  }
@@ -29076,215 +29549,748 @@ function scanStaleUserBlocks(deps) {
29076
29549
  return { checked, unchecked, stale };
29077
29550
  }
29078
29551
 
29079
- // src/lib/context/workspace-claude-md.ts
29080
- var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
29081
- function rosterBlock(roster, activeProjectCountBySlug) {
29082
- if (roster.length === 0)
29083
- return "_No artists synced yet._";
29084
- return roster.map((a) => {
29085
- const statusPart = a.status ? `, ${a.status}` : "";
29086
- const n = activeProjectCountBySlug.get(a.slug) ?? 0;
29087
- const projects = `${n} active project${n === 1 ? "" : "s"}`;
29088
- return `- **${a.name}** (\`${a.slug}\`${statusPart}) · ${projects} · master context → \`artists/${a.slug}/_artist.md\``;
29089
- }).join(`
29090
- `);
29552
+ // src/lib/files/api.ts
29553
+ var MAX_WORKSPACE_FILE_BYTES = 4000000;
29554
+ var HASH_RE = /^[0-9a-f]{64}$/;
29555
+ var TextQualitySchema = exports_external.enum(["ok", "partial", "garbled"]);
29556
+ var FileLiveSchema = exports_external.object({
29557
+ id: exports_external.string().uuid(),
29558
+ path: exports_external.string().min(1),
29559
+ mime_type: exports_external.string(),
29560
+ size_bytes: exports_external.number().int().nonnegative(),
29561
+ content_hash: exports_external.string().regex(HASH_RE),
29562
+ artist_id: exports_external.string().uuid().nullable(),
29563
+ project_id: exports_external.string().uuid().nullable(),
29564
+ updated_at: exports_external.string().datetime({ offset: true }),
29565
+ deleted_at: exports_external.null(),
29566
+ last_edited_by: exports_external.string().uuid().nullable(),
29567
+ content_quality: TextQualitySchema.nullable().optional().default(null).catch(null),
29568
+ has_extracted_text: exports_external.boolean().optional().catch(undefined),
29569
+ has_stored_bytes: exports_external.boolean().optional().catch(undefined)
29570
+ });
29571
+ function storedBytesSignal(row) {
29572
+ if (row.has_stored_bytes === true)
29573
+ return "present";
29574
+ if (row.has_stored_bytes === false)
29575
+ return "absent";
29576
+ return "unknown";
29091
29577
  }
29092
- function buildWorkspaceClaudeMd(input) {
29093
- const {
29094
- workspaceName,
29095
- workspaceSlug,
29096
- roster,
29097
- activeProjectCountBySlug,
29098
- schemaVersion
29099
- } = input;
29100
- const entityLines = ENTITY_CATALOG.map((e) => `- **${e.label}**: ${e.blurb}${e.file ? "" : " _(MCP only)_"}`);
29101
- const skillLines = SKILL_LOCATIONS.map((s) => `- ${s.label}: ${s.detail}`);
29102
- const skillCount = SKILL_LOCATIONS.length;
29103
- const lines = [
29104
- `<!-- plinth-schema-version: ${schemaVersion} -->`,
29105
- `<!-- Auto-generated by Plinth. ${GENERATED_AT_PREFIX}${GENERATED_AT_PLACEHOLDER}; ` + `that stamp moves only when this file's content changes, so an unchanged file keeps an older one ` + `and is still re-checked against Plinth on every sync. Run \`plinth status\` for when the mirror last synced. ` + `Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
29106
- ``,
29107
- `# ${workspaceName} · Plinth workspace`,
29108
- ``,
29109
- `Local mirror of the **${workspaceName}** workspace (\`${workspaceSlug}\`). ${SECURITY_NOTE}`,
29110
- ``,
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\`).`,
29112
- ``,
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.`,
29114
- ``,
29115
- `## Folder map`,
29116
- `- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread`,
29117
- `- \`projects/\` \`tasks/\` \`documents/\` \`meetings/\` workspace-root flat entity files (one \`.md\` per entity, NOT nested under \`artists/\`)`,
29118
- `- \`files/\` free-form document store: PDFs, executed agreements, spreadsheets, anything that is a FILE rather than an entity. Mirrored byte-for-byte both ways, folders and all`,
29119
- `- \`${extractedTextRelPath("<path>")}\` where \`<path>\` is the file's path under \`files/\`: the text Plinth extracted from that file, so a PDF's contents can be searched. Not every file has one - text files are already their own text, and extraction can fail. Where it produced junk the file exists and says so instead of carrying it, so read what is there before trusting a silence. Derived: edits here are overwritten and never reach Plinth`,
29120
- `- \`.claude/commands/\` local skills, incl. generated \`${START_COMMAND_INVOCATION}\` + \`${CLOSE_COMMAND_INVOCATION}\` + \`${ONBOARD_COMMAND_INVOCATION}\` · at the mirror root: \`workspace-memory.md\` \`workspace-rules.md\` (workspace-wide) + \`user-memory.md\` (yours), synced to Plinth · \`voice.md\` (yours: how you write, read by the agent, edited only by you) · \`CLAUDE.local.md\` private, gitignored`,
29121
- ``,
29122
- `## Where key context lives: look here before hunting`,
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`,
29124
- `- **Contracts, executed agreements & any PDF** → \`files/\` (search here first; \`documents/\` holds markdown bodies, not attachments)`,
29125
- `- Workspace-wide **notes, briefs, bios & context docs** → \`documents/\` (one markdown file per doc, shared across artists)`,
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`,
29127
- `- ${MCP_AVAILABILITY_POINTER}`,
29128
- ``,
29129
- `## Navigating the corpus`,
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`,
29131
- `- **What does this file link to?** → grep \`[[\` in it`,
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_*\``,
29133
- ``,
29134
- `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
29135
- ...entityLines,
29136
- ``,
29137
- `## Roster (${roster.length})`,
29138
- rosterBlock(roster, activeProjectCountBySlug),
29139
- ``,
29140
- `## Skills: ${skillCount} discoverable location${skillCount === 1 ? "" : "s"}`,
29141
- ...skillLines,
29142
- ``,
29143
- USER_BLOCK_BEGIN,
29144
- USER_BLOCK_PLACEHOLDER,
29145
- USER_BLOCK_END
29146
- ];
29147
- return lines.join(`
29148
- `) + `
29149
- `;
29578
+ function extractedTextSignal(row) {
29579
+ if (row.has_extracted_text === true)
29580
+ return "present";
29581
+ if (row.has_extracted_text === false)
29582
+ return "absent";
29583
+ return "unknown";
29150
29584
  }
29151
-
29152
- // src/lib/context/generate.ts
29153
- var CLAUDE_MD = "CLAUDE.md";
29154
- var CLAUDE_LOCAL_MD = "CLAUDE.local.md";
29155
- var GITIGNORE = ".gitignore";
29156
- var GITIGNORE_ENTRIES = [
29157
- CLAUDE_LOCAL_MD,
29158
- "**/.claude/settings.json",
29159
- "**/.claude/settings.local.json"
29160
- ];
29161
- var CLAUDE_LOCAL_STUB = "<!-- Per-user overrides for this workspace. Private, gitignored, and " + `never overwritten by Plinth. Run /onboard to populate, or edit freely. -->
29162
- `;
29163
- function currentSchemaVersion() {
29164
- return computeSchemaVersion({
29165
- templateVersion: TEMPLATE_VERSION,
29166
- entityCatalog: ENTITY_CATALOG,
29167
- skillLocations: SKILL_LOCATIONS,
29168
- securityNote: SECURITY_NOTE,
29169
- mcpAvailabilityPointer: MCP_AVAILABILITY_POINTER
29170
- });
29585
+ var FileTombstoneSchema = exports_external.object({
29586
+ id: exports_external.string().uuid(),
29587
+ path: exports_external.string().min(1),
29588
+ updated_at: exports_external.string().datetime({ offset: true }),
29589
+ deleted_at: exports_external.string().datetime({ offset: true })
29590
+ });
29591
+ var FileRowSchema = exports_external.union([FileLiveSchema, FileTombstoneSchema]);
29592
+ var FilesPageSchema = exports_external.object({
29593
+ files: exports_external.array(FileRowSchema),
29594
+ next_cursor: exports_external.string().nullable()
29595
+ });
29596
+ var FileWithDownloadSchema = FileLiveSchema.extend({
29597
+ download_url: exports_external.string().url().nullable()
29598
+ });
29599
+ var FileTextSchema = exports_external.object({
29600
+ id: exports_external.string().uuid(),
29601
+ path: exports_external.string().min(1),
29602
+ mime_type: exports_external.string(),
29603
+ updated_at: exports_external.string().datetime({ offset: true }),
29604
+ content_quality: TextQualitySchema.nullable(),
29605
+ source_uri: exports_external.string().nullable(),
29606
+ text: exports_external.string().nullable(),
29607
+ chars: exports_external.number().int().nonnegative(),
29608
+ withheld: exports_external.enum(["bytes_are_text", "no_text"]).nullable()
29609
+ });
29610
+ var PreconditionSchema = exports_external.object({
29611
+ code: exports_external.literal("precondition_failed"),
29612
+ current_updated_at: exports_external.string()
29613
+ });
29614
+ function baseHeaders(pat) {
29615
+ return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
29171
29616
  }
29172
- function ensureWorkspaceContext(deps) {
29173
- const { workspaceSlug, workspaceName, force, out, now } = deps;
29174
- const version2 = currentSchemaVersion();
29175
- const roster = readRoster(workspaceSlug);
29176
- const activeProjectCountBySlug = activeProjectCountsBySlug(workspaceSlug, loadState(workspaceSlug));
29177
- const generated = buildWorkspaceClaudeMd({
29178
- workspaceName,
29179
- workspaceSlug,
29180
- roster,
29181
- activeProjectCountBySlug,
29182
- schemaVersion: version2
29183
- });
29184
- const existing = readContextFile(workspaceSlug, CLAUDE_MD);
29185
- const decision = decideRegen({
29186
- existing,
29187
- currentVersion: "",
29188
- generated,
29189
- force,
29190
- normalise: normaliseGeneratedAt
29191
- });
29192
- if (decision.action === "backup-regen" && decision.backup !== undefined) {
29193
- const bakRel = `${CLAUDE_MD}.bak-${backupStamp3(now())}`;
29194
- writeContextFile(workspaceSlug, bakRel, decision.backup);
29195
- out.warn(`${CLAUDE_MD} had no recognisable BEGIN/END markers; backed your copy ` + `up to ${bakRel} before regenerating.`);
29617
+ async function syncFilesPage(args) {
29618
+ const { baseUrl, pat, cursor, limit, fetchImpl = fetch } = args;
29619
+ const params = new URLSearchParams;
29620
+ if (cursor)
29621
+ params.set("since", cursor);
29622
+ if (limit)
29623
+ params.set("limit", String(limit));
29624
+ const qs = params.toString();
29625
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files` + (qs.length > 0 ? `?${qs}` : "");
29626
+ let res;
29627
+ try {
29628
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
29629
+ } catch (err) {
29630
+ const msg = err instanceof Error ? err.message : "network error";
29631
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
29196
29632
  }
29197
- if (decision.content !== null) {
29198
- writeContextFile(workspaceSlug, CLAUDE_MD, renderGeneratedAt(decision.content, now()));
29633
+ if (res.status === 401) {
29634
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
29199
29635
  }
29200
- if (!existsSync2(contextFilePath(workspaceSlug, CLAUDE_LOCAL_MD))) {
29201
- writeContextFile(workspaceSlug, CLAUDE_LOCAL_MD, CLAUDE_LOCAL_STUB);
29636
+ if (res.status === 429) {
29637
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
29202
29638
  }
29203
- ensureWorkspaceCommands({ workspaceSlug, force, out, now });
29204
- ensureGitignore(workspaceSlug);
29205
- const resolved = resolvePlinthBinary();
29206
- reportResolution(out, resolved);
29207
- if (ensureSessionHook(workspaceSlug, "", out, resolved) === "skipped") {
29208
- deps.onHookSkipped?.();
29639
+ if (!res.ok) {
29640
+ throw new ApiError(res.status, `sync files failed with HTTP ${res.status}`);
29209
29641
  }
29210
- if (decision.action === "create") {
29211
- out.info(`Generated ${CLAUDE_MD} (${roster.length} artists).`);
29212
- } else if (decision.action === "regen" || decision.action === "backup-regen") {
29213
- out.info(`Refreshed ${CLAUDE_MD} (your edits preserved).`);
29642
+ const body = await res.json();
29643
+ const parsed = FilesPageSchema.safeParse(body);
29644
+ if (!parsed.success) {
29645
+ throw new ApiError(res.status, `sync files response did not match the expected shape: ${parsed.error.message}`);
29214
29646
  }
29647
+ return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
29215
29648
  }
29216
- function ensureGitignore(workspaceSlug) {
29217
- const existing = readContextFile(workspaceSlug, GITIGNORE);
29218
- const present2 = new Set((existing ?? "").split(`
29219
- `).map((l) => l.trim()).filter(Boolean));
29220
- const missing = GITIGNORE_ENTRIES.filter((e) => !present2.has(e));
29221
- if (missing.length === 0)
29222
- return;
29223
- const block = `${missing.join(`
29224
- `)}
29225
- `;
29226
- if (existing === null || existing === "") {
29227
- writeContextFile(workspaceSlug, GITIGNORE, block);
29228
- return;
29649
+ async function getFile(args) {
29650
+ const { baseUrl, pat, id, fetchImpl = fetch } = args;
29651
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
29652
+ let res;
29653
+ try {
29654
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
29655
+ } catch (err) {
29656
+ const msg = err instanceof Error ? err.message : "network error";
29657
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
29229
29658
  }
29230
- const lead = existing.endsWith(`
29231
- `) ? "" : `
29232
- `;
29233
- writeContextFile(workspaceSlug, GITIGNORE, `${existing}${lead}${block}`);
29234
- }
29235
- function backupStamp3(d) {
29236
- return d.toISOString().replace(/[-:.]/g, "").replace("Z", "");
29237
- }
29238
-
29239
- // src/commands/refresh-context.ts
29240
- init_output();
29241
- function runRefreshContext(workspaceSlug, deps = {}) {
29242
- const out = new Output({ sink: deps.sink, noColor: deps.noColor });
29243
- const now = deps.now ?? (() => new Date);
29244
- const config2 = loadConfig();
29245
- if (!config2) {
29246
- out.error("No workspace configured. Run `plinth login --workspace <slug>` first.");
29247
- return 1;
29659
+ if (res.status === 404)
29660
+ return { ok: false, code: 404 };
29661
+ if (res.status === 401) {
29662
+ throw new ApiError(401, "Token was rejected by the server.");
29248
29663
  }
29249
- const slug = workspaceSlug ?? config2.active_workspace;
29250
- const entry2 = config2.workspaces[slug];
29251
- if (!entry2) {
29252
- out.error(`Workspace "${slug}" not found. Run \`plinth login --workspace ${slug}\` first.`);
29253
- return 1;
29664
+ if (res.status === 429) {
29665
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29254
29666
  }
29255
- let skipped = 0;
29256
- const onHookSkipped = () => {
29257
- skipped++;
29258
- };
29667
+ if (!res.ok) {
29668
+ throw new ApiError(res.status, `get file failed with HTTP ${res.status}`);
29669
+ }
29670
+ const body = await res.json();
29671
+ const parsed = FileWithDownloadSchema.safeParse(body);
29672
+ if (!parsed.success) {
29673
+ throw new ApiError(res.status, `get file response did not match the expected shape`);
29674
+ }
29675
+ return { ok: true, row: parsed.data };
29676
+ }
29677
+ async function getFileText(args) {
29678
+ const { baseUrl, pat, id, fetchImpl = fetch } = args;
29679
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}/text`;
29680
+ let res;
29259
29681
  try {
29260
- ensureWorkspaceContext({
29261
- workspaceSlug: slug,
29262
- workspaceName: entry2.workspace_name ?? slug,
29263
- force: true,
29264
- out,
29265
- now,
29266
- onHookSkipped
29267
- });
29268
- ensurePerArtistContext({
29269
- workspaceSlug: slug,
29270
- force: true,
29271
- out,
29272
- now,
29273
- onHookSkipped
29274
- });
29682
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
29275
29683
  } catch (err) {
29276
- const msg = err instanceof Error ? err.message : "unknown error";
29277
- out.error(`Could not refresh context: ${msg}`);
29278
- return 1;
29684
+ const msg = err instanceof Error ? err.message : "network error";
29685
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
29279
29686
  }
29280
- if (skipped > 0) {
29281
- out.error(`Context refreshed, but ${skipped} Claude Code hook mount${skipped === 1 ? " was" : "s were"} ` + `SKIPPED — ${skipped === 1 ? "that location is" : "those locations are"} ungrounded and ` + `agent-originated drafts there are UNGATED. See the errors above.`);
29282
- return 1;
29687
+ if (res.status === 404)
29688
+ return { ok: false, code: 404 };
29689
+ if (res.status === 401) {
29690
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
29283
29691
  }
29284
- out.success(`Context up to date for ${slug}.`);
29285
- return 0;
29692
+ if (res.status === 429) {
29693
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29694
+ }
29695
+ if (!res.ok) {
29696
+ throw new ApiError(res.status, `get file text failed with HTTP ${res.status}`);
29697
+ }
29698
+ const body = await res.json();
29699
+ const parsed = FileTextSchema.safeParse(body);
29700
+ if (!parsed.success) {
29701
+ throw new ApiError(res.status, `get file text response did not match the expected shape: ${parsed.error.message}`);
29702
+ }
29703
+ return { ok: true, row: parsed.data };
29286
29704
  }
29287
- var refreshContextCommand = defineCommand({
29705
+ async function downloadBytes(downloadUrl, fetchImpl = fetch) {
29706
+ let res;
29707
+ try {
29708
+ res = await fetchImpl(downloadUrl, { method: "GET" });
29709
+ } catch (err) {
29710
+ const msg = err instanceof Error ? err.message : "network error";
29711
+ throw new ApiError(0, `Could not download bytes: ${msg}`);
29712
+ }
29713
+ if (!res.ok) {
29714
+ throw new ApiError(res.status, `download failed with HTTP ${res.status}`);
29715
+ }
29716
+ const ab = await res.arrayBuffer();
29717
+ return Buffer.from(ab);
29718
+ }
29719
+ async function postFile(args) {
29720
+ const {
29721
+ baseUrl,
29722
+ pat,
29723
+ relPath,
29724
+ bytes,
29725
+ contentHash,
29726
+ mimeType,
29727
+ expectedUpdatedAt,
29728
+ fetchImpl = fetch
29729
+ } = args;
29730
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files`;
29731
+ const headers = {
29732
+ Authorization: `Bearer ${pat}`,
29733
+ Accept: "application/json",
29734
+ "Content-Type": mimeType,
29735
+ "X-Plinth-File-Path": encodeURIComponent(relPath),
29736
+ "X-Plinth-Content-Hash": contentHash
29737
+ };
29738
+ if (expectedUpdatedAt)
29739
+ headers["X-Plinth-Expected-Updated-At"] = expectedUpdatedAt;
29740
+ let res;
29741
+ try {
29742
+ res = await fetchImpl(url2, { method: "POST", headers, body: bytes });
29743
+ } catch (err) {
29744
+ const msg = err instanceof Error ? err.message : "network error";
29745
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
29746
+ }
29747
+ if (res.status === 200 || res.status === 201) {
29748
+ const body = await res.json();
29749
+ const parsed = FileLiveSchema.safeParse(body);
29750
+ if (!parsed.success) {
29751
+ throw new ApiError(res.status, `post file response did not match the expected shape`);
29752
+ }
29753
+ return { ok: true, row: parsed.data };
29754
+ }
29755
+ if (res.status === 413) {
29756
+ let maxBytes = null;
29757
+ try {
29758
+ const json2 = await res.json();
29759
+ if (typeof json2.max_bytes === "number")
29760
+ maxBytes = json2.max_bytes;
29761
+ } catch {}
29762
+ return { ok: false, code: 413, maxBytes };
29763
+ }
29764
+ if (res.status === 409) {
29765
+ const json2 = await res.json();
29766
+ const parsed = PreconditionSchema.safeParse(json2);
29767
+ if (!parsed.success) {
29768
+ throw new ApiError(409, `post file 409 body did not match precondition_failed shape`);
29769
+ }
29770
+ return { ok: false, code: 409, currentUpdatedAt: parsed.data.current_updated_at };
29771
+ }
29772
+ if (res.status === 400) {
29773
+ let code;
29774
+ try {
29775
+ const json2 = await res.json();
29776
+ code = json2.code;
29777
+ } catch {}
29778
+ if (code === "hash_mismatch")
29779
+ return { ok: false, code: 400, reason: "hash_mismatch" };
29780
+ throw new ApiError(400, "post file rejected as bad request");
29781
+ }
29782
+ if (res.status === 401) {
29783
+ throw new ApiError(401, "Token was rejected by the server.");
29784
+ }
29785
+ if (res.status === 429) {
29786
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29787
+ }
29788
+ throw new ApiError(res.status, `post file failed with HTTP ${res.status}`);
29789
+ }
29790
+ async function deleteFile(args) {
29791
+ const { baseUrl, pat, id, expectedUpdatedAt, fetchImpl = fetch } = args;
29792
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
29793
+ let res;
29794
+ try {
29795
+ res = await fetchImpl(url2, {
29796
+ method: "DELETE",
29797
+ headers: { ...baseHeaders(pat), "X-Plinth-Expected-Updated-At": expectedUpdatedAt }
29798
+ });
29799
+ } catch (err) {
29800
+ const msg = err instanceof Error ? err.message : "network error";
29801
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
29802
+ }
29803
+ if (res.status === 200) {
29804
+ const body = await res.json();
29805
+ const parsed = FileTombstoneSchema.safeParse(body);
29806
+ if (!parsed.success) {
29807
+ throw new ApiError(200, `delete file response did not match the expected tombstone shape`);
29808
+ }
29809
+ return { ok: true, tombstone: parsed.data };
29810
+ }
29811
+ if (res.status === 404)
29812
+ return { ok: false, code: 404 };
29813
+ if (res.status === 409) {
29814
+ const json2 = await res.json();
29815
+ const parsed = PreconditionSchema.safeParse(json2);
29816
+ if (!parsed.success) {
29817
+ throw new ApiError(409, `delete file 409 body did not match precondition_failed shape`);
29818
+ }
29819
+ return { ok: false, code: 409, currentUpdatedAt: parsed.data.current_updated_at };
29820
+ }
29821
+ if (res.status === 401) {
29822
+ throw new ApiError(401, "Token was rejected by the server.");
29823
+ }
29824
+ if (res.status === 429) {
29825
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29826
+ }
29827
+ throw new ApiError(res.status, `delete file failed with HTTP ${res.status}`);
29828
+ }
29829
+
29830
+ // src/lib/rejected.ts
29831
+ import { relative as relative3 } from "node:path";
29832
+
29833
+ // src/lib/log-tokens.ts
29834
+ function categorizeError(err) {
29835
+ if (err instanceof FrontmatterValidationError) {
29836
+ return { status: 422, reason: "invalid_frontmatter", detail: err.detail };
29837
+ }
29838
+ if (err instanceof ApiError) {
29839
+ const { status } = err;
29840
+ if (status === 0)
29841
+ return { status, reason: "network" };
29842
+ if (status === 401 || status === 403)
29843
+ return { status, reason: "auth" };
29844
+ if (status === 429)
29845
+ return { status, reason: "rate_limit" };
29846
+ if (status === 400 || status === 422) {
29847
+ const serverMessage = sanitizeServerMessage(err.message);
29848
+ return serverMessage ? { status, reason: "bad_request", serverMessage } : { status, reason: "bad_request" };
29849
+ }
29850
+ if (status >= 500 && status <= 599)
29851
+ return { status, reason: "server" };
29852
+ return { status, reason: "http" };
29853
+ }
29854
+ return { status: 0, reason: "unexpected" };
29855
+ }
29856
+ function sanitizeServerMessage(message) {
29857
+ const oneLine2 = message.replace(/\s+/g, " ").trim();
29858
+ if (oneLine2.length === 0)
29859
+ return "";
29860
+ return oneLine2.length > 200 ? `${oneLine2.slice(0, 197)}...` : oneLine2;
29861
+ }
29862
+ function pushFailedLine(f) {
29863
+ const idPart = f.id ? ` id=${f.id}` : "";
29864
+ const detailPart = f.detail ? ` detail=${f.detail}` : "";
29865
+ const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
29866
+ return `[PUSH_FAILED entityType=${f.entityType} slug=${f.slug}${idPart} status=${f.status} reason=${f.reason}${detailPart}${serverPart}]`;
29867
+ }
29868
+ function pullFailedLine(f) {
29869
+ return `[PULL_FAILED status=${f.status} reason=${f.reason}]`;
29870
+ }
29871
+ function contextDocConflictLine(f) {
29872
+ const localIdPart = f.localId ? ` localId=${f.localId}` : "";
29873
+ const reasonPart = f.reason ? ` reason=${f.reason}` : "";
29874
+ const stashPart = f.stash ? ` stash=${f.stash}` : "";
29875
+ return `[CONTEXT_DOC_CONFLICT entityType=${f.entityType} localSlug=${f.localSlug}${localIdPart} ` + `existingSlug=${f.existingSlug} existingId=${f.existingId} scope=${f.scope} ` + `action=${f.action}${reasonPart}${stashPart}]`;
29876
+ }
29877
+ function syncErrorLine(f) {
29878
+ return `[SYNC_ERROR entityType=${f.entityType} slug=${f.slug} id=${f.id} ` + `fields=${f.fields.join(",")} pushed=${f.pushed}]`;
29879
+ }
29880
+ function filePushLine(f) {
29881
+ return `[FILE_PUSH path=${f.path} id=${f.id} action=${f.action}]`;
29882
+ }
29883
+ function fileDeleteLine(f) {
29884
+ const notePart = f.note ? ` note=${f.note}` : "";
29885
+ return `[FILE_DELETE path=${f.path} id=${f.id}${notePart}]`;
29886
+ }
29887
+ function fileDeleteSuppressedLine(f) {
29888
+ return `[FILE_DELETE_SUPPRESSED path=${f.path} reason=${f.reason}]`;
29889
+ }
29890
+ function entityDeleteSuppressedLine(f) {
29891
+ return `[ENTITY_DELETE_SUPPRESSED entityType=${f.entityType} slug=${f.slug} reason=${f.reason}]`;
29892
+ }
29893
+ function fileOverCapLine(f) {
29894
+ return `[FILE_OVER_CAP path=${f.path} size=${f.size} max=${f.max} max_from=${f.source}]`;
29895
+ }
29896
+ function fileConflictLine(f) {
29897
+ return `[FILE_CONFLICT path=${f.path} stash=${f.stash}]`;
29898
+ }
29899
+ function conflictsViewFailedLine(f) {
29900
+ const msg = f.message === undefined ? "" : sanitizeServerMessage(f.message);
29901
+ const msgPart = msg === "" ? "" : ` message="${msg.replace(/"/g, "'")}"`;
29902
+ return `[CONFLICTS_VIEW_FAILED path=${f.path} reason=${f.reason}${msgPart}]`;
29903
+ }
29904
+ function fileFailedLine(f) {
29905
+ const detailPart = f.detail ? ` detail=${f.detail}` : "";
29906
+ const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
29907
+ return `[FILE_PUSH_FAILED path=${f.path} status=${f.status} reason=${f.reason}${detailPart}${serverPart}]`;
29908
+ }
29909
+ function frontmatterIdBackfillLine(r) {
29910
+ return `[FRONTMATTER_ID_BACKFILL scanned=${r.scanned} written=${r.written} ` + `blocks_created=${r.blocksCreated} ` + `inodes_restamped=${r.inodesRestamped} failed=${r.failed} ` + `skipped_frozen=${r.skipped.frozen} ` + `skipped_foreign_id=${r.skipped.foreignId} ` + `skipped_unwritable=${r.skipped.unwritable} ` + `skipped_slug_collision=${r.skipped.slugCollision} ` + `skipped_diverged=${r.skipped.diverged}]`;
29911
+ }
29912
+ function pullNoticeLine(kind) {
29913
+ const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
29914
+ return `[${token} reason=${kind}]`;
29915
+ }
29916
+ function calendarProjectedLine(f) {
29917
+ return `[CALENDAR_PROJECTED written=${f.written} pruned=${f.pruned} ` + `calendars=${f.calendars} not_read=${f.notRead}]`;
29918
+ }
29919
+ function calendarFailedLine(f) {
29920
+ const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
29921
+ return `[CALENDAR_FAILED status=${f.status} reason=${f.reason} staled=${f.staled}${serverPart}]`;
29922
+ }
29923
+ function calendarNoticeLine(kind) {
29924
+ const token = kind === "auth" ? "CALENDAR_PAUSED" : kind === "unsupported" ? "CALENDAR_UNSUPPORTED" : "CALENDAR_DEGRADED";
29925
+ return `[${token} reason=${kind}]`;
29926
+ }
29927
+
29928
+ // src/lib/rejected.ts
29929
+ var PERMANENT_REJECT_REASONS = new Set(["bad_request", "invalid_frontmatter"]);
29930
+ function isPermanentReject(err) {
29931
+ return PERMANENT_REJECT_REASONS.has(categorizeError(err).reason);
29932
+ }
29933
+ function persist(state, next) {
29934
+ if (!next.changed)
29935
+ return state;
29936
+ return commitState(state, next.state);
29937
+ }
29938
+ function recordEntityRejected(args) {
29939
+ const cat = categorizeError(args.err);
29940
+ let path3;
29941
+ try {
29942
+ path3 = relative3(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
29943
+ } catch {
29944
+ path3 = args.absolutePath;
29945
+ }
29946
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
29947
+ return persist(withoutStale, upsertRejected(withoutStale, {
29948
+ target: "entity",
29949
+ op: args.op,
29950
+ path: path3,
29951
+ status: cat.status,
29952
+ reason: cat.reason,
29953
+ detail: cat.detail,
29954
+ message: cat.serverMessage,
29955
+ rejected_at: args.now().toISOString()
29956
+ }));
29957
+ }
29958
+ function recordFileRejected(args) {
29959
+ const cat = categorizeError(args.err);
29960
+ const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
29961
+ return persist(withoutStale, upsertRejected(withoutStale, {
29962
+ target: "file",
29963
+ op: args.op,
29964
+ path: args.relPath,
29965
+ status: cat.status,
29966
+ reason: cat.reason,
29967
+ detail: cat.detail,
29968
+ message: cat.serverMessage,
29969
+ rejected_at: args.now().toISOString()
29970
+ }));
29971
+ }
29972
+ var CLOUD_ROW_DELETED_REASON = "cloud_row_deleted";
29973
+ function recordEntityCloudRowDeleted(args) {
29974
+ let path3;
29975
+ try {
29976
+ path3 = relative3(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
29977
+ } catch {
29978
+ path3 = args.absolutePath;
29979
+ }
29980
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
29981
+ return persist(withoutStale, upsertRejected(withoutStale, {
29982
+ target: "entity",
29983
+ op: "upsert",
29984
+ path: path3,
29985
+ status: 404,
29986
+ reason: CLOUD_ROW_DELETED_REASON,
29987
+ message: "This was deleted in Plinth, and your copy has changes the cloud never " + "saw. Nothing can push it back — Plinth has no row for it. Keep the " + "file as your own, or delete it. To put the text into Plinth again, " + "copy it into a NEW file with a different name and no `id:`, `slug:` " + "or `revision:` lines.",
29988
+ rejected_at: args.now().toISOString()
29989
+ }));
29990
+ }
29991
+ function clearEntityCloudRowDeleted(state, path3) {
29992
+ const list = state.rejected ?? [];
29993
+ const next = list.filter((o) => !(o.target === "entity" && o.path === path3 && o.reason === CLOUD_ROW_DELETED_REASON));
29994
+ if (next.length === list.length)
29995
+ return state;
29996
+ return commitState(state, { ...state, rejected: next });
29997
+ }
29998
+ function humanBytes(bytes) {
29999
+ if (bytes >= 1e6)
30000
+ return `${(bytes / 1e6).toFixed(1)} MB`;
30001
+ if (bytes >= 1000)
30002
+ return `${Math.round(bytes / 1000)} KB`;
30003
+ return `${bytes} bytes`;
30004
+ }
30005
+ function recordFileOverCap(args) {
30006
+ const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
30007
+ const ceiling = args.source === "server" ? `the ${humanBytes(args.max)} server limit` : `the ${humanBytes(args.max)} upload ceiling (measured platform limit)`;
30008
+ const message = args.tracked ? `Your local change to this file (${humanBytes(args.size)}) exceeds ${ceiling}, ` + `so it cannot be pushed. The copy in Plinth is unchanged; your edit is on disk here only.` : `${humanBytes(args.size)} exceeds ${ceiling}. The file is still on disk ` + `here but is not in Plinth; shrink or split it to sync it.`;
30009
+ return persist(withoutStale, upsertRejected(withoutStale, {
30010
+ target: "file",
30011
+ op: "upsert",
30012
+ path: args.relPath,
30013
+ status: 413,
30014
+ reason: "over_cap",
30015
+ message,
30016
+ rejected_at: args.now().toISOString()
30017
+ }));
30018
+ }
30019
+ function recordFileSymlinkRefused(args) {
30020
+ const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
30021
+ return persist(withoutStale, upsertRejected(withoutStale, {
30022
+ target: "file",
30023
+ op: args.op,
30024
+ path: args.relPath,
30025
+ status: 0,
30026
+ reason: "symlink",
30027
+ message: `A symlink on the path to files/${args.relPath} means syncing it would ` + `read or write outside your Plinth folder, so it was left alone. ` + `Replace the link with a real folder or file to sync it.`,
30028
+ rejected_at: args.now().toISOString()
30029
+ }));
30030
+ }
30031
+ function recordEntitySymlinkRefused(args) {
30032
+ const path3 = args.mirrorRelativePath;
30033
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
30034
+ return persist(withoutStale, upsertRejected(withoutStale, {
30035
+ target: "entity",
30036
+ op: args.op,
30037
+ path: path3,
30038
+ status: 0,
30039
+ reason: "symlink",
30040
+ message: `A symlink on the path to ${path3} means syncing it would read or write ` + `outside your Plinth folder, so it was left alone. Replace the link ` + `with a real folder or file to sync it.`,
30041
+ rejected_at: args.now().toISOString()
30042
+ }));
30043
+ }
30044
+ function clearSymlinkRefused(state, target, path3) {
30045
+ const list = state.rejected ?? [];
30046
+ const next = list.filter((o) => !(o.target === target && o.path === path3 && o.reason === "symlink"));
30047
+ if (next.length === list.length)
30048
+ return state;
30049
+ return commitState(state, { ...state, rejected: next });
30050
+ }
30051
+ function clearEntityRejected(state, workspaceSlug, absolutePath) {
30052
+ let path3;
30053
+ try {
30054
+ path3 = relative3(workspaceMirrorPath(workspaceSlug), absolutePath);
30055
+ } catch {
30056
+ path3 = absolutePath;
30057
+ }
30058
+ const next = removeRejected(state, "entity", path3);
30059
+ if (!next.changed)
30060
+ return state;
30061
+ return commitState(state, next.state);
30062
+ }
30063
+ function clearFileRejected(state, relPath) {
30064
+ const next = removeRejected(state, "file", relPath);
30065
+ if (!next.changed)
30066
+ return state;
30067
+ return commitState(state, next.state);
30068
+ }
30069
+
30070
+ // src/lib/context/workspace-claude-md.ts
30071
+ var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
30072
+ function rosterBlock(roster, activeProjectCountBySlug) {
30073
+ if (roster.length === 0)
30074
+ return "_No artists synced yet._";
30075
+ return roster.map((a) => {
30076
+ const statusPart = a.status ? `, ${a.status}` : "";
30077
+ const n = activeProjectCountBySlug.get(a.slug) ?? 0;
30078
+ const projects = `${n} active project${n === 1 ? "" : "s"}`;
30079
+ return `- **${a.name}** (\`${a.slug}\`${statusPart}) · ${projects} · master context → \`artists/${a.slug}/_artist.md\``;
30080
+ }).join(`
30081
+ `);
30082
+ }
30083
+ function emptyNote(folders, names) {
30084
+ if (folders === undefined)
30085
+ return "";
30086
+ const byName = new Map(folders.map((f) => [f.name, f.empty]));
30087
+ const empty = names.filter((n) => byName.get(n) === true);
30088
+ if (empty.length === 0)
30089
+ return "";
30090
+ const list = empty.map((n) => `\`${n}/\``).join(" ");
30091
+ return ` · empty so far: ${list}`;
30092
+ }
30093
+ function buildWorkspaceClaudeMd(input) {
30094
+ const {
30095
+ workspaceName,
30096
+ workspaceSlug,
30097
+ roster,
30098
+ activeProjectCountBySlug,
30099
+ schemaVersion,
30100
+ folders
30101
+ } = input;
30102
+ const entityLines = ENTITY_CATALOG.map((e) => `- **${e.label}**: ${e.blurb}${e.file ? "" : " _(MCP only)_"}`);
30103
+ const skillLines = SKILL_LOCATIONS.map((s) => `- ${s.label}: ${s.detail}`);
30104
+ const skillCount = SKILL_LOCATIONS.length;
30105
+ const lines = [
30106
+ `<!-- plinth-schema-version: ${schemaVersion} -->`,
30107
+ `<!-- Auto-generated by Plinth. ${GENERATED_AT_PREFIX}${GENERATED_AT_PLACEHOLDER}; ` + `that stamp moves only when this file's content changes, so an unchanged file keeps an older one ` + `and is still re-checked against Plinth on every sync. Run \`plinth status\` for when the mirror last synced. ` + `Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
30108
+ ``,
30109
+ `# ${workspaceName} · Plinth workspace`,
30110
+ ``,
30111
+ `Local mirror of the **${workspaceName}** workspace (\`${workspaceSlug}\`). ${SECURITY_NOTE}`,
30112
+ ``,
30113
+ `> **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\`).`,
30114
+ ``,
30115
+ `> **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. **A file over ${humanBytes(MAX_WORKSPACE_FILE_BYTES)} is refused and never reaches Plinth at all**, so its copy stays on this machine only and \`plinth status\` lists it as rejected; that ceiling decides whether the file syncs, and is not the separate limit on how much text is extracted from one. Over it, the second half below stops being the tidy option and becomes the only record that survives. Then put the **original** in the user's own file storage with whatever tool you have for that, and write down where it went in a memory file, routed by the scope rule in your session grounding. 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.`,
30116
+ ``,
30117
+ `## Folder map`,
30118
+ `- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread${emptyNote(folders, ["artists", "threads"])}`,
30119
+ `- \`projects/\` \`tasks/\` \`documents/\` \`meetings/\` workspace-root flat entity files (one \`.md\` per entity, NOT nested under \`artists/\`, so one artist's rows are found by matching \`artist_id:\` in their frontmatter against that artist's own \`id:\` in \`artists/<slug>/_artist.md\`, never by matching the artist's name: a name match returned 16 of one artist's 46 projects and read as "no such project")${emptyNote(folders, ["projects", "tasks", "documents", "meetings"])}`,
30120
+ `- \`files/\` free-form document store: PDFs, executed agreements, spreadsheets, anything that is a FILE rather than an entity. Mirrored byte-for-byte both ways, folders and all${emptyNote(folders, [FILES_SUBTREE_DIR])}`,
30121
+ `- \`${extractedTextRelPath("<path>")}\` where \`<path>\` is the file's path under \`files/\`: the text Plinth extracted from that file, so a PDF's contents can be searched. Not every file has one - text files are already their own text, and extraction can fail. Where it produced junk the file exists and says so instead of carrying it, so read what is there before trusting a silence. Derived: edits here are overwritten and never reach Plinth`,
30122
+ `- \`.claude/commands/\` local skills, incl. generated \`${START_COMMAND_INVOCATION}\` + \`${CLOSE_COMMAND_INVOCATION}\` + \`${ONBOARD_COMMAND_INVOCATION}\` · at the mirror root: \`workspace-memory.md\` \`workspace-rules.md\` (workspace-wide) + \`user-memory.md\` (yours), synced to Plinth · \`voice.md\` (yours: how you write, read by the agent, edited only by you) · \`CLAUDE.local.md\` private, gitignored`,
30123
+ ``,
30124
+ `## Where key context lives: look here before hunting`,
30125
+ `- 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`,
30126
+ `- **Contracts, executed agreements & any PDF** → \`files/\` (search here first; \`documents/\` holds markdown bodies, not attachments)`,
30127
+ `- Workspace-wide **notes, briefs, bios & context docs** → \`documents/\` (one markdown file per doc, shared across artists)`,
30128
+ `- 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 · which fact belongs in which file is stated once in your session grounding · \`artists/<slug>/daily-log.md\` is local scratch, never synced and no longer created; an older one may hold real history, and nothing writes there now`,
30129
+ `- ${MCP_AVAILABILITY_POINTER}`,
30130
+ ``,
30131
+ `## Navigating the corpus`,
30132
+ `- **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`,
30133
+ `- **What does this file link to?** → grep \`[[\` in it`,
30134
+ `- **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_*\``,
30135
+ ``,
30136
+ `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
30137
+ ...entityLines,
30138
+ ``,
30139
+ `## Roster (${roster.length})`,
30140
+ rosterBlock(roster, activeProjectCountBySlug),
30141
+ ``,
30142
+ `## Skills: ${skillCount} discoverable location${skillCount === 1 ? "" : "s"}`,
30143
+ ...skillLines,
30144
+ ``,
30145
+ USER_BLOCK_BEGIN,
30146
+ USER_BLOCK_PLACEHOLDER,
30147
+ USER_BLOCK_END
30148
+ ];
30149
+ return lines.join(`
30150
+ `) + `
30151
+ `;
30152
+ }
30153
+
30154
+ // src/lib/context/generate.ts
30155
+ var CLAUDE_MD = "CLAUDE.md";
30156
+ var CLAUDE_LOCAL_MD = "CLAUDE.local.md";
30157
+ var GITIGNORE = ".gitignore";
30158
+ var GITIGNORE_ENTRIES = [
30159
+ CLAUDE_LOCAL_MD,
30160
+ "**/.claude/settings.json",
30161
+ "**/.claude/settings.local.json"
30162
+ ];
30163
+ var CLAUDE_LOCAL_STUB = "<!-- Per-user overrides for this workspace. Private, gitignored, and " + `never overwritten by Plinth. Run /onboard to populate, or edit freely. -->
30164
+ `;
30165
+ function currentSchemaVersion() {
30166
+ return computeSchemaVersion({
30167
+ templateVersion: TEMPLATE_VERSION,
30168
+ entityCatalog: ENTITY_CATALOG,
30169
+ skillLocations: SKILL_LOCATIONS,
30170
+ securityNote: SECURITY_NOTE,
30171
+ mcpAvailabilityPointer: MCP_AVAILABILITY_POINTER
30172
+ });
30173
+ }
30174
+ function ensureWorkspaceContext(deps) {
30175
+ const { workspaceSlug, workspaceName, force, out, now } = deps;
30176
+ const version2 = currentSchemaVersion();
30177
+ const roster = readRoster(workspaceSlug);
30178
+ const activeProjectCountBySlug = activeProjectCountsBySlug(workspaceSlug, loadState(workspaceSlug));
30179
+ const generated = buildWorkspaceClaudeMd({
30180
+ workspaceName,
30181
+ workspaceSlug,
30182
+ roster,
30183
+ activeProjectCountBySlug,
30184
+ schemaVersion: version2,
30185
+ folders: observeMirrorFolders(workspaceSlug, [
30186
+ ...allEntityTypes(),
30187
+ FILES_SUBTREE_DIR
30188
+ ])
30189
+ });
30190
+ const existing = readContextFile(workspaceSlug, CLAUDE_MD);
30191
+ const decision = decideRegen({
30192
+ existing,
30193
+ currentVersion: "",
30194
+ generated,
30195
+ force,
30196
+ normalise: normaliseGeneratedAt
30197
+ });
30198
+ if (decision.action === "backup-regen" && decision.backup !== undefined) {
30199
+ const bakRel = `${CLAUDE_MD}.bak-${backupStamp3(now())}`;
30200
+ writeContextFile(workspaceSlug, bakRel, decision.backup);
30201
+ out.warn(`${CLAUDE_MD} had no recognisable BEGIN/END markers; backed your copy ` + `up to ${bakRel} before regenerating.`);
30202
+ }
30203
+ if (decision.content !== null) {
30204
+ writeContextFile(workspaceSlug, CLAUDE_MD, renderGeneratedAt(decision.content, now()));
30205
+ }
30206
+ if (!existsSync2(contextFilePath(workspaceSlug, CLAUDE_LOCAL_MD))) {
30207
+ writeContextFile(workspaceSlug, CLAUDE_LOCAL_MD, CLAUDE_LOCAL_STUB);
30208
+ }
30209
+ ensureWorkspaceCommands({ workspaceSlug, force, out, now });
30210
+ ensureGitignore(workspaceSlug);
30211
+ const resolved = resolvePlinthBinary();
30212
+ reportResolution(out, resolved);
30213
+ if (ensureSessionHook(workspaceSlug, "", out, resolved) === "skipped") {
30214
+ deps.onHookSkipped?.();
30215
+ }
30216
+ if (decision.action === "create") {
30217
+ out.info(`Generated ${CLAUDE_MD} (${roster.length} artists).`);
30218
+ } else if (decision.action === "regen" || decision.action === "backup-regen") {
30219
+ out.info(`Refreshed ${CLAUDE_MD} (your edits preserved).`);
30220
+ }
30221
+ }
30222
+ function ensureGitignore(workspaceSlug) {
30223
+ const existing = readContextFile(workspaceSlug, GITIGNORE);
30224
+ const present2 = new Set((existing ?? "").split(`
30225
+ `).map((l) => l.trim()).filter(Boolean));
30226
+ const missing = GITIGNORE_ENTRIES.filter((e) => !present2.has(e));
30227
+ if (missing.length === 0)
30228
+ return;
30229
+ const block = `${missing.join(`
30230
+ `)}
30231
+ `;
30232
+ if (existing === null || existing === "") {
30233
+ writeContextFile(workspaceSlug, GITIGNORE, block);
30234
+ return;
30235
+ }
30236
+ const lead = existing.endsWith(`
30237
+ `) ? "" : `
30238
+ `;
30239
+ writeContextFile(workspaceSlug, GITIGNORE, `${existing}${lead}${block}`);
30240
+ }
30241
+ function backupStamp3(d) {
30242
+ return d.toISOString().replace(/[-:.]/g, "").replace("Z", "");
30243
+ }
30244
+
30245
+ // src/commands/refresh-context.ts
30246
+ init_output();
30247
+ function runRefreshContext(workspaceSlug, deps = {}) {
30248
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor });
30249
+ const now = deps.now ?? (() => new Date);
30250
+ const config2 = loadConfig();
30251
+ if (!config2) {
30252
+ out.error("No workspace configured. Run `plinth login --workspace <slug>` first.");
30253
+ return 1;
30254
+ }
30255
+ const slug = workspaceSlug ?? config2.active_workspace;
30256
+ const entry2 = config2.workspaces[slug];
30257
+ if (!entry2) {
30258
+ out.error(`Workspace "${slug}" not found. Run \`plinth login --workspace ${slug}\` first.`);
30259
+ return 1;
30260
+ }
30261
+ let skipped = 0;
30262
+ const onHookSkipped = () => {
30263
+ skipped++;
30264
+ };
30265
+ try {
30266
+ ensureWorkspaceContext({
30267
+ workspaceSlug: slug,
30268
+ workspaceName: entry2.workspace_name ?? slug,
30269
+ force: true,
30270
+ out,
30271
+ now,
30272
+ onHookSkipped
30273
+ });
30274
+ ensurePerArtistContext({
30275
+ workspaceSlug: slug,
30276
+ force: true,
30277
+ out,
30278
+ now,
30279
+ onHookSkipped
30280
+ });
30281
+ } catch (err) {
30282
+ const msg = err instanceof Error ? err.message : "unknown error";
30283
+ out.error(`Could not refresh context: ${msg}`);
30284
+ return 1;
30285
+ }
30286
+ if (skipped > 0) {
30287
+ out.error(`Context refreshed, but ${skipped} Claude Code hook mount${skipped === 1 ? " was" : "s were"} ` + `SKIPPED — ${skipped === 1 ? "that location is" : "those locations are"} ungrounded and ` + `agent-originated drafts there are UNGATED. See the errors above.`);
30288
+ return 1;
30289
+ }
30290
+ out.success(`Context up to date for ${slug}.`);
30291
+ return 0;
30292
+ }
30293
+ var refreshContextCommand = defineCommand({
29288
30294
  meta: {
29289
30295
  name: "refresh-context",
29290
30296
  description: "Regenerate CLAUDE.md from the current Plinth schema + workspace"
@@ -29930,8 +30936,8 @@ var sessionCommand = defineCommand({
29930
30936
  });
29931
30937
 
29932
30938
  // src/commands/start.ts
29933
- var import_gray_matter12 = __toESM(require_gray_matter(), 1);
29934
- import { readFileSync as readFileSync23, statSync as statSync15 } from "node:fs";
30939
+ var import_gray_matter13 = __toESM(require_gray_matter(), 1);
30940
+ import { existsSync as existsSync8, readFileSync as readFileSync24, statSync as statSync16 } from "node:fs";
29935
30941
 
29936
30942
  // src/lib/daemon-log.ts
29937
30943
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync9, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -29998,7 +31004,7 @@ class DaemonLog {
29998
31004
 
29999
31005
  // src/lib/destructive-gate.ts
30000
31006
  var import_gray_matter7 = __toESM(require_gray_matter(), 1);
30001
- import { readFileSync as readFileSync12, statSync as statSync5 } from "node:fs";
31007
+ import { readFileSync as readFileSync13, statSync as statSync5 } from "node:fs";
30002
31008
  import { basename as basename3 } from "node:path";
30003
31009
  var GUTTING_RATIO_DEFAULT = 0.5;
30004
31010
  var GUTTING_MIN_PRIOR_BYTES = 200;
@@ -30042,7 +31048,7 @@ function classifyDestructive(event, entityType, state, deps = {}) {
30042
31048
  absolutePath: event.absolutePath
30043
31049
  };
30044
31050
  }
30045
- const readFile = deps.readFile ?? ((p) => readFileSync12(p, "utf8"));
31051
+ const readFile = deps.readFile ?? ((p) => readFileSync13(p, "utf8"));
30046
31052
  const statInode = deps.statInode ?? ((p) => {
30047
31053
  try {
30048
31054
  return statSync5(p).ino;
@@ -30081,670 +31087,186 @@ function classifyDestructive(event, entityType, state, deps = {}) {
30081
31087
  newLen
30082
31088
  };
30083
31089
  }
30084
- function defaultThresholds() {
30085
- return {
30086
- absCount: envInt("PLINTH_GATE_ABS_COUNT", 3),
30087
- propMinOps: envInt("PLINTH_GATE_PROP_MIN_OPS", 2),
30088
- propPct: envFloat("PLINTH_GATE_PROP_PCT", 0.5)
30089
- };
30090
- }
30091
- function evaluateBatch(ops, typePopulations, thresholds = defaultThresholds()) {
30092
- const count = ops.length;
30093
- if (count === 0)
30094
- return { tripped: false };
30095
- if (count >= thresholds.absCount) {
30096
- return { tripped: true, reason: { kind: "count", count } };
30097
- }
30098
- const byType = new Map;
30099
- for (const op of ops) {
30100
- byType.set(op.entityType, (byType.get(op.entityType) ?? 0) + 1);
30101
- }
30102
- for (const [entityType, typeCount] of byType) {
30103
- const typePopulation = typePopulations[entityType] ?? typeCount;
30104
- if (typeCount >= thresholds.propMinOps && typePopulation > 0 && typeCount >= thresholds.propPct * typePopulation) {
30105
- return {
30106
- tripped: true,
30107
- reason: {
30108
- kind: "proportion",
30109
- count,
30110
- entityType,
30111
- typeCount,
30112
- typePopulation,
30113
- pct: typeCount / typePopulation
30114
- }
30115
- };
30116
- }
30117
- }
30118
- return { tripped: false };
30119
- }
30120
- function dedupeOps(ops) {
30121
- const byKey = new Map;
30122
- for (const op of ops)
30123
- byKey.set(`${op.entityType}:${op.id}`, op);
30124
- return Array.from(byKey.values());
30125
- }
30126
- var DEFAULT_SETTLE_MS = 600;
30127
- var DEFAULT_MAX_BATCH = 500;
30128
-
30129
- class DestructiveGate {
30130
- pending = [];
30131
- timer = null;
30132
- settleMs;
30133
- maxBatch;
30134
- onSettle;
30135
- onError;
30136
- constructor(opts) {
30137
- this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
30138
- this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH;
30139
- this.onSettle = opts.onSettle;
30140
- this.onError = opts.onError;
30141
- }
30142
- consider(op) {
30143
- this.pending.push(op);
30144
- if (this.pending.length >= this.maxBatch) {
30145
- this.flush();
30146
- return;
30147
- }
30148
- if (this.timer)
30149
- clearTimeout(this.timer);
30150
- this.timer = setTimeout(() => {
30151
- this.flush();
30152
- }, this.settleMs);
30153
- }
30154
- async drain() {
30155
- await this.flush();
30156
- }
30157
- size() {
30158
- return this.pending.length;
30159
- }
30160
- async flush() {
30161
- if (this.timer) {
30162
- clearTimeout(this.timer);
30163
- this.timer = null;
30164
- }
30165
- if (this.pending.length === 0)
30166
- return;
30167
- const batch = this.pending;
30168
- this.pending = [];
30169
- try {
30170
- await this.onSettle(batch);
30171
- } catch (err) {
30172
- this.onError?.(err);
30173
- }
30174
- }
30175
- }
30176
-
30177
- // src/lib/resolve-pending.ts
30178
- import { readFileSync as readFileSync16 } from "node:fs";
30179
-
30180
- // src/lib/conflicts.ts
30181
- import { mkdirSync as mkdirSync10, readdirSync as readdirSync5, statSync as statSync6, writeFileSync as writeFileSync7 } from "node:fs";
30182
- import { dirname as dirname7, join as join11 } from "node:path";
30183
- function conflictsRoot(workspaceSlug) {
30184
- return join11(plinthHome(), "_conflicts", workspaceSlug);
30185
- }
30186
- function conflictFilePath(args) {
30187
- const { workspaceSlug, entityType, entitySlug, timestamp } = args;
30188
- const iso = timestamp.toISOString().replace(/[:.]/g, "-");
30189
- return join11(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
30190
- }
30191
- function stashConflict(args) {
30192
- const target = conflictFilePath(args);
30193
- mkdirSync10(dirname7(target), { recursive: true, mode: 448 });
30194
- writeFileSync7(target, args.contents, { mode: 384 });
30195
- return target;
30196
- }
30197
- function fileConflictPath(args) {
30198
- const { workspaceSlug, relPath, timestamp } = args;
30199
- const iso = timestamp.toISOString().replace(/[:.]/g, "-");
30200
- const safe = relPath.replace(/[/\\]/g, "__");
30201
- return join11(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
30202
- }
30203
- function stashFileConflict(args) {
30204
- const target = fileConflictPath(args);
30205
- mkdirSync10(dirname7(target), { recursive: true, mode: 448 });
30206
- writeFileSync7(target, args.bytes, { mode: 384 });
30207
- return target;
30208
- }
30209
- var STAMP_RE2 = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(\.md)?$/;
30210
- function parseStamp(stamp) {
30211
- const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
30212
- const d = new Date(iso);
30213
- return Number.isNaN(d.getTime()) ? null : d;
30214
- }
30215
- function labelFor(file2) {
30216
- const m = STAMP_RE2.exec(file2);
30217
- if (!m)
30218
- return file2;
30219
- const head = file2.slice(0, m.index);
30220
- if (head.startsWith("files.")) {
30221
- return `files/${head.slice("files.".length).replace(/__/g, "/")}`;
30222
- }
30223
- const dot = head.indexOf(".");
30224
- if (dot === -1)
30225
- return head;
30226
- return `${head.slice(0, dot)}/${head.slice(dot + 1)}`;
30227
- }
30228
- function listConflictStashes(workspaceSlug) {
30229
- const dir = conflictsRoot(workspaceSlug);
30230
- let names;
30231
- try {
30232
- names = readdirSync5(dir);
30233
- } catch {
30234
- return [];
30235
- }
30236
- const out = [];
30237
- for (const file2 of names) {
30238
- const absPath = join11(dir, file2);
30239
- let st;
30240
- try {
30241
- st = statSync6(absPath);
30242
- } catch {
30243
- continue;
30244
- }
30245
- if (!st.isFile())
30246
- continue;
30247
- if (file2.startsWith("."))
30248
- continue;
30249
- const m = STAMP_RE2.exec(file2);
30250
- out.push({
30251
- file: file2,
30252
- absPath,
30253
- label: labelFor(file2),
30254
- stashedAt: m ? parseStamp(m[1]) : null,
30255
- mtimeMs: st.mtimeMs
30256
- });
30257
- }
30258
- return out.sort((a, b) => (b.stashedAt?.getTime() ?? b.mtimeMs) - (a.stashedAt?.getTime() ?? a.mtimeMs));
30259
- }
30260
-
30261
- // src/lib/files/push.ts
30262
- import { readFileSync as readFileSync13, statSync as statSync7 } from "node:fs";
30263
-
30264
- // src/lib/log-tokens.ts
30265
- function categorizeError(err) {
30266
- if (err instanceof FrontmatterValidationError) {
30267
- return { status: 422, reason: "invalid_frontmatter", detail: err.detail };
30268
- }
30269
- if (err instanceof ApiError) {
30270
- const { status } = err;
30271
- if (status === 0)
30272
- return { status, reason: "network" };
30273
- if (status === 401 || status === 403)
30274
- return { status, reason: "auth" };
30275
- if (status === 429)
30276
- return { status, reason: "rate_limit" };
30277
- if (status === 400 || status === 422) {
30278
- const serverMessage = sanitizeServerMessage(err.message);
30279
- return serverMessage ? { status, reason: "bad_request", serverMessage } : { status, reason: "bad_request" };
30280
- }
30281
- if (status >= 500 && status <= 599)
30282
- return { status, reason: "server" };
30283
- return { status, reason: "http" };
30284
- }
30285
- return { status: 0, reason: "unexpected" };
30286
- }
30287
- function sanitizeServerMessage(message) {
30288
- const oneLine = message.replace(/\s+/g, " ").trim();
30289
- if (oneLine.length === 0)
30290
- return "";
30291
- return oneLine.length > 200 ? `${oneLine.slice(0, 197)}...` : oneLine;
30292
- }
30293
- function pushFailedLine(f) {
30294
- const idPart = f.id ? ` id=${f.id}` : "";
30295
- const detailPart = f.detail ? ` detail=${f.detail}` : "";
30296
- const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
30297
- return `[PUSH_FAILED entityType=${f.entityType} slug=${f.slug}${idPart} status=${f.status} reason=${f.reason}${detailPart}${serverPart}]`;
30298
- }
30299
- function pullFailedLine(f) {
30300
- return `[PULL_FAILED status=${f.status} reason=${f.reason}]`;
30301
- }
30302
- function contextDocConflictLine(f) {
30303
- const localIdPart = f.localId ? ` localId=${f.localId}` : "";
30304
- const reasonPart = f.reason ? ` reason=${f.reason}` : "";
30305
- const stashPart = f.stash ? ` stash=${f.stash}` : "";
30306
- return `[CONTEXT_DOC_CONFLICT entityType=${f.entityType} localSlug=${f.localSlug}${localIdPart} ` + `existingSlug=${f.existingSlug} existingId=${f.existingId} scope=${f.scope} ` + `action=${f.action}${reasonPart}${stashPart}]`;
30307
- }
30308
- function syncErrorLine(f) {
30309
- return `[SYNC_ERROR entityType=${f.entityType} slug=${f.slug} id=${f.id} ` + `fields=${f.fields.join(",")} pushed=${f.pushed}]`;
30310
- }
30311
- function filePushLine(f) {
30312
- return `[FILE_PUSH path=${f.path} id=${f.id} action=${f.action}]`;
30313
- }
30314
- function fileDeleteLine(f) {
30315
- const notePart = f.note ? ` note=${f.note}` : "";
30316
- return `[FILE_DELETE path=${f.path} id=${f.id}${notePart}]`;
30317
- }
30318
- function fileOverCapLine(f) {
30319
- return `[FILE_OVER_CAP path=${f.path} size=${f.size} max=${f.max} max_from=${f.source}]`;
30320
- }
30321
- function fileConflictLine(f) {
30322
- return `[FILE_CONFLICT path=${f.path} stash=${f.stash}]`;
30323
- }
30324
- function conflictsViewFailedLine(f) {
30325
- const msg = f.message === undefined ? "" : sanitizeServerMessage(f.message);
30326
- const msgPart = msg === "" ? "" : ` message="${msg.replace(/"/g, "'")}"`;
30327
- return `[CONFLICTS_VIEW_FAILED path=${f.path} reason=${f.reason}${msgPart}]`;
30328
- }
30329
- function fileFailedLine(f) {
30330
- const detailPart = f.detail ? ` detail=${f.detail}` : "";
30331
- const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
30332
- return `[FILE_PUSH_FAILED path=${f.path} status=${f.status} reason=${f.reason}${detailPart}${serverPart}]`;
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
- }
30337
- function pullNoticeLine(kind) {
30338
- const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
30339
- return `[${token} reason=${kind}]`;
30340
- }
30341
- function calendarProjectedLine(f) {
30342
- return `[CALENDAR_PROJECTED written=${f.written} pruned=${f.pruned} ` + `calendars=${f.calendars} not_read=${f.notRead}]`;
30343
- }
30344
- function calendarFailedLine(f) {
30345
- const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
30346
- return `[CALENDAR_FAILED status=${f.status} reason=${f.reason} staled=${f.staled}${serverPart}]`;
30347
- }
30348
- function calendarNoticeLine(kind) {
30349
- const token = kind === "auth" ? "CALENDAR_PAUSED" : kind === "unsupported" ? "CALENDAR_UNSUPPORTED" : "CALENDAR_DEGRADED";
30350
- return `[${token} reason=${kind}]`;
30351
- }
30352
-
30353
- // src/lib/rejected.ts
30354
- import { relative as relative3 } from "node:path";
30355
- var PERMANENT_REJECT_REASONS = new Set(["bad_request", "invalid_frontmatter"]);
30356
- function isPermanentReject(err) {
30357
- return PERMANENT_REJECT_REASONS.has(categorizeError(err).reason);
30358
- }
30359
- function persist(state, next) {
30360
- if (!next.changed)
30361
- return state;
30362
- return commitState(state, next.state);
30363
- }
30364
- function recordEntityRejected(args) {
30365
- const cat = categorizeError(args.err);
30366
- let path5;
30367
- try {
30368
- path5 = relative3(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
30369
- } catch {
30370
- path5 = args.absolutePath;
30371
- }
30372
- const withoutStale = removeUnsynced(args.state, "entity", path5).state;
30373
- return persist(withoutStale, upsertRejected(withoutStale, {
30374
- target: "entity",
30375
- op: args.op,
30376
- path: path5,
30377
- status: cat.status,
30378
- reason: cat.reason,
30379
- detail: cat.detail,
30380
- message: cat.serverMessage,
30381
- rejected_at: args.now().toISOString()
30382
- }));
30383
- }
30384
- function recordFileRejected(args) {
30385
- const cat = categorizeError(args.err);
30386
- const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
30387
- return persist(withoutStale, upsertRejected(withoutStale, {
30388
- target: "file",
30389
- op: args.op,
30390
- path: args.relPath,
30391
- status: cat.status,
30392
- reason: cat.reason,
30393
- detail: cat.detail,
30394
- message: cat.serverMessage,
30395
- rejected_at: args.now().toISOString()
30396
- }));
30397
- }
30398
- function humanBytes(bytes) {
30399
- if (bytes >= 1e6)
30400
- return `${(bytes / 1e6).toFixed(1)} MB`;
30401
- if (bytes >= 1000)
30402
- return `${Math.round(bytes / 1000)} KB`;
30403
- return `${bytes} bytes`;
30404
- }
30405
- function recordFileOverCap(args) {
30406
- const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
30407
- const ceiling = args.source === "server" ? `the ${humanBytes(args.max)} server limit` : `the ${humanBytes(args.max)} upload ceiling (measured platform limit)`;
30408
- const message = args.tracked ? `Your local change to this file (${humanBytes(args.size)}) exceeds ${ceiling}, ` + `so it cannot be pushed. The copy in Plinth is unchanged; your edit is on disk here only.` : `${humanBytes(args.size)} exceeds ${ceiling}. The file is still on disk ` + `here but is not in Plinth; shrink or split it to sync it.`;
30409
- return persist(withoutStale, upsertRejected(withoutStale, {
30410
- target: "file",
30411
- op: "upsert",
30412
- path: args.relPath,
30413
- status: 413,
30414
- reason: "over_cap",
30415
- message,
30416
- rejected_at: args.now().toISOString()
30417
- }));
30418
- }
30419
- function recordFileSymlinkRefused(args) {
30420
- const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
30421
- return persist(withoutStale, upsertRejected(withoutStale, {
30422
- target: "file",
30423
- op: args.op,
30424
- path: args.relPath,
30425
- status: 0,
30426
- reason: "symlink",
30427
- message: `A symlink on the path to files/${args.relPath} means syncing it would ` + `read or write outside your Plinth folder, so it was left alone. ` + `Replace the link with a real folder or file to sync it.`,
30428
- rejected_at: args.now().toISOString()
30429
- }));
30430
- }
30431
- function recordEntitySymlinkRefused(args) {
30432
- const path5 = args.mirrorRelativePath;
30433
- const withoutStale = removeUnsynced(args.state, "entity", path5).state;
30434
- return persist(withoutStale, upsertRejected(withoutStale, {
30435
- target: "entity",
30436
- op: args.op,
30437
- path: path5,
30438
- status: 0,
30439
- reason: "symlink",
30440
- message: `A symlink on the path to ${path5} means syncing it would read or write ` + `outside your Plinth folder, so it was left alone. Replace the link ` + `with a real folder or file to sync it.`,
30441
- rejected_at: args.now().toISOString()
30442
- }));
30443
- }
30444
- function clearSymlinkRefused(state, target, path5) {
30445
- const list = state.rejected ?? [];
30446
- const next = list.filter((o) => !(o.target === target && o.path === path5 && o.reason === "symlink"));
30447
- if (next.length === list.length)
30448
- return state;
30449
- return commitState(state, { ...state, rejected: next });
30450
- }
30451
- function clearEntityRejected(state, workspaceSlug, absolutePath) {
30452
- let path5;
30453
- try {
30454
- path5 = relative3(workspaceMirrorPath(workspaceSlug), absolutePath);
30455
- } catch {
30456
- path5 = absolutePath;
30457
- }
30458
- const next = removeRejected(state, "entity", path5);
30459
- if (!next.changed)
30460
- return state;
30461
- return commitState(state, next.state);
30462
- }
30463
- function clearFileRejected(state, relPath) {
30464
- const next = removeRejected(state, "file", relPath);
30465
- if (!next.changed)
30466
- return state;
30467
- return commitState(state, next.state);
30468
- }
30469
-
30470
- // src/lib/files/api.ts
30471
- var MAX_WORKSPACE_FILE_BYTES = 4000000;
30472
- var HASH_RE = /^[0-9a-f]{64}$/;
30473
- var TextQualitySchema = exports_external.enum(["ok", "partial", "garbled"]);
30474
- var FileLiveSchema = exports_external.object({
30475
- id: exports_external.string().uuid(),
30476
- path: exports_external.string().min(1),
30477
- mime_type: exports_external.string(),
30478
- size_bytes: exports_external.number().int().nonnegative(),
30479
- content_hash: exports_external.string().regex(HASH_RE),
30480
- artist_id: exports_external.string().uuid().nullable(),
30481
- project_id: exports_external.string().uuid().nullable(),
30482
- updated_at: exports_external.string().datetime({ offset: true }),
30483
- deleted_at: exports_external.null(),
30484
- last_edited_by: exports_external.string().uuid().nullable(),
30485
- content_quality: TextQualitySchema.nullable().optional().default(null).catch(null),
30486
- has_extracted_text: exports_external.boolean().optional().catch(undefined),
30487
- has_stored_bytes: exports_external.boolean().optional().catch(undefined)
30488
- });
30489
- function storedBytesSignal(row) {
30490
- if (row.has_stored_bytes === true)
30491
- return "present";
30492
- if (row.has_stored_bytes === false)
30493
- return "absent";
30494
- return "unknown";
30495
- }
30496
- function extractedTextSignal(row) {
30497
- if (row.has_extracted_text === true)
30498
- return "present";
30499
- if (row.has_extracted_text === false)
30500
- return "absent";
30501
- return "unknown";
30502
- }
30503
- var FileTombstoneSchema = exports_external.object({
30504
- id: exports_external.string().uuid(),
30505
- path: exports_external.string().min(1),
30506
- updated_at: exports_external.string().datetime({ offset: true }),
30507
- deleted_at: exports_external.string().datetime({ offset: true })
30508
- });
30509
- var FileRowSchema = exports_external.union([FileLiveSchema, FileTombstoneSchema]);
30510
- var FilesPageSchema = exports_external.object({
30511
- files: exports_external.array(FileRowSchema),
30512
- next_cursor: exports_external.string().nullable()
30513
- });
30514
- var FileWithDownloadSchema = FileLiveSchema.extend({
30515
- download_url: exports_external.string().url().nullable()
30516
- });
30517
- var FileTextSchema = exports_external.object({
30518
- id: exports_external.string().uuid(),
30519
- path: exports_external.string().min(1),
30520
- mime_type: exports_external.string(),
30521
- updated_at: exports_external.string().datetime({ offset: true }),
30522
- content_quality: TextQualitySchema.nullable(),
30523
- source_uri: exports_external.string().nullable(),
30524
- text: exports_external.string().nullable(),
30525
- chars: exports_external.number().int().nonnegative(),
30526
- withheld: exports_external.enum(["bytes_are_text", "no_text"]).nullable()
30527
- });
30528
- var PreconditionSchema = exports_external.object({
30529
- code: exports_external.literal("precondition_failed"),
30530
- current_updated_at: exports_external.string()
30531
- });
30532
- function baseHeaders(pat) {
30533
- return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
30534
- }
30535
- async function syncFilesPage(args) {
30536
- const { baseUrl, pat, cursor, limit, fetchImpl = fetch } = args;
30537
- const params = new URLSearchParams;
30538
- if (cursor)
30539
- params.set("since", cursor);
30540
- if (limit)
30541
- params.set("limit", String(limit));
30542
- const qs = params.toString();
30543
- const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files` + (qs.length > 0 ? `?${qs}` : "");
30544
- let res;
30545
- try {
30546
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
30547
- } catch (err) {
30548
- const msg = err instanceof Error ? err.message : "network error";
30549
- throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
30550
- }
30551
- if (res.status === 401) {
30552
- throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
30553
- }
30554
- if (res.status === 429) {
30555
- throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
30556
- }
30557
- if (!res.ok) {
30558
- throw new ApiError(res.status, `sync files failed with HTTP ${res.status}`);
30559
- }
30560
- const body = await res.json();
30561
- const parsed = FilesPageSchema.safeParse(body);
30562
- if (!parsed.success) {
30563
- throw new ApiError(res.status, `sync files response did not match the expected shape: ${parsed.error.message}`);
30564
- }
30565
- return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
30566
- }
30567
- async function getFile(args) {
30568
- const { baseUrl, pat, id, fetchImpl = fetch } = args;
30569
- const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
30570
- let res;
30571
- try {
30572
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
30573
- } catch (err) {
30574
- const msg = err instanceof Error ? err.message : "network error";
30575
- throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
30576
- }
30577
- if (res.status === 404)
30578
- return { ok: false, code: 404 };
30579
- if (res.status === 401) {
30580
- throw new ApiError(401, "Token was rejected by the server.");
30581
- }
30582
- if (res.status === 429) {
30583
- throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
30584
- }
30585
- if (!res.ok) {
30586
- throw new ApiError(res.status, `get file failed with HTTP ${res.status}`);
30587
- }
30588
- const body = await res.json();
30589
- const parsed = FileWithDownloadSchema.safeParse(body);
30590
- if (!parsed.success) {
30591
- throw new ApiError(res.status, `get file response did not match the expected shape`);
30592
- }
30593
- return { ok: true, row: parsed.data };
30594
- }
30595
- async function getFileText(args) {
30596
- const { baseUrl, pat, id, fetchImpl = fetch } = args;
30597
- const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}/text`;
30598
- let res;
30599
- try {
30600
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
30601
- } catch (err) {
30602
- const msg = err instanceof Error ? err.message : "network error";
30603
- throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
30604
- }
30605
- if (res.status === 404)
30606
- return { ok: false, code: 404 };
30607
- if (res.status === 401) {
30608
- throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
30609
- }
30610
- if (res.status === 429) {
30611
- throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
31090
+ function defaultThresholds() {
31091
+ return {
31092
+ absCount: envInt("PLINTH_GATE_ABS_COUNT", 3),
31093
+ propMinOps: envInt("PLINTH_GATE_PROP_MIN_OPS", 2),
31094
+ propPct: envFloat("PLINTH_GATE_PROP_PCT", 0.5)
31095
+ };
31096
+ }
31097
+ function evaluateBatch(ops, typePopulations, thresholds = defaultThresholds()) {
31098
+ const count = ops.length;
31099
+ if (count === 0)
31100
+ return { tripped: false };
31101
+ if (count >= thresholds.absCount) {
31102
+ return { tripped: true, reason: { kind: "count", count } };
30612
31103
  }
30613
- if (!res.ok) {
30614
- throw new ApiError(res.status, `get file text failed with HTTP ${res.status}`);
31104
+ const byType = new Map;
31105
+ for (const op of ops) {
31106
+ byType.set(op.entityType, (byType.get(op.entityType) ?? 0) + 1);
30615
31107
  }
30616
- const body = await res.json();
30617
- const parsed = FileTextSchema.safeParse(body);
30618
- if (!parsed.success) {
30619
- throw new ApiError(res.status, `get file text response did not match the expected shape: ${parsed.error.message}`);
31108
+ for (const [entityType, typeCount] of byType) {
31109
+ const typePopulation = typePopulations[entityType] ?? typeCount;
31110
+ if (typeCount >= thresholds.propMinOps && typePopulation > 0 && typeCount >= thresholds.propPct * typePopulation) {
31111
+ return {
31112
+ tripped: true,
31113
+ reason: {
31114
+ kind: "proportion",
31115
+ count,
31116
+ entityType,
31117
+ typeCount,
31118
+ typePopulation,
31119
+ pct: typeCount / typePopulation
31120
+ }
31121
+ };
31122
+ }
30620
31123
  }
30621
- return { ok: true, row: parsed.data };
31124
+ return { tripped: false };
30622
31125
  }
30623
- async function downloadBytes(downloadUrl, fetchImpl = fetch) {
30624
- let res;
30625
- try {
30626
- res = await fetchImpl(downloadUrl, { method: "GET" });
30627
- } catch (err) {
30628
- const msg = err instanceof Error ? err.message : "network error";
30629
- throw new ApiError(0, `Could not download bytes: ${msg}`);
30630
- }
30631
- if (!res.ok) {
30632
- throw new ApiError(res.status, `download failed with HTTP ${res.status}`);
30633
- }
30634
- const ab = await res.arrayBuffer();
30635
- return Buffer.from(ab);
31126
+ function dedupeOps(ops) {
31127
+ const byKey = new Map;
31128
+ for (const op of ops)
31129
+ byKey.set(`${op.entityType}:${op.id}`, op);
31130
+ return Array.from(byKey.values());
30636
31131
  }
30637
- async function postFile(args) {
30638
- const {
30639
- baseUrl,
30640
- pat,
30641
- relPath,
30642
- bytes,
30643
- contentHash,
30644
- mimeType,
30645
- expectedUpdatedAt,
30646
- fetchImpl = fetch
30647
- } = args;
30648
- const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files`;
30649
- const headers = {
30650
- Authorization: `Bearer ${pat}`,
30651
- Accept: "application/json",
30652
- "Content-Type": mimeType,
30653
- "X-Plinth-File-Path": encodeURIComponent(relPath),
30654
- "X-Plinth-Content-Hash": contentHash
30655
- };
30656
- if (expectedUpdatedAt)
30657
- headers["X-Plinth-Expected-Updated-At"] = expectedUpdatedAt;
30658
- let res;
30659
- try {
30660
- res = await fetchImpl(url2, { method: "POST", headers, body: bytes });
30661
- } catch (err) {
30662
- const msg = err instanceof Error ? err.message : "network error";
30663
- throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
31132
+ var DEFAULT_SETTLE_MS = 600;
31133
+ var DEFAULT_MAX_BATCH = 500;
31134
+
31135
+ class DestructiveGate {
31136
+ pending = [];
31137
+ timer = null;
31138
+ settleMs;
31139
+ maxBatch;
31140
+ onSettle;
31141
+ onError;
31142
+ constructor(opts) {
31143
+ this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
31144
+ this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH;
31145
+ this.onSettle = opts.onSettle;
31146
+ this.onError = opts.onError;
30664
31147
  }
30665
- if (res.status === 200 || res.status === 201) {
30666
- const body = await res.json();
30667
- const parsed = FileLiveSchema.safeParse(body);
30668
- if (!parsed.success) {
30669
- throw new ApiError(res.status, `post file response did not match the expected shape`);
31148
+ consider(op) {
31149
+ this.pending.push(op);
31150
+ if (this.pending.length >= this.maxBatch) {
31151
+ this.flush();
31152
+ return;
30670
31153
  }
30671
- return { ok: true, row: parsed.data };
31154
+ if (this.timer)
31155
+ clearTimeout(this.timer);
31156
+ this.timer = setTimeout(() => {
31157
+ this.flush();
31158
+ }, this.settleMs);
30672
31159
  }
30673
- if (res.status === 413) {
30674
- let maxBytes = null;
30675
- try {
30676
- const json2 = await res.json();
30677
- if (typeof json2.max_bytes === "number")
30678
- maxBytes = json2.max_bytes;
30679
- } catch {}
30680
- return { ok: false, code: 413, maxBytes };
31160
+ async drain() {
31161
+ await this.flush();
30681
31162
  }
30682
- if (res.status === 409) {
30683
- const json2 = await res.json();
30684
- const parsed = PreconditionSchema.safeParse(json2);
30685
- if (!parsed.success) {
30686
- throw new ApiError(409, `post file 409 body did not match precondition_failed shape`);
30687
- }
30688
- return { ok: false, code: 409, currentUpdatedAt: parsed.data.current_updated_at };
31163
+ size() {
31164
+ return this.pending.length;
30689
31165
  }
30690
- if (res.status === 400) {
30691
- let code;
31166
+ async flush() {
31167
+ if (this.timer) {
31168
+ clearTimeout(this.timer);
31169
+ this.timer = null;
31170
+ }
31171
+ if (this.pending.length === 0)
31172
+ return;
31173
+ const batch = this.pending;
31174
+ this.pending = [];
30692
31175
  try {
30693
- const json2 = await res.json();
30694
- code = json2.code;
30695
- } catch {}
30696
- if (code === "hash_mismatch")
30697
- return { ok: false, code: 400, reason: "hash_mismatch" };
30698
- throw new ApiError(400, "post file rejected as bad request");
30699
- }
30700
- if (res.status === 401) {
30701
- throw new ApiError(401, "Token was rejected by the server.");
31176
+ await this.onSettle(batch);
31177
+ } catch (err) {
31178
+ this.onError?.(err);
31179
+ }
30702
31180
  }
30703
- if (res.status === 429) {
30704
- throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
31181
+ }
31182
+
31183
+ // src/lib/resolve-pending.ts
31184
+ import { readFileSync as readFileSync17 } from "node:fs";
31185
+
31186
+ // src/lib/conflicts.ts
31187
+ import { mkdirSync as mkdirSync10, readdirSync as readdirSync5, statSync as statSync6, writeFileSync as writeFileSync7 } from "node:fs";
31188
+ import { dirname as dirname7, join as join11 } from "node:path";
31189
+ function conflictsRoot(workspaceSlug) {
31190
+ return join11(plinthHome(), "_conflicts", workspaceSlug);
31191
+ }
31192
+ function conflictFilePath(args) {
31193
+ const { workspaceSlug, entityType, entitySlug, timestamp } = args;
31194
+ const iso = timestamp.toISOString().replace(/[:.]/g, "-");
31195
+ return join11(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
31196
+ }
31197
+ function stashConflict(args) {
31198
+ const target = conflictFilePath(args);
31199
+ mkdirSync10(dirname7(target), { recursive: true, mode: 448 });
31200
+ writeFileSync7(target, args.contents, { mode: 384 });
31201
+ return target;
31202
+ }
31203
+ function fileConflictPath(args) {
31204
+ const { workspaceSlug, relPath, timestamp } = args;
31205
+ const iso = timestamp.toISOString().replace(/[:.]/g, "-");
31206
+ const safe = relPath.replace(/[/\\]/g, "__");
31207
+ return join11(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
31208
+ }
31209
+ function stashFileConflict(args) {
31210
+ const target = fileConflictPath(args);
31211
+ mkdirSync10(dirname7(target), { recursive: true, mode: 448 });
31212
+ writeFileSync7(target, args.bytes, { mode: 384 });
31213
+ return target;
31214
+ }
31215
+ var STAMP_RE2 = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(\.md)?$/;
31216
+ function parseStamp(stamp) {
31217
+ const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
31218
+ const d = new Date(iso);
31219
+ return Number.isNaN(d.getTime()) ? null : d;
31220
+ }
31221
+ function labelFor(file2) {
31222
+ const m = STAMP_RE2.exec(file2);
31223
+ if (!m)
31224
+ return file2;
31225
+ const head = file2.slice(0, m.index);
31226
+ if (head.startsWith("files.")) {
31227
+ return `files/${head.slice("files.".length).replace(/__/g, "/")}`;
30705
31228
  }
30706
- throw new ApiError(res.status, `post file failed with HTTP ${res.status}`);
31229
+ const dot = head.indexOf(".");
31230
+ if (dot === -1)
31231
+ return head;
31232
+ return `${head.slice(0, dot)}/${head.slice(dot + 1)}`;
30707
31233
  }
30708
- async function deleteFile(args) {
30709
- const { baseUrl, pat, id, expectedUpdatedAt, fetchImpl = fetch } = args;
30710
- const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
30711
- let res;
31234
+ function listConflictStashes(workspaceSlug) {
31235
+ const dir = conflictsRoot(workspaceSlug);
31236
+ let names;
30712
31237
  try {
30713
- res = await fetchImpl(url2, {
30714
- method: "DELETE",
30715
- headers: { ...baseHeaders(pat), "X-Plinth-Expected-Updated-At": expectedUpdatedAt }
30716
- });
30717
- } catch (err) {
30718
- const msg = err instanceof Error ? err.message : "network error";
30719
- throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
30720
- }
30721
- if (res.status === 200) {
30722
- const body = await res.json();
30723
- const parsed = FileTombstoneSchema.safeParse(body);
30724
- if (!parsed.success) {
30725
- throw new ApiError(200, `delete file response did not match the expected tombstone shape`);
30726
- }
30727
- return { ok: true, tombstone: parsed.data };
31238
+ names = readdirSync5(dir);
31239
+ } catch {
31240
+ return [];
30728
31241
  }
30729
- if (res.status === 404)
30730
- return { ok: false, code: 404 };
30731
- if (res.status === 409) {
30732
- const json2 = await res.json();
30733
- const parsed = PreconditionSchema.safeParse(json2);
30734
- if (!parsed.success) {
30735
- throw new ApiError(409, `delete file 409 body did not match precondition_failed shape`);
31242
+ const out = [];
31243
+ for (const file2 of names) {
31244
+ const absPath = join11(dir, file2);
31245
+ let st;
31246
+ try {
31247
+ st = statSync6(absPath);
31248
+ } catch {
31249
+ continue;
30736
31250
  }
30737
- return { ok: false, code: 409, currentUpdatedAt: parsed.data.current_updated_at };
30738
- }
30739
- if (res.status === 401) {
30740
- throw new ApiError(401, "Token was rejected by the server.");
30741
- }
30742
- if (res.status === 429) {
30743
- throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
31251
+ if (!st.isFile())
31252
+ continue;
31253
+ if (file2.startsWith("."))
31254
+ continue;
31255
+ const m = STAMP_RE2.exec(file2);
31256
+ out.push({
31257
+ file: file2,
31258
+ absPath,
31259
+ label: labelFor(file2),
31260
+ stashedAt: m ? parseStamp(m[1]) : null,
31261
+ mtimeMs: st.mtimeMs
31262
+ });
30744
31263
  }
30745
- throw new ApiError(res.status, `delete file failed with HTTP ${res.status}`);
31264
+ return out.sort((a, b) => (b.stashedAt?.getTime() ?? b.mtimeMs) - (a.stashedAt?.getTime() ?? a.mtimeMs));
30746
31265
  }
30747
31266
 
31267
+ // src/lib/files/push.ts
31268
+ import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
31269
+
30748
31270
  // src/lib/files/content-hash.ts
30749
31271
  import { createHash as createHash2 } from "node:crypto";
30750
31272
  function sha256Bytes(bytes) {
@@ -30899,7 +31421,7 @@ async function pushFilesUpsert(args) {
30899
31421
  state: next
30900
31422
  };
30901
31423
  }
30902
- bytes = readFileSync13(absolutePath);
31424
+ bytes = readFileSync14(absolutePath);
30903
31425
  hash2 = sha256Bytes(bytes);
30904
31426
  inode = st.ino;
30905
31427
  }
@@ -31067,15 +31589,60 @@ class PerIdMutex {
31067
31589
  }
31068
31590
 
31069
31591
  // src/lib/push.ts
31070
- var import_gray_matter9 = __toESM(require_gray_matter(), 1);
31071
- import { readFileSync as readFileSync15, statSync as statSync8 } from "node:fs";
31592
+ var import_gray_matter10 = __toESM(require_gray_matter(), 1);
31593
+ import { readFileSync as readFileSync16, statSync as statSync8 } from "node:fs";
31072
31594
  import { basename as basename4, relative as relative4 } from "node:path";
31073
31595
 
31074
- // src/lib/pull-write.ts
31596
+ // src/lib/frontmatter-id.ts
31075
31597
  var import_gray_matter8 = __toESM(require_gray_matter(), 1);
31076
- import { readFileSync as readFileSync14 } from "node:fs";
31598
+ function eolOf(content) {
31599
+ return /\r\n/.test(content) ? `\r
31600
+ ` : `
31601
+ `;
31602
+ }
31603
+ function spliceFrontmatterId(content, id) {
31604
+ const eol = /^---\r\n/.test(content) ? `\r
31605
+ ` : `
31606
+ `;
31607
+ if (!content.startsWith(`---${eol}`))
31608
+ return null;
31609
+ const closeAt = content.indexOf(`${eol}---`, 3);
31610
+ if (closeAt === -1)
31611
+ return null;
31612
+ return `${content.slice(0, closeAt)}${eol}id: ${id}${content.slice(closeAt)}`;
31613
+ }
31614
+ function prependFrontmatterBlock(content, id) {
31615
+ const eol = eolOf(content);
31616
+ return `---${eol}id: ${id}${eol}---${eol}${content}`;
31617
+ }
31618
+ function isFrontmatterless(raw, parsed) {
31619
+ return parsed.content === raw;
31620
+ }
31621
+ function withFrontmatterId(raw, parsed, id) {
31622
+ if (parsed.data?.["id"] !== undefined) {
31623
+ return null;
31624
+ }
31625
+ const spliced = spliceFrontmatterId(raw, id);
31626
+ if (spliced !== null)
31627
+ return { content: spliced, blockCreated: false };
31628
+ if (!isFrontmatterless(raw, parsed))
31629
+ return null;
31630
+ return { content: prependFrontmatterBlock(raw, id), blockCreated: true };
31631
+ }
31632
+ function frontmatterIdWriteIsSound(next, id, expectedBody) {
31633
+ try {
31634
+ const check2 = import_gray_matter8.default(next);
31635
+ return check2.data?.["id"] === id && check2.content === expectedBody;
31636
+ } catch {
31637
+ return false;
31638
+ }
31639
+ }
31640
+
31641
+ // src/lib/pull-write.ts
31642
+ var import_gray_matter9 = __toESM(require_gray_matter(), 1);
31643
+ import { readFileSync as readFileSync15 } from "node:fs";
31077
31644
  function computeMirrorHashes(strategy, content) {
31078
- const parsed = import_gray_matter8.default(content);
31645
+ const parsed = import_gray_matter9.default(content);
31079
31646
  const fm = parsed.data ?? {};
31080
31647
  const lifted = splitUserFrontmatter(strategy, fm);
31081
31648
  const userFm = userFrontmatter(strategy, lifted.frontmatter);
@@ -31083,7 +31650,8 @@ function computeMirrorHashes(strategy, content) {
31083
31650
  return {
31084
31651
  bodyHash: stableHash(hashBody),
31085
31652
  frontmatterHash: stableHash({ ...userFm, ...lifted.topLevel }),
31086
- bodyLen: hashBody.length
31653
+ bodyLen: hashBody.length,
31654
+ frontmatterId: fm["id"]
31087
31655
  };
31088
31656
  }
31089
31657
  function hashableBody(strategy, body) {
@@ -31095,15 +31663,15 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
31095
31663
  const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
31096
31664
  let existing = null;
31097
31665
  try {
31098
- existing = readFileSync14(target, "utf8");
31666
+ existing = readFileSync15(target, "utf8");
31099
31667
  } catch (err) {
31100
31668
  if (err?.code !== "ENOENT")
31101
31669
  throw err;
31102
31670
  }
31103
31671
  if (existing !== null) {
31104
- const existingParsed = import_gray_matter8.default(existing);
31105
- const cloudParsed = import_gray_matter8.default(content);
31106
- const stitched = import_gray_matter8.default.stringify(existingParsed.content, cloudParsed.data);
31672
+ const existingParsed = import_gray_matter9.default(existing);
31673
+ const cloudParsed = import_gray_matter9.default(content);
31674
+ const stitched = import_gray_matter9.default.stringify(existingParsed.content, cloudParsed.data);
31107
31675
  content = stitched.endsWith(`
31108
31676
  `) ? stitched : stitched + `
31109
31677
  `;
@@ -31222,8 +31790,8 @@ function cloudMatchesAcked(strategy, cloud, acked) {
31222
31790
  function stampStateFromMirror(args) {
31223
31791
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
31224
31792
  const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
31225
- const mirrorRaw = readFileSync15(mirrorPath, "utf8");
31226
- const mirrorParsed = import_gray_matter9.default(mirrorRaw);
31793
+ const mirrorRaw = readFileSync16(mirrorPath, "utf8");
31794
+ const mirrorParsed = import_gray_matter10.default(mirrorRaw);
31227
31795
  const mirrorFm = mirrorParsed.data ?? {};
31228
31796
  const mirrorLifted = splitUserFrontmatter(strategy, mirrorFm);
31229
31797
  const mirrorUserFm = userFrontmatter(strategy, mirrorLifted.frontmatter);
@@ -31275,6 +31843,7 @@ async function pushFile(args) {
31275
31843
  const now = deps?.now ?? (() => new Date);
31276
31844
  const mutex = deps?.mutex ?? new PerIdMutex;
31277
31845
  const output = deps?.output;
31846
+ const suppressor = deps?.suppressor;
31278
31847
  const strategy = getStrategy(entityType);
31279
31848
  if (!strategy) {
31280
31849
  return {
@@ -31302,7 +31871,7 @@ async function pushFile(args) {
31302
31871
  }
31303
31872
  let raw;
31304
31873
  try {
31305
- raw = readFileSync15(absolutePath, "utf8");
31874
+ raw = readFileSync16(absolutePath, "utf8");
31306
31875
  } catch (err) {
31307
31876
  if (err?.code === "ENOENT") {
31308
31877
  return runPushDelete({
@@ -31322,7 +31891,7 @@ async function pushFile(args) {
31322
31891
  }
31323
31892
  let parsed;
31324
31893
  try {
31325
- parsed = import_gray_matter9.default(raw);
31894
+ parsed = import_gray_matter10.default(raw);
31326
31895
  } catch {
31327
31896
  return {
31328
31897
  outcome: { kind: "ignored", absolutePath, reason: "frontmatter parse error" },
@@ -31350,14 +31919,14 @@ async function pushFile(args) {
31350
31919
  if (strategy.classifyFieldErrors) {
31351
31920
  const desiredBody = spliceSyncErrorBlock(parsed.content, fieldErrors);
31352
31921
  if (desiredBody.replace(/\s+$/, "") !== parsed.content.replace(/\s+$/, "")) {
31353
- let rewritten = import_gray_matter9.default.stringify(desiredBody, parsed.data ?? {});
31922
+ let rewritten = import_gray_matter10.default.stringify(desiredBody, parsed.data ?? {});
31354
31923
  if (!rewritten.endsWith(`
31355
31924
  `))
31356
31925
  rewritten += `
31357
31926
  `;
31358
31927
  writeMirrorFile(workspaceSlug, entityType, slug, rewritten);
31359
31928
  raw = rewritten;
31360
- parsed = import_gray_matter9.default(rewritten);
31929
+ parsed = import_gray_matter10.default(rewritten);
31361
31930
  }
31362
31931
  }
31363
31932
  const liveInode = safeInode2(absolutePath);
@@ -31379,7 +31948,9 @@ async function pushFile(args) {
31379
31948
  now,
31380
31949
  output,
31381
31950
  quarantinedKeys,
31382
- identity
31951
+ identity,
31952
+ suppressor,
31953
+ writeBackReadBack: deps?.writeBackReadBack
31383
31954
  });
31384
31955
  }
31385
31956
  return runPushUpsert({
@@ -31604,6 +32175,15 @@ async function runPushUpsert(args) {
31604
32175
  const liveEntry = getEntry(args.state, strategy.type, args.stateEntry.id) ?? args.stateEntry;
31605
32176
  const bodyChanged = newBodyHash !== liveEntry.body_hash;
31606
32177
  const frontmatterChanged = newFrontmatterHash !== liveEntry.frontmatter_hash;
32178
+ let workingState = args.state;
32179
+ if (fm["id"] === undefined && workingState.frontmatter_id_backfill_version !== undefined) {
32180
+ const cleared = clearFrontmatterIdBackfillMarker(workingState);
32181
+ if (cleared.changed) {
32182
+ try {
32183
+ workingState = commitState(workingState, cleared.state);
32184
+ } catch {}
32185
+ }
32186
+ }
31607
32187
  if (!bodyChanged && !frontmatterChanged) {
31608
32188
  return {
31609
32189
  outcome: {
@@ -31611,7 +32191,7 @@ async function runPushUpsert(args) {
31611
32191
  entityType: strategy.type,
31612
32192
  entitySlug: liveEntry.slug
31613
32193
  },
31614
- state: args.state
32194
+ state: workingState
31615
32195
  };
31616
32196
  }
31617
32197
  const wireBodyFields = bodyChanged ? strategy.bodyToWire(bodyMarkdown) : undefined;
@@ -31630,7 +32210,7 @@ async function runPushUpsert(args) {
31630
32210
  entityType: strategy.type,
31631
32211
  entitySlug: liveEntry.slug
31632
32212
  },
31633
- state: persistEntry(args.state, strategy.type, updated)
32213
+ state: persistEntry(workingState, strategy.type, updated)
31634
32214
  };
31635
32215
  }
31636
32216
  const mergedTopLevel = split || wireBodyFields?.topLevel ? {
@@ -31649,7 +32229,7 @@ async function runPushUpsert(args) {
31649
32229
  inode: safeInode2(args.absolutePath),
31650
32230
  body_len: hashBody.length
31651
32231
  };
31652
- const nextState2 = persistEntry(args.state, strategy.type, parked);
32232
+ const nextState2 = persistEntry(workingState, strategy.type, parked);
31653
32233
  const fields = [...quarantined];
31654
32234
  args.output?.log(syncErrorLine({
31655
32235
  entityType: strategy.type,
@@ -31720,7 +32300,7 @@ async function runPushUpsert(args) {
31720
32300
  body_len: hashBody.length,
31721
32301
  memory_hash: liveEntry.memory_hash
31722
32302
  };
31723
- const nextState2 = persistEntry(args.state, strategy.type, updated);
32303
+ const nextState2 = persistEntry(workingState, strategy.type, updated);
31724
32304
  args.output?.log(`[PATCH entityType=${strategy.type} slug=${updated.slug} id=${updated.id} revision=${updated.revision}]`);
31725
32305
  if (quarantined.size > 0) {
31726
32306
  const fields = [...quarantined];
@@ -31758,7 +32338,7 @@ async function runPushUpsert(args) {
31758
32338
  entityType: strategy.type,
31759
32339
  entitySlug: liveEntry.slug
31760
32340
  },
31761
- state: args.state
32341
+ state: workingState
31762
32342
  };
31763
32343
  }
31764
32344
  if (patchResult.reason === "slug_conflict") {
@@ -31776,7 +32356,7 @@ async function runPushUpsert(args) {
31776
32356
  existing: patchResult.existing,
31777
32357
  scope: patchResult.scope,
31778
32358
  staleEntryId: liveEntry.id,
31779
- state: args.state,
32359
+ state: workingState,
31780
32360
  fetchImpl: args.fetchImpl,
31781
32361
  now: args.now,
31782
32362
  output: args.output
@@ -31793,7 +32373,7 @@ async function runPushUpsert(args) {
31793
32373
  parsed: args.parsed,
31794
32374
  liveEntry,
31795
32375
  currentUpdatedAt: patchResult.currentUpdatedAt,
31796
- state: args.state,
32376
+ state: workingState,
31797
32377
  fetchImpl: args.fetchImpl,
31798
32378
  now: args.now,
31799
32379
  output: args.output
@@ -31812,7 +32392,7 @@ async function runPushUpsert(args) {
31812
32392
  baseUrl: args.baseUrl,
31813
32393
  pat: args.pat,
31814
32394
  id: liveEntry.id,
31815
- state: args.state,
32395
+ state: workingState,
31816
32396
  fetchImpl: args.fetchImpl
31817
32397
  });
31818
32398
  return {
@@ -31928,6 +32508,24 @@ async function reconcileTaskConflict(args) {
31928
32508
  state
31929
32509
  };
31930
32510
  }
32511
+ function writeCreatedIdBack(args) {
32512
+ const write = withFrontmatterId(args.raw, args.parsed, args.id);
32513
+ if (write === null)
32514
+ return;
32515
+ if (!frontmatterIdWriteIsSound(write.content, args.id, args.parsed.content)) {
32516
+ return;
32517
+ }
32518
+ try {
32519
+ if (entityFilePath(args.workspaceSlug, args.strategy.type, args.slug) !== args.absolutePath) {
32520
+ return;
32521
+ }
32522
+ const readBack = args.readBack ?? ((p) => readFileSync16(p, "utf8"));
32523
+ if (readBack(args.absolutePath) !== args.raw)
32524
+ return;
32525
+ args.suppressor?.registerWrite(args.absolutePath, computeMirrorHashes(args.strategy, write.content));
32526
+ writeMirrorFile(args.workspaceSlug, args.strategy.type, args.slug, write.content);
32527
+ } catch {}
32528
+ }
31931
32529
  async function runPushCreate(args) {
31932
32530
  const strategy = args.strategy;
31933
32531
  const key = `create:${strategy.type}:${args.slug}`;
@@ -31978,6 +32576,17 @@ async function runPushCreate(args) {
31978
32576
  const createLifted = splitUserFrontmatter(strategy, createFm);
31979
32577
  const userFm = userFrontmatter(strategy, createLifted.frontmatter);
31980
32578
  const createHashBody = hashableBody(strategy, args.parsed.content);
32579
+ writeCreatedIdBack({
32580
+ strategy,
32581
+ workspaceSlug: args.workspaceSlug,
32582
+ absolutePath: args.absolutePath,
32583
+ raw: args.raw,
32584
+ parsed: args.parsed,
32585
+ slug: doc2.slug,
32586
+ id: doc2.id,
32587
+ suppressor: args.suppressor,
32588
+ readBack: args.writeBackReadBack
32589
+ });
31981
32590
  const nextState2 = persistEntry(args.state, strategy.type, {
31982
32591
  id: doc2.id,
31983
32592
  slug: doc2.slug,
@@ -32225,8 +32834,8 @@ async function pushRename(args) {
32225
32834
  };
32226
32835
  if (!updated.body_hash || !updated.frontmatter_hash) {
32227
32836
  try {
32228
- const mirrorRaw = readFileSync15(toPath, "utf8");
32229
- const mirrorParsed = import_gray_matter9.default(mirrorRaw);
32837
+ const mirrorRaw = readFileSync16(toPath, "utf8");
32838
+ const mirrorParsed = import_gray_matter10.default(mirrorRaw);
32230
32839
  const renameFm = mirrorParsed.data ?? {};
32231
32840
  const renameLifted = splitUserFrontmatter(strategy, renameFm);
32232
32841
  const renameUserFm = userFrontmatter(strategy, renameLifted.frontmatter);
@@ -32260,7 +32869,7 @@ async function pushRename(args) {
32260
32869
  if (patchResult.reason === "slug_conflict") {
32261
32870
  let contents2 = "";
32262
32871
  try {
32263
- contents2 = readFileSync15(toPath, "utf8");
32872
+ contents2 = readFileSync16(toPath, "utf8");
32264
32873
  } catch {}
32265
32874
  const stashPath2 = stashConflict({
32266
32875
  workspaceSlug,
@@ -32300,7 +32909,7 @@ async function pushRename(args) {
32300
32909
  }
32301
32910
  let contents = "";
32302
32911
  try {
32303
- contents = readFileSync15(toPath, "utf8");
32912
+ contents = readFileSync16(toPath, "utf8");
32304
32913
  } catch {}
32305
32914
  const stashPath = stashConflict({
32306
32915
  workspaceSlug,
@@ -32421,7 +33030,7 @@ async function discardBatchOps(batch, deps) {
32421
33030
  const path5 = opPath(deps.workspaceSlug, op);
32422
33031
  let local = null;
32423
33032
  try {
32424
- local = readFileSync16(path5, "utf8");
33033
+ local = readFileSync17(path5, "utf8");
32425
33034
  } catch {
32426
33035
  local = null;
32427
33036
  }
@@ -32455,7 +33064,7 @@ async function discardFileOp(op, deps) {
32455
33064
  const abs = filesSubtreePath(deps.workspaceSlug, op.path);
32456
33065
  let local = null;
32457
33066
  try {
32458
- local = readFileSync16(abs);
33067
+ local = readFileSync17(abs);
32459
33068
  } catch {
32460
33069
  local = null;
32461
33070
  }
@@ -32643,7 +33252,7 @@ class FileDestructiveGate {
32643
33252
  }
32644
33253
 
32645
33254
  // src/lib/files/reconcile.ts
32646
- import { readFileSync as readFileSync17, statSync as statSync10 } from "node:fs";
33255
+ import { readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
32647
33256
 
32648
33257
  // src/lib/unsynced.ts
32649
33258
  import { join as join12, relative as relative6 } from "node:path";
@@ -34669,7 +35278,7 @@ function readBlobForPush(args) {
34669
35278
  }
34670
35279
  let bytes;
34671
35280
  try {
34672
- bytes = readFileSync17(absolutePath);
35281
+ bytes = readFileSync18(absolutePath);
34673
35282
  } catch (err) {
34674
35283
  if (err?.code === "ENOENT")
34675
35284
  return { kind: "gone" };
@@ -35530,7 +36139,7 @@ var FULL_BAND_DAYS = 14;
35530
36139
  function yamlString(value) {
35531
36140
  return JSON.stringify(value);
35532
36141
  }
35533
- function oneLine(value, maxLength = 200) {
36142
+ function oneLine2(value, maxLength = 200) {
35534
36143
  const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
35535
36144
  if (flattened.length === 0)
35536
36145
  return "(untitled)";
@@ -35579,7 +36188,7 @@ function sortInstant(event) {
35579
36188
  function describeWhen(event, referenceYear) {
35580
36189
  const start = dateParts(event.start);
35581
36190
  if (!start)
35582
- return oneLine(event.start, 60);
36191
+ return oneLine2(event.start, 60);
35583
36192
  if (event.all_day) {
35584
36193
  const end2 = dateParts(event.end);
35585
36194
  const last = end2 ? shiftDays(end2, -1) : start;
@@ -35605,19 +36214,19 @@ function fullLine(event, referenceYear, artistPrefix, includeUrl) {
35605
36214
  const parts = [
35606
36215
  `**${describeWhen(event, referenceYear)}**`,
35607
36216
  artistPrefix,
35608
- `${oneLine(event.title)} (${event.booking_status})`
36217
+ `${oneLine2(event.title)} (${event.booking_status})`
35609
36218
  ].filter((p) => p.length > 0);
35610
36219
  if (event.location)
35611
- parts.push(oneLine(event.location, 80));
36220
+ parts.push(oneLine2(event.location, 80));
35612
36221
  if (includeUrl && event.url)
35613
- parts.push(oneLine(event.url, 300));
36222
+ parts.push(oneLine2(event.url, 300));
35614
36223
  return `- ${parts.join(" - ")}`;
35615
36224
  }
35616
36225
  function compactLine(event, referenceYear, artistPrefix) {
35617
36226
  const parts = [
35618
36227
  describeWhen(event, referenceYear),
35619
36228
  artistPrefix,
35620
- `${oneLine(event.title)} (${event.booking_status})`
36229
+ `${oneLine2(event.title)} (${event.booking_status})`
35621
36230
  ].filter((p) => p.length > 0);
35622
36231
  return `- ${parts.join(" - ")}`;
35623
36232
  }
@@ -35653,7 +36262,7 @@ function statusLine(args) {
35653
36262
  case "token-expired":
35654
36263
  return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
35655
36264
  case "google-error":
35656
- return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
36265
+ return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine2(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
35657
36266
  }
35658
36267
  }
35659
36268
  function shortStatus(row) {
@@ -35669,7 +36278,7 @@ function shortStatus(row) {
35669
36278
  case "token-expired":
35670
36279
  return "NOT READ - the Google connection has expired; unknown, not empty";
35671
36280
  case "google-error":
35672
- return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
36281
+ return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine2(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
35673
36282
  }
35674
36283
  }
35675
36284
  function rowFileName(row, internalName) {
@@ -35677,8 +36286,8 @@ function rowFileName(row, internalName) {
35677
36286
  }
35678
36287
  function rowDisplayName(row) {
35679
36288
  if (row.artist_name)
35680
- return oneLine(row.artist_name, 80);
35681
- return `${oneLine(row.calendar_name, 80)} (internal)`;
36289
+ return oneLine2(row.artist_name, 80);
36290
+ return `${oneLine2(row.calendar_name, 80)} (internal)`;
35682
36291
  }
35683
36292
  function splitBands(events, generatedAt) {
35684
36293
  const base2 = Date.parse(generatedAt);
@@ -35816,8 +36425,8 @@ function renderUpcoming(args) {
35816
36425
  "",
35817
36426
  "## Calendar status",
35818
36427
  "",
35819
- ...rows.map((row) => `- **${rowDisplayName(row)}**` + `${row.artist_slug ? ` (\`${oneLine(row.artist_slug, 80)}\`)` : ""}` + ` - ${shortStatus(row)} - \`views/calendar/${rowFileName(row, internalName)}.md\``),
35820
- ...unmappedArtists.map((a) => `- **${oneLine(a.name, 80)}** (\`${oneLine(a.slug, 80)}\`) - NO CALENDAR MAPPED in Plinth, so there is nothing to read and no file for them. Map one in Plinth if they should have dates.`),
36428
+ ...rows.map((row) => `- **${rowDisplayName(row)}**` + `${row.artist_slug ? ` (\`${oneLine2(row.artist_slug, 80)}\`)` : ""}` + ` - ${shortStatus(row)} - \`views/calendar/${rowFileName(row, internalName)}.md\``),
36429
+ ...unmappedArtists.map((a) => `- **${oneLine2(a.name, 80)}** (\`${oneLine2(a.slug, 80)}\`) - NO CALENDAR MAPPED in Plinth, so there is nothing to read and no file for them. Map one in Plinth if they should have dates.`),
35821
36430
  ""
35822
36431
  ].join(`
35823
36432
  `);
@@ -35886,14 +36495,14 @@ function renderStaleFile(existing, args) {
35886
36495
  ...carried,
35887
36496
  "fetch_state: stale",
35888
36497
  `last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
35889
- `last_refresh_error: ${yamlString(oneLine(args.reason, 300))}`,
36498
+ `last_refresh_error: ${yamlString(oneLine2(args.reason, 300))}`,
35890
36499
  "---",
35891
36500
  "",
35892
36501
  OWNERSHIP_NOTE2,
35893
36502
  "",
35894
36503
  `# ${title}`,
35895
36504
  "",
35896
- `**STALE - the last refresh FAILED and nothing below was updated.** Attempted ` + `${humanStamp(args.attemptedAt)} and it did not succeed: ` + `${oneLine(args.reason, 300)}`,
36505
+ `**STALE - the last refresh FAILED and nothing below was updated.** Attempted ` + `${humanStamp(args.attemptedAt)} and it did not succeed: ` + `${oneLine2(args.reason, 300)}`,
35897
36506
  "",
35898
36507
  `**What is below was last read at ${stamp}** and has not moved since. Anything ` + `changed in Google after that is not here, and something cancelled after that ` + `is still listed. Say the age out loud rather than briefing it as current.`,
35899
36508
  ""
@@ -36075,8 +36684,8 @@ class PullSuppressor {
36075
36684
  }
36076
36685
 
36077
36686
  // 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";
36687
+ var import_gray_matter11 = __toESM(require_gray_matter(), 1);
36688
+ import { readFileSync as readFileSync19, statSync as statSync11 } from "node:fs";
36080
36689
  var DISCOVERY_WRITE_CAP_DEFAULT = 10;
36081
36690
  function discoveryWriteCap() {
36082
36691
  const raw = process.env.PLINTH_DISCOVERY_WRITE_CAP;
@@ -36175,7 +36784,7 @@ function planDiscoveryWrites(args) {
36175
36784
  counts.discovered++;
36176
36785
  let frontmatter;
36177
36786
  try {
36178
- frontmatter = import_gray_matter10.default(readFileSync18(absolutePath, "utf8")).data ?? {};
36787
+ frontmatter = import_gray_matter11.default(readFileSync19(absolutePath, "utf8")).data ?? {};
36179
36788
  } catch {
36180
36789
  counts.discovered--;
36181
36790
  continue;
@@ -36403,21 +37012,23 @@ function recordDiscoveryFailure(args) {
36403
37012
  }
36404
37013
 
36405
37014
  // 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";
37015
+ var import_gray_matter12 = __toESM(require_gray_matter(), 1);
37016
+ import { readFileSync as readFileSync20, statSync as statSync12 } from "node:fs";
37017
+ var FRONTMATTER_ID_BACKFILL_VERSION = 1;
36408
37018
  function backfillWorthReporting(r) {
36409
- return r.written > 0 || r.failed > 0 || r.skipped.foreignId > 0;
37019
+ return r.written > 0 || r.failed > 0 || r.skipped.foreignId > 0 || r.skipped.unwritable > 0 || r.skipped.slugCollision > 0;
36410
37020
  }
36411
37021
  function emptyResult() {
36412
37022
  return {
36413
37023
  scanned: 0,
36414
37024
  written: 0,
37025
+ blocksCreated: 0,
36415
37026
  alreadyPresent: 0,
36416
37027
  missing: 0,
36417
37028
  skipped: {
36418
37029
  frozen: 0,
36419
37030
  foreignId: 0,
36420
- noFrontmatter: 0,
37031
+ unwritable: 0,
36421
37032
  slugCollision: 0,
36422
37033
  diverged: 0
36423
37034
  },
@@ -36442,21 +37053,14 @@ function safeInode3(path5) {
36442
37053
  return;
36443
37054
  }
36444
37055
  }
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
37056
  function backfillFrontmatterIds(args) {
36457
37057
  const { workspaceSlug, state } = args;
36458
- const splice = args.splice ?? spliceFrontmatterId;
37058
+ const compose = args.compose ?? withFrontmatterId;
37059
+ const readRaw = args.readFile ?? ((p) => readFileSync20(p, "utf8"));
36459
37060
  const result = emptyResult();
37061
+ if (state.frontmatter_id_backfill_version === FRONTMATTER_ID_BACKFILL_VERSION) {
37062
+ return result;
37063
+ }
36460
37064
  let held;
36461
37065
  try {
36462
37066
  held = heldByAnyBatch(workspaceSlug);
@@ -36465,10 +37069,13 @@ function backfillFrontmatterIds(args) {
36465
37069
  }
36466
37070
  let nextState = state;
36467
37071
  let stateChanged = false;
37072
+ let sawUnknownType = false;
36468
37073
  for (const [entityType, bucket] of Object.entries(state.entries)) {
36469
37074
  const strategy = getStrategy(entityType);
36470
- if (!strategy)
37075
+ if (!strategy) {
37076
+ sawUnknownType = true;
36471
37077
  continue;
37078
+ }
36472
37079
  const slugCounts = new Map;
36473
37080
  for (const e of Object.values(bucket)) {
36474
37081
  slugCounts.set(e.slug, (slugCounts.get(e.slug) ?? 0) + 1);
@@ -36485,7 +37092,7 @@ function backfillFrontmatterIds(args) {
36485
37092
  }
36486
37093
  let raw;
36487
37094
  try {
36488
- raw = readFileSync19(entityFilePath(workspaceSlug, entityType, entry2.slug), "utf8");
37095
+ raw = readRaw(entityFilePath(workspaceSlug, entityType, entry2.slug));
36489
37096
  } catch (err) {
36490
37097
  if (err?.code === "ENOENT") {
36491
37098
  result.missing++;
@@ -36496,9 +37103,11 @@ function backfillFrontmatterIds(args) {
36496
37103
  }
36497
37104
  let existing;
36498
37105
  let rawBody;
37106
+ let parsedData;
36499
37107
  try {
36500
- const parsed = import_gray_matter11.default(raw);
36501
- existing = (parsed.data ?? {})["id"];
37108
+ const parsed = import_gray_matter12.default(raw);
37109
+ parsedData = parsed.data ?? {};
37110
+ existing = parsedData["id"];
36502
37111
  rawBody = parsed.content;
36503
37112
  } catch {
36504
37113
  result.failed++;
@@ -36516,30 +37125,27 @@ function backfillFrontmatterIds(args) {
36516
37125
  result.skipped.diverged++;
36517
37126
  continue;
36518
37127
  }
36519
- const next = splice(raw, id);
36520
- if (next === null) {
36521
- result.skipped.noFrontmatter++;
37128
+ const write = compose(raw, { data: parsedData, content: rawBody }, id);
37129
+ if (write === null) {
37130
+ result.skipped.unwritable++;
36522
37131
  continue;
36523
37132
  }
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 {
37133
+ const next = write.content;
37134
+ if (!frontmatterIdWriteIsSound(next, id, rawBody)) {
36531
37135
  result.failed++;
36532
37136
  continue;
36533
37137
  }
36534
37138
  let ino;
36535
37139
  try {
36536
- const stillRaw = readFileSync19(entityFilePath(workspaceSlug, entityType, entry2.slug), "utf8");
37140
+ const stillRaw = readRaw(entityFilePath(workspaceSlug, entityType, entry2.slug));
36537
37141
  if (stillRaw !== raw) {
36538
37142
  result.skipped.diverged++;
36539
37143
  continue;
36540
37144
  }
36541
37145
  writeMirrorFile(workspaceSlug, entityType, entry2.slug, next);
36542
37146
  result.written++;
37147
+ if (write.blockCreated)
37148
+ result.blocksCreated++;
36543
37149
  ino = safeInode3(entityFilePath(workspaceSlug, entityType, entry2.slug));
36544
37150
  } catch {
36545
37151
  result.failed++;
@@ -36555,6 +37161,17 @@ function backfillFrontmatterIds(args) {
36555
37161
  }
36556
37162
  }
36557
37163
  }
37164
+ const retryable = result.written + result.failed + result.skipped.frozen + result.skipped.diverged + result.skipped.unwritable + result.skipped.slugCollision;
37165
+ if (retryable === 0 && !sawUnknownType) {
37166
+ const stamped = {
37167
+ ...nextState,
37168
+ frontmatter_id_backfill_version: FRONTMATTER_ID_BACKFILL_VERSION
37169
+ };
37170
+ if (stamped.frontmatter_id_backfill_version !== nextState.frontmatter_id_backfill_version) {
37171
+ nextState = stamped;
37172
+ stateChanged = true;
37173
+ }
37174
+ }
36558
37175
  if (stateChanged) {
36559
37176
  try {
36560
37177
  commitState(state, nextState);
@@ -36567,7 +37184,7 @@ function backfillFrontmatterIds(args) {
36567
37184
  }
36568
37185
 
36569
37186
  // src/lib/reconcile.ts
36570
- import { readFileSync as readFileSync20 } from "node:fs";
37187
+ import { readFileSync as readFileSync21, statSync as statSync13 } from "node:fs";
36571
37188
  async function reconcileDivergedRows(args) {
36572
37189
  const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
36573
37190
  const now = args.now ?? (() => new Date);
@@ -36609,6 +37226,14 @@ async function reconcileDivergedRows(args) {
36609
37226
  case "noop":
36610
37227
  noop++;
36611
37228
  break;
37229
+ case "not_found":
37230
+ state = recordEntityCloudRowDeleted({
37231
+ state,
37232
+ workspaceSlug,
37233
+ absolutePath,
37234
+ now
37235
+ });
37236
+ break;
36612
37237
  default:
36613
37238
  break;
36614
37239
  }
@@ -36635,6 +37260,7 @@ async function reconcileDivergedRows(args) {
36635
37260
  }
36636
37261
  };
36637
37262
  const guttingCandidates = [];
37263
+ let sawTrackedFileWithoutId = false;
36638
37264
  for (const [entityType, bucket] of Object.entries(state.entries)) {
36639
37265
  const strategy = getStrategy(entityType);
36640
37266
  if (!strategy)
@@ -36651,15 +37277,32 @@ async function reconcileDivergedRows(args) {
36651
37277
  continue;
36652
37278
  throw err;
36653
37279
  }
37280
+ let liveInode;
37281
+ try {
37282
+ liveInode = statSync13(absolutePath).ino;
37283
+ } catch {
37284
+ liveInode = undefined;
37285
+ }
37286
+ if (liveInode !== undefined && entry2.inode !== liveInode) {
37287
+ const trueOwner = getEntryByInode(state, entityType, liveInode);
37288
+ if (trueOwner) {
37289
+ output?.log(`[RECONCILE_SKIP entityType=${entityType} slug=${entry2.slug} ` + `id=${entry2.id} owner_id=${trueOwner.id} reason=path_owned_by_other_row]`);
37290
+ continue;
37291
+ }
37292
+ }
36654
37293
  let content;
36655
37294
  try {
36656
- content = readFileSync20(absolutePath, "utf8");
37295
+ content = readFileSync21(absolutePath, "utf8");
36657
37296
  } catch (err) {
36658
- if (err?.code === "ENOENT")
37297
+ if (err?.code === "ENOENT") {
37298
+ state = clearEntityCloudRowDeleted(state, strategy.mirrorRelativePath(entry2.slug));
36659
37299
  continue;
37300
+ }
36660
37301
  throw err;
36661
37302
  }
36662
37303
  const hashes = computeMirrorHashes(strategy, content);
37304
+ if (hashes.frontmatterId === undefined)
37305
+ sawTrackedFileWithoutId = true;
36663
37306
  const isDiverged = hashes.bodyHash !== entry2.body_hash || hashes.frontmatterHash !== entry2.frontmatter_hash;
36664
37307
  if (!isDiverged)
36665
37308
  continue;
@@ -36726,6 +37369,14 @@ async function reconcileDivergedRows(args) {
36726
37369
  isFrozen
36727
37370
  });
36728
37371
  state = discovery.state;
37372
+ if (sawTrackedFileWithoutId) {
37373
+ const cleared = clearFrontmatterIdBackfillMarker(state);
37374
+ if (cleared.changed) {
37375
+ try {
37376
+ state = commitState(state, cleared.state);
37377
+ } catch {}
37378
+ }
37379
+ }
36729
37380
  return {
36730
37381
  state,
36731
37382
  scanned,
@@ -37734,9 +38385,9 @@ function createThrottledFetch(baseFetch, opts = {}) {
37734
38385
  import {
37735
38386
  existsSync as existsSync7,
37736
38387
  readdirSync as readdirSync6,
37737
- readFileSync as readFileSync22,
37738
- statSync as statSync14,
37739
- unlinkSync as unlinkSync7
38388
+ readFileSync as readFileSync23,
38389
+ statSync as statSync15,
38390
+ unlinkSync as unlinkSync8
37740
38391
  } from "node:fs";
37741
38392
  import { join as join16 } from "node:path";
37742
38393
 
@@ -37861,7 +38512,7 @@ function refreshConflictsView(workspaceSlug) {
37861
38512
  }
37862
38513
 
37863
38514
  // src/lib/files/pull.ts
37864
- import { existsSync as existsSync6, readFileSync as readFileSync21, statSync as statSync13 } from "node:fs";
38515
+ import { existsSync as existsSync6, readFileSync as readFileSync22, statSync as statSync14 } from "node:fs";
37865
38516
 
37866
38517
  // src/lib/files/extracted-text.ts
37867
38518
  import { createHash as createHash3 } from "node:crypto";
@@ -38132,7 +38783,7 @@ async function applyFileRow(args) {
38132
38783
  }
38133
38784
  let localBytes;
38134
38785
  try {
38135
- localBytes = readFileSync21(abs);
38786
+ localBytes = readFileSync22(abs);
38136
38787
  } catch {
38137
38788
  localBytes = undefined;
38138
38789
  }
@@ -38229,7 +38880,7 @@ async function applyFileRow(args) {
38229
38880
  }
38230
38881
  if (!suppressor && existing && existing.content_hash !== row.content_hash && existsSync6(abs)) {
38231
38882
  try {
38232
- const localBytes = readFileSync21(abs);
38883
+ const localBytes = readFileSync22(abs);
38233
38884
  if (sha256Bytes(localBytes) !== existing.content_hash) {
38234
38885
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
38235
38886
  output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
@@ -38270,7 +38921,7 @@ async function applyFileRow(args) {
38270
38921
  }
38271
38922
  let inode;
38272
38923
  try {
38273
- inode = statSync13(abs).ino;
38924
+ inode = statSync14(abs).ino;
38274
38925
  } catch {
38275
38926
  inode = undefined;
38276
38927
  }
@@ -38339,6 +38990,8 @@ function textIsCurrent(existing, row, workspaceSlug, relPath) {
38339
38990
 
38340
38991
  // src/commands/sync.ts
38341
38992
  init_output();
38993
+ var memberRefreshFailedAt = new Map;
38994
+ var MEMBER_REFRESH_RETRY_AFTER_MS = 60 * 60 * 1000;
38342
38995
  function classifyApiError(err) {
38343
38996
  if (err.status === 401 || err.status === 403) {
38344
38997
  return { kind: "auth", status: err.status };
@@ -38367,31 +39020,50 @@ function hasExistingContent(workspacePath) {
38367
39020
  return false;
38368
39021
  }
38369
39022
  }
38370
- function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInode, suppressor) {
39023
+ function otherEntryAtSlug(state, entityType, slug, exceptId) {
39024
+ const bucket = state.entries[entityType];
39025
+ if (!bucket)
39026
+ return;
39027
+ for (const candidate of Object.values(bucket)) {
39028
+ if (candidate.id !== exceptId && candidate.slug === slug)
39029
+ return candidate;
39030
+ }
39031
+ return;
39032
+ }
39033
+ function unlinkIfProvablyOurs(workspaceSlug, strategy, entry2, suppressor) {
38371
39034
  let target;
38372
39035
  try {
38373
- target = entityFilePath(workspaceSlug, entityType, entrySlug);
39036
+ target = entityFilePath(workspaceSlug, strategy.type, entry2.slug);
38374
39037
  } catch (err) {
38375
39038
  if (err instanceof MirrorPathError)
38376
39039
  return { didUnlink: false };
38377
39040
  throw err;
38378
39041
  }
38379
- let liveInode;
39042
+ let raw;
39043
+ try {
39044
+ raw = readFileSync23(target, "utf8");
39045
+ } catch (err) {
39046
+ if (err?.code === "ENOENT") {
39047
+ return { didUnlink: false };
39048
+ }
39049
+ return { didUnlink: false, refusedReason: "unreadable" };
39050
+ }
39051
+ let hashes;
38380
39052
  try {
38381
- liveInode = statSync14(target).ino;
39053
+ hashes = computeMirrorHashes(strategy, raw);
38382
39054
  } catch {
38383
- return { didUnlink: false, skippedReason: "absent" };
39055
+ return { didUnlink: false, refusedReason: "unreadable" };
38384
39056
  }
38385
- if (expectedInode !== undefined && expectedInode !== liveInode) {
38386
- return { didUnlink: false, skippedReason: "inode_mismatch" };
39057
+ if (hashes.bodyHash !== entry2.body_hash || hashes.frontmatterHash !== entry2.frontmatter_hash) {
39058
+ return { didUnlink: false, refusedReason: "diverged" };
38387
39059
  }
38388
39060
  try {
38389
39061
  suppressor?.registerDelete(target);
38390
- unlinkSync7(target);
39062
+ unlinkSync8(target);
38391
39063
  return { didUnlink: true };
38392
39064
  } catch (err) {
38393
39065
  if (err?.code === "ENOENT") {
38394
- return { didUnlink: false, skippedReason: "absent" };
39066
+ return { didUnlink: false };
38395
39067
  }
38396
39068
  throw err;
38397
39069
  }
@@ -38475,7 +39147,7 @@ async function runSync(workspaceSlug, deps = {}) {
38475
39147
  ctr.fetched++;
38476
39148
  let outcome;
38477
39149
  try {
38478
- outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen);
39150
+ outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen, deps.loggedTombstoneSkips);
38479
39151
  } catch (err) {
38480
39152
  if (!(err instanceof MirrorSymlinkError))
38481
39153
  throw err;
@@ -38633,6 +39305,18 @@ async function runSync(workspaceSlug, deps = {}) {
38633
39305
  out.error(`Sync state could not be persisted: ${msg}`);
38634
39306
  return { exitCode: 1 };
38635
39307
  }
39308
+ try {
39309
+ ensureFilesSubtree(slug);
39310
+ } catch (err) {
39311
+ const msg = err instanceof Error ? err.message : "unknown error";
39312
+ out.warn(`Could not create the files/ folder in this workspace mirror: ${msg}`);
39313
+ }
39314
+ try {
39315
+ ensureEntityFolders(slug);
39316
+ } catch (err) {
39317
+ const msg = err instanceof Error ? err.message : "unknown error";
39318
+ out.warn(`Could not scaffold the workspace entity folders: ${msg}`);
39319
+ }
38636
39320
  const generateContext = deps.generateContext ?? ensureWorkspaceContext;
38637
39321
  try {
38638
39322
  generateContext({
@@ -38646,6 +39330,17 @@ async function runSync(workspaceSlug, deps = {}) {
38646
39330
  const msg = err instanceof Error ? err.message : "unknown error";
38647
39331
  out.warn(`Context generation skipped: ${msg}`);
38648
39332
  }
39333
+ const lastFailure = memberRefreshFailedAt.get(slug);
39334
+ const failureCooling = lastFailure !== undefined && now().getTime() - lastFailure < MEMBER_REFRESH_RETRY_AFTER_MS;
39335
+ if (!failureCooling && memberNeedsRefresh(entry2, now)) {
39336
+ try {
39337
+ const principal = await whoami(entry2.api_base_url, pat, fetchImpl);
39338
+ setMemberPrincipal(slug, memberPrincipalFrom(principal, now));
39339
+ memberRefreshFailedAt.delete(slug);
39340
+ } catch {
39341
+ memberRefreshFailedAt.set(slug, now().getTime());
39342
+ }
39343
+ }
38649
39344
  const generatePerArtistContext = deps.generatePerArtistContext ?? ensurePerArtistContext;
38650
39345
  try {
38651
39346
  generatePerArtistContext({ workspaceSlug: slug, force: false, out, now });
@@ -38674,48 +39369,58 @@ async function runSync(workspaceSlug, deps = {}) {
38674
39369
  out.success(`Done in ${elapsed}s.`);
38675
39370
  return { exitCode: 0, applied: totalWritten + totalDeleted };
38676
39371
  }
38677
- function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen) {
39372
+ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
38678
39373
  if (isFrozen?.(strategy.type, row.id)) {
38679
39374
  return { state, action: "skipped" };
38680
39375
  }
38681
39376
  if (row.deleted_at !== null) {
38682
39377
  const bucket = state.entries[strategy.type];
38683
39378
  const entry2 = bucket?.[row.id];
38684
- let didUnlink = false;
38685
- if (entry2) {
38686
- const tombstone = unlinkIfInodeMatches(workspaceSlug, strategy.type, entry2.slug, entry2.inode, suppressor);
38687
- didUnlink = tombstone.didUnlink;
38688
- if (tombstone.skippedReason === "inode_mismatch") {
38689
- out.log(`[TOMBSTONE_SKIP entityType=${strategy.type} slug=${entry2.slug} id=${row.id} reason=inode_mismatch expected=${entry2.inode} live_inode_present=true]`);
38690
- } else if (didUnlink) {
38691
- out.log(`[TOMBSTONE_UNLINK entityType=${strategy.type} slug=${entry2.slug} id=${row.id}]`);
39379
+ const targetSlug = entry2?.slug ?? row.slug;
39380
+ const dropEntry2 = () => {
39381
+ if (!entry2)
39382
+ return state;
39383
+ const nextBucket = { ...bucket };
39384
+ delete nextBucket[row.id];
39385
+ const cleared = clearEntityCloudRowDeleted({ ...state, entries: { ...state.entries, [strategy.type]: nextBucket } }, strategy.mirrorRelativePath(entry2.slug));
39386
+ return cleared;
39387
+ };
39388
+ const logOnce = (line, key) => {
39389
+ if (loggedTombstoneSkips) {
39390
+ if (loggedTombstoneSkips.has(key))
39391
+ return;
39392
+ loggedTombstoneSkips.add(key);
38692
39393
  }
38693
- } else {
39394
+ out.log(line);
39395
+ };
39396
+ const owner = otherEntryAtSlug(state, strategy.type, targetSlug, row.id);
39397
+ if (owner) {
39398
+ logOnce(`[TOMBSTONE_SKIP entityType=${strategy.type} slug=${targetSlug} id=${row.id} owner_id=${owner.id} reason=slug_owned_by_other_row]`, `entity:${strategy.type}:${row.id}:${owner.id}:${targetSlug}`);
39399
+ const next = dropEntry2();
39400
+ return { state: next, action: "tombstone_noop" };
39401
+ }
39402
+ if (!entry2) {
39403
+ let present2 = false;
38694
39404
  try {
38695
- const target = entityFilePath(workspaceSlug, strategy.type, row.slug);
38696
- suppressor?.registerDelete(target);
38697
- didUnlink = deleteMirrorFile(workspaceSlug, strategy.type, row.slug);
39405
+ present2 = existsSync7(entityFilePath(workspaceSlug, strategy.type, row.slug));
38698
39406
  } catch (err) {
38699
39407
  if (!(err instanceof MirrorPathError))
38700
39408
  throw err;
38701
39409
  }
39410
+ if (present2) {
39411
+ logOnce(`[TOMBSTONE_KEEP entityType=${strategy.type} slug=${row.slug} id=${row.id} reason=untracked]`, `entity:${strategy.type}:${row.id}:untracked:${row.slug}`);
39412
+ }
39413
+ return { state, action: "tombstone_noop" };
38702
39414
  }
38703
- const wasTracked = !!entry2;
38704
- if (!wasTracked && !didUnlink) {
39415
+ const tombstone = unlinkIfProvablyOurs(workspaceSlug, strategy, entry2, suppressor);
39416
+ if (tombstone.refusedReason) {
39417
+ logOnce(`[TOMBSTONE_KEEP entityType=${strategy.type} slug=${entry2.slug} id=${row.id} reason=${tombstone.refusedReason}]`, `entity:${strategy.type}:${row.id}:${tombstone.refusedReason}:${entry2.slug}`);
38705
39418
  return { state, action: "tombstone_noop" };
38706
39419
  }
38707
- if (!wasTracked) {
38708
- return { state, action: "deleted" };
39420
+ if (tombstone.didUnlink) {
39421
+ out.log(`[TOMBSTONE_UNLINK entityType=${strategy.type} slug=${entry2.slug} id=${row.id}]`);
38709
39422
  }
38710
- const nextBucket = { ...bucket };
38711
- delete nextBucket[row.id];
38712
- return {
38713
- state: {
38714
- ...state,
38715
- entries: { ...state.entries, [strategy.type]: nextBucket }
38716
- },
38717
- action: "deleted"
38718
- };
39423
+ return { state: dropEntry2(), action: "deleted" };
38719
39424
  }
38720
39425
  const live = row;
38721
39426
  const existingEntry = state.entries[strategy.type]?.[live.id];
@@ -38741,7 +39446,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38741
39446
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
38742
39447
  let localRaw = null;
38743
39448
  try {
38744
- localRaw = readFileSync22(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
39449
+ localRaw = readFileSync23(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
38745
39450
  } catch {
38746
39451
  localRaw = null;
38747
39452
  }
@@ -38764,7 +39469,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38764
39469
  const hashes = computeMirrorHashes(strategy, content);
38765
39470
  let inode;
38766
39471
  try {
38767
- inode = statSync14(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
39472
+ inode = statSync15(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38768
39473
  } catch {
38769
39474
  inode = undefined;
38770
39475
  }
@@ -38857,7 +39562,7 @@ var syncCommand = defineCommand({
38857
39562
  // src/commands/start.ts
38858
39563
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
38859
39564
  var PULL_HEARTBEAT_MS = 900000;
38860
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync15(p)) {
39565
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync16(p)) {
38861
39566
  let changed = false;
38862
39567
  const nextEntries = {};
38863
39568
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38890,7 +39595,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync15(p)
38890
39595
  return { state, changed: false };
38891
39596
  return { state: { ...state, entries: nextEntries }, changed: true };
38892
39597
  }
38893
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync23(p, "utf8")) {
39598
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync24(p, "utf8")) {
38894
39599
  let changed = false;
38895
39600
  const nextEntries = {};
38896
39601
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38902,7 +39607,7 @@ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync2
38902
39607
  }
38903
39608
  try {
38904
39609
  const path5 = entityFilePath(workspaceSlug, entityType, entry2.slug);
38905
- const bodyLen = import_gray_matter12.default(readFn(path5)).content.length;
39610
+ const bodyLen = import_gray_matter13.default(readFn(path5)).content.length;
38906
39611
  nextBucket[id] = { ...entry2, body_len: bodyLen };
38907
39612
  changed = true;
38908
39613
  } catch {
@@ -38953,8 +39658,14 @@ function makeWatchHandler(deps) {
38953
39658
  return;
38954
39659
  }
38955
39660
  if (!exists) {
38956
- if (suppressor?.shouldSuppressDelete(absolutePath))
39661
+ if (suppressor?.shouldSuppressDelete(absolutePath)) {
39662
+ output?.log(fileDeleteSuppressedLine({ path: relPath, reason: "pull_echo" }));
39663
+ return;
39664
+ }
39665
+ if (existsSync8(absolutePath)) {
39666
+ output?.log(fileDeleteSuppressedLine({ path: relPath, reason: "path_exists" }));
38957
39667
  return;
39668
+ }
38958
39669
  if (fileGate) {
38959
39670
  const op = classifyFileDestructive({ relPath, absolutePath, exists: false }, getState());
38960
39671
  if (op) {
@@ -38978,7 +39689,7 @@ function makeWatchHandler(deps) {
38978
39689
  }
38979
39690
  let st;
38980
39691
  try {
38981
- st = statSync15(absolutePath);
39692
+ st = statSync16(absolutePath);
38982
39693
  } catch (err) {
38983
39694
  if (err?.code === "ENOENT")
38984
39695
  return;
@@ -39009,7 +39720,7 @@ function makeWatchHandler(deps) {
39009
39720
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
39010
39721
  return;
39011
39722
  }
39012
- const bytes = readFileSync23(absolutePath);
39723
+ const bytes = readFileSync24(absolutePath);
39013
39724
  const hash2 = sha256Bytes(bytes);
39014
39725
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
39015
39726
  bodyHash: hash2,
@@ -39067,7 +39778,7 @@ function makeWatchHandler(deps) {
39067
39778
  if (suppressor) {
39068
39779
  let raw = null;
39069
39780
  try {
39070
- raw = readFileSync23(absolutePath, "utf8");
39781
+ raw = readFileSync24(absolutePath, "utf8");
39071
39782
  } catch {
39072
39783
  raw = null;
39073
39784
  }
@@ -39105,7 +39816,7 @@ function makeWatchHandler(deps) {
39105
39816
  if (suppressor) {
39106
39817
  let raw = null;
39107
39818
  try {
39108
- raw = readFileSync23(absolutePath, "utf8");
39819
+ raw = readFileSync24(absolutePath, "utf8");
39109
39820
  } catch {
39110
39821
  raw = null;
39111
39822
  }
@@ -39143,7 +39854,7 @@ function makeWatchHandler(deps) {
39143
39854
  if (suppressor) {
39144
39855
  let raw = null;
39145
39856
  try {
39146
- raw = readFileSync23(absolutePath, "utf8");
39857
+ raw = readFileSync24(absolutePath, "utf8");
39147
39858
  } catch {
39148
39859
  raw = null;
39149
39860
  }
@@ -39181,7 +39892,7 @@ function makeWatchHandler(deps) {
39181
39892
  if (suppressor) {
39182
39893
  let raw = null;
39183
39894
  try {
39184
- raw = readFileSync23(absolutePath, "utf8");
39895
+ raw = readFileSync24(absolutePath, "utf8");
39185
39896
  } catch {
39186
39897
  raw = null;
39187
39898
  }
@@ -39219,7 +39930,7 @@ function makeWatchHandler(deps) {
39219
39930
  if (suppressor) {
39220
39931
  let raw = null;
39221
39932
  try {
39222
- raw = readFileSync23(absolutePath, "utf8");
39933
+ raw = readFileSync24(absolutePath, "utf8");
39223
39934
  } catch {
39224
39935
  raw = null;
39225
39936
  }
@@ -39251,7 +39962,7 @@ function makeWatchHandler(deps) {
39251
39962
  spinner.warn(`Push failed for artists/${slug}/memory.md: ${msg}`);
39252
39963
  }
39253
39964
  };
39254
- return async (event) => {
39965
+ const handle = async (event) => {
39255
39966
  try {
39256
39967
  if (event.kind === "renamed") {
39257
39968
  const classifiedFrom = classifyMirrorPath({
@@ -39321,14 +40032,20 @@ function makeWatchHandler(deps) {
39321
40032
  }
39322
40033
  if (suppressor) {
39323
40034
  if (event.kind === "unlink") {
39324
- if (suppressor.shouldSuppressDelete(absolutePath))
40035
+ if (suppressor.shouldSuppressDelete(absolutePath)) {
40036
+ output?.log(entityDeleteSuppressedLine({
40037
+ entityType: classified.entityType,
40038
+ slug: classified.entitySlug,
40039
+ reason: "pull_echo"
40040
+ }));
39325
40041
  return;
40042
+ }
39326
40043
  } else if (event.kind === "upsert") {
39327
40044
  const strategy = getStrategy(classified.entityType);
39328
40045
  if (strategy) {
39329
40046
  let raw = null;
39330
40047
  try {
39331
- raw = readFileSync23(absolutePath, "utf8");
40048
+ raw = readFileSync24(absolutePath, "utf8");
39332
40049
  } catch {
39333
40050
  raw = null;
39334
40051
  }
@@ -39338,6 +40055,15 @@ function makeWatchHandler(deps) {
39338
40055
  }
39339
40056
  }
39340
40057
  }
40058
+ if (event.kind === "unlink" && existsSync8(absolutePath)) {
40059
+ output?.log(entityDeleteSuppressedLine({
40060
+ entityType: classified.entityType,
40061
+ slug: classified.entitySlug,
40062
+ reason: "path_exists"
40063
+ }));
40064
+ await handle({ kind: "upsert", absolutePath });
40065
+ return;
40066
+ }
39341
40067
  if (event.kind === "unlink" && event.renameFallback) {
39342
40068
  const slugHint = event.stateEntry?.slug ? `${classified.entityType}/${event.stateEntry.slug}` : absolutePath;
39343
40069
  spinner.warn(`Rename pair miss for ${slugHint} — no inode in state, treated as delete (classifier degraded to create+delete). ` + `If you just ran \`mv\`, the cloud row was likely tombstoned; check ~/.plinth/_conflicts/ or restore from cloud.`);
@@ -39364,7 +40090,7 @@ function makeWatchHandler(deps) {
39364
40090
  slug: event.stateEntry.slug,
39365
40091
  updated_at: event.stateEntry.updated_at
39366
40092
  } : null,
39367
- deps: { fetchImpl, now, mutex, output }
40093
+ deps: { fetchImpl, now, mutex, output, suppressor }
39368
40094
  });
39369
40095
  setState(clearEntityRejected(result.state, workspaceSlug, absolutePath));
39370
40096
  reportOutcome(spinner, workspaceSlug, getState(), result.outcome);
@@ -39403,6 +40129,7 @@ function makeWatchHandler(deps) {
39403
40129
  spinner.warn(`Push failed for ${path5}: ${msg}`);
39404
40130
  }
39405
40131
  };
40132
+ return handle;
39406
40133
  }
39407
40134
  function discardSink() {
39408
40135
  const stream = { write: () => true };
@@ -40459,11 +41186,101 @@ var startCommand = defineCommand({
40459
41186
  });
40460
41187
 
40461
41188
  // src/commands/status.ts
40462
- import { statSync as statSync17 } from "node:fs";
41189
+ import { statSync as statSync18 } from "node:fs";
41190
+
41191
+ // src/lib/context/memory-bars.ts
41192
+ var RULE_FILE_BAR_BYTES = 6000;
41193
+ var FACT_STORE_BAR_BYTES = 12000;
41194
+ var MAX_NAMED_FACT_STORES = 5;
41195
+ function countRuleShapedLines(body) {
41196
+ let count = 0;
41197
+ for (const line of body.split(`
41198
+ `)) {
41199
+ const trimmed = line.trim();
41200
+ if (/^[-*+]\s/.test(trimmed))
41201
+ count += 1;
41202
+ else if (/^\*\*[^*]+\*\*/.test(trimmed))
41203
+ count += 1;
41204
+ }
41205
+ return count;
41206
+ }
41207
+ function factBody(raw) {
41208
+ return raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").split(`
41209
+ `).filter((line) => !/^\s*#{1,6}\s/.test(line)).join(`
41210
+ `).trim();
41211
+ }
41212
+ var RULE_FILES = [
41213
+ { filename: VOICE_FILENAME, agentMayEdit: false, memberOwned: true, ridesInjection: false },
41214
+ { filename: WORKSPACE_RULES_FILENAME, agentMayEdit: true, memberOwned: false, ridesInjection: true }
41215
+ ];
41216
+ function assessMemoryBars(workspaceSlug, deps = {}) {
41217
+ const readContext = deps.readContext ?? readContextFile;
41218
+ const readArtistMemory = deps.readArtistMemory ?? readArtistMemoryFile;
41219
+ const rosterOf = deps.readRosterFn ?? readRoster;
41220
+ const over = [];
41221
+ for (const spec of RULE_FILES) {
41222
+ let raw = null;
41223
+ try {
41224
+ raw = readContext(workspaceSlug, spec.filename);
41225
+ } catch {
41226
+ raw = null;
41227
+ }
41228
+ if (raw === null)
41229
+ continue;
41230
+ const bytes = Buffer.byteLength(raw);
41231
+ if (bytes <= RULE_FILE_BAR_BYTES)
41232
+ continue;
41233
+ over.push({
41234
+ path: spec.filename,
41235
+ kind: "rule",
41236
+ bytes,
41237
+ bar: RULE_FILE_BAR_BYTES,
41238
+ rules: countRuleShapedLines(raw),
41239
+ facts: groupFacts(factBody(raw)).length,
41240
+ agentMayEdit: spec.agentMayEdit,
41241
+ memberOwned: spec.memberOwned,
41242
+ buys: !spec.ridesInjection ? "per-read" : Buffer.byteLength(extractWorkspaceRules(raw) ?? "") > 0 ? "injection" : "nothing"
41243
+ });
41244
+ }
41245
+ let roster;
41246
+ try {
41247
+ roster = rosterOf(workspaceSlug);
41248
+ } catch {
41249
+ roster = [];
41250
+ }
41251
+ const stores = [];
41252
+ for (const artist of roster) {
41253
+ let raw = null;
41254
+ try {
41255
+ raw = readArtistMemory(workspaceSlug, artist.slug);
41256
+ } catch {
41257
+ raw = null;
41258
+ }
41259
+ if (raw === null)
41260
+ continue;
41261
+ const bytes = Buffer.byteLength(raw);
41262
+ if (bytes <= FACT_STORE_BAR_BYTES)
41263
+ continue;
41264
+ stores.push({
41265
+ path: `artists/${artist.slug}/memory.md`,
41266
+ kind: "fact",
41267
+ bytes,
41268
+ bar: FACT_STORE_BAR_BYTES,
41269
+ rules: countRuleShapedLines(raw),
41270
+ facts: groupFacts(factBody(raw)).length,
41271
+ agentMayEdit: true,
41272
+ memberOwned: true,
41273
+ buys: "per-read"
41274
+ });
41275
+ }
41276
+ stores.sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
41277
+ const named = stores.slice(0, MAX_NAMED_FACT_STORES);
41278
+ return { over: [...over, ...named], notNamed: stores.length - named.length };
41279
+ }
40463
41280
 
40464
41281
  // src/lib/build-stamp.ts
40465
41282
  import { execFileSync as execFileSync4 } from "node:child_process";
40466
- import { existsSync as existsSync8, mkdirSync as mkdirSync11, readFileSync as readFileSync24, writeFileSync as writeFileSync8 } from "node:fs";
41283
+ import { existsSync as existsSync9, mkdirSync as mkdirSync11, readFileSync as readFileSync25, writeFileSync as writeFileSync8 } from "node:fs";
40467
41284
  import { dirname as dirname10, join as join17 } from "node:path";
40468
41285
  import { fileURLToPath } from "node:url";
40469
41286
  var BUILD_STAMP_FILENAME = "build-stamp.json";
@@ -40487,7 +41304,7 @@ function readHead(repoDir) {
40487
41304
  }
40488
41305
  function readBuildStamp(distDir) {
40489
41306
  try {
40490
- const parsed = JSON.parse(readFileSync24(stampPath(distDir), "utf8"));
41307
+ const parsed = JSON.parse(readFileSync25(stampPath(distDir), "utf8"));
40491
41308
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
40492
41309
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
40493
41310
  }
@@ -40499,7 +41316,7 @@ function readBuildStamp(distDir) {
40499
41316
  function findRepoRoot(fromDir) {
40500
41317
  let dir = fromDir;
40501
41318
  for (;; ) {
40502
- if (existsSync8(join17(dir, ".git")))
41319
+ if (existsSync9(join17(dir, ".git")))
40503
41320
  return dir;
40504
41321
  const parent = dirname10(dir);
40505
41322
  if (parent === dir)
@@ -40547,13 +41364,59 @@ function checkBuildStaleness(deps = {}) {
40547
41364
  return { kind: "fresh", stamp, head };
40548
41365
  }
40549
41366
 
41367
+ // src/lib/memory-links.ts
41368
+ import { readFileSync as readFileSync26 } from "node:fs";
41369
+ import { relative as relative13, sep as sep5 } from "node:path";
41370
+ var MEMORY_FILE_RE = /^(workspace-memory\.md|artists\/[^/]+\/memory\.md)$/i;
41371
+ function scanMemoryLinks(deps) {
41372
+ const listFiles = deps.listFiles ?? ((r) => walkSubtreeFiles(r, isVcsInternal));
41373
+ const readFile = deps.readFile ?? ((p) => readFileSync26(p, "utf8"));
41374
+ let root;
41375
+ let absPaths;
41376
+ try {
41377
+ root = workspaceMirrorPath(deps.workspaceSlug);
41378
+ absPaths = listFiles(root);
41379
+ } catch {
41380
+ return { scanned: 0, unreadable: [], dead: [] };
41381
+ }
41382
+ const relPaths = absPaths.map((p) => relative13(root, p).split(sep5).join("/"));
41383
+ const artists = buildArtistIndex(relPaths, root, (abs) => readFile(abs));
41384
+ const dead = [];
41385
+ const unreadable = [];
41386
+ let scanned = 0;
41387
+ for (let i = 0;i < relPaths.length; i++) {
41388
+ const rel = relPaths[i];
41389
+ if (!MEMORY_FILE_RE.test(rel))
41390
+ continue;
41391
+ let body;
41392
+ try {
41393
+ body = readFile(absPaths[i]);
41394
+ } catch {
41395
+ unreadable.push(rel);
41396
+ continue;
41397
+ }
41398
+ scanned++;
41399
+ for (const link of parseMirrorLinks(body)) {
41400
+ if (link.kind !== "prose")
41401
+ continue;
41402
+ const resolution = resolveTarget(link.matchTarget, relPaths, artists);
41403
+ if (resolution.kind !== "unresolved")
41404
+ continue;
41405
+ dead.push({ file: rel, line: link.line, target: link.target });
41406
+ }
41407
+ }
41408
+ dead.sort((a, b) => a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file));
41409
+ unreadable.sort();
41410
+ return { scanned, unreadable, dead };
41411
+ }
41412
+
40550
41413
  // src/commands/status.ts
40551
41414
  init_output();
40552
41415
 
40553
41416
  // src/lib/pending-writes.ts
40554
- import { readFileSync as readFileSync25, statSync as statSync16 } from "node:fs";
40555
- import { relative as relative13 } from "node:path";
40556
- var import_gray_matter13 = __toESM(require_gray_matter(), 1);
41417
+ import { readFileSync as readFileSync27, statSync as statSync17 } from "node:fs";
41418
+ import { relative as relative14 } from "node:path";
41419
+ var import_gray_matter14 = __toESM(require_gray_matter(), 1);
40557
41420
  var EMPTY2 = { writes: [], count: 0 };
40558
41421
  function reportedElsewhere(state) {
40559
41422
  const entities = new Set;
@@ -40581,7 +41444,7 @@ function scanPendingWrites(args) {
40581
41444
  const writes = [];
40582
41445
  const discovered = [];
40583
41446
  for (const absolutePath of walkSubtreeFiles(mirrorRootPath, ignored)) {
40584
- const relPath = relative13(mirrorRootPath, absolutePath);
41447
+ const relPath = relative14(mirrorRootPath, absolutePath);
40585
41448
  const fileMatch = classifyFilesPath({ workspaceSlug, absolutePath });
40586
41449
  if (fileMatch) {
40587
41450
  if (frozenFiles.has(fileMatch.relPath))
@@ -40591,7 +41454,7 @@ function scanPendingWrites(args) {
40591
41454
  const entry3 = getFileEntry(state, fileMatch.relPath);
40592
41455
  let size;
40593
41456
  try {
40594
- size = statSync16(absolutePath).size;
41457
+ size = statSync17(absolutePath).size;
40595
41458
  } catch {
40596
41459
  continue;
40597
41460
  }
@@ -40599,7 +41462,7 @@ function scanPendingWrites(args) {
40599
41462
  continue;
40600
41463
  let hash2;
40601
41464
  try {
40602
- hash2 = sha256Bytes(readFileSync25(absolutePath));
41465
+ hash2 = sha256Bytes(readFileSync27(absolutePath));
40603
41466
  } catch {
40604
41467
  continue;
40605
41468
  }
@@ -40620,7 +41483,7 @@ function scanPendingWrites(args) {
40620
41483
  continue;
40621
41484
  let liveInode;
40622
41485
  try {
40623
- liveInode = statSync16(absolutePath).ino;
41486
+ liveInode = statSync17(absolutePath).ino;
40624
41487
  } catch {
40625
41488
  liveInode = undefined;
40626
41489
  }
@@ -40630,14 +41493,14 @@ function scanPendingWrites(args) {
40630
41493
  continue;
40631
41494
  let content;
40632
41495
  try {
40633
- content = readFileSync25(absolutePath, "utf8");
41496
+ content = readFileSync27(absolutePath, "utf8");
40634
41497
  } catch {
40635
41498
  continue;
40636
41499
  }
40637
41500
  if (!entry2) {
40638
41501
  let frontmatter;
40639
41502
  try {
40640
- frontmatter = import_gray_matter13.default(content).data ?? {};
41503
+ frontmatter = import_gray_matter14.default(content).data ?? {};
40641
41504
  } catch {
40642
41505
  continue;
40643
41506
  }
@@ -40799,6 +41662,15 @@ function formatAbsolute(date5) {
40799
41662
  const pad3 = (n) => String(n).padStart(2, "0");
40800
41663
  return `${date5.getFullYear()}-${pad3(date5.getMonth() + 1)}-${pad3(date5.getDate())} ` + `${pad3(date5.getHours())}:${pad3(date5.getMinutes())}:${pad3(date5.getSeconds())}`;
40801
41664
  }
41665
+ function describeTokens(tokens) {
41666
+ const versions2 = new Set(tokens.map((t) => t.version));
41667
+ const names = tokens.map((t) => versions2.size === 1 ? `\`${t.token}\`` : `\`${t.token}\` (${t.version})`);
41668
+ const joined = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
41669
+ return {
41670
+ joined,
41671
+ inVersion: versions2.size === 1 ? ` in ${tokens[0].version}` : ""
41672
+ };
41673
+ }
40802
41674
  function short(sha) {
40803
41675
  return sha.slice(0, 7);
40804
41676
  }
@@ -40820,7 +41692,7 @@ function renderBuild(out, staleness) {
40820
41692
  }
40821
41693
  function lastActivity(now) {
40822
41694
  try {
40823
- const mtimeMs = statSync17(daemonLogPath()).mtimeMs;
41695
+ const mtimeMs = statSync18(daemonLogPath()).mtimeMs;
40824
41696
  return formatRelative(now.getTime() - mtimeMs);
40825
41697
  } catch {
40826
41698
  return null;
@@ -41003,17 +41875,60 @@ async function runStatus(args, deps = {}) {
41003
41875
  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)`));
41004
41876
  }
41005
41877
  out.info("");
41006
- const commandScan = scanStaleUserBlocks({ workspaceSlug: slug });
41878
+ const links = scanMemoryLinks({ workspaceSlug: slug });
41879
+ const unreadableLinkLines = () => {
41880
+ const n = links.unreadable.length;
41881
+ return [
41882
+ `${n} memory file${n === 1 ? "" : "s"} could not be read, so ${n === 1 ? "its" : "their"} pointers are unchecked.`,
41883
+ ...links.unreadable
41884
+ ];
41885
+ };
41886
+ if (links.dead.length > 0) {
41887
+ const n = links.dead.length;
41888
+ out.info(row("Links", `${n} memory pointer${n === 1 ? "" : "s"} resolve${n === 1 ? "s" : ""} to nothing.`));
41889
+ const MAX_LINKS_SHOWN = 10;
41890
+ for (const d of links.dead.slice(0, MAX_LINKS_SHOWN)) {
41891
+ out.info(cont(`${d.file}:${d.line} — [[${d.target}]]`));
41892
+ }
41893
+ if (n > MAX_LINKS_SHOWN) {
41894
+ out.info(cont(`…and ${n - MAX_LINKS_SHOWN} more.`));
41895
+ }
41896
+ out.info(cont("The fact's pointer is its source. Restore the document, repoint the link, or fold what the pointer was carrying into the fact itself."));
41897
+ if (links.unreadable.length > 0) {
41898
+ for (const line of unreadableLinkLines())
41899
+ out.info(cont(line));
41900
+ }
41901
+ } else if (links.unreadable.length > 0) {
41902
+ const [head, ...rest] = unreadableLinkLines();
41903
+ out.info(row("Links", head));
41904
+ for (const line of rest)
41905
+ out.info(cont(line));
41906
+ } else if (links.scanned === 0) {
41907
+ out.info(row("Links", "No memory files yet. They arrive on the next sync."));
41908
+ } else {
41909
+ out.info(row("Links", "Every memory pointer resolves."));
41910
+ }
41911
+ out.info("");
41912
+ const commandScan = scanStaleUserBlocks({
41913
+ workspaceSlug: slug,
41914
+ retired: deps.retiredTokens
41915
+ });
41007
41916
  const unchecked = commandScan.unchecked;
41008
41917
  if (commandScan.stale.length > 0) {
41009
41918
  const n = commandScan.stale.length;
41010
41919
  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
41920
  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.`));
41921
+ const stillLive = f.tokens.filter((t) => t.stillACommand);
41922
+ const gone = f.tokens.filter((t) => !t.stillACommand);
41923
+ if (gone.length > 0) {
41924
+ const { joined, inVersion } = describeTokens(gone);
41925
+ out.info(cont(`${f.commandRel} names ${joined}, retired from ${f.invocation}` + `${inVersion}. Review it.`));
41926
+ }
41927
+ if (stillLive.length > 0) {
41928
+ const { joined, inVersion } = describeTokens(stillLive);
41929
+ const noun = stillLive.length === 1 ? "Still a working command" : "Still working commands";
41930
+ out.info(cont(`${f.commandRel} names ${joined}, which ${f.invocation} stopped ` + `running${inVersion}. ${noun} — keep the step if you want it.`));
41931
+ }
41017
41932
  }
41018
41933
  if (unchecked.length > 0) {
41019
41934
  for (const line of uncheckedLines(unchecked))
@@ -41054,7 +41969,7 @@ async function runStatus(args, deps = {}) {
41054
41969
  out.info("");
41055
41970
  let report = null;
41056
41971
  try {
41057
- report = runGroundingReport({ workspaceSlug: slug });
41972
+ report = (deps.groundingReport ?? (() => runGroundingReport({ workspaceSlug: slug })))();
41058
41973
  } catch {
41059
41974
  report = null;
41060
41975
  }
@@ -41065,6 +41980,16 @@ async function runStatus(args, deps = {}) {
41065
41980
  const bytes = Buffer.byteLength(injected);
41066
41981
  const overBudget = bytes > GROUNDING_BUDGET_BYTES;
41067
41982
  out.info(row("Injection", `${bytes.toLocaleString("en-GB")} bytes at every session start` + (overBudget ? "" : ` (budget ${GROUNDING_BUDGET_BYTES.toLocaleString("en-GB")})`)));
41983
+ const split = report.split;
41984
+ const spent = GROUNDING_CATEGORY_ORDER.filter((c) => split[c] > 0);
41985
+ if (spent.length > 0) {
41986
+ out.info(cont("Where those bytes go:"));
41987
+ const width = Math.max(...spent.map((c) => GROUNDING_CATEGORY_LABELS[c].length));
41988
+ for (const c of spent) {
41989
+ out.info(cont(` ${GROUNDING_CATEGORY_LABELS[c].padEnd(width)} ` + `${split[c].toLocaleString("en-GB").padStart(6)}` + (MEMBER_EDITABLE_CATEGORIES.includes(c) ? " *" : "")));
41990
+ }
41991
+ out.info(cont(`A * marks what is yours to change; the rest is Plinth's own ` + `grounding and is the same on every workspace.`));
41992
+ }
41068
41993
  const contributors = [];
41069
41994
  const rides = (name, reduce) => {
41070
41995
  let raw = null;
@@ -41085,8 +42010,8 @@ async function runStatus(args, deps = {}) {
41085
42010
  { name: WORKSPACE_MEMORY_FILENAME, pick: report.picks.wmPick },
41086
42011
  { name: USER_MEMORY_FILENAME, pick: report.picks.umPick }
41087
42012
  ].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);
42013
+ const dropped = indexes.filter((i) => i.kept === 0 && i.named === 0 && i.total > 0);
42014
+ const partial2 = indexes.filter((i) => i.kept + i.named > 0 && i.kept < i.total);
41090
42015
  if (overBudget) {
41091
42016
  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
42017
  } else if (dropped.length > 0 || partial2.length > 0) {
@@ -41094,7 +42019,10 @@ async function runStatus(args, deps = {}) {
41094
42019
  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
42020
  }
41096
42021
  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.`));
42022
+ const unnamed = i.total - i.kept - i.named;
42023
+ const head = i.kept === 0 ? `No fact in ${i.name} fits whole, but ${i.named} of ${i.total} ride as their opening line, so the agent knows they exist.` : `Only ${i.kept} of ${i.total} facts in ${i.name} fit in the injection.` + (i.named > 0 ? ` Another ${i.named} ride as their opening line, so the agent knows they exist.` : ``);
42024
+ const tail = unnamed > 0 ? ` The remaining ${unnamed} stay in the file and are read on demand.` : ` The full text of all of them stays in the file.`;
42025
+ out.info(cont(`${head}${tail}`));
41098
42026
  }
41099
42027
  if (largest !== undefined) {
41100
42028
  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.`));
@@ -41103,6 +42031,45 @@ async function runStatus(args, deps = {}) {
41103
42031
  out.info(cont("Every memory fact fits; nothing is being trimmed."));
41104
42032
  }
41105
42033
  }
42034
+ let bars = { over: [], notNamed: 0 };
42035
+ try {
42036
+ bars = assessMemoryBars(slug);
42037
+ } catch {
42038
+ bars = { over: [], notNamed: 0 };
42039
+ }
42040
+ if (bars.over.length > 0) {
42041
+ const overCount = bars.over.length;
42042
+ out.info(row("Too long", `${overCount} file${overCount === 1 ? " is" : "s are"} past the size ` + `${overCount === 1 ? "it is" : "they are"} useful at.`));
42043
+ const size = (n) => n.toLocaleString("en-GB");
42044
+ const buys = (f) => {
42045
+ if (f.buys === "injection") {
42046
+ return " It rides the injection and is never trimmed, so those bytes come off your memory facts.";
42047
+ }
42048
+ if (f.buys === "nothing") {
42049
+ return " None of it rides the injection today: it reduces to comments and headings, so trimming it buys nothing until it holds a rule.";
42050
+ }
42051
+ return "";
42052
+ };
42053
+ for (const f of bars.over.filter((o) => o.kind === "rule")) {
42054
+ const tail = !f.agentMayEdit ? " Yours to edit; the agent is denied." : f.memberOwned ? "" : " It belongs to the workspace, so a person trims it and no session close does.";
42055
+ out.info(cont(`${f.path} is ${size(f.bytes)} bytes against a ${size(f.bar)}-byte bar, ` + `about ${f.rules} rules.${buys(f)}${tail}`));
42056
+ }
42057
+ if (bars.over.some((o) => o.kind === "rule")) {
42058
+ out.info(cont("A rule file gets the tighter bar because it spends the instruction " + "budget: a model follows about 150 reliably and the harness already " + "takes 50. Facts cost room, not instructions."));
42059
+ }
42060
+ for (const f of bars.over.filter((o) => o.kind === "fact")) {
42061
+ out.info(cont(`${f.path} is ${size(f.bytes)} bytes against a ${size(f.bar)}-byte bar, ` + `${f.facts} facts.${buys(f)}`));
42062
+ }
42063
+ if (bars.notNamed > 0) {
42064
+ out.info(cont(`And ${bars.notNamed} more artist ${bars.notNamed === 1 ? "store" : "stores"} past the bar, not named here.`));
42065
+ }
42066
+ if (bars.over.some((o) => o.buys === "per-read")) {
42067
+ out.info(cont((bars.over.some((o) => o.buys !== "per-read") ? "The others are" : "These are") + " read whole when something opens them rather than injected, so" + " trimming them buys context on every read and no injection room."));
42068
+ }
42069
+ if (bars.over.some((o) => o.kind === "fact")) {
42070
+ out.info(cont(`Ending a session with ${CLOSE_COMMAND_INVOCATION} offers to propose a trim for the artist stores.`));
42071
+ }
42072
+ }
41106
42073
  out.info("");
41107
42074
  return { exitCode: 0 };
41108
42075
  }
@@ -41143,7 +42110,7 @@ var versionCommand = defineCommand({
41143
42110
  });
41144
42111
 
41145
42112
  // src/commands/voice-gate.ts
41146
- import { readFileSync as readFileSync28 } from "node:fs";
42113
+ import { readFileSync as readFileSync30 } from "node:fs";
41147
42114
 
41148
42115
  // src/lib/voice-gate/api.ts
41149
42116
  var VoiceGateSurfaces = [
@@ -41248,9 +42215,9 @@ async function callVoiceGate(req) {
41248
42215
  import { createHash as createHash4 } from "node:crypto";
41249
42216
  import {
41250
42217
  mkdirSync as mkdirSync12,
41251
- readFileSync as readFileSync26,
42218
+ readFileSync as readFileSync28,
41252
42219
  renameSync as renameSync8,
41253
- unlinkSync as unlinkSync8,
42220
+ unlinkSync as unlinkSync9,
41254
42221
  writeFileSync as writeFileSync9
41255
42222
  } from "node:fs";
41256
42223
  import { join as join18 } from "node:path";
@@ -41279,7 +42246,7 @@ function emptyFile() {
41279
42246
  }
41280
42247
  function load2(workspaceSlug) {
41281
42248
  try {
41282
- const raw = readFileSync26(attemptsPath(workspaceSlug), "utf8");
42249
+ const raw = readFileSync28(attemptsPath(workspaceSlug), "utf8");
41283
42250
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
41284
42251
  return parsed.success ? parsed.data : emptyFile();
41285
42252
  } catch {
@@ -41306,7 +42273,7 @@ function save(workspaceSlug, file2) {
41306
42273
  renameSync8(tmp, target);
41307
42274
  } catch (err) {
41308
42275
  try {
41309
- unlinkSync8(tmp);
42276
+ unlinkSync9(tmp);
41310
42277
  } catch {}
41311
42278
  throw err;
41312
42279
  }
@@ -41442,7 +42409,7 @@ function denyUngated(detail) {
41442
42409
  }
41443
42410
 
41444
42411
  // src/lib/voice-gate/payload.ts
41445
- import { readFileSync as readFileSync27, statSync as statSync18 } from "node:fs";
42412
+ import { readFileSync as readFileSync29, statSync as statSync19 } from "node:fs";
41446
42413
  import { homedir as homedir2 } from "node:os";
41447
42414
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
41448
42415
  var MAX_BODY_BYTES = 256 * 1024;
@@ -41599,11 +42566,11 @@ function isHelperInvocation(tokens) {
41599
42566
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
41600
42567
  }
41601
42568
  function defaultReadBodyFile(path5) {
41602
- const stat4 = statSync18(path5);
42569
+ const stat4 = statSync19(path5);
41603
42570
  if (stat4.size > MAX_BODY_BYTES) {
41604
42571
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
41605
42572
  }
41606
- return readFileSync27(path5, "utf8");
42573
+ return readFileSync29(path5, "utf8");
41607
42574
  }
41608
42575
  function parseRecipients(...raw) {
41609
42576
  const all = raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
@@ -41963,7 +42930,7 @@ async function runVoiceGateDirect(args, deps = {}) {
41963
42930
  let text;
41964
42931
  if (args.file) {
41965
42932
  try {
41966
- text = readFileSync28(args.file, "utf8");
42933
+ text = readFileSync30(args.file, "utf8");
41967
42934
  } catch (err) {
41968
42935
  const msg = err instanceof Error ? err.message : "unknown error";
41969
42936
  out.error(`Could not read ${args.file}: ${msg}`);
@@ -42023,12 +42990,12 @@ async function runVoiceGateCommand(args, deps = {}) {
42023
42990
  var voiceGateCommand = defineCommand({
42024
42991
  meta: {
42025
42992
  name: "voice-gate",
42026
- description: "Gate agent-originated copy against the workspace voice rubric. Use --hook as a Claude Code PreToolUse hook on Gmail draft creation."
42993
+ description: "Check agent-written copy against this workspace's voice rubric, on demand. Nothing gates drafting automatically: Plinth stopped mounting this as a hook and un-mounts the entry it used to write, so applying the rubric is the agent's own job."
42027
42994
  },
42028
42995
  args: {
42029
42996
  hook: {
42030
42997
  type: "boolean",
42031
- description: "PreToolUse hook mode: read the hook payload on stdin and block non-compliant Gmail drafts.",
42998
+ description: "PreToolUse hook mode: read a hook payload on stdin and block non-compliant Gmail drafts. Plinth does not mount this and un-mounts the entry it used to write; one you add by hand is left alone and still runs.",
42032
42999
  required: false
42033
43000
  },
42034
43001
  workspace: {
@@ -42083,6 +43050,24 @@ var voiceGateCommand = defineCommand({
42083
43050
  }
42084
43051
  });
42085
43052
 
43053
+ // src/commands/registry.ts
43054
+ var subCommands = {
43055
+ login: loginCommand,
43056
+ sync: syncCommand,
43057
+ start: startCommand,
43058
+ status: statusCommand,
43059
+ confirm: confirmCommand,
43060
+ version: versionCommand,
43061
+ "refresh-context": refreshContextCommand,
43062
+ grounding: groundingCommand,
43063
+ "voice-gate": voiceGateCommand,
43064
+ backlinks: backlinksCommand,
43065
+ declare: declareCommand,
43066
+ "review-tier": reviewTierCommand,
43067
+ session: sessionCommand,
43068
+ "memory-gate": memoryGateCommand
43069
+ };
43070
+
42086
43071
  // src/cli.ts
42087
43072
  var argv = process.argv.slice(2);
42088
43073
  var main = defineCommand({
@@ -42091,22 +43076,7 @@ var main = defineCommand({
42091
43076
  version: package_default.version,
42092
43077
  description: "Plinth capstone sync daemon and CLI"
42093
43078
  },
42094
- subCommands: {
42095
- login: loginCommand,
42096
- sync: syncCommand,
42097
- start: startCommand,
42098
- status: statusCommand,
42099
- confirm: confirmCommand,
42100
- version: versionCommand,
42101
- "refresh-context": refreshContextCommand,
42102
- grounding: groundingCommand,
42103
- "voice-gate": voiceGateCommand,
42104
- backlinks: backlinksCommand,
42105
- declare: declareCommand,
42106
- "review-tier": reviewTierCommand,
42107
- session: sessionCommand,
42108
- "memory-gate": memoryGateCommand
42109
- }
43079
+ subCommands
42110
43080
  });
42111
43081
  var globalSlotEnd = 0;
42112
43082
  while (globalSlotEnd < argv.length && argv[globalSlotEnd].startsWith("-"))