@plinth-music/cli 0.12.0 → 0.13.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.12.0",
7587
+ version: "0.13.0",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -26170,7 +26170,7 @@ function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemor
26170
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
26171
  if (identity !== null)
26172
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.`);
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, forwarding and deleting 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 one deleted out of a mailbox is not yours to lose; the person whose name ` + `is on it is the one who decides.`);
26174
26174
  if (workspaceRules === null && voiceProfile === null) {
26175
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
26176
  }
@@ -26458,7 +26458,25 @@ var RejectedWriteSchema = exports_external.object({
26458
26458
  reason: exports_external.string().min(1),
26459
26459
  detail: exports_external.string().optional(),
26460
26460
  message: exports_external.string().optional(),
26461
- rejected_at: exports_external.string().datetime({ offset: true }).optional()
26461
+ rejected_at: exports_external.string().datetime({ offset: true }).optional(),
26462
+ size_bytes: exports_external.number().int().nonnegative().optional(),
26463
+ max_bytes: exports_external.number().int().nonnegative().optional(),
26464
+ reported_at: exports_external.string().datetime({ offset: true }).optional()
26465
+ });
26466
+ var TeamFileRejectionSchema = exports_external.object({
26467
+ path: exports_external.string().min(1),
26468
+ reason: exports_external.string().min(1),
26469
+ detail: exports_external.string().nullish(),
26470
+ size_bytes: exports_external.number().int().nonnegative().nullish(),
26471
+ max_bytes: exports_external.number().int().nonnegative().nullish(),
26472
+ reported_by: exports_external.string().nullish(),
26473
+ reported_by_name: exports_external.string().nullish(),
26474
+ first_seen_at: exports_external.string().nullish(),
26475
+ last_seen_at: exports_external.string().nullish()
26476
+ });
26477
+ var TeamRejectedSchema = exports_external.object({
26478
+ fetched_at: exports_external.string().datetime({ offset: true }),
26479
+ rows: exports_external.array(TeamFileRejectionSchema)
26462
26480
  });
26463
26481
  var WorkspaceStateV2Schema = exports_external.object({
26464
26482
  version: exports_external.literal(2),
@@ -26472,7 +26490,8 @@ var WorkspaceStateV2Schema = exports_external.object({
26472
26490
  user_memory: UserMemoryStateSchema.optional(),
26473
26491
  user_voice: UserVoiceStateSchema.optional(),
26474
26492
  derived_text_version: exports_external.number().int().nonnegative().optional(),
26475
- frontmatter_id_backfill_version: exports_external.number().int().nonnegative().optional()
26493
+ frontmatter_id_backfill_version: exports_external.number().int().nonnegative().optional(),
26494
+ team_rejected: TeamRejectedSchema.optional()
26476
26495
  });
26477
26496
  var WorkspaceStateV1Schema = exports_external.object({
26478
26497
  version: exports_external.literal(1),
@@ -26654,7 +26673,8 @@ function commitState(before, after) {
26654
26673
  user_memory: userMemory.value,
26655
26674
  user_voice: userVoice.value,
26656
26675
  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)
26676
+ frontmatter_id_backfill_version: mergeScalar(fresh.frontmatter_id_backfill_version, before.frontmatter_id_backfill_version, after.frontmatter_id_backfill_version),
26677
+ team_rejected: mergeScalar(fresh.team_rejected, before.team_rejected, after.team_rejected)
26658
26678
  };
26659
26679
  writeState(merged);
26660
26680
  const singletonOverruled = rules.overruled || memory.overruled || userMemory.overruled || userVoice.overruled;
@@ -26825,6 +26845,7 @@ function upsertRejected(state, candidate) {
26825
26845
  const list = state.rejected ?? [];
26826
26846
  const idx = list.findIndex((o) => o.target === candidate.target && o.path === candidate.path);
26827
26847
  const existing = idx >= 0 ? list[idx] : undefined;
26848
+ const payloadMoved = existing !== undefined && (existing.reason !== candidate.reason || (existing.detail ?? existing.message) !== (candidate.detail ?? candidate.message) || existing.size_bytes !== candidate.size_bytes || existing.max_bytes !== candidate.max_bytes);
26828
26849
  const merged = {
26829
26850
  target: candidate.target,
26830
26851
  op: candidate.op,
@@ -26834,7 +26855,10 @@ function upsertRejected(state, candidate) {
26834
26855
  reason: candidate.reason,
26835
26856
  detail: candidate.detail,
26836
26857
  message: candidate.message,
26837
- rejected_at: existing?.rejected_at ?? candidate.rejected_at
26858
+ rejected_at: existing?.rejected_at ?? candidate.rejected_at,
26859
+ size_bytes: candidate.size_bytes,
26860
+ max_bytes: candidate.max_bytes,
26861
+ reported_at: payloadMoved ? undefined : existing?.reported_at
26838
26862
  };
26839
26863
  const next = [...list];
26840
26864
  if (idx >= 0)
@@ -26850,6 +26874,53 @@ function removeRejected(state, target, path3) {
26850
26874
  return { state, changed: false };
26851
26875
  return { state: { ...state, rejected: next }, changed: true };
26852
26876
  }
26877
+ function markRejectedReported(state, path3, reportedAt) {
26878
+ const list = state.rejected ?? [];
26879
+ const idx = list.findIndex((o) => o.target === "file" && o.path === path3);
26880
+ if (idx < 0)
26881
+ return { state, changed: false };
26882
+ if (list[idx].reported_at === reportedAt)
26883
+ return { state, changed: false };
26884
+ const next = [...list];
26885
+ next[idx] = { ...list[idx], reported_at: reportedAt };
26886
+ return { state: { ...state, rejected: next }, changed: true };
26887
+ }
26888
+ function clearRejectedReported(state, path3) {
26889
+ const list = state.rejected ?? [];
26890
+ const idx = list.findIndex((o) => o.target === "file" && o.path === path3);
26891
+ if (idx < 0)
26892
+ return { state, changed: false };
26893
+ if (list[idx].reported_at === undefined)
26894
+ return { state, changed: false };
26895
+ const next = [...list];
26896
+ const { reported_at: _dropped, ...rest } = list[idx];
26897
+ next[idx] = rest;
26898
+ return { state: { ...state, rejected: next }, changed: true };
26899
+ }
26900
+ function listTeamRejected(state) {
26901
+ return state.team_rejected?.rows ?? [];
26902
+ }
26903
+ function teamRejectedFetchedAt(state) {
26904
+ return state.team_rejected?.fetched_at;
26905
+ }
26906
+ function setTeamRejected(state, rows, fetchedAt) {
26907
+ const existing = state.team_rejected;
26908
+ if (existing && rowsEqual(existing.rows, rows)) {
26909
+ return { state, changed: false };
26910
+ }
26911
+ return {
26912
+ state: { ...state, team_rejected: { fetched_at: fetchedAt, rows } },
26913
+ changed: true
26914
+ };
26915
+ }
26916
+ function rowsEqual(a, b) {
26917
+ if (a.length !== b.length)
26918
+ return false;
26919
+ return a.every((x, i) => {
26920
+ const y = b[i];
26921
+ return x.path === y.path && x.reason === y.reason && x.detail === y.detail && x.size_bytes === y.size_bytes && x.max_bytes === y.max_bytes && x.reported_by === y.reported_by && x.reported_by_name === y.reported_by_name && x.first_seen_at === y.first_seen_at && x.last_seen_at === y.last_seen_at;
26922
+ });
26923
+ }
26853
26924
  function getWorkspaceRulesState(state) {
26854
26925
  return state.workspace_rules;
26855
26926
  }
@@ -28555,16 +28626,24 @@ var MANAGED_COMMENT = [
28555
28626
  "entries in this file (they are preserved verbatim, including their matchers)",
28556
28627
  "or in .claude/settings.local.json.",
28557
28628
  "The permissions.deny entries below are managed the same way. They stop an agent",
28558
- "in this workspace sending, replying to or forwarding mail and writing a calendar;",
28559
- "drafting and reading stay allowed. They match by VERB FAMILY rather than by",
28629
+ "in this workspace sending, replying to or forwarding mail, deleting or trashing",
28630
+ "it, marking it spam, and writing a calendar; drafting, filing, labelling and",
28631
+ "reading stay allowed. The refusal covers a draft the agent staged itself: on a",
28632
+ "Gmail mount there is no delete-draft tool, so trashing was the only way to",
28633
+ "discard one and it is now refused. Fix a wrong draft with update_draft, which",
28634
+ "keeps its thread. They match by VERB FAMILY rather than by",
28560
28635
  "tool name - send_mail, sendEmail, send-mail and outlook_send_message are one",
28561
28636
  "rule set, not four - and the matcher cannot express an alternation, so each",
28562
28637
  "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.",
28638
+ "merely begins with a send verb - getForwardingSettings is the live example -",
28639
+ "and it stops an agent trashing any file, not only mail: nothing here can tell",
28640
+ "a mailbox's trash from a drive's.",
28564
28641
  "You cannot re-open one of these here: a deny beats an allow, including yours,",
28565
28642
  "and this list is rewritten every sync. Tell us and we will narrow the pattern.",
28566
28643
  "We took that trade knowingly - a refusal is visible and recoverable, and a",
28567
- "message that has gone cannot be recalled.",
28644
+ "message that has gone cannot be recalled. Nor is a deleted one yours to lose:",
28645
+ "it is somebody else's mailbox, spam-marking is deletion on a thirty-day fuse,",
28646
+ "and neither is undone by noticing afterwards.",
28568
28647
  "One more entry stops an agent EDITING voice.md, the",
28569
28648
  "voice profile it is judged against: that file is yours to write and theirs to read.",
28570
28649
  "Your own allow list is untouched, and your own",
@@ -28643,6 +28722,36 @@ var MANAGED_DENY = [
28643
28722
  "mcp__*__*_cancel*Event*",
28644
28723
  "mcp__*__*-cancel*Event*",
28645
28724
  "mcp__*__*Cancel*Event*",
28725
+ "mcp__*__trash*",
28726
+ "mcp__*__*_trash*",
28727
+ "mcp__*__*-trash*",
28728
+ "mcp__*__*Trash*",
28729
+ "mcp__*__delete*message*",
28730
+ "mcp__*__*_delete*message*",
28731
+ "mcp__*__*-delete*message*",
28732
+ "mcp__*__*Delete*message*",
28733
+ "mcp__*__delete*Message*",
28734
+ "mcp__*__*_delete*Message*",
28735
+ "mcp__*__*-delete*Message*",
28736
+ "mcp__*__*Delete*Message*",
28737
+ "mcp__*__delete*thread*",
28738
+ "mcp__*__*_delete*thread*",
28739
+ "mcp__*__*-delete*thread*",
28740
+ "mcp__*__*Delete*thread*",
28741
+ "mcp__*__delete*Thread*",
28742
+ "mcp__*__*_delete*Thread*",
28743
+ "mcp__*__*-delete*Thread*",
28744
+ "mcp__*__*Delete*Thread*",
28745
+ "mcp__*__delete*mail*",
28746
+ "mcp__*__*_delete*mail*",
28747
+ "mcp__*__*-delete*mail*",
28748
+ "mcp__*__*Delete*mail*",
28749
+ "mcp__*__delete*Mail*",
28750
+ "mcp__*__*_delete*Mail*",
28751
+ "mcp__*__*-delete*Mail*",
28752
+ "mcp__*__*Delete*Mail*",
28753
+ "mcp__*__*spam*",
28754
+ "mcp__*__*Spam*",
28646
28755
  "Edit(//**/voice.md)"
28647
28756
  ];
28648
28757
  var RETIRED_DENY = [
@@ -28865,7 +28974,7 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
28865
28974
  parsed = raw;
28866
28975
  } catch (err) {
28867
28976
  const msg = err instanceof Error ? err.message : "unknown error";
28868
- out.error(`${rel} could not be parsed (${msg}), so the Plinth session-grounding ` + `hook, the session-visibility hook and the permissions.deny list were ` + `NOT installed there. Fix or remove the file and re-run — until then ` + `this workspace is ungrounded and an agent here can SEND mail and ` + `write calendars.`);
28977
+ out.error(`${rel} could not be parsed (${msg}), so the Plinth session-grounding ` + `hook, the session-visibility hook and the permissions.deny list were ` + `NOT installed there. Fix or remove the file and re-run — until then ` + `this workspace is ungrounded and an agent here can SEND mail, ` + `DELETE mail and write calendars.`);
28869
28978
  return "skipped";
28870
28979
  }
28871
28980
  }
@@ -28874,7 +28983,7 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
28874
28983
  generated = buildSettingsJson(resolved.invocation, workspaceSlug, parsed);
28875
28984
  } catch (err) {
28876
28985
  const msg = err instanceof Error ? err.message : "unknown error";
28877
- out.error(`${rel} has a structure the generator could not merge into (${msg}), so ` + `the Plinth session-grounding hook, the session-visibility hook and the ` + `permissions.deny list were NOT installed there. This workspace is ` + `ungrounded and an agent here can SEND mail and write calendars. Fix or ` + `remove the file and re-run.`);
28986
+ out.error(`${rel} has a structure the generator could not merge into (${msg}), so ` + `the Plinth session-grounding hook, the session-visibility hook and the ` + `permissions.deny list were NOT installed there. This workspace is ` + `ungrounded and an agent here can SEND mail, DELETE mail and write ` + `calendars. Fix or ` + `remove the file and re-run.`);
28878
28987
  return "skipped";
28879
28988
  }
28880
28989
  if (existing === generated)
@@ -28900,7 +29009,7 @@ function reportResolution(out, resolved) {
28900
29009
  }
28901
29010
  }
28902
29011
  function warnUnresolvedBinary(out) {
28903
- out.error("Could not locate an installed `plinth` binary, so no Claude Code settings " + "were written for this workspace — sessions will be UNGROUNDED and an " + "agent here can SEND mail and write calendars. Install the CLI globally " + "(`npm i -g @plinth-music/cli`) so `command -v plinth` resolves, then " + "re-run `plinth refresh-context`.");
29012
+ out.error("Could not locate an installed `plinth` binary, so no Claude Code settings " + "were written for this workspace — sessions will be UNGROUNDED and an " + "agent here can SEND mail, DELETE mail and write calendars. Install the " + "CLI globally " + "(`npm i -g @plinth-music/cli`) so `command -v plinth` resolves, then " + "re-run `plinth refresh-context`.");
28904
29013
  }
28905
29014
 
28906
29015
  // src/lib/context/artist-context.ts
@@ -29208,6 +29317,8 @@ function buildCloseCommandMd(workspaceSlug) {
29208
29317
  "",
29209
29318
  "**A session that touched no artist writes one dated note under `documents/` instead**, with the same content. That is a new file rather than an edit to a tracked one, so it reaches the cloud only with the sync running - Step 2 is where you already saw whether it is.",
29210
29319
  "",
29320
+ "**Something rule-shaped is not a loose end and is not memory. Sort it here, before Step 4 offers it a memory index. This close writes none of them** - name which of these four it is in the closing summary and write no prose for it. *Mechanical* (a tool to refuse, or a check a program could run): a deny rule or a hook. *Procedural* (a sequence somebody repeats): a skill, or a step in the customisable block at the foot of these commands. *Workspace-wide and stable*: `workspace-rules.md`, and **the member writes it, not you** - it reaches every teammate on every machine. *A preference dressed as a rule*: say so and let it go.",
29321
+ "",
29211
29322
  "## Step 4: bank what was learned, by type first and scope second",
29212
29323
  "",
29213
29324
  "**Two sorts, in this order, and the order is the whole of it.** First: does this fact already have a surface of its own? Then, and only then: whose fact is it? Sorting by scope alone is how a contact record ends up in a memory file wearing a memory line's clothes.",
@@ -29229,6 +29340,12 @@ function buildCloseCommandMd(workspaceSlug) {
29229
29340
  "",
29230
29341
  "### Step 4b: two piles, and the artist pile comes first",
29231
29342
  "",
29343
+ "**Before either pile, the gate. These four are never banked:** *raw transcript* - what it taught you may be a fact, what was said is not · *a guess, or anything only true today* - live state, which Step 3 routes · *a duplicate of a line already in the index* · *anything the generated `CLAUDE.md` or this session's grounding already states*. **Every line you do bank names its source** - who told you, or which tool read returned it - with an absolute date. **If you cannot name the source, write `source: unknown` - never infer one.**",
29344
+ "",
29345
+ "**Read the index before you add to it, and reconcile what disagrees.** If two lines on your subject contradict each other, **which pair it is decides what you may do**. *One is the line you are adding now*: do not add a third - edit the older line until it reads as superseded, then write yours. **It may well be a teammate's, so mark it superseded, never delete it, and name whose line you changed in the closing summary.** *Both were there before this session*: **propose the supersede and let the member confirm it**, as Step 6 does. Neither is yours and this step has no confirmation gate, so **resolving somebody else's is not yours to do unasked**.",
29346
+ "",
29347
+ "**When two lines disagree, this order decides which is current:** a line the member confirmed outranks a newer one the agent wrote; then the newer line; then one verified against a tool read; then one repeated across sessions; mark the loser superseded in place, never delete it silently.",
29348
+ "",
29232
29349
  "**Most of what a session learns is true of exactly one artist**, and that is the pile that gets forgotten. The other is true of the member in front of you. Write both directly: they are scoped to the person or the act they describe, and a wrong line in either is theirs to prune.",
29233
29350
  "",
29234
29351
  "**The sorting test: would a teammate who never touches that artist be worse off without this line?** If not, it is the artist pile.",
@@ -29239,7 +29356,7 @@ function buildCloseCommandMd(workspaceSlug) {
29239
29356
  "",
29240
29357
  "That artist's rate card, a contact of theirs whose behaviour you had to learn, how their kit actually works, what a venue reliably does. It syncs to that artist's Plinth record, so a teammate opening that artist gets it. **List what you wrote here in the closing summary.**",
29241
29358
  "",
29242
- "**Follow the conventions in the file's own header comment**, which states them and is the version that stays current. Do not work from a copy of them here.",
29359
+ "**Follow the conventions in the file's own header comment**, which states them and is the version that stays current. Do not work from a copy of them here. **The source convention and the conflict order above are the exception**: they reach a header only on a file this command creates, so they hold wherever an existing header is silent.",
29243
29360
  "",
29244
29361
  "**Re-read the file at the moment you write it, not when you were planning.** The daemon can pull a `memory.md` down from Plinth at any point in a session, so a file that was missing minutes ago may exist now - and if it does, add your line to it rather than creating it over the top. This is not hypothetical: a close on 26 Aug 2026 checked, found nothing, and clobbered a copy the daemon had pulled in ninety seconds earlier.",
29245
29362
  "",
@@ -29251,6 +29368,11 @@ function buildCloseCommandMd(workspaceSlug) {
29251
29368
  "<!-- Durable facts about <Artist> that hold across sessions. One line per fact, with",
29252
29369
  " provenance and an absolute date. SUPERSEDE, do not accumulate - when a fact here",
29253
29370
  " becomes wrong, edit that line in place rather than adding a contradicting one.",
29371
+ " If you cannot name the source, write `source: unknown` - never infer one.",
29372
+ " When two lines disagree, this order decides which is current: a line the member",
29373
+ " confirmed outranks a newer one the agent wrote; then the newer line; then one",
29374
+ " verified against a tool read; then one repeated across sessions; mark the loser",
29375
+ " superseded in place, never delete it silently.",
29254
29376
  " Live state does NOT belong here - that goes in the body of `_artist.md`. -->",
29255
29377
  "```",
29256
29378
  "",
@@ -29278,7 +29400,9 @@ function buildCloseCommandMd(workspaceSlug) {
29278
29400
  "",
29279
29401
  "**Anything named there was NOT banked, whatever the write appeared to return.** Say so plainly, name the file, and point the user at the restore command rather than reporting the work as done. A close that reports a stashed session record as banked is the one failure this step exists to prevent, because the record was the point of the close.",
29280
29402
  "",
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.",
29403
+ "**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, **anything rule-shaped you are handing back**, **any contradicting pair you found**, 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.",
29404
+ "",
29405
+ "**When more than seven lines is needed, what this close could NOT do comes first** - a proposal handed back, a contradiction unresolved, a fact with no home. Those exist nowhere else once this session ends.",
29282
29406
  "",
29283
29407
  "## Step 6: offer to trim what has outgrown its file, and do not trim it",
29284
29408
  "",
@@ -29294,20 +29418,38 @@ function buildCloseCommandMd(workspaceSlug) {
29294
29418
  "",
29295
29419
  "**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
29420
  "",
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:",
29421
+ "**On yes: propose it line by line, and write nothing yet.** Take one file at a time. **This is a list of proposals, not a file operation.** The member accepts or rejects each line on its own, and a line they reject stays exactly as it is. For every line you want to remove or rewrite, give these three fields and no others; a line you leave alone still appears in the listing:",
29422
+ "",
29423
+ "1. **The line itself, verbatim.**",
29424
+ "2. **Its age**, counted from the absolute date the line carries to the date the session hook injected. **A line carrying no date has no age - say so rather than estimating one.**",
29425
+ "3. **What you propose, and one reason.** **To REMOVE**, the reason is one of these four and no others: **superseded** by a later line you name · **duplicate** of another line here · **live state** belonging in the body of `artists/<slug>/_artist.md` · **over-long**, whose remedy is a split and never a cut. **To KEEP but change**, say which rewrite it is: **shortened**, the fact whole and only the wording gone, or **merged** with a line you name. A rewrite is not a removal and needs none of the four.",
29426
+ "",
29427
+ "**Shortening is what actually clears the bar, so reach for it first** - a split makes the file longer, and the four removal reasons are about lines that should not be there rather than about size. **A file whose lines are all correct, current and singular is trimmed by shortening or not at all**: say so rather than manufacturing a removal to reach a number. **An undated line is the member's call** - you cannot show it is over 30 days, so the floor cannot clear it: say it has no date, give your reason, let them decide. **Never treat undated as recent and drop it silently.**",
29428
+ "",
29429
+ "Then the rules the proposal itself obeys:",
29298
29430
  "",
29299
29431
  "- **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
29432
  "- **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
29433
  "- **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
29434
  "- **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
29435
  "- **Merging two facts into one is a rewrite and shows as one**, with both original lines visible in the proposal.",
29436
+ "- **A contradicting pair is a supersede, not a cut**, resolved by Step 4b's order; the loser stays in the file.",
29304
29437
  "",
29305
29438
  "**Then write only what the member confirms**, and only to the files this command can reach:",
29306
29439
  "",
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.",
29440
+ "- `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 writes that reaches Plinth.**",
29308
29441
  `- \`${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.`,
29442
+ "- **`workspace-rules.md` is reported here and never edited, and nothing in the harness stops you.** It is injected whole into every teammate's session, so **it is the member's to change** - Step 3's call, and a size row does not change it. Say how far over it is and propose nothing.",
29309
29443
  "- **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
29444
  "",
29445
+ "**Every line the trim changes is preserved, rewrites included.** The ORIGINAL of each line you remove, shorten or merge goes verbatim into `artists/<slug>/memory-archive.md` beside the store, under a heading carrying the date the session hook injected. Create it if absent, with this first line:",
29446
+ "",
29447
+ "```md",
29448
+ "# <Artist> - RETIRED facts, removed from `memory.md`. Not current, and not synced.",
29449
+ "```",
29450
+ "",
29451
+ "**Write the archive first and `artists/<slug>/memory.md` second, never the other way round** - two files cannot be one edit, and in this order a failure between them over-claims rather than losing lines. **Say plainly that the archive stays on this machine**: Plinth never syncs, reads or rewrites it, so a teammate sees the facts gone from `memory.md` and no trace of where they went. Undoing a trim is a copy back by hand. **The proposal is the control; the archive is only the undo.** **Report it when you report the trim, not in the Step 5 summary**, which is already written - name the file, and **if the `memory.md` write was set aside say so**, because the archive then lists lines still in the store.",
29452
+ "",
29311
29453
  "**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
29454
  "",
29313
29455
  "## Step 7: end the session",
@@ -29896,6 +30038,18 @@ function fileOverCapLine(f) {
29896
30038
  function fileConflictLine(f) {
29897
30039
  return `[FILE_CONFLICT path=${f.path} stash=${f.stash}]`;
29898
30040
  }
30041
+ function fileRejectionSyncFailedLine(f) {
30042
+ const parts = [`[FILE_REJECTION_SYNC_FAILED verb=${f.verb}`];
30043
+ if (f.path !== undefined)
30044
+ parts.push(`path=${f.path}`);
30045
+ parts.push(`reason=${f.reason}`);
30046
+ if (f.status !== undefined)
30047
+ parts.push(`status=${f.status}`);
30048
+ return `${parts.join(" ")}]`;
30049
+ }
30050
+ function fileRejectionWithdrawnLine(f) {
30051
+ return `[FILE_REJECTION_WITHDRAWN path=${f.path} disposition=${f.disposition}]`;
30052
+ }
29899
30053
  function conflictsViewFailedLine(f) {
29900
30054
  const msg = f.message === undefined ? "" : sanitizeServerMessage(f.message);
29901
30055
  const msgPart = msg === "" ? "" : ` message="${msg.replace(/"/g, "'")}"`;
@@ -30005,7 +30159,7 @@ function humanBytes(bytes) {
30005
30159
  function recordFileOverCap(args) {
30006
30160
  const withoutStale = removeUnsynced(args.state, "file", args.relPath).state;
30007
30161
  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.`;
30162
+ const message = args.tracked ? `A local change to this file (${humanBytes(args.size)}) exceeds ${ceiling}, ` + `so it cannot be pushed and 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
30163
  return persist(withoutStale, upsertRejected(withoutStale, {
30010
30164
  target: "file",
30011
30165
  op: "upsert",
@@ -30013,6 +30167,8 @@ function recordFileOverCap(args) {
30013
30167
  status: 413,
30014
30168
  reason: "over_cap",
30015
30169
  message,
30170
+ size_bytes: args.size,
30171
+ max_bytes: args.max,
30016
30172
  rejected_at: args.now().toISOString()
30017
30173
  }));
30018
30174
  }
@@ -30066,6 +30222,12 @@ function clearFileRejected(state, relPath) {
30066
30222
  return state;
30067
30223
  return commitState(state, next.state);
30068
30224
  }
30225
+ function clearReportedSafeFileRejected(state, relPath) {
30226
+ const existing = (state.rejected ?? []).find((o) => o.target === "file" && o.path === relPath);
30227
+ if (existing?.reported_at !== undefined)
30228
+ return state;
30229
+ return clearFileRejected(state, relPath);
30230
+ }
30069
30231
 
30070
30232
  // src/lib/context/workspace-claude-md.ts
30071
30233
  var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
@@ -30937,7 +31099,7 @@ var sessionCommand = defineCommand({
30937
31099
 
30938
31100
  // src/commands/start.ts
30939
31101
  var import_gray_matter13 = __toESM(require_gray_matter(), 1);
30940
- import { existsSync as existsSync8, readFileSync as readFileSync24, statSync as statSync16 } from "node:fs";
31102
+ import { existsSync as existsSync8, readFileSync as readFileSync25, statSync as statSync17 } from "node:fs";
30941
31103
 
30942
31104
  // src/lib/daemon-log.ts
30943
31105
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync9, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -31190,9 +31352,9 @@ function conflictsRoot(workspaceSlug) {
31190
31352
  return join11(plinthHome(), "_conflicts", workspaceSlug);
31191
31353
  }
31192
31354
  function conflictFilePath(args) {
31193
- const { workspaceSlug, entityType, entitySlug, timestamp } = args;
31355
+ const { workspaceSlug, entityType, entitySlug, timestamp, side } = args;
31194
31356
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
31195
- return join11(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
31357
+ return join11(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.${side}.md`);
31196
31358
  }
31197
31359
  function stashConflict(args) {
31198
31360
  const target = conflictFilePath(args);
@@ -31201,10 +31363,10 @@ function stashConflict(args) {
31201
31363
  return target;
31202
31364
  }
31203
31365
  function fileConflictPath(args) {
31204
- const { workspaceSlug, relPath, timestamp } = args;
31366
+ const { workspaceSlug, relPath, timestamp, side } = args;
31205
31367
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
31206
31368
  const safe = relPath.replace(/[/\\]/g, "__");
31207
- return join11(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
31369
+ return join11(conflictsRoot(workspaceSlug), `files.${safe}.${iso}.${side}`);
31208
31370
  }
31209
31371
  function stashFileConflict(args) {
31210
31372
  const target = fileConflictPath(args);
@@ -31212,7 +31374,7 @@ function stashFileConflict(args) {
31212
31374
  writeFileSync7(target, args.bytes, { mode: 384 });
31213
31375
  return target;
31214
31376
  }
31215
- var STAMP_RE2 = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(\.md)?$/;
31377
+ var STAMP_RE2 = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(?:\.(local|cloud))?(?:\.md)?$/;
31216
31378
  function parseStamp(stamp) {
31217
31379
  const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
31218
31380
  const d = new Date(iso);
@@ -31253,11 +31415,13 @@ function listConflictStashes(workspaceSlug) {
31253
31415
  if (file2.startsWith("."))
31254
31416
  continue;
31255
31417
  const m = STAMP_RE2.exec(file2);
31418
+ const side = m?.[2];
31256
31419
  out.push({
31257
31420
  file: file2,
31258
31421
  absPath,
31259
31422
  label: labelFor(file2),
31260
31423
  stashedAt: m ? parseStamp(m[1]) : null,
31424
+ side: side === "local" || side === "cloud" ? side : null,
31261
31425
  mtimeMs: st.mtimeMs
31262
31426
  });
31263
31427
  }
@@ -31512,7 +31676,7 @@ async function pushFilesUpsert(args) {
31512
31676
  cloudRow = undefined;
31513
31677
  }
31514
31678
  }
31515
- const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes });
31679
+ const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes, side: "local" });
31516
31680
  output?.log(fileConflictLine({ path: relPath, stash: stashPath }));
31517
31681
  if (existing) {
31518
31682
  nextState = await restoreFileFromCloud({
@@ -32002,7 +32166,8 @@ async function reconcileContextDocConflict(args) {
32002
32166
  entityType: strategy.type,
32003
32167
  entitySlug: fileSlug,
32004
32168
  timestamp: now(),
32005
- contents: raw
32169
+ contents: raw,
32170
+ side: "local"
32006
32171
  });
32007
32172
  const nextState = await pullAndWriteMirror({
32008
32173
  strategy,
@@ -32034,12 +32199,13 @@ async function reconcileContextDocConflict(args) {
32034
32199
  state: nextState
32035
32200
  };
32036
32201
  }
32037
- const preLandStash = stashConflict({
32202
+ const stashPreLandCloudCopy = () => stashConflict({
32038
32203
  workspaceSlug,
32039
32204
  entityType: strategy.type,
32040
32205
  entitySlug: existingSlug,
32041
32206
  timestamp: now(),
32042
- contents: strategy.serialise(existing)
32207
+ contents: strategy.serialise(existing),
32208
+ side: "cloud"
32043
32209
  });
32044
32210
  const fm = parsed.data ?? {};
32045
32211
  const lifted = splitUserFrontmatter(strategy, fm);
@@ -32073,6 +32239,7 @@ async function reconcileContextDocConflict(args) {
32073
32239
  body_len: hashBody.length
32074
32240
  };
32075
32241
  state = persistEntry(state, strategy.type, updated);
32242
+ const preLandStash = stashPreLandCloudCopy();
32076
32243
  output?.log(contextDocConflictLine({
32077
32244
  entityType: strategy.type,
32078
32245
  localSlug: fileSlug,
@@ -32088,7 +32255,8 @@ async function reconcileContextDocConflict(args) {
32088
32255
  outcome: {
32089
32256
  kind: "pushed",
32090
32257
  entityType: strategy.type,
32091
- entitySlug: updated.slug
32258
+ entitySlug: updated.slug,
32259
+ stashPath: preLandStash
32092
32260
  },
32093
32261
  state
32094
32262
  };
@@ -32112,7 +32280,8 @@ async function reconcileContextDocConflict(args) {
32112
32280
  entityType: strategy.type,
32113
32281
  entitySlug: fileSlug,
32114
32282
  timestamp: now(),
32115
- contents: raw
32283
+ contents: raw,
32284
+ side: "local"
32116
32285
  });
32117
32286
  if (staleEntryId && staleEntryId !== existingId) {
32118
32287
  state = dropEntry(state, strategy.type, staleEntryId);
@@ -32384,7 +32553,8 @@ async function runPushUpsert(args) {
32384
32553
  entityType: strategy.type,
32385
32554
  entitySlug: liveEntry.slug,
32386
32555
  timestamp: args.now(),
32387
- contents: args.raw
32556
+ contents: args.raw,
32557
+ side: "local"
32388
32558
  });
32389
32559
  const nextState = await pullAndWriteMirror({
32390
32560
  strategy,
@@ -32422,16 +32592,18 @@ async function reconcileTaskConflict(args) {
32422
32592
  output
32423
32593
  } = args;
32424
32594
  let state = args.state;
32425
- const stashPath = stashConflict({
32595
+ const stashLocalSide = () => stashConflict({
32426
32596
  workspaceSlug,
32427
32597
  entityType: strategy.type,
32428
32598
  entitySlug: liveEntry.slug,
32429
32599
  timestamp: now(),
32430
- contents: raw
32600
+ contents: raw,
32601
+ side: "local"
32431
32602
  });
32432
32603
  const localProse = stripSyncErrorBlock(stripCommentsBlock(parsed.content));
32433
32604
  const wireBody = normaliseWireBody(strategy.bodyToWire(localProse).body);
32434
32605
  if (wireBody == null) {
32606
+ const stashPath2 = stashLocalSide();
32435
32607
  state = await pullAndWriteMirror({
32436
32608
  strategy,
32437
32609
  workspaceSlug,
@@ -32446,11 +32618,37 @@ async function reconcileTaskConflict(args) {
32446
32618
  kind: "conflict",
32447
32619
  entityType: strategy.type,
32448
32620
  entitySlug: liveEntry.slug,
32449
- stashPath
32621
+ stashPath: stashPath2
32622
+ },
32623
+ state
32624
+ };
32625
+ }
32626
+ let cloudRow = null;
32627
+ try {
32628
+ cloudRow = await getEntity({ strategy, baseUrl, pat, id: liveEntry.id, fetchImpl });
32629
+ } catch (err) {
32630
+ if (!(err instanceof ApiError) || err.status !== 404)
32631
+ throw err;
32632
+ }
32633
+ if (cloudRow === null) {
32634
+ stashLocalSide();
32635
+ return {
32636
+ outcome: {
32637
+ kind: "not_found",
32638
+ entityType: strategy.type,
32639
+ entitySlug: liveEntry.slug
32450
32640
  },
32451
32641
  state
32452
32642
  };
32453
32643
  }
32644
+ const stashCloudSide = () => stashConflict({
32645
+ workspaceSlug,
32646
+ entityType: strategy.type,
32647
+ entitySlug: liveEntry.slug,
32648
+ timestamp: now(),
32649
+ contents: strategy.serialise(cloudRow),
32650
+ side: "cloud"
32651
+ });
32454
32652
  const patchResult = await patchEntity({
32455
32653
  strategy,
32456
32654
  baseUrl,
@@ -32461,6 +32659,7 @@ async function reconcileTaskConflict(args) {
32461
32659
  fetchImpl
32462
32660
  });
32463
32661
  if (patchResult.ok) {
32662
+ const stashPath2 = stashCloudSide();
32464
32663
  writeMirrorWithBodyPreserve(workspaceSlug, strategy, patchResult.doc);
32465
32664
  state = stampStateFromMirror({
32466
32665
  strategy,
@@ -32474,12 +32673,14 @@ async function reconcileTaskConflict(args) {
32474
32673
  outcome: {
32475
32674
  kind: "pushed",
32476
32675
  entityType: strategy.type,
32477
- entitySlug: patchResult.doc.slug
32676
+ entitySlug: patchResult.doc.slug,
32677
+ stashPath: stashPath2
32478
32678
  },
32479
32679
  state
32480
32680
  };
32481
32681
  }
32482
32682
  if (patchResult.code === 404) {
32683
+ stashLocalSide();
32483
32684
  return {
32484
32685
  outcome: {
32485
32686
  kind: "not_found",
@@ -32489,6 +32690,7 @@ async function reconcileTaskConflict(args) {
32489
32690
  state
32490
32691
  };
32491
32692
  }
32693
+ const stashPath = stashLocalSide();
32492
32694
  state = await pullAndWriteMirror({
32493
32695
  strategy,
32494
32696
  workspaceSlug,
@@ -32650,7 +32852,8 @@ async function runPushCreate(args) {
32650
32852
  entityType: strategy.type,
32651
32853
  entitySlug: args.slug,
32652
32854
  timestamp: args.now(),
32653
- contents: args.raw
32855
+ contents: args.raw,
32856
+ side: "local"
32654
32857
  });
32655
32858
  const nextState = await pullAndWriteMirror({
32656
32859
  strategy,
@@ -32876,7 +33079,8 @@ async function pushRename(args) {
32876
33079
  entityType,
32877
33080
  entitySlug: fromSlug,
32878
33081
  timestamp: now(),
32879
- contents: contents2
33082
+ contents: contents2,
33083
+ side: "local"
32880
33084
  });
32881
33085
  const afterColliding = await pullAndWriteMirror({
32882
33086
  strategy,
@@ -32916,7 +33120,8 @@ async function pushRename(args) {
32916
33120
  entityType,
32917
33121
  entitySlug: fromSlug,
32918
33122
  timestamp: now(),
32919
- contents
33123
+ contents,
33124
+ side: "local"
32920
33125
  });
32921
33126
  const nextState = await pullAndWriteMirror({
32922
33127
  strategy,
@@ -33040,7 +33245,8 @@ async function discardBatchOps(batch, deps) {
33040
33245
  entityType: op.entityType,
33041
33246
  entitySlug: op.slug,
33042
33247
  timestamp: (deps.now ?? (() => new Date))(),
33043
- contents: local
33248
+ contents: local,
33249
+ side: "local"
33044
33250
  });
33045
33251
  }
33046
33252
  }
@@ -33073,7 +33279,8 @@ async function discardFileOp(op, deps) {
33073
33279
  workspaceSlug: deps.workspaceSlug,
33074
33280
  relPath: op.path,
33075
33281
  timestamp: (deps.now ?? (() => new Date))(),
33076
- bytes: local
33282
+ bytes: local,
33283
+ side: "local"
33077
33284
  });
33078
33285
  }
33079
33286
  const next = await restoreFileFromCloud({
@@ -35328,6 +35535,7 @@ async function reconcileFilesSubtree(args) {
35328
35535
  case "created":
35329
35536
  case "updated":
35330
35537
  countPushed();
35538
+ state = clearFileRejected(state, relPath);
35331
35539
  break;
35332
35540
  case "conflict":
35333
35541
  conflicts++;
@@ -35567,7 +35775,8 @@ function applyArtistMemoryOnPull(args) {
35567
35775
  entityType: ENTITY_TYPE,
35568
35776
  entitySlug: conflictSlug(slug),
35569
35777
  timestamp: now(),
35570
- contents: local
35778
+ contents: local,
35779
+ side: "local"
35571
35780
  });
35572
35781
  out.warn(`Conflict on artists/${slug}/memory.md → stashed local edit to ${stashPath} (cloud-wins).`);
35573
35782
  }
@@ -35647,7 +35856,8 @@ async function pushArtistMemory(args) {
35647
35856
  entityType: ENTITY_TYPE,
35648
35857
  entitySlug: conflictSlug(slug),
35649
35858
  timestamp: now(),
35650
- contents: local
35859
+ contents: local,
35860
+ side: "local"
35651
35861
  });
35652
35862
  const nextState = adoptCloudMemory({ workspaceSlug, slug, cloud, state: base2, suppressor });
35653
35863
  output?.log(`[ARTIST_MEMORY_CONFLICT slug=${slug} id=${entry2.id} action=cloud_wins_stash]`);
@@ -37192,6 +37402,7 @@ async function reconcileDivergedRows(args) {
37192
37402
  let scanned = 0;
37193
37403
  let diverged = 0;
37194
37404
  let pushed = 0;
37405
+ let merged = 0;
37195
37406
  let conflicts = 0;
37196
37407
  let noop = 0;
37197
37408
  let failed = 0;
@@ -37214,7 +37425,10 @@ async function reconcileDivergedRows(args) {
37214
37425
  state = result.state;
37215
37426
  switch (result.outcome.kind) {
37216
37427
  case "pushed":
37217
- pushed++;
37428
+ if (result.outcome.stashPath)
37429
+ merged++;
37430
+ else
37431
+ pushed++;
37218
37432
  break;
37219
37433
  case "conflict":
37220
37434
  case "create_slug_conflict":
@@ -37382,6 +37596,7 @@ async function reconcileDivergedRows(args) {
37382
37596
  scanned,
37383
37597
  diverged,
37384
37598
  pushed,
37599
+ merged,
37385
37600
  conflicts,
37386
37601
  noop,
37387
37602
  failed,
@@ -37578,7 +37793,8 @@ async function pullWorkspaceRules(args) {
37578
37793
  entityType: CONFLICT_ENTITY,
37579
37794
  entitySlug: CONFLICT_SLUG,
37580
37795
  timestamp: now(),
37581
- contents: local
37796
+ contents: local,
37797
+ side: "local"
37582
37798
  });
37583
37799
  output?.warn(`Conflict on ${WORKSPACE_RULES_FILENAME} -> stashed local edit to ${stashPath} (cloud-wins).`);
37584
37800
  output?.log(`[WORKSPACE_RULES_PULL_CONFLICT ws=${workspaceSlug} stashed]`);
@@ -37664,7 +37880,8 @@ async function pushWorkspaceRules(args) {
37664
37880
  entityType: CONFLICT_ENTITY,
37665
37881
  entitySlug: CONFLICT_SLUG,
37666
37882
  timestamp: now(),
37667
- contents: local
37883
+ contents: local,
37884
+ side: "local"
37668
37885
  });
37669
37886
  const next = await restoreFromCloud(args);
37670
37887
  output?.warn(`Conflict on ${WORKSPACE_RULES_FILENAME} -> stashed local edit to ${stashPath} (cloud-wins; cloud copy restored).`);
@@ -37844,7 +38061,8 @@ async function pullWorkspaceMemory(args) {
37844
38061
  entityType: CONFLICT_ENTITY2,
37845
38062
  entitySlug: CONFLICT_SLUG2,
37846
38063
  timestamp: now(),
37847
- contents: local
38064
+ contents: local,
38065
+ side: "local"
37848
38066
  });
37849
38067
  output?.warn(`Conflict on ${WORKSPACE_MEMORY_FILENAME} -> stashed local edit to ${stashPath} (cloud-wins).`);
37850
38068
  output?.log(`[WORKSPACE_MEMORY_PULL_CONFLICT ws=${workspaceSlug} stashed]`);
@@ -37930,7 +38148,8 @@ async function pushWorkspaceMemory(args) {
37930
38148
  entityType: CONFLICT_ENTITY2,
37931
38149
  entitySlug: CONFLICT_SLUG2,
37932
38150
  timestamp: now(),
37933
- contents: local
38151
+ contents: local,
38152
+ side: "local"
37934
38153
  });
37935
38154
  const next = await restoreFromCloud2(args);
37936
38155
  output?.warn(`Conflict on ${WORKSPACE_MEMORY_FILENAME} -> stashed local edit to ${stashPath} (cloud-wins; cloud copy restored).`);
@@ -38118,7 +38337,8 @@ async function pullSingleton(descriptor, args) {
38118
38337
  entityType: descriptor.conflictEntity,
38119
38338
  entitySlug: descriptor.conflictSlug,
38120
38339
  timestamp: now(),
38121
- contents: local
38340
+ contents: local,
38341
+ side: "local"
38122
38342
  });
38123
38343
  output?.warn(`Conflict on ${descriptor.filename} -> stashed local edit to ${stashPath} (cloud-wins).`);
38124
38344
  output?.log(`[${descriptor.logTag}_PULL_CONFLICT ws=${workspaceSlug} stashed]`);
@@ -38192,7 +38412,8 @@ async function pushSingleton(descriptor, args) {
38192
38412
  entityType: descriptor.conflictEntity,
38193
38413
  entitySlug: descriptor.conflictSlug,
38194
38414
  timestamp: now(),
38195
- contents: local
38415
+ contents: local,
38416
+ side: "local"
38196
38417
  });
38197
38418
  const next = await restoreFromCloud3(descriptor, args);
38198
38419
  output?.warn(`Conflict on ${descriptor.filename} -> stashed local edit to ${stashPath} (cloud-wins; cloud copy restored).`);
@@ -38385,8 +38606,8 @@ function createThrottledFetch(baseFetch, opts = {}) {
38385
38606
  import {
38386
38607
  existsSync as existsSync7,
38387
38608
  readdirSync as readdirSync6,
38388
- readFileSync as readFileSync23,
38389
- statSync as statSync15,
38609
+ readFileSync as readFileSync24,
38610
+ statSync as statSync16,
38390
38611
  unlinkSync as unlinkSync8
38391
38612
  } from "node:fs";
38392
38613
  import { join as join16 } from "node:path";
@@ -38405,8 +38626,8 @@ var FOOTER = [
38405
38626
  var NOTHING_SET_ASIDE = [
38406
38627
  "# Conflicts",
38407
38628
  "",
38408
- "**Nothing has been set aside.** No local edit in this workspace has been",
38409
- "replaced by a cloud copy."
38629
+ "**Nothing has been set aside.** Nothing in this workspace has changed in two",
38630
+ "places at once and had one side put aside."
38410
38631
  ].join(`
38411
38632
  `);
38412
38633
  function absolute(date5) {
@@ -38443,9 +38664,47 @@ function resolveConflictRows(workspaceSlug, stashes) {
38443
38664
  };
38444
38665
  });
38445
38666
  }
38667
+ function entityNoun(label) {
38668
+ const head = label.slice(0, label.indexOf("/"));
38669
+ switch (head) {
38670
+ case "tasks":
38671
+ return "task";
38672
+ case "documents":
38673
+ return "document";
38674
+ case "projects":
38675
+ return "project";
38676
+ case "artists":
38677
+ return "artist";
38678
+ default:
38679
+ return "record";
38680
+ }
38681
+ }
38446
38682
  function renderRow(row) {
38447
38683
  const { stash } = row;
38448
38684
  const when = stash.stashedAt === null ? "at a time its filename does not record" : `on ${absolute(stash.stashedAt)}`;
38685
+ if (stash.side === "cloud") {
38686
+ const noun = entityNoun(stash.label);
38687
+ return [
38688
+ `## ${stash.label}`,
38689
+ "",
38690
+ `Set aside ${when}.`,
38691
+ "",
38692
+ "**This one is the cloud's copy, not yours.** This " + noun + " changed in",
38693
+ "two places at once and Plinth merged it field by field: your writing won",
38694
+ "and is in your file now, the cloud's other fields won, and what is set",
38695
+ "aside below is the version your writing replaced. That is someone else's",
38696
+ "work, and it is nowhere else.",
38697
+ "",
38698
+ "It is at:",
38699
+ "",
38700
+ "```",
38701
+ stash.absPath,
38702
+ "```",
38703
+ "",
38704
+ `Read it, fold anything worth keeping into the ${noun}, then delete the stash.`
38705
+ ].join(`
38706
+ `);
38707
+ }
38449
38708
  const out = [`## ${stash.label}`, "", `Set aside ${when}.`, ""];
38450
38709
  if (row.kind !== "restorable") {
38451
38710
  out.push("Your version is at:", "", "```", stash.absPath, "```", "");
@@ -38471,15 +38730,24 @@ function renderConflictsView(rows) {
38471
38730
  ${FOOTER}
38472
38731
  `;
38473
38732
  const n = rows.length;
38733
+ const cloudSide = rows.filter((r) => r.stash.side === "cloud").length;
38734
+ const localSide = n - cloudSide;
38474
38735
  const head = [
38475
38736
  "# Conflicts",
38476
38737
  "",
38477
- `**${n} local ${n === 1 ? "copy was" : "copies were"} set aside because the cloud version won.**`,
38478
- `Your ${n === 1 ? "edit is" : "edits are"} not lost. ${n === 1 ? "It is" : "They are"} listed below with how to put ${n === 1 ? "it" : "them"} back.`,
38738
+ `**${n} ${n === 1 ? "copy was" : "copies were"} set aside when the same thing changed in two places at once.**`,
38739
+ `Nothing here is lost. ${n === 1 ? "It is" : "They are"} listed below, each saying which side it holds.`,
38479
38740
  "",
38480
- "This happens when the same file changed in two places at once. Plinth keeps",
38481
- "the cloud version in your mirror and puts your version aside rather than",
38482
- "choosing between them."
38741
+ ...localSide > 0 ? [
38742
+ "For most files Plinth keeps the cloud version in your mirror and puts your",
38743
+ "version aside rather than choosing between them."
38744
+ ] : [],
38745
+ ...localSide > 0 && cloudSide > 0 ? [""] : [],
38746
+ ...cloudSide > 0 ? [
38747
+ "Some records are merged field by field instead: your writing wins and the",
38748
+ "cloud's other fields win. There the copy set aside is the CLOUD's, which",
38749
+ "your version replaced. Each of those says so in its own section."
38750
+ ] : []
38483
38751
  ].join(`
38484
38752
  `);
38485
38753
  return `${head}
@@ -38882,7 +39150,7 @@ async function applyFileRow(args) {
38882
39150
  try {
38883
39151
  const localBytes = readFileSync22(abs);
38884
39152
  if (sha256Bytes(localBytes) !== existing.content_hash) {
38885
- const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
39153
+ const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes, side: "local" });
38886
39154
  output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
38887
39155
  }
38888
39156
  } catch {}
@@ -38988,6 +39256,203 @@ function textIsCurrent(existing, row, workspaceSlug, relPath) {
38988
39256
  return extractedTextHash(onDisk) === existing.text_hash;
38989
39257
  }
38990
39258
 
39259
+ // src/lib/files/rejection-sync.ts
39260
+ import { readFileSync as readFileSync23, statSync as statSync15 } from "node:fs";
39261
+
39262
+ // src/lib/files/rejections-api.ts
39263
+ var RejectionsFeedSchema = exports_external.object({
39264
+ rejections: exports_external.array(TeamFileRejectionSchema)
39265
+ });
39266
+
39267
+ class RejectionFeedShapeError extends Error {
39268
+ constructor(message) {
39269
+ super(message);
39270
+ this.name = "RejectionFeedShapeError";
39271
+ }
39272
+ }
39273
+ var WithdrawResponseSchema = exports_external.object({
39274
+ cleared: exports_external.number().int().nonnegative()
39275
+ });
39276
+ function headers(pat) {
39277
+ return {
39278
+ authorization: `Bearer ${pat}`,
39279
+ "content-type": "application/json"
39280
+ };
39281
+ }
39282
+ function endpoint(baseUrl) {
39283
+ return `${baseUrl.replace(/\/$/, "")}/api/sync/files/rejections`;
39284
+ }
39285
+ async function listFileRejections(args) {
39286
+ const doFetch = args.fetchImpl ?? fetch;
39287
+ const res = await doFetch(endpoint(args.baseUrl), {
39288
+ method: "GET",
39289
+ headers: headers(args.pat)
39290
+ });
39291
+ if (!res.ok) {
39292
+ throw new ApiError(res.status, `file-rejections feed failed with HTTP ${res.status}`);
39293
+ }
39294
+ const parsed = RejectionsFeedSchema.safeParse(await res.json());
39295
+ if (!parsed.success) {
39296
+ throw new RejectionFeedShapeError("file-rejections feed did not match the expected shape");
39297
+ }
39298
+ return parsed.data.rejections;
39299
+ }
39300
+ async function postFileRejection(args) {
39301
+ const doFetch = args.fetchImpl ?? fetch;
39302
+ const res = await doFetch(endpoint(args.baseUrl), {
39303
+ method: "POST",
39304
+ headers: headers(args.pat),
39305
+ body: JSON.stringify({
39306
+ path: args.path,
39307
+ reason: args.reason,
39308
+ detail: args.detail ?? null,
39309
+ size_bytes: args.sizeBytes ?? null,
39310
+ max_bytes: args.maxBytes ?? null
39311
+ })
39312
+ });
39313
+ if (!res.ok) {
39314
+ throw new ApiError(res.status, `recording the file rejection failed with HTTP ${res.status}`);
39315
+ }
39316
+ }
39317
+ async function deleteFileRejection(args) {
39318
+ const doFetch = args.fetchImpl ?? fetch;
39319
+ const url2 = `${endpoint(args.baseUrl)}?path=${encodeURIComponent(args.path)}`;
39320
+ const res = await doFetch(url2, {
39321
+ method: "DELETE",
39322
+ headers: headers(args.pat)
39323
+ });
39324
+ if (!res.ok) {
39325
+ throw new ApiError(res.status, `withdrawing the file rejection failed with HTTP ${res.status}`);
39326
+ }
39327
+ const parsed = WithdrawResponseSchema.safeParse(await res.json());
39328
+ return parsed.success ? parsed.data.cleared : 0;
39329
+ }
39330
+
39331
+ // src/lib/files/rejection-sync.ts
39332
+ var TEAM_VISIBLE_REJECT_REASONS = new Set([
39333
+ "over_cap",
39334
+ "bad_request",
39335
+ "invalid_frontmatter"
39336
+ ]);
39337
+ async function drainFileRejectionReports(args) {
39338
+ let state = args.state;
39339
+ const pending = listRejected(state).filter((r) => r.target === "file" && !r.reported_at && TEAM_VISIBLE_REJECT_REASONS.has(r.reason));
39340
+ for (const r of pending) {
39341
+ try {
39342
+ await postFileRejection({
39343
+ baseUrl: args.baseUrl,
39344
+ pat: args.pat,
39345
+ path: r.path,
39346
+ reason: r.reason,
39347
+ detail: r.detail ?? r.message ?? null,
39348
+ sizeBytes: r.size_bytes ?? null,
39349
+ maxBytes: r.max_bytes ?? null,
39350
+ fetchImpl: args.fetchImpl
39351
+ });
39352
+ const next = markRejectedReported(state, r.path, args.now().toISOString());
39353
+ if (next.changed)
39354
+ state = commitState(state, next.state);
39355
+ } catch (err) {
39356
+ args.onError?.(r.path, err);
39357
+ }
39358
+ }
39359
+ return state;
39360
+ }
39361
+ async function fetchTeamRejections(args) {
39362
+ const rows = await listFileRejections({
39363
+ baseUrl: args.baseUrl,
39364
+ pat: args.pat,
39365
+ fetchImpl: args.fetchImpl
39366
+ });
39367
+ const next = setTeamRejected(args.state, rows, args.now().toISOString());
39368
+ return next.changed ? commitState(args.state, next.state) : args.state;
39369
+ }
39370
+ function settledDisposition(args) {
39371
+ const entry2 = getFileEntry(args.state, args.relPath);
39372
+ let absolute2;
39373
+ try {
39374
+ absolute2 = filesSubtreePath(args.workspaceSlug, args.relPath);
39375
+ } catch {
39376
+ return null;
39377
+ }
39378
+ let size = null;
39379
+ try {
39380
+ const st = statSync15(absolute2);
39381
+ size = st.isFile() ? st.size : null;
39382
+ } catch {
39383
+ size = null;
39384
+ }
39385
+ if (size === null)
39386
+ return entry2 === undefined ? "gone" : null;
39387
+ if (entry2 === undefined)
39388
+ return null;
39389
+ if (size !== entry2.size_bytes)
39390
+ return null;
39391
+ let bytes;
39392
+ try {
39393
+ bytes = readFileSync23(absolute2);
39394
+ } catch {
39395
+ return null;
39396
+ }
39397
+ return sha256Bytes(bytes) === entry2.content_hash ? "synced" : null;
39398
+ }
39399
+ async function withdrawSettledRejections(args) {
39400
+ let state = args.state;
39401
+ const live = new Set(listTeamRejected(state).map((r) => r.path));
39402
+ const owned = listRejected(state).filter((r) => r.target === "file" && r.reported_at !== undefined);
39403
+ const withdrawn = new Set;
39404
+ for (const record2 of owned) {
39405
+ if (!live.has(record2.path))
39406
+ continue;
39407
+ const disposition = settledDisposition({
39408
+ state,
39409
+ workspaceSlug: args.workspaceSlug,
39410
+ relPath: record2.path
39411
+ });
39412
+ if (disposition === null)
39413
+ continue;
39414
+ try {
39415
+ await deleteFileRejection({
39416
+ baseUrl: args.baseUrl,
39417
+ pat: args.pat,
39418
+ path: record2.path,
39419
+ fetchImpl: args.fetchImpl
39420
+ });
39421
+ withdrawn.add(record2.path);
39422
+ const cleared = removeRejected(state, "file", record2.path);
39423
+ if (cleared.changed)
39424
+ state = commitState(state, cleared.state);
39425
+ args.onWithdrawn?.(record2.path, disposition);
39426
+ } catch (err) {
39427
+ args.onError?.(record2.path, err);
39428
+ }
39429
+ }
39430
+ if (withdrawn.size > 0) {
39431
+ const fetchedAt = state.team_rejected?.fetched_at;
39432
+ if (fetchedAt) {
39433
+ const kept = listTeamRejected(state).filter((r) => !withdrawn.has(r.path));
39434
+ const pruned = setTeamRejected(state, kept, fetchedAt);
39435
+ if (pruned.changed)
39436
+ state = commitState(state, pruned.state);
39437
+ }
39438
+ }
39439
+ return state;
39440
+ }
39441
+ function rearmMissingReports(state) {
39442
+ const live = new Set(listTeamRejected(state).map((r) => r.path));
39443
+ let next = state;
39444
+ for (const r of listRejected(state)) {
39445
+ if (r.reported_at === undefined)
39446
+ continue;
39447
+ if (live.has(r.path))
39448
+ continue;
39449
+ const cleared = clearRejectedReported(next, r.path);
39450
+ if (cleared.changed)
39451
+ next = commitState(next, cleared.state);
39452
+ }
39453
+ return next;
39454
+ }
39455
+
38991
39456
  // src/commands/sync.ts
38992
39457
  init_output();
38993
39458
  var memberRefreshFailedAt = new Map;
@@ -39041,7 +39506,7 @@ function unlinkIfProvablyOurs(workspaceSlug, strategy, entry2, suppressor) {
39041
39506
  }
39042
39507
  let raw;
39043
39508
  try {
39044
- raw = readFileSync23(target, "utf8");
39509
+ raw = readFileSync24(target, "utf8");
39045
39510
  } catch (err) {
39046
39511
  if (err?.code === "ENOENT") {
39047
39512
  return { didUnlink: false };
@@ -39115,6 +39580,66 @@ async function runSync(workspaceSlug, deps = {}) {
39115
39580
  }));
39116
39581
  out.warn(`Conflict list could not be written to ${CONFLICTS_VIEW_REL_PATH}: ${res.failed}`);
39117
39582
  };
39583
+ const syncFileRejections = async () => {
39584
+ const logFailure = (verb) => (path5, err) => {
39585
+ if (err instanceof RejectionFeedShapeError) {
39586
+ out.log(fileRejectionSyncFailedLine({ verb, path: path5, reason: "shape" }));
39587
+ return;
39588
+ }
39589
+ const cat = categorizeError(err);
39590
+ out.log(fileRejectionSyncFailedLine({
39591
+ verb,
39592
+ path: path5,
39593
+ reason: cat.reason,
39594
+ status: cat.status
39595
+ }));
39596
+ };
39597
+ let feedRead = false;
39598
+ try {
39599
+ await fetchTeamRejections({
39600
+ state: loadState(slug),
39601
+ baseUrl: entry2.api_base_url,
39602
+ pat,
39603
+ now,
39604
+ fetchImpl
39605
+ });
39606
+ feedRead = true;
39607
+ } catch (err) {
39608
+ logFailure("feed")(undefined, err);
39609
+ }
39610
+ if (feedRead) {
39611
+ try {
39612
+ rearmMissingReports(loadState(slug));
39613
+ } catch (err) {
39614
+ logFailure("report")(undefined, err);
39615
+ }
39616
+ }
39617
+ try {
39618
+ await drainFileRejectionReports({
39619
+ state: loadState(slug),
39620
+ baseUrl: entry2.api_base_url,
39621
+ pat,
39622
+ now,
39623
+ fetchImpl,
39624
+ onError: (path5, err) => logFailure("report")(path5, err)
39625
+ });
39626
+ } catch (err) {
39627
+ logFailure("report")(undefined, err);
39628
+ }
39629
+ try {
39630
+ await withdrawSettledRejections({
39631
+ state: loadState(slug),
39632
+ workspaceSlug: slug,
39633
+ baseUrl: entry2.api_base_url,
39634
+ pat,
39635
+ fetchImpl,
39636
+ onError: (path5, err) => logFailure("withdraw")(path5, err),
39637
+ onWithdrawn: (path5, disposition) => out.log(fileRejectionWithdrawnLine({ path: path5, disposition }))
39638
+ });
39639
+ } catch (err) {
39640
+ logFailure("withdraw")(undefined, err);
39641
+ }
39642
+ };
39118
39643
  refreshBanner();
39119
39644
  out.info(`Syncing ${slug}…`);
39120
39645
  const startedAt = now();
@@ -39349,6 +39874,7 @@ async function runSync(workspaceSlug, deps = {}) {
39349
39874
  out.warn(`Per-artist context generation skipped: ${msg}`);
39350
39875
  }
39351
39876
  refreshBanner();
39877
+ await syncFileRejections();
39352
39878
  const totalWritten = Object.values(counters).reduce((sum, c) => sum + c.written, 0);
39353
39879
  const totalDeleted = Object.values(counters).reduce((sum, c) => sum + c.deleted, 0);
39354
39880
  if (totalWritten === 0 && totalDeleted === 0) {
@@ -39446,7 +39972,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
39446
39972
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
39447
39973
  let localRaw = null;
39448
39974
  try {
39449
- localRaw = readFileSync23(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
39975
+ localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
39450
39976
  } catch {
39451
39977
  localRaw = null;
39452
39978
  }
@@ -39459,7 +39985,8 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
39459
39985
  entityType: strategy.type,
39460
39986
  entitySlug: live.slug,
39461
39987
  timestamp: now(),
39462
- contents: localRaw
39988
+ contents: localRaw,
39989
+ side: "local"
39463
39990
  });
39464
39991
  out.warn(`Conflict on ${strategy.type}/${live.slug} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
39465
39992
  }
@@ -39469,7 +39996,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
39469
39996
  const hashes = computeMirrorHashes(strategy, content);
39470
39997
  let inode;
39471
39998
  try {
39472
- inode = statSync15(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
39999
+ inode = statSync16(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
39473
40000
  } catch {
39474
40001
  inode = undefined;
39475
40002
  }
@@ -39562,7 +40089,7 @@ var syncCommand = defineCommand({
39562
40089
  // src/commands/start.ts
39563
40090
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
39564
40091
  var PULL_HEARTBEAT_MS = 900000;
39565
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync16(p)) {
40092
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync17(p)) {
39566
40093
  let changed = false;
39567
40094
  const nextEntries = {};
39568
40095
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -39595,7 +40122,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync16(p)
39595
40122
  return { state, changed: false };
39596
40123
  return { state: { ...state, entries: nextEntries }, changed: true };
39597
40124
  }
39598
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync24(p, "utf8")) {
40125
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync25(p, "utf8")) {
39599
40126
  let changed = false;
39600
40127
  const nextEntries = {};
39601
40128
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -39682,14 +40209,14 @@ function makeWatchHandler(deps) {
39682
40209
  state: getState(),
39683
40210
  deps: { fetchImpl, now, output, suppressor }
39684
40211
  });
39685
- setState(clearFileRejected(result.state, relPath));
40212
+ setState(clearReportedSafeFileRejected(result.state, relPath));
39686
40213
  reportFileOutcome(spinner, workspaceSlug, getState(), result.outcome);
39687
40214
  });
39688
40215
  return;
39689
40216
  }
39690
40217
  let st;
39691
40218
  try {
39692
- st = statSync16(absolutePath);
40219
+ st = statSync17(absolutePath);
39693
40220
  } catch (err) {
39694
40221
  if (err?.code === "ENOENT")
39695
40222
  return;
@@ -39720,7 +40247,7 @@ function makeWatchHandler(deps) {
39720
40247
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
39721
40248
  return;
39722
40249
  }
39723
- const bytes = readFileSync24(absolutePath);
40250
+ const bytes = readFileSync25(absolutePath);
39724
40251
  const hash2 = sha256Bytes(bytes);
39725
40252
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
39726
40253
  bodyHash: hash2,
@@ -39778,7 +40305,7 @@ function makeWatchHandler(deps) {
39778
40305
  if (suppressor) {
39779
40306
  let raw = null;
39780
40307
  try {
39781
- raw = readFileSync24(absolutePath, "utf8");
40308
+ raw = readFileSync25(absolutePath, "utf8");
39782
40309
  } catch {
39783
40310
  raw = null;
39784
40311
  }
@@ -39816,7 +40343,7 @@ function makeWatchHandler(deps) {
39816
40343
  if (suppressor) {
39817
40344
  let raw = null;
39818
40345
  try {
39819
- raw = readFileSync24(absolutePath, "utf8");
40346
+ raw = readFileSync25(absolutePath, "utf8");
39820
40347
  } catch {
39821
40348
  raw = null;
39822
40349
  }
@@ -39854,7 +40381,7 @@ function makeWatchHandler(deps) {
39854
40381
  if (suppressor) {
39855
40382
  let raw = null;
39856
40383
  try {
39857
- raw = readFileSync24(absolutePath, "utf8");
40384
+ raw = readFileSync25(absolutePath, "utf8");
39858
40385
  } catch {
39859
40386
  raw = null;
39860
40387
  }
@@ -39892,7 +40419,7 @@ function makeWatchHandler(deps) {
39892
40419
  if (suppressor) {
39893
40420
  let raw = null;
39894
40421
  try {
39895
- raw = readFileSync24(absolutePath, "utf8");
40422
+ raw = readFileSync25(absolutePath, "utf8");
39896
40423
  } catch {
39897
40424
  raw = null;
39898
40425
  }
@@ -39930,7 +40457,7 @@ function makeWatchHandler(deps) {
39930
40457
  if (suppressor) {
39931
40458
  let raw = null;
39932
40459
  try {
39933
- raw = readFileSync24(absolutePath, "utf8");
40460
+ raw = readFileSync25(absolutePath, "utf8");
39934
40461
  } catch {
39935
40462
  raw = null;
39936
40463
  }
@@ -40045,7 +40572,7 @@ function makeWatchHandler(deps) {
40045
40572
  if (strategy) {
40046
40573
  let raw = null;
40047
40574
  try {
40048
- raw = readFileSync24(absolutePath, "utf8");
40575
+ raw = readFileSync25(absolutePath, "utf8");
40049
40576
  } catch {
40050
40577
  raw = null;
40051
40578
  }
@@ -40305,21 +40832,9 @@ async function runStart(workspaceSlug, deps = {}) {
40305
40832
  if (backfillWorthReporting(idBackfill)) {
40306
40833
  daemonLog.log(frontmatterIdBackfillLine(idBackfill));
40307
40834
  }
40308
- if (recon.diverged > 0 || recon.quarantined > 0) {
40309
- const parts = [];
40310
- if (recon.pushed > 0)
40311
- parts.push(`${recon.pushed} pushed`);
40312
- if (recon.conflicts > 0)
40313
- parts.push(`${recon.conflicts} stashed to ~/.plinth/_conflicts/`);
40314
- if (recon.syncErrors > 0)
40315
- parts.push(`${recon.syncErrors} with sync errors (see # sync-error in the file)`);
40316
- if (recon.quarantined > 0)
40317
- parts.push(`${recon.quarantined} held for \`plinth confirm\``);
40318
- if (recon.failed > 0)
40319
- parts.push(`${recon.failed} deferred`);
40320
- const acted = recon.diverged + recon.quarantined;
40321
- out.info(`Reconciled ${acted} offline edit${acted === 1 ? "" : "s"}${parts.length ? `: ${parts.join(", ")}` : ""}.`);
40322
- }
40835
+ const reconLine = reconcileSummaryLine(recon);
40836
+ if (reconLine !== null)
40837
+ out.info(reconLine);
40323
40838
  const disc = recon.discovery;
40324
40839
  if (disc.created > 0 || disc.renamed > 0 || disc.collisions > 0 || disc.renameConflicts > 0) {
40325
40840
  const parts = [];
@@ -40987,11 +41502,34 @@ function watchingSummary(state) {
40987
41502
  const parts = types.map((t) => `${counts[t] ?? 0} ${t}`);
40988
41503
  return parts.join(" · ");
40989
41504
  }
41505
+ function reconcileSummaryLine(recon) {
41506
+ if (recon.diverged === 0 && recon.quarantined === 0)
41507
+ return null;
41508
+ const parts = [];
41509
+ if (recon.pushed > 0)
41510
+ parts.push(`${recon.pushed} pushed`);
41511
+ if (recon.merged > 0)
41512
+ parts.push(`${recon.merged} merged field by field (the cloud's description stashed to ~/.plinth/_conflicts/)`);
41513
+ if (recon.conflicts > 0)
41514
+ parts.push(`${recon.conflicts} stashed to ~/.plinth/_conflicts/`);
41515
+ if (recon.syncErrors > 0)
41516
+ parts.push(`${recon.syncErrors} with sync errors (see # sync-error in the file)`);
41517
+ if (recon.quarantined > 0)
41518
+ parts.push(`${recon.quarantined} held for \`plinth confirm\``);
41519
+ if (recon.failed > 0)
41520
+ parts.push(`${recon.failed} deferred`);
41521
+ const acted = recon.diverged + recon.quarantined;
41522
+ return `Reconciled ${acted} offline edit${acted === 1 ? "" : "s"}${parts.length ? `: ${parts.join(", ")}` : ""}.`;
41523
+ }
40990
41524
  function reportOutcome(spinner, slug, state, outcome) {
40991
41525
  const summary = watchingSummary(state);
40992
41526
  switch (outcome.kind) {
40993
41527
  case "pushed":
40994
- spinner.success(`Pushed ${outcome.entityType}/${outcome.entitySlug}`);
41528
+ if (outcome.stashPath) {
41529
+ spinner.warn(`Merged ${outcome.entityType}/${outcome.entitySlug} field by field → the cloud's copy is at ${outcome.stashPath}`);
41530
+ } else {
41531
+ spinner.success(`Pushed ${outcome.entityType}/${outcome.entitySlug}`);
41532
+ }
40995
41533
  spinner.update(`Watching ${slug} · ${summary}`);
40996
41534
  return;
40997
41535
  case "created":
@@ -41186,7 +41724,7 @@ var startCommand = defineCommand({
41186
41724
  });
41187
41725
 
41188
41726
  // src/commands/status.ts
41189
- import { statSync as statSync18 } from "node:fs";
41727
+ import { statSync as statSync19 } from "node:fs";
41190
41728
 
41191
41729
  // src/lib/context/memory-bars.ts
41192
41730
  var RULE_FILE_BAR_BYTES = 6000;
@@ -41280,7 +41818,7 @@ function assessMemoryBars(workspaceSlug, deps = {}) {
41280
41818
 
41281
41819
  // src/lib/build-stamp.ts
41282
41820
  import { execFileSync as execFileSync4 } from "node:child_process";
41283
- import { existsSync as existsSync9, mkdirSync as mkdirSync11, readFileSync as readFileSync25, writeFileSync as writeFileSync8 } from "node:fs";
41821
+ import { existsSync as existsSync9, mkdirSync as mkdirSync11, readFileSync as readFileSync26, writeFileSync as writeFileSync8 } from "node:fs";
41284
41822
  import { dirname as dirname10, join as join17 } from "node:path";
41285
41823
  import { fileURLToPath } from "node:url";
41286
41824
  var BUILD_STAMP_FILENAME = "build-stamp.json";
@@ -41304,7 +41842,7 @@ function readHead(repoDir) {
41304
41842
  }
41305
41843
  function readBuildStamp(distDir) {
41306
41844
  try {
41307
- const parsed = JSON.parse(readFileSync25(stampPath(distDir), "utf8"));
41845
+ const parsed = JSON.parse(readFileSync26(stampPath(distDir), "utf8"));
41308
41846
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
41309
41847
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
41310
41848
  }
@@ -41365,12 +41903,12 @@ function checkBuildStaleness(deps = {}) {
41365
41903
  }
41366
41904
 
41367
41905
  // src/lib/memory-links.ts
41368
- import { readFileSync as readFileSync26 } from "node:fs";
41906
+ import { readFileSync as readFileSync27 } from "node:fs";
41369
41907
  import { relative as relative13, sep as sep5 } from "node:path";
41370
41908
  var MEMORY_FILE_RE = /^(workspace-memory\.md|artists\/[^/]+\/memory\.md)$/i;
41371
41909
  function scanMemoryLinks(deps) {
41372
41910
  const listFiles = deps.listFiles ?? ((r) => walkSubtreeFiles(r, isVcsInternal));
41373
- const readFile = deps.readFile ?? ((p) => readFileSync26(p, "utf8"));
41911
+ const readFile = deps.readFile ?? ((p) => readFileSync27(p, "utf8"));
41374
41912
  let root;
41375
41913
  let absPaths;
41376
41914
  try {
@@ -41414,7 +41952,7 @@ function scanMemoryLinks(deps) {
41414
41952
  init_output();
41415
41953
 
41416
41954
  // src/lib/pending-writes.ts
41417
- import { readFileSync as readFileSync27, statSync as statSync17 } from "node:fs";
41955
+ import { readFileSync as readFileSync28, statSync as statSync18 } from "node:fs";
41418
41956
  import { relative as relative14 } from "node:path";
41419
41957
  var import_gray_matter14 = __toESM(require_gray_matter(), 1);
41420
41958
  var EMPTY2 = { writes: [], count: 0 };
@@ -41454,7 +41992,7 @@ function scanPendingWrites(args) {
41454
41992
  const entry3 = getFileEntry(state, fileMatch.relPath);
41455
41993
  let size;
41456
41994
  try {
41457
- size = statSync17(absolutePath).size;
41995
+ size = statSync18(absolutePath).size;
41458
41996
  } catch {
41459
41997
  continue;
41460
41998
  }
@@ -41462,7 +42000,7 @@ function scanPendingWrites(args) {
41462
42000
  continue;
41463
42001
  let hash2;
41464
42002
  try {
41465
- hash2 = sha256Bytes(readFileSync27(absolutePath));
42003
+ hash2 = sha256Bytes(readFileSync28(absolutePath));
41466
42004
  } catch {
41467
42005
  continue;
41468
42006
  }
@@ -41483,7 +42021,7 @@ function scanPendingWrites(args) {
41483
42021
  continue;
41484
42022
  let liveInode;
41485
42023
  try {
41486
- liveInode = statSync17(absolutePath).ino;
42024
+ liveInode = statSync18(absolutePath).ino;
41487
42025
  } catch {
41488
42026
  liveInode = undefined;
41489
42027
  }
@@ -41493,7 +42031,7 @@ function scanPendingWrites(args) {
41493
42031
  continue;
41494
42032
  let content;
41495
42033
  try {
41496
- content = readFileSync27(absolutePath, "utf8");
42034
+ content = readFileSync28(absolutePath, "utf8");
41497
42035
  } catch {
41498
42036
  continue;
41499
42037
  }
@@ -41635,6 +42173,40 @@ function row(label, value) {
41635
42173
  function cont(value) {
41636
42174
  return `${" ".repeat(LABEL_WIDTH)} ${value}`;
41637
42175
  }
42176
+ function teamRejectedHeadline(count, state) {
42177
+ const noun = count === 1 ? "file is" : "files are";
42178
+ const at = teamRejectedFetchedAt(state);
42179
+ const when = at ? readStamp(at) : undefined;
42180
+ const freshness = when ? `read from Plinth at the last sync, ${when}` : `read from Plinth at an unrecorded time`;
42181
+ return `${count} more ${noun} also rejected on other machines (${freshness} — start the daemon for a fresher list):`;
42182
+ }
42183
+ function teamRejectedDetail(r) {
42184
+ const why = r.detail ? firstSentence(r.detail) : r.reason;
42185
+ const who = r.reported_by_name ?? "another machine";
42186
+ const since = r.first_seen_at ? shortDate(r.first_seen_at) : undefined;
42187
+ return since ? `${why} — ${who}, first seen ${since}` : `${why} — ${who}`;
42188
+ }
42189
+ function firstSentence(text) {
42190
+ const end = text.indexOf(". ");
42191
+ return end === -1 ? text : text.slice(0, end + 1);
42192
+ }
42193
+ function readStamp(iso, today = new Date) {
42194
+ const d = new Date(iso);
42195
+ if (Number.isNaN(d.getTime()))
42196
+ return;
42197
+ const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
42198
+ const day = shortDate(iso);
42199
+ return day === localDate(today) ? hhmm : `${day} ${hhmm}`;
42200
+ }
42201
+ function shortDate(iso) {
42202
+ const d = new Date(iso);
42203
+ if (Number.isNaN(d.getTime()))
42204
+ return;
42205
+ return localDate(d);
42206
+ }
42207
+ function localDate(d) {
42208
+ return `${d.getFullYear()}-` + `${String(d.getMonth() + 1).padStart(2, "0")}-` + `${String(d.getDate()).padStart(2, "0")}`;
42209
+ }
41638
42210
  function uncheckedLines(items) {
41639
42211
  const n = items.length;
41640
42212
  const head = `${n} generated command file${n === 1 ? "" : "s"} could not be checked, ` + `so ${n === 1 ? "its block was" : "their blocks were"} not compared.`;
@@ -41692,7 +42264,7 @@ function renderBuild(out, staleness) {
41692
42264
  }
41693
42265
  function lastActivity(now) {
41694
42266
  try {
41695
- const mtimeMs = statSync18(daemonLogPath()).mtimeMs;
42267
+ const mtimeMs = statSync19(daemonLogPath()).mtimeMs;
41696
42268
  return formatRelative(now.getTime() - mtimeMs);
41697
42269
  } catch {
41698
42270
  return null;
@@ -41802,13 +42374,13 @@ async function runStatus(args, deps = {}) {
41802
42374
  const heldArtists = pending.writes.filter((w) => w.held === "artist");
41803
42375
  const heldCloudRows = pending.writes.filter((w) => w.held === "cloud-row");
41804
42376
  const heldRenames = pending.writes.filter((w) => w.held === "rename-taken");
41805
- const MAX_SHOWN = 10;
42377
+ const MAX_SHOWN2 = 10;
41806
42378
  const listPaths = (writes) => {
41807
- for (const w of writes.slice(0, MAX_SHOWN)) {
42379
+ for (const w of writes.slice(0, MAX_SHOWN2)) {
41808
42380
  out.info(cont(`${w.path} (${w.kind})`));
41809
42381
  }
41810
- if (writes.length > MAX_SHOWN) {
41811
- out.info(cont(`…and ${writes.length - MAX_SHOWN} more.`));
42382
+ if (writes.length > MAX_SHOWN2) {
42383
+ out.info(cont(`…and ${writes.length - MAX_SHOWN2} more.`));
41812
42384
  }
41813
42385
  };
41814
42386
  let labelled = false;
@@ -41849,11 +42421,16 @@ async function runStatus(args, deps = {}) {
41849
42421
  }
41850
42422
  out.info("");
41851
42423
  const rejected = listRejected(localState);
41852
- if (rejected.length === 0) {
42424
+ const localRejectedPaths = new Set(rejected.filter((r) => r.target === "file").map((r) => r.path));
42425
+ const teamRejected = listTeamRejected(localState).filter((r) => !localRejectedPaths.has(r.path));
42426
+ const MAX_SHOWN = 10;
42427
+ if (rejected.length === 0 && teamRejected.length === 0) {
41853
42428
  out.info(row("Rejected", "No writes rejected."));
42429
+ } else if (rejected.length === 0) {
42430
+ out.info(row("Rejected", "No writes rejected on this machine."));
41854
42431
  } else {
41855
- out.info(row("Rejected", `${rejected.length} write${rejected.length === 1 ? "" : "s"} rejected and not retried. Each line says what to do.`));
41856
- const MAX_SHOWN = 10;
42432
+ const scope = teamRejected.length > 0 ? " on this machine" : "";
42433
+ out.info(row("Rejected", `${rejected.length} write${rejected.length === 1 ? "" : "s"} rejected${scope} and not retried. Each line says what to do.`));
41857
42434
  for (const r of rejected.slice(0, MAX_SHOWN)) {
41858
42435
  const why = r.detail ?? r.message ?? r.reason;
41859
42436
  out.info(cont(`${r.path} — ${why}`));
@@ -41862,16 +42439,34 @@ async function runStatus(args, deps = {}) {
41862
42439
  out.info(cont(`…and ${rejected.length - MAX_SHOWN} more (see ~/.plinth/daemon.log).`));
41863
42440
  }
41864
42441
  }
42442
+ if (teamRejected.length > 0) {
42443
+ out.info(cont(""));
42444
+ out.info(cont(teamRejectedHeadline(teamRejected.length, localState)));
42445
+ for (const r of teamRejected.slice(0, MAX_SHOWN)) {
42446
+ out.info(cont(`${r.path} — ${teamRejectedDetail(r)}`));
42447
+ }
42448
+ if (teamRejected.length > MAX_SHOWN) {
42449
+ out.info(cont(`…and ${teamRejected.length - MAX_SHOWN} more.`));
42450
+ }
42451
+ }
41865
42452
  out.info("");
41866
42453
  const stashes = listConflictStashes(slug);
41867
42454
  if (stashes.length === 0) {
41868
42455
  out.info(row("Conflicts", "No conflict stashes."));
41869
42456
  } else {
41870
- out.info(row("Conflicts", `${stashes.length} stashed local cop${stashes.length === 1 ? "y" : "ies"} in ${conflictsRoot(slug)}`));
42457
+ const cloudSide = stashes.filter((s) => s.side === "cloud");
42458
+ const localSide = stashes.filter((s) => s.side !== "cloud");
42459
+ out.info(row("Conflicts", `${stashes.length} stashed cop${stashes.length === 1 ? "y" : "ies"} in ${conflictsRoot(slug)}`));
41871
42460
  const newest = stashes[0];
41872
42461
  const when = newest.stashedAt ? `${formatAbsolute(newest.stashedAt)} (${formatRelative(now.getTime() - newest.stashedAt.getTime())})` : "time unknown";
41873
42462
  out.info(cont(`Newest: ${newest.label} — ${when}`));
41874
- out.info(cont("Each is your version of a file that was replaced by the cloud copy. Compare it with the file in your mirror, re-apply anything you still want, then delete the stash."));
42463
+ const lead = (n) => n === stashes.length ? "Each is" : n === 1 ? "1 is" : `${n} are`;
42464
+ if (localSide.length > 0) {
42465
+ out.info(cont(`${lead(localSide.length)} your version of a file that was replaced by the cloud copy. Compare it with the file in your mirror, re-apply anything you still want, then delete the stash.`));
42466
+ }
42467
+ if (cloudSide.length > 0) {
42468
+ out.info(cont(`${lead(cloudSide.length)} the cloud's copy from a merge that went field by field: your writing won and is in your file, the cloud's other fields won, and the stash is what your writing replaced. Read it, fold in anything you still want, then delete the stash.`));
42469
+ }
41875
42470
  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)`));
41876
42471
  }
41877
42472
  out.info("");
@@ -42076,7 +42671,7 @@ async function runStatus(args, deps = {}) {
42076
42671
  var statusCommand = defineCommand({
42077
42672
  meta: {
42078
42673
  name: "status",
42079
- description: "Print local sync state: daemon liveness, last sync + per-type counts, held destructive batches, and the voice profile's status"
42674
+ description: "Print local sync state: daemon liveness, last sync + per-type counts, held destructive batches, the voice profile's status, and files rejected anywhere in the workspace (rejections are grouped by the member who reported them; two machines signed in as the same person cannot be told apart)"
42080
42675
  },
42081
42676
  args: {
42082
42677
  workspace: {
@@ -42110,7 +42705,7 @@ var versionCommand = defineCommand({
42110
42705
  });
42111
42706
 
42112
42707
  // src/commands/voice-gate.ts
42113
- import { readFileSync as readFileSync30 } from "node:fs";
42708
+ import { readFileSync as readFileSync31 } from "node:fs";
42114
42709
 
42115
42710
  // src/lib/voice-gate/api.ts
42116
42711
  var VoiceGateSurfaces = [
@@ -42215,7 +42810,7 @@ async function callVoiceGate(req) {
42215
42810
  import { createHash as createHash4 } from "node:crypto";
42216
42811
  import {
42217
42812
  mkdirSync as mkdirSync12,
42218
- readFileSync as readFileSync28,
42813
+ readFileSync as readFileSync29,
42219
42814
  renameSync as renameSync8,
42220
42815
  unlinkSync as unlinkSync9,
42221
42816
  writeFileSync as writeFileSync9
@@ -42246,7 +42841,7 @@ function emptyFile() {
42246
42841
  }
42247
42842
  function load2(workspaceSlug) {
42248
42843
  try {
42249
- const raw = readFileSync28(attemptsPath(workspaceSlug), "utf8");
42844
+ const raw = readFileSync29(attemptsPath(workspaceSlug), "utf8");
42250
42845
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
42251
42846
  return parsed.success ? parsed.data : emptyFile();
42252
42847
  } catch {
@@ -42409,7 +43004,7 @@ function denyUngated(detail) {
42409
43004
  }
42410
43005
 
42411
43006
  // src/lib/voice-gate/payload.ts
42412
- import { readFileSync as readFileSync29, statSync as statSync19 } from "node:fs";
43007
+ import { readFileSync as readFileSync30, statSync as statSync20 } from "node:fs";
42413
43008
  import { homedir as homedir2 } from "node:os";
42414
43009
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
42415
43010
  var MAX_BODY_BYTES = 256 * 1024;
@@ -42566,11 +43161,11 @@ function isHelperInvocation(tokens) {
42566
43161
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
42567
43162
  }
42568
43163
  function defaultReadBodyFile(path5) {
42569
- const stat4 = statSync19(path5);
43164
+ const stat4 = statSync20(path5);
42570
43165
  if (stat4.size > MAX_BODY_BYTES) {
42571
43166
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
42572
43167
  }
42573
- return readFileSync29(path5, "utf8");
43168
+ return readFileSync30(path5, "utf8");
42574
43169
  }
42575
43170
  function parseRecipients(...raw) {
42576
43171
  const all = raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
@@ -42930,7 +43525,7 @@ async function runVoiceGateDirect(args, deps = {}) {
42930
43525
  let text;
42931
43526
  if (args.file) {
42932
43527
  try {
42933
- text = readFileSync30(args.file, "utf8");
43528
+ text = readFileSync31(args.file, "utf8");
42934
43529
  } catch (err) {
42935
43530
  const msg = err instanceof Error ? err.message : "unknown error";
42936
43531
  out.error(`Could not read ${args.file}: ${msg}`);