@plinth-music/cli 0.5.0 → 0.7.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
@@ -7714,6 +7714,7 @@ var init_ora = __esm(() => {
7714
7714
  var exports_output = {};
7715
7715
  __export(exports_output, {
7716
7716
  spinnerEnabled: () => spinnerEnabled,
7717
+ noColorFromArgs: () => noColorFromArgs,
7717
7718
  createSpinner: () => createSpinner,
7718
7719
  Output: () => Output
7719
7720
  });
@@ -7743,6 +7744,9 @@ function createSpinner(opts = {}) {
7743
7744
  }
7744
7745
  };
7745
7746
  }
7747
+ function noColorFromArgs(args) {
7748
+ return args.color === false || args["no-color"] === true;
7749
+ }
7746
7750
  function selectChalk(noColor) {
7747
7751
  if (noColor)
7748
7752
  return new Chalk({ level: 0 });
@@ -9189,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
9189
9193
  // package.json
9190
9194
  var package_default = {
9191
9195
  name: "@plinth-music/cli",
9192
- version: "0.5.0",
9196
+ version: "0.7.0",
9193
9197
  description: "Plinth capstone sync daemon and CLI",
9194
9198
  type: "module",
9195
9199
  private: false,
@@ -24832,7 +24836,9 @@ var ThreadLiveSchema = exports_external.object({
24832
24836
  scope: exports_external.enum(["internal", "artist"]),
24833
24837
  frontmatter: exports_external.record(exports_external.string(), exports_external.unknown()),
24834
24838
  last_edited_at: exports_external.string().datetime({ offset: true }).nullable(),
24835
- last_edited_by: exports_external.string().uuid().nullable()
24839
+ last_edited_by: exports_external.string().uuid().nullable(),
24840
+ message_count: exports_external.number().int().nonnegative(),
24841
+ last_message_at: exports_external.string().datetime({ offset: true }).nullable()
24836
24842
  });
24837
24843
  var ThreadsPageResponseSchema = makePageResponseSchema("threads", ThreadLiveSchema);
24838
24844
  var SERVER_AUTHORITATIVE_KEYS6 = new Set([
@@ -24842,7 +24848,9 @@ var SERVER_AUTHORITATIVE_KEYS6 = new Set([
24842
24848
  "last_edited_by",
24843
24849
  "scope",
24844
24850
  "name",
24845
- "artist_id"
24851
+ "artist_id",
24852
+ "message_count",
24853
+ "last_message_at"
24846
24854
  ]);
24847
24855
  var WIRE_EDITABLE_TOP_LEVEL_KEYS6 = new Set([
24848
24856
  "name",
@@ -24854,7 +24862,9 @@ var PROJECTION_KEYS6 = [
24854
24862
  "artist_id",
24855
24863
  "scope",
24856
24864
  "revision",
24857
- "last_edited_at"
24865
+ "last_edited_at",
24866
+ "message_count",
24867
+ "last_message_at"
24858
24868
  ];
24859
24869
  function buildCreatePayload6(args) {
24860
24870
  const fm = args.parsed.data ?? {};
@@ -25174,6 +25184,132 @@ function deleteFilesFile(workspaceSlug, relPath) {
25174
25184
  throw err;
25175
25185
  }
25176
25186
  }
25187
+ var VIEWS_SUBTREE = "views";
25188
+ var EXTRACTED_VIEW_DIR = "extracted";
25189
+ function extractedTextRelPath(relPath) {
25190
+ return `${VIEWS_SUBTREE}/${EXTRACTED_VIEW_DIR}/${relPath}.md`;
25191
+ }
25192
+ function extractedTextPath(workspaceSlug, relPath) {
25193
+ if (validateFilesRelPath(relPath) === null) {
25194
+ throw new MirrorPathError(`Invalid files sub-path for extracted text: ${JSON.stringify(relPath)}`);
25195
+ }
25196
+ const ws = workspaceMirrorPath(workspaceSlug);
25197
+ const posix = extractedTextRelPath(relPath);
25198
+ const native = sep2 === "/" ? posix : posix.split("/").join(sep2);
25199
+ const target = resolve(join2(ws, native));
25200
+ const prefix = join2(ws, VIEWS_SUBTREE, EXTRACTED_VIEW_DIR) + sep2;
25201
+ if (!target.startsWith(prefix)) {
25202
+ throw new MirrorPathError(`Resolved extracted-text path escapes the views/extracted subtree`);
25203
+ }
25204
+ return target;
25205
+ }
25206
+ function writeExtractedText(workspaceSlug, relPath, content) {
25207
+ const target = extractedTextPath(workspaceSlug, relPath);
25208
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
25209
+ mkdirSync(dirname(target), { recursive: true, mode: 448 });
25210
+ writeFileSync(tmp, content, { mode: 384 });
25211
+ try {
25212
+ renameSync(tmp, target);
25213
+ } catch (err) {
25214
+ try {
25215
+ unlinkSync(tmp);
25216
+ } catch {}
25217
+ throw err;
25218
+ }
25219
+ }
25220
+ function readExtractedText(workspaceSlug, relPath) {
25221
+ try {
25222
+ return readFileSync(extractedTextPath(workspaceSlug, relPath), "utf8");
25223
+ } catch (err) {
25224
+ if (err?.code === "ENOENT")
25225
+ return null;
25226
+ throw err;
25227
+ }
25228
+ }
25229
+ function deleteExtractedText(workspaceSlug, relPath) {
25230
+ const target = extractedTextPath(workspaceSlug, relPath);
25231
+ try {
25232
+ unlinkSync(target);
25233
+ return true;
25234
+ } catch (err) {
25235
+ if (err?.code === "ENOENT")
25236
+ return false;
25237
+ throw err;
25238
+ }
25239
+ }
25240
+ var CALENDAR_VIEW_DIR = "calendar";
25241
+ var CALENDAR_UPCOMING_NAME = "upcoming";
25242
+ var CALENDAR_INTERNAL_NAME = "_internal";
25243
+ var CALENDAR_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
25244
+ function calendarViewRelPath(name) {
25245
+ return `${VIEWS_SUBTREE}/${CALENDAR_VIEW_DIR}/${name}.md`;
25246
+ }
25247
+ function calendarViewPath(workspaceSlug, name) {
25248
+ if (name !== CALENDAR_INTERNAL_NAME && !CALENDAR_NAME_RE.test(name)) {
25249
+ throw new MirrorPathError(`Invalid calendar view name: ${JSON.stringify(name)}`);
25250
+ }
25251
+ if (name.length > 200) {
25252
+ throw new MirrorPathError(`Calendar view name too long: ${name.length}`);
25253
+ }
25254
+ const ws = workspaceMirrorPath(workspaceSlug);
25255
+ const posix = calendarViewRelPath(name);
25256
+ const native = sep2 === "/" ? posix : posix.split("/").join(sep2);
25257
+ const target = resolve(join2(ws, native));
25258
+ const prefix = join2(ws, VIEWS_SUBTREE, CALENDAR_VIEW_DIR) + sep2;
25259
+ if (!target.startsWith(prefix)) {
25260
+ throw new MirrorPathError(`Resolved calendar-view path escapes the views/calendar subtree`);
25261
+ }
25262
+ return target;
25263
+ }
25264
+ function writeCalendarView(workspaceSlug, name, content) {
25265
+ const target = calendarViewPath(workspaceSlug, name);
25266
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
25267
+ mkdirSync(dirname(target), { recursive: true, mode: 448 });
25268
+ writeFileSync(tmp, content, { mode: 384 });
25269
+ try {
25270
+ renameSync(tmp, target);
25271
+ } catch (err) {
25272
+ try {
25273
+ unlinkSync(tmp);
25274
+ } catch {}
25275
+ throw err;
25276
+ }
25277
+ }
25278
+ function readCalendarView(workspaceSlug, name) {
25279
+ try {
25280
+ return readFileSync(calendarViewPath(workspaceSlug, name), "utf8");
25281
+ } catch (err) {
25282
+ if (err?.code === "ENOENT")
25283
+ return null;
25284
+ throw err;
25285
+ }
25286
+ }
25287
+ function deleteCalendarView(workspaceSlug, name) {
25288
+ const target = calendarViewPath(workspaceSlug, name);
25289
+ try {
25290
+ unlinkSync(target);
25291
+ return true;
25292
+ } catch (err) {
25293
+ if (err?.code === "ENOENT")
25294
+ return false;
25295
+ throw err;
25296
+ }
25297
+ }
25298
+ function listCalendarViews(workspaceSlug) {
25299
+ let dir;
25300
+ try {
25301
+ dir = join2(workspaceMirrorPath(workspaceSlug), VIEWS_SUBTREE, CALENDAR_VIEW_DIR);
25302
+ } catch {
25303
+ return [];
25304
+ }
25305
+ let entries;
25306
+ try {
25307
+ entries = readdirSync(dir, { withFileTypes: true });
25308
+ } catch {
25309
+ return [];
25310
+ }
25311
+ return entries.filter((e2) => e2.isFile() && e2.name.endsWith(".md")).map((e2) => e2.name.slice(0, -3)).sort();
25312
+ }
25177
25313
  function contextFilePath(workspaceSlug, relativePath) {
25178
25314
  const ws = workspaceMirrorPath(workspaceSlug);
25179
25315
  const resolved = resolve(join2(ws, relativePath));
@@ -26772,9 +26908,15 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
26772
26908
  ``,
26773
26909
  `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends. (Native dry_run support in the Plinth MCP tools ` + `ships separately; until then this is a behavioural rule.)`,
26774
26910
  ``,
26775
- `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 one file under \`artists/<slug>/\` the daemon syncs both ways and what the ` + `web context pane renders, 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.`,
26911
+ `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.`,
26776
26912
  ``,
26777
- `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). There is ` + `no calendar-write tool in this cockpit yet, so when a date needs adding (a show, ` + `a release, a deadline) do not invent a home for it: tell the user the date and ask ` + `them to add it to the artist's Google Calendar. Reading dates is fine ` + `(\`list_key_dates\`, \`list_artist_calendars\`); only creating them is off-limits.`
26913
+ `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`, \`list_key_dates\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty.`,
26914
+ ``,
26915
+ `Read the calendar, and hand a date over rather than writing it yourself. An ` + `artist's dates live on their mapped Google Calendar and reading them is yours: ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs. Writing it is not yours. When a date needs ` + `adding or correcting (a show, a hold, a release, a deadline), say plainly what ` + `the change is, say that it goes in through Plinth's calendar view, and say that ` + `a person makes that change rather than you. Say the reason too, because a date ` + `left looking handled is worse than one openly outstanding: a calendar write ` + `goes out to everyone invited the moment it lands, and it cannot be recalled. ` + `This is not a puzzle to route around. Do not reach for a shell command line to ` + `do it, the Google Workspace one included, and do not go looking for another ` + `tool that writes a calendar. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, the rule is the ` + `same and you are the only thing holding it.`,
26916
+ ``,
26917
+ `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying and forwarding are not yours, and neither is any shell command line ` + `that does the same thing. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, nothing changes ` + `except that you are the only thing holding it. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`,
26918
+ ``,
26919
+ `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 the Voice section of your own \`user-memory.md\` holds the personal ` + `part, 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.`
26778
26920
  ];
26779
26921
  if (workspaceRules !== null) {
26780
26922
  block.push(``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:
@@ -26851,7 +26993,8 @@ var FileStateEntrySchema = exports_external.object({
26851
26993
  size_bytes: exports_external.number().int().nonnegative(),
26852
26994
  mime_type: exports_external.string().min(1),
26853
26995
  updated_at: exports_external.string().datetime({ offset: true }),
26854
- inode: exports_external.number().int().nonnegative().optional()
26996
+ inode: exports_external.number().int().nonnegative().optional(),
26997
+ text_hash: exports_external.string().min(1).optional()
26855
26998
  });
26856
26999
  var FileEntriesSchema = exports_external.record(exports_external.string(), FileStateEntrySchema);
26857
27000
  var WorkspaceRulesStateSchema = exports_external.object({
@@ -26895,7 +27038,8 @@ var WorkspaceStateV2Schema = exports_external.object({
26895
27038
  rejected: exports_external.array(RejectedWriteSchema).optional(),
26896
27039
  workspace_rules: WorkspaceRulesStateSchema.optional(),
26897
27040
  workspace_memory: WorkspaceMemoryStateSchema.optional(),
26898
- user_memory: UserMemoryStateSchema.optional()
27041
+ user_memory: UserMemoryStateSchema.optional(),
27042
+ derived_text_version: exports_external.number().int().nonnegative().optional()
26899
27043
  });
26900
27044
  var WorkspaceStateV1Schema = exports_external.object({
26901
27045
  version: exports_external.literal(1),
@@ -27745,106 +27889,744 @@ var loginCommand = defineCommand({
27745
27889
  }
27746
27890
  });
27747
27891
 
27748
- // src/lib/context/artist-context.ts
27749
- import { existsSync } from "node:fs";
27892
+ // src/lib/memory-gate/log.ts
27893
+ import {
27894
+ appendFileSync,
27895
+ closeSync,
27896
+ mkdirSync as mkdirSync7,
27897
+ openSync,
27898
+ readFileSync as readFileSync8,
27899
+ readSync,
27900
+ statSync as statSync2
27901
+ } from "node:fs";
27902
+ import { join as join7 } from "node:path";
27750
27903
 
27751
- // src/lib/context/entity-catalog.ts
27752
- var ENTITY_CATALOG = [
27753
- { label: "artists", blurb: "roster acts; one folder per artist under `artists/<slug>/`", file: true },
27754
- { label: "projects", blurb: "campaigns, releases, and workstreams", file: true },
27755
- { label: "tasks", blurb: "actionable to-dos, optionally tied to an artist or project", file: true },
27756
- { label: "meetings + meeting_notes", blurb: "meeting records and their attached notes", file: true },
27757
- { label: "documents", blurb: "notes, briefs, bios, transcripts, context docs (markdown bodies, not attachments)", file: true },
27758
- {
27759
- label: "workspace_files",
27760
- blurb: "the free-form document store: PDFs, executed agreements, spreadsheets, " + "anything that is a FILE rather than an entity. Mirrored byte-for-byte " + "both ways under `files/`, folders and all. Look here for contracts",
27761
- file: true
27762
- },
27763
- {
27764
- label: "threads + thread_messages",
27765
- blurb: "threaded discussions, one folder per thread anchored at " + "`threads/<slug>/_thread.md`; messages live in Plinth, not the mirror, and " + "the anchor's own body is a local scratchpad that never pushes",
27766
- file: true
27767
- },
27768
- { label: "key_dates", blurb: "calendar milestones and release dates", file: false },
27769
- { label: "comments", blurb: "comments anchored to any entity", file: false },
27770
- { label: "tracks", blurb: "track and release metadata", file: false },
27771
- { label: "social_posts", blurb: "planned and published social content", file: false },
27772
- { label: "useful_links", blurb: "bookmarked links per artist or workspace", file: false },
27773
- { label: "financial_entries", blurb: "income and cost ledger rows", file: false },
27774
- { label: "commissions", blurb: "agent, sync, streaming, publishing & other statements", file: false }
27775
- ];
27776
- var MCP_INTROSPECTION_POINTER = "ask the `mcp__plinth__*` tools for exact fields & enum values (the " + "platform is the schema source of truth)";
27777
- var MCP_AVAILABILITY_POINTER = "Other MCP servers may be connected on this machine: `claude mcp list` " + "names them and their connection status. Plinth neither configures nor " + "ships them.";
27778
- var SKILL_LOCATIONS = [
27779
- {
27780
- label: "workspace",
27781
- detail: "`.claude/commands/` in this folder; this machine only, the daemon does not sync it"
27782
- },
27783
- { label: "personal", detail: "`~/.claude/commands/`; your own, available everywhere" }
27784
- ];
27785
- var SECURITY_NOTE = "Scoped to a single Plinth workspace: do not read or write across " + "workspace folders; each has its own token and isolation boundary.";
27904
+ // src/lib/sessions/registry.ts
27905
+ import {
27906
+ mkdirSync as mkdirSync6,
27907
+ readdirSync as readdirSync3,
27908
+ readFileSync as readFileSync7,
27909
+ renameSync as renameSync6,
27910
+ statSync,
27911
+ unlinkSync as unlinkSync6,
27912
+ writeFileSync as writeFileSync6
27913
+ } from "node:fs";
27914
+ import { execFileSync } from "node:child_process";
27915
+ import { basename, dirname as dirname5, join as join6 } from "node:path";
27786
27916
 
27787
- // src/lib/context/markers.ts
27788
- var USER_BLOCK_BEGIN = "<!-- BEGIN: user-customisable -->";
27789
- var USER_BLOCK_END = "<!-- END -->";
27790
- var USER_BLOCK_PLACEHOLDER = "_Add workspace-specific guidance here. This block is the only part of " + "the file Plinth preserves across regenerations._";
27791
- function extractUserBlock(content) {
27792
- const beginIdx = content.indexOf(USER_BLOCK_BEGIN);
27793
- if (beginIdx === -1)
27794
- return null;
27795
- const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
27796
- const endIdx = content.indexOf(USER_BLOCK_END, afterBegin);
27797
- if (endIdx === -1)
27917
+ // src/lib/daemon-pid.ts
27918
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync6, renameSync as renameSync5, unlinkSync as unlinkSync5, writeFileSync as writeFileSync5 } from "node:fs";
27919
+ import { dirname as dirname4, join as join5 } from "node:path";
27920
+ var DaemonPidSchema = exports_external.object({
27921
+ version: exports_external.literal(1),
27922
+ pid: exports_external.number().int().positive(),
27923
+ workspace_slug: exports_external.string().min(1),
27924
+ started_at: exports_external.string().min(1)
27925
+ });
27926
+ function daemonPidPath(workspaceSlug) {
27927
+ return join5(plinthHome(), `daemon-${workspaceSlug}.pid`);
27928
+ }
27929
+ function daemonPidTmpPath(workspaceSlug) {
27930
+ return join5(plinthHome(), `daemon-${workspaceSlug}.pid.tmp`);
27931
+ }
27932
+ function writeDaemonPid(workspaceSlug, pid, startedAt) {
27933
+ const file2 = {
27934
+ version: 1,
27935
+ pid,
27936
+ workspace_slug: workspaceSlug,
27937
+ started_at: startedAt.toISOString()
27938
+ };
27939
+ const target = daemonPidPath(workspaceSlug);
27940
+ const tmp = daemonPidTmpPath(workspaceSlug);
27941
+ mkdirSync5(dirname4(target), { recursive: true, mode: 448 });
27942
+ writeFileSync5(tmp, JSON.stringify(file2, null, 2) + `
27943
+ `, { mode: 384 });
27944
+ try {
27945
+ renameSync5(tmp, target);
27946
+ } catch (err) {
27947
+ try {
27948
+ unlinkSync5(tmp);
27949
+ } catch {}
27950
+ throw err;
27951
+ }
27952
+ }
27953
+ function readDaemonPid(workspaceSlug) {
27954
+ let raw;
27955
+ try {
27956
+ raw = readFileSync6(daemonPidPath(workspaceSlug), "utf8");
27957
+ } catch (err) {
27958
+ if (err?.code === "ENOENT")
27959
+ return null;
27960
+ throw err;
27961
+ }
27962
+ let json2;
27963
+ try {
27964
+ json2 = JSON.parse(raw);
27965
+ } catch {
27798
27966
  return null;
27799
- return content.slice(afterBegin, endIdx);
27967
+ }
27968
+ const parsed = DaemonPidSchema.safeParse(json2);
27969
+ return parsed.success ? parsed.data : null;
27800
27970
  }
27801
- function injectUserBlock(generated, preserved) {
27802
- const beginIdx = generated.indexOf(USER_BLOCK_BEGIN);
27803
- if (beginIdx === -1) {
27804
- throw new Error("injectUserBlock: generated content has no BEGIN marker");
27971
+ function clearDaemonPid(workspaceSlug) {
27972
+ try {
27973
+ unlinkSync5(daemonPidPath(workspaceSlug));
27974
+ } catch (err) {
27975
+ if (err?.code === "ENOENT")
27976
+ return;
27977
+ throw err;
27805
27978
  }
27806
- const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
27807
- const endIdx = generated.indexOf(USER_BLOCK_END, afterBegin);
27808
- if (endIdx === -1) {
27809
- throw new Error("injectUserBlock: generated content has no END marker");
27979
+ }
27980
+ function isPidAlive(pid) {
27981
+ try {
27982
+ process.kill(pid, 0);
27983
+ return true;
27984
+ } catch (err) {
27985
+ return err?.code === "EPERM";
27810
27986
  }
27811
- return generated.slice(0, afterBegin) + preserved + generated.slice(endIdx);
27987
+ }
27988
+ function readLiveDaemon(workspaceSlug, aliveFn = isPidAlive) {
27989
+ const pidFile = readDaemonPid(workspaceSlug);
27990
+ if (!pidFile)
27991
+ return null;
27992
+ if (aliveFn(pidFile.pid))
27993
+ return pidFile;
27994
+ clearDaemonPid(workspaceSlug);
27995
+ return null;
27812
27996
  }
27813
27997
 
27814
- // src/lib/context/artist-claude-md.ts
27815
- var CONTEXT_VERSION_HEADER = "plinth-context-version";
27816
- var TASK_DISPLAY_CAP = 10;
27817
- var PRIORITY_RANK = {
27818
- urgent: 0,
27819
- high: 1,
27820
- medium: 2,
27821
- low: 3
27822
- };
27823
- function buildArtistClaudeMd(input) {
27824
- const body = buildBody(input);
27825
- const version2 = stableHash(body).slice(0, 12);
27826
- return `<!-- ${CONTEXT_VERSION_HEADER}: ${version2} -->
27827
- ${body}`;
27998
+ // src/lib/sessions/registry.ts
27999
+ var SessionRecordSchema = exports_external.object({
28000
+ version: exports_external.literal(1),
28001
+ session_id: exports_external.string().min(1),
28002
+ pid: exports_external.number().int().positive(),
28003
+ pid_start_time: exports_external.string().min(1),
28004
+ cwd: exports_external.string().min(1),
28005
+ workspace_slug: exports_external.string().min(1),
28006
+ started_at: exports_external.string().min(1),
28007
+ transcript_path: exports_external.string().min(1).nullable(),
28008
+ tab_id: exports_external.string().min(1).nullable().default(null),
28009
+ ended_at: exports_external.string().min(1).nullable().default(null)
28010
+ });
28011
+ var SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
28012
+ function isSafeSegment(value) {
28013
+ return SAFE_SEGMENT.test(value) && !value.includes("..");
27828
28014
  }
27829
- function buildBody(input) {
27830
- const { slug, name, status, uuid: uuid3, profile, projects, tasks } = input;
27831
- const lines = [];
27832
- lines.push(`<!-- Auto-generated by Plinth. Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`);
27833
- lines.push("");
27834
- lines.push(`# ${name} · Plinth artist`);
27835
- lines.push("");
27836
- const statusPart = status ? `, status ${status}` : "";
27837
- const bioPart = profile.bio ? ` ${profile.bio}` : "";
27838
- lines.push(`${name} (artist \`${slug}\`${statusPart}).${bioPart}`);
27839
- lines.push(`Working dir for ${name}-scoped AI sessions. ${SECURITY_NOTE}`);
27840
- lines.push("");
27841
- const links = buildLinks(profile);
27842
- if (links) {
27843
- lines.push(`## Links`);
27844
- lines.push(links);
27845
- lines.push("");
28015
+ function sessionsDir(workspaceSlug) {
28016
+ if (!isSafeSegment(workspaceSlug)) {
28017
+ throw new Error(`Refusing unsafe workspace slug: ${workspaceSlug}`);
27846
28018
  }
27847
- const sortedProjects = sortProjects(projects);
28019
+ return join6(plinthHome(), workspaceSlug, "sessions");
28020
+ }
28021
+ function sessionRecordPath(workspaceSlug, sessionId) {
28022
+ if (!isSafeSegment(sessionId)) {
28023
+ throw new Error(`Refusing unsafe session id: ${sessionId}`);
28024
+ }
28025
+ return join6(sessionsDir(workspaceSlug), `${sessionId}.json`);
28026
+ }
28027
+ function readPidStartTime(pid) {
28028
+ try {
28029
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
28030
+ encoding: "utf8",
28031
+ timeout: 5000,
28032
+ stdio: ["ignore", "pipe", "ignore"],
28033
+ env: { ...process.env, TZ: "UTC" }
28034
+ });
28035
+ const collapsed = out.trim().replace(/\s+/g, " ");
28036
+ return collapsed.length > 0 ? collapsed : null;
28037
+ } catch {
28038
+ return null;
28039
+ }
28040
+ }
28041
+ function registerSession(input) {
28042
+ const startTime = (input.startTimeFn ?? readPidStartTime)(input.pid);
28043
+ if (startTime === null)
28044
+ return null;
28045
+ const record2 = {
28046
+ version: 1,
28047
+ session_id: input.sessionId,
28048
+ pid: input.pid,
28049
+ pid_start_time: startTime,
28050
+ cwd: input.cwd,
28051
+ workspace_slug: input.workspaceSlug,
28052
+ started_at: (input.now ?? new Date).toISOString(),
28053
+ transcript_path: input.transcriptPath,
28054
+ tab_id: input.tabId ?? null,
28055
+ ended_at: null
28056
+ };
28057
+ writeRecordAtomic(sessionRecordPath(input.workspaceSlug, input.sessionId), record2);
28058
+ return record2;
28059
+ }
28060
+ var tmpCounter = 0;
28061
+ function writeRecordAtomic(target, record2) {
28062
+ const dir = dirname5(target);
28063
+ mkdirSync6(dir, { recursive: true, mode: 448 });
28064
+ const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
28065
+ sweepDeadTmpFiles(dir, target);
28066
+ writeFileSync6(tmp, JSON.stringify(record2, null, 2) + `
28067
+ `, { mode: 384 });
28068
+ try {
28069
+ renameSync6(tmp, target);
28070
+ } catch (err) {
28071
+ try {
28072
+ unlinkSync6(tmp);
28073
+ } catch {}
28074
+ throw err;
28075
+ }
28076
+ }
28077
+ function sweepDeadTmpFiles(dir, target) {
28078
+ const prefix = `${basename(target)}.`;
28079
+ let names;
28080
+ try {
28081
+ names = readdirSync3(dir);
28082
+ } catch {
28083
+ return;
28084
+ }
28085
+ for (const name of names) {
28086
+ if (!name.startsWith(prefix) || !name.endsWith(".tmp"))
28087
+ continue;
28088
+ const parts = name.slice(0, -".tmp".length).split(".");
28089
+ const pid = Number.parseInt(parts[parts.length - 2] ?? "", 10);
28090
+ if (!Number.isInteger(pid) || pid <= 0)
28091
+ continue;
28092
+ if (pid === process.pid || isPidAlive(pid))
28093
+ continue;
28094
+ try {
28095
+ unlinkSync6(join6(dir, name));
28096
+ } catch {}
28097
+ }
28098
+ }
28099
+ function endSession(workspaceSlug, sessionId, now = new Date, deps = {}) {
28100
+ const read = deps.readFn ?? readSessionRecord;
28101
+ const path3 = sessionRecordPath(workspaceSlug, sessionId);
28102
+ const current = read(path3);
28103
+ if (current === null)
28104
+ return null;
28105
+ const latest = read(path3);
28106
+ if (latest === null || latest.pid !== current.pid || latest.pid_start_time !== current.pid_start_time) {
28107
+ return null;
28108
+ }
28109
+ const record2 = { ...latest, ended_at: now.toISOString() };
28110
+ writeRecordAtomic(path3, record2);
28111
+ return record2;
28112
+ }
28113
+ function readSessionRecord(path3) {
28114
+ let raw;
28115
+ try {
28116
+ raw = readFileSync7(path3, "utf8");
28117
+ } catch {
28118
+ return null;
28119
+ }
28120
+ let json2;
28121
+ try {
28122
+ json2 = JSON.parse(raw);
28123
+ } catch {
28124
+ return null;
28125
+ }
28126
+ const parsed = SessionRecordSchema.safeParse(json2);
28127
+ return parsed.success ? parsed.data : null;
28128
+ }
28129
+ function sessionLiveness(record2, deps = {}) {
28130
+ if (!(deps.aliveFn ?? isPidAlive)(record2.pid))
28131
+ return "dead";
28132
+ const startTime = (deps.startTimeFn ?? readPidStartTime)(record2.pid);
28133
+ if (startTime === null)
28134
+ return "unknown";
28135
+ return startTime === record2.pid_start_time ? "live" : "dead";
28136
+ }
28137
+ function transcriptMtime(path3) {
28138
+ try {
28139
+ return statSync(path3).mtimeMs;
28140
+ } catch {
28141
+ return null;
28142
+ }
28143
+ }
28144
+ function listSessions(workspaceSlug, deps = {}) {
28145
+ let names;
28146
+ try {
28147
+ names = readdirSync3(sessionsDir(workspaceSlug));
28148
+ } catch {
28149
+ return { live: [], reaped: 0, unknown: 0, ended: 0 };
28150
+ }
28151
+ const live = [];
28152
+ let reaped = 0;
28153
+ let unknown2 = 0;
28154
+ let ended = 0;
28155
+ for (const name of names) {
28156
+ if (!name.endsWith(".json"))
28157
+ continue;
28158
+ const path3 = join6(sessionsDir(workspaceSlug), name);
28159
+ const record2 = readSessionRecord(path3);
28160
+ if (!record2) {
28161
+ continue;
28162
+ }
28163
+ const verdict = sessionLiveness(record2, deps);
28164
+ if (verdict === "unknown") {
28165
+ unknown2 += 1;
28166
+ continue;
28167
+ }
28168
+ if (verdict === "dead") {
28169
+ const current = readSessionRecord(path3);
28170
+ const sameRecord = current !== null && current.pid === record2.pid && current.pid_start_time === record2.pid_start_time;
28171
+ if (sameRecord) {
28172
+ try {
28173
+ unlinkSync6(path3);
28174
+ reaped += 1;
28175
+ } catch {}
28176
+ }
28177
+ continue;
28178
+ }
28179
+ if (record2.ended_at !== null) {
28180
+ ended += 1;
28181
+ continue;
28182
+ }
28183
+ const mtime = record2.transcript_path ? (deps.mtimeFn ?? transcriptMtime)(record2.transcript_path) : null;
28184
+ live.push({ record: record2, lastActivityMs: mtime });
28185
+ }
28186
+ live.sort((a2, b2) => (b2.lastActivityMs ?? -1) - (a2.lastActivityMs ?? -1));
28187
+ return { live, reaped, unknown: unknown2, ended };
28188
+ }
28189
+
28190
+ // src/lib/memory-gate/log.ts
28191
+ var MEMORY_GATE_DECISIONS = ["accept", "reject", "edit"];
28192
+ var Common = {
28193
+ version: exports_external.literal(1),
28194
+ at: exports_external.string().min(1),
28195
+ session_id: exports_external.string().min(1).nullable(),
28196
+ member: exports_external.string().min(1).nullable()
28197
+ };
28198
+ var DecisionRecordSchema = exports_external.object({
28199
+ ...Common,
28200
+ kind: exports_external.literal("decision"),
28201
+ proposed: exports_external.string().min(1),
28202
+ decision: exports_external.enum(MEMORY_GATE_DECISIONS),
28203
+ final: exports_external.string().min(1).nullable()
28204
+ });
28205
+ var NothingProposedRecordSchema = exports_external.object({
28206
+ ...Common,
28207
+ kind: exports_external.literal("nothing-proposed")
28208
+ });
28209
+ var MemoryGateRecordSchema = exports_external.discriminatedUnion("kind", [
28210
+ DecisionRecordSchema,
28211
+ NothingProposedRecordSchema
28212
+ ]);
28213
+ function memoryGateDir(workspaceSlug) {
28214
+ if (!isSafeSegment(workspaceSlug)) {
28215
+ throw new Error(`Refusing unsafe workspace slug: ${workspaceSlug}`);
28216
+ }
28217
+ return join7(plinthHome(), "_memory_gate", workspaceSlug);
28218
+ }
28219
+ function memoryGateLogPath(workspaceSlug) {
28220
+ return join7(memoryGateDir(workspaceSlug), "log.jsonl");
28221
+ }
28222
+ function buildDecisionRecord(input) {
28223
+ const final = input.decision === "accept" ? input.proposed : input.decision === "reject" ? null : input.final ?? null;
28224
+ return {
28225
+ version: 1,
28226
+ kind: "decision",
28227
+ at: (input.now ?? new Date).toISOString(),
28228
+ session_id: input.sessionId,
28229
+ member: input.member,
28230
+ proposed: input.proposed,
28231
+ decision: input.decision,
28232
+ final
28233
+ };
28234
+ }
28235
+ function buildNothingProposedRecord(input) {
28236
+ return {
28237
+ version: 1,
28238
+ kind: "nothing-proposed",
28239
+ at: (input.now ?? new Date).toISOString(),
28240
+ session_id: input.sessionId,
28241
+ member: input.member
28242
+ };
28243
+ }
28244
+ function endsMidLine(path3) {
28245
+ let size;
28246
+ try {
28247
+ size = statSync2(path3).size;
28248
+ } catch {
28249
+ return false;
28250
+ }
28251
+ if (size === 0)
28252
+ return false;
28253
+ const fd = openSync(path3, "r");
28254
+ try {
28255
+ const last = Buffer.alloc(1);
28256
+ readSync(fd, last, 0, 1, size - 1);
28257
+ return last[0] !== 10;
28258
+ } finally {
28259
+ closeSync(fd);
28260
+ }
28261
+ }
28262
+ function appendMemoryGateRecord(workspaceSlug, record2) {
28263
+ const checked = MemoryGateRecordSchema.parse(record2);
28264
+ const path3 = memoryGateLogPath(workspaceSlug);
28265
+ mkdirSync7(memoryGateDir(workspaceSlug), { recursive: true, mode: 448 });
28266
+ const prefix = endsMidLine(path3) ? `
28267
+ ` : "";
28268
+ appendFileSync(path3, `${prefix}${JSON.stringify(checked)}
28269
+ `, { mode: 384 });
28270
+ }
28271
+ function readMemoryGateLog(workspaceSlug) {
28272
+ let raw;
28273
+ try {
28274
+ raw = readFileSync8(memoryGateLogPath(workspaceSlug), "utf8");
28275
+ } catch {
28276
+ return { records: [], skipped: 0 };
28277
+ }
28278
+ const records = [];
28279
+ let skipped = 0;
28280
+ for (const line of raw.split(`
28281
+ `)) {
28282
+ if (line.trim().length === 0)
28283
+ continue;
28284
+ let json2;
28285
+ try {
28286
+ json2 = JSON.parse(line);
28287
+ } catch {
28288
+ skipped += 1;
28289
+ continue;
28290
+ }
28291
+ const parsed = MemoryGateRecordSchema.safeParse(json2);
28292
+ if (parsed.success)
28293
+ records.push(parsed.data);
28294
+ else
28295
+ skipped += 1;
28296
+ }
28297
+ return { records, skipped };
28298
+ }
28299
+
28300
+ // src/commands/memory-gate.ts
28301
+ init_output();
28302
+ function resolveTarget2(explicitSlug, explicitMember, loadConfigFn) {
28303
+ let config2;
28304
+ try {
28305
+ config2 = loadConfigFn();
28306
+ } catch {
28307
+ return { ok: false, message: "Config at ~/.plinth/config.json could not be read. Run `plinth login --workspace <slug>` first." };
28308
+ }
28309
+ if (!config2) {
28310
+ return { ok: false, message: "No workspace configured. Run `plinth login --workspace <slug>` first." };
28311
+ }
28312
+ const slug = explicitSlug ?? config2.active_workspace;
28313
+ const entry2 = config2.workspaces[slug];
28314
+ if (!entry2) {
28315
+ return {
28316
+ ok: false,
28317
+ message: explicitSlug === undefined ? "No workspace configured. Run `plinth login --workspace <slug>` first." : `Workspace "${slug}" not found. Run \`plinth login --workspace ${slug}\` first.`
28318
+ };
28319
+ }
28320
+ return { ok: true, slug, member: explicitMember ?? entry2.user_id ?? null };
28321
+ }
28322
+ function isDecision(value) {
28323
+ return MEMORY_GATE_DECISIONS.includes(value);
28324
+ }
28325
+ async function runMemoryGateLog(args, deps = {}) {
28326
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
28327
+ const env3 = deps.env ?? process.env;
28328
+ const now = (deps.now ?? (() => new Date))();
28329
+ const nothing = args.nothingProposed === true;
28330
+ const hasDecisionArgs = args.proposed !== undefined || args.decision !== undefined;
28331
+ if (nothing && hasDecisionArgs) {
28332
+ out.error("--nothing-proposed records a close that proposed nothing; it cannot be combined with --proposed/--decision.");
28333
+ return { exitCode: 1 };
28334
+ }
28335
+ if (!nothing && !hasDecisionArgs) {
28336
+ out.error("Nothing to log. Pass --proposed and --decision for one decision, or --nothing-proposed for a close that had nothing to bank.");
28337
+ return { exitCode: 1 };
28338
+ }
28339
+ const target = resolveTarget2(args.workspace, args.member, deps.loadConfigFn ?? loadConfig);
28340
+ if (!target.ok) {
28341
+ out.error(target.message);
28342
+ return { exitCode: 1 };
28343
+ }
28344
+ const sessionId = typeof env3.CLAUDE_CODE_SESSION_ID === "string" && env3.CLAUDE_CODE_SESSION_ID.length > 0 ? env3.CLAUDE_CODE_SESSION_ID : null;
28345
+ const append = deps.appendFn ?? appendMemoryGateRecord;
28346
+ if (nothing) {
28347
+ try {
28348
+ append(target.slug, buildNothingProposedRecord({ sessionId, member: target.member, now }));
28349
+ } catch (err) {
28350
+ out.error(`Could not write the memory-gate log: ${err.message}`);
28351
+ return { exitCode: 1 };
28352
+ }
28353
+ out.info("Logged: this close proposed nothing for shared memory.");
28354
+ return { exitCode: 0 };
28355
+ }
28356
+ const proposed = args.proposed?.trim() ?? "";
28357
+ if (proposed.length === 0) {
28358
+ out.error("--proposed must carry the line as it was put to the user.");
28359
+ return { exitCode: 1 };
28360
+ }
28361
+ const decisionRaw = args.decision?.trim() ?? "";
28362
+ if (!isDecision(decisionRaw)) {
28363
+ out.error(`--decision must be one of ${MEMORY_GATE_DECISIONS.join(", ")} (got ${decisionRaw === "" ? "nothing" : `"${decisionRaw}"`}).`);
28364
+ return { exitCode: 1 };
28365
+ }
28366
+ const finalArg = args.final?.trim() ?? "";
28367
+ if (decisionRaw === "edit" && finalArg.length === 0) {
28368
+ out.error("--decision edit needs --final: the user's rewritten line is the record.");
28369
+ return { exitCode: 1 };
28370
+ }
28371
+ if (decisionRaw !== "edit" && finalArg.length > 0) {
28372
+ out.error(`--final applies to --decision edit only (accept records the proposed line, reject records none).`);
28373
+ return { exitCode: 1 };
28374
+ }
28375
+ try {
28376
+ append(target.slug, buildDecisionRecord({
28377
+ proposed,
28378
+ decision: decisionRaw,
28379
+ final: finalArg.length > 0 ? finalArg : null,
28380
+ sessionId,
28381
+ member: target.member,
28382
+ now
28383
+ }));
28384
+ } catch (err) {
28385
+ out.error(`Could not write the memory-gate log: ${err.message}`);
28386
+ return { exitCode: 1 };
28387
+ }
28388
+ out.info(`Logged ${decisionRaw} for shared memory (${target.slug}).`);
28389
+ return { exitCode: 0 };
28390
+ }
28391
+ async function runMemoryGateShow(args, deps = {}) {
28392
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
28393
+ const target = resolveTarget2(args.workspace, args.member, deps.loadConfigFn ?? loadConfig);
28394
+ if (!target.ok) {
28395
+ out.error(target.message);
28396
+ return { exitCode: 1 };
28397
+ }
28398
+ const { records, skipped } = (deps.readFn ?? readMemoryGateLog)(target.slug);
28399
+ const limit = args.limit !== undefined && args.limit > 0 ? args.limit : undefined;
28400
+ const shown = limit === undefined ? records : records.slice(-limit);
28401
+ if (args.json) {
28402
+ out.info(JSON.stringify({
28403
+ workspace_slug: target.slug,
28404
+ path: memoryGateLogPath(target.slug),
28405
+ records: shown,
28406
+ withheld: records.length - shown.length,
28407
+ unreadable: skipped
28408
+ }, null, 2));
28409
+ return { exitCode: 0 };
28410
+ }
28411
+ if (shown.length === 0) {
28412
+ out.info(`No memory-gate records for ${target.slug}.`);
28413
+ }
28414
+ for (const record2 of shown) {
28415
+ if (record2.kind === "nothing-proposed") {
28416
+ out.info(`${record2.at} · nothing proposed`);
28417
+ continue;
28418
+ }
28419
+ const tail = record2.final === null ? "" : ` → ${record2.final}`;
28420
+ out.info(`${record2.at} · ${record2.decision} · ${record2.proposed}${tail}`);
28421
+ }
28422
+ if (records.length - shown.length > 0) {
28423
+ out.info(`${records.length - shown.length} earlier record(s) not shown (--limit).`);
28424
+ }
28425
+ if (skipped > 0) {
28426
+ out.info(`${skipped} line(s) could not be read and are not counted above.`);
28427
+ }
28428
+ return { exitCode: 0 };
28429
+ }
28430
+ var memoryGateCommand = defineCommand({
28431
+ meta: {
28432
+ name: "memory-gate",
28433
+ description: "Record what a /close ritual proposed for shared memory, and what the user decided"
28434
+ },
28435
+ subCommands: {
28436
+ log: defineCommand({
28437
+ meta: {
28438
+ name: "log",
28439
+ description: "Append one decision (or a nothing-proposed close) to the local memory-gate log. Machine-local: nothing under ~/.plinth syncs."
28440
+ },
28441
+ args: {
28442
+ workspace: { type: "string", description: "Workspace slug" },
28443
+ proposed: { type: "string", description: "The line as it was put to the user" },
28444
+ decision: { type: "string", description: `One of ${MEMORY_GATE_DECISIONS.join(", ")}` },
28445
+ final: { type: "string", description: "The user's rewritten line (--decision edit only)" },
28446
+ "nothing-proposed": {
28447
+ type: "boolean",
28448
+ description: "Record a close that proposed nothing for shared memory"
28449
+ },
28450
+ member: { type: "string", description: "Override the member id (defaults to this login's user)" },
28451
+ "no-color": { type: "boolean", description: "Disable colour" }
28452
+ },
28453
+ async run({ args }) {
28454
+ const result = await runMemoryGateLog({
28455
+ workspace: args.workspace,
28456
+ proposed: args.proposed,
28457
+ decision: args.decision,
28458
+ final: args.final,
28459
+ nothingProposed: args["nothing-proposed"],
28460
+ member: args.member
28461
+ }, { noColor: noColorFromArgs(args) });
28462
+ process.exitCode = result.exitCode;
28463
+ }
28464
+ }),
28465
+ show: defineCommand({
28466
+ meta: {
28467
+ name: "show",
28468
+ description: "Read the local memory-gate log back. Machine-local: nothing under ~/.plinth syncs."
28469
+ },
28470
+ args: {
28471
+ workspace: { type: "string", description: "Workspace slug" },
28472
+ json: { type: "boolean", description: "Machine-readable output" },
28473
+ limit: { type: "string", description: "Show only the most recent N records" },
28474
+ "no-color": { type: "boolean", description: "Disable colour" }
28475
+ },
28476
+ async run({ args }) {
28477
+ const rawLimit = args.limit;
28478
+ const parsed = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
28479
+ const result = await runMemoryGateShow({
28480
+ workspace: args.workspace,
28481
+ json: args.json,
28482
+ limit: parsed !== undefined && Number.isInteger(parsed) ? parsed : undefined
28483
+ }, { noColor: noColorFromArgs(args) });
28484
+ process.exitCode = result.exitCode;
28485
+ }
28486
+ })
28487
+ }
28488
+ });
28489
+
28490
+ // src/lib/context/artist-context.ts
28491
+ import { existsSync } from "node:fs";
28492
+
28493
+ // src/lib/context/command-paths.ts
28494
+ var WORKSPACE_COMMANDS_DIR = ".claude/commands/plinth";
28495
+ var WORKSPACE_COMMANDS_SUBFOLDER = WORKSPACE_COMMANDS_DIR.split("/").pop() ?? "";
28496
+ var START_COMMAND_REL = `${WORKSPACE_COMMANDS_DIR}/start.md`;
28497
+ var CLOSE_COMMAND_REL = `${WORKSPACE_COMMANDS_DIR}/close.md`;
28498
+ function invocationFor(relPath) {
28499
+ const parts = relPath.split("/");
28500
+ const name = (parts.pop() ?? "").replace(/\.md$/, "");
28501
+ const namespace = parts.pop() ?? "";
28502
+ return `/${namespace}:${name}`;
28503
+ }
28504
+ var START_COMMAND_INVOCATION = invocationFor(START_COMMAND_REL);
28505
+ var CLOSE_COMMAND_INVOCATION = invocationFor(CLOSE_COMMAND_REL);
28506
+
28507
+ // src/lib/context/entity-catalog.ts
28508
+ var ENTITY_CATALOG = [
28509
+ { label: "artists", blurb: "roster acts; one folder per artist under `artists/<slug>/`", file: true },
28510
+ { label: "projects", blurb: "campaigns, releases, and workstreams", file: true },
28511
+ { label: "tasks", blurb: "actionable to-dos, optionally tied to an artist or project", file: true },
28512
+ { label: "meetings + meeting_notes", blurb: "meeting records and their attached notes", file: true },
28513
+ { label: "documents", blurb: "notes, briefs, bios, transcripts, context docs (markdown bodies, not attachments)", file: true },
28514
+ {
28515
+ label: "workspace_files",
28516
+ blurb: "the free-form document store: PDFs, executed agreements, spreadsheets, " + "anything that is a FILE rather than an entity. Mirrored byte-for-byte " + "both ways under `files/`, folders and all. Look here for contracts",
28517
+ file: true
28518
+ },
28519
+ {
28520
+ label: "threads + thread_messages",
28521
+ blurb: "threaded discussions, one folder per thread anchored at " + "`threads/<slug>/_thread.md`; messages live in Plinth, not the mirror, and " + "the anchor's own body is a local scratchpad that never pushes",
28522
+ file: true
28523
+ },
28524
+ { label: "key_dates", blurb: "calendar milestones and release dates", file: false },
28525
+ { label: "comments", blurb: "comments anchored to any entity", file: false },
28526
+ { label: "tracks", blurb: "track and release metadata", file: false },
28527
+ { label: "social_posts", blurb: "planned and published social content", file: false },
28528
+ { label: "useful_links", blurb: "bookmarked links per artist or workspace", file: false },
28529
+ { label: "financial_entries", blurb: "income and cost ledger rows", file: false },
28530
+ { label: "commissions", blurb: "agent, sync, streaming, publishing & other statements", file: false }
28531
+ ];
28532
+ var MCP_INTROSPECTION_POINTER = "ask the `mcp__plinth__*` tools for exact fields & enum values (the " + "platform is the schema source of truth)";
28533
+ var MCP_AVAILABILITY_POINTER = "Other MCP servers may be connected on this machine: `claude mcp list` " + "names them and their connection status, though that command is blocked " + "inside the Plinth cockpit (it denies `Bash(claude:*)`), so in the cockpit " + "ask the user instead of concluding there are none. Plinth neither " + "configures nor ships them.";
28534
+ var SKILL_LOCATIONS = [
28535
+ {
28536
+ label: "workspace",
28537
+ detail: "`.claude/commands/` in this folder; this machine only, the daemon does not sync it. " + `Plinth generates two into \`${WORKSPACE_COMMANDS_SUBFOLDER}/\` on every sync: ` + `**${START_COMMAND_INVOCATION}** to open a session and **${CLOSE_COMMAND_INVOCATION}** to ` + "bank what it learned before the context is gone. Anything else in the folder is yours and is never touched"
28538
+ },
28539
+ { label: "personal", detail: "`~/.claude/commands/`; your own, available everywhere" }
28540
+ ];
28541
+ var SECURITY_NOTE = "Scoped to a single Plinth workspace: do not read or write across " + "workspace folders; each has its own token and isolation boundary.";
28542
+
28543
+ // src/lib/context/generated-at.ts
28544
+ var GENERATED_AT_PLACEHOLDER = "{{plinth-generated-at}}";
28545
+ var GENERATED_AT_PREFIX = "Content built ";
28546
+ var STAMP_RE = /\d{4}-\d{2}-\d{2} \d{2}:\d{2} [+-]\d{2}:\d{2}/;
28547
+ function escapeRegExp(literal2) {
28548
+ return literal2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28549
+ }
28550
+ var STAMP_SLOT_RE = new RegExp(escapeRegExp(GENERATED_AT_PREFIX) + "(?:" + STAMP_RE.source + "|" + escapeRegExp(GENERATED_AT_PLACEHOLDER) + ")");
28551
+ function pad2(n2) {
28552
+ return String(n2).padStart(2, "0");
28553
+ }
28554
+ function formatGeneratedAt(d2) {
28555
+ const date5 = `${d2.getFullYear()}-${pad2(d2.getMonth() + 1)}-${pad2(d2.getDate())}`;
28556
+ const time3 = `${pad2(d2.getHours())}:${pad2(d2.getMinutes())}`;
28557
+ const offsetMinutes = -d2.getTimezoneOffset();
28558
+ const sign = offsetMinutes < 0 ? "-" : "+";
28559
+ const abs = Math.abs(offsetMinutes);
28560
+ return `${date5} ${time3} ${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
28561
+ }
28562
+ function renderGeneratedAt(content, at) {
28563
+ return content.replace(GENERATED_AT_PLACEHOLDER, formatGeneratedAt(at));
28564
+ }
28565
+ function normaliseGeneratedAt(content) {
28566
+ return content.replace(STAMP_SLOT_RE, GENERATED_AT_PREFIX + GENERATED_AT_PLACEHOLDER);
28567
+ }
28568
+
28569
+ // src/lib/context/markers.ts
28570
+ var USER_BLOCK_BEGIN = "<!-- BEGIN: user-customisable -->";
28571
+ var USER_BLOCK_END = "<!-- END -->";
28572
+ var USER_BLOCK_PLACEHOLDER = "_Add workspace-specific guidance here. This block is the only part of " + "the file Plinth preserves across regenerations._";
28573
+ function extractUserBlock(content) {
28574
+ const beginIdx = content.indexOf(USER_BLOCK_BEGIN);
28575
+ if (beginIdx === -1)
28576
+ return null;
28577
+ const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
28578
+ const endIdx = content.indexOf(USER_BLOCK_END, afterBegin);
28579
+ if (endIdx === -1)
28580
+ return null;
28581
+ return content.slice(afterBegin, endIdx);
28582
+ }
28583
+ function injectUserBlock(generated, preserved) {
28584
+ const beginIdx = generated.indexOf(USER_BLOCK_BEGIN);
28585
+ if (beginIdx === -1) {
28586
+ throw new Error("injectUserBlock: generated content has no BEGIN marker");
28587
+ }
28588
+ const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
28589
+ const endIdx = generated.indexOf(USER_BLOCK_END, afterBegin);
28590
+ if (endIdx === -1) {
28591
+ throw new Error("injectUserBlock: generated content has no END marker");
28592
+ }
28593
+ return generated.slice(0, afterBegin) + preserved + generated.slice(endIdx);
28594
+ }
28595
+
28596
+ // src/lib/context/artist-claude-md.ts
28597
+ var CONTEXT_VERSION_HEADER = "plinth-context-version";
28598
+ var TASK_DISPLAY_CAP = 10;
28599
+ var PRIORITY_RANK = {
28600
+ urgent: 0,
28601
+ high: 1,
28602
+ medium: 2,
28603
+ low: 3
28604
+ };
28605
+ function buildArtistClaudeMd(input) {
28606
+ const body = buildBody(input);
28607
+ const version2 = stableHash(body).slice(0, 12);
28608
+ return `<!-- ${CONTEXT_VERSION_HEADER}: ${version2} -->
28609
+ ${body}`;
28610
+ }
28611
+ function buildBody(input) {
28612
+ const { slug, name, status, uuid: uuid3, profile, projects, tasks } = input;
28613
+ const lines = [];
28614
+ lines.push(`<!-- Auto-generated by Plinth. ${GENERATED_AT_PREFIX}${GENERATED_AT_PLACEHOLDER}; ` + `that stamp moves only when this file's content changes, so an unchanged file keeps an older one ` + `and is still re-checked against Plinth on every sync. Run \`plinth status\` for when the mirror last synced. ` + `Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`);
28615
+ lines.push("");
28616
+ lines.push(`# ${name} · Plinth artist`);
28617
+ lines.push("");
28618
+ const statusPart = status ? `, status ${status}` : "";
28619
+ const bioPart = profile.bio ? ` ${profile.bio}` : "";
28620
+ lines.push(`${name} (artist \`${slug}\`${statusPart}).${bioPart}`);
28621
+ lines.push(`Working dir for ${name}-scoped AI sessions. ${SECURITY_NOTE}`);
28622
+ lines.push("");
28623
+ const links = buildLinks(profile);
28624
+ if (links) {
28625
+ lines.push(`## Links`);
28626
+ lines.push(links);
28627
+ lines.push("");
28628
+ }
28629
+ const sortedProjects = sortProjects(projects);
27848
28630
  const names = sortedProjects.map((p) => p.name).join(", ");
27849
28631
  lines.push(`## Active projects (${sortedProjects.length})${names ? `: ${names}` : ""}`);
27850
28632
  if (sortedProjects.length === 0) {
@@ -27877,7 +28659,8 @@ function buildBody(input) {
27877
28659
  lines.push(`- Master context (stable profile + current state) → \`./_artist.md\`; edit its body and the daemon syncs both ways. Blocks labelled "rendered from Plinth" are the exception: they are overwritten on the next sync`);
27878
28660
  lines.push(`- Closed/archived, meetings & full lists: \`mcp__plinth__list_projects\` / \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}`);
27879
28661
  lines.push(`- Schema: ${MCP_INTROSPECTION_POINTER}`);
27880
- lines.push(`- Long-term facts → \`./memory.md\` · daily activity → \`./daily-log.md\``);
28662
+ lines.push(`- Long-term facts → \`./memory.md\`, which syncs to this artist's Plinth record. Most artists have none yet: the file appears once the record carries memory, and creating it here is how you start one. One line per fact with its source and an absolute date; when a fact goes wrong, edit that line rather than adding a second`);
28663
+ lines.push(`- Rolling day-to-day activity → \`./daily-log.md\`, local to this machine and never synced. Durable facts do not belong here; they belong in \`./memory.md\` above`);
27881
28664
  lines.push("");
27882
28665
  lines.push(USER_BLOCK_BEGIN);
27883
28666
  lines.push(USER_BLOCK_PLACEHOLDER);
@@ -27950,7 +28733,7 @@ function datePart(value) {
27950
28733
 
27951
28734
  // src/lib/context/artist-data.ts
27952
28735
  var import_gray_matter5 = __toESM(require_gray_matter(), 1);
27953
- import { readFileSync as readFileSync6 } from "node:fs";
28736
+ import { readFileSync as readFileSync9 } from "node:fs";
27954
28737
  function artistUuidBySlug(state) {
27955
28738
  const map2 = new Map;
27956
28739
  for (const entry2 of Object.values(state.entries.artists ?? {})) {
@@ -28062,7 +28845,7 @@ function readEntityFrontmatter(workspaceSlug, entityType, slug) {
28062
28845
  }
28063
28846
  let raw;
28064
28847
  try {
28065
- raw = readFileSync6(path3, "utf8");
28848
+ raw = readFileSync9(path3, "utf8");
28066
28849
  } catch {
28067
28850
  return null;
28068
28851
  }
@@ -28090,6 +28873,7 @@ function readSchemaVersion(content) {
28090
28873
  // src/lib/context/regen.ts
28091
28874
  function decideRegen(input) {
28092
28875
  const { existing, currentVersion, generated, force } = input;
28876
+ const normalise = input.normalise ?? ((c3) => c3);
28093
28877
  if (existing === null || existing.trim() === "") {
28094
28878
  return { action: "create", content: generated };
28095
28879
  }
@@ -28103,7 +28887,7 @@ function decideRegen(input) {
28103
28887
  return { action: "backup-regen", content: generated, backup: existing };
28104
28888
  }
28105
28889
  const merged = injectUserBlock(generated, preserved);
28106
- if (merged === existing) {
28890
+ if (normalise(merged) === normalise(existing)) {
28107
28891
  return { action: "skip", content: null };
28108
28892
  }
28109
28893
  return { action: "regen", content: merged };
@@ -28111,18 +28895,18 @@ function decideRegen(input) {
28111
28895
 
28112
28896
  // src/lib/context/roster.ts
28113
28897
  var import_gray_matter6 = __toESM(require_gray_matter(), 1);
28114
- import { readdirSync as readdirSync3, readFileSync as readFileSync7 } from "node:fs";
28115
- import { join as join5 } from "node:path";
28898
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
28899
+ import { join as join8 } from "node:path";
28116
28900
  function readRoster(workspaceSlug) {
28117
28901
  let artistsDir;
28118
28902
  try {
28119
- artistsDir = join5(workspaceMirrorPath(workspaceSlug), "artists");
28903
+ artistsDir = join8(workspaceMirrorPath(workspaceSlug), "artists");
28120
28904
  } catch {
28121
28905
  return [];
28122
28906
  }
28123
28907
  let slugs;
28124
28908
  try {
28125
- slugs = readdirSync3(artistsDir, { withFileTypes: true }).filter((d2) => d2.isDirectory()).map((d2) => d2.name).sort();
28909
+ slugs = readdirSync4(artistsDir, { withFileTypes: true }).filter((d2) => d2.isDirectory()).map((d2) => d2.name).sort();
28126
28910
  } catch {
28127
28911
  return [];
28128
28912
  }
@@ -28130,7 +28914,7 @@ function readRoster(workspaceSlug) {
28130
28914
  for (const slug of slugs) {
28131
28915
  let raw;
28132
28916
  try {
28133
- raw = readFileSync7(join5(artistsDir, slug, "_artist.md"), "utf8");
28917
+ raw = readFileSync10(join8(artistsDir, slug, "_artist.md"), "utf8");
28134
28918
  } catch {
28135
28919
  continue;
28136
28920
  }
@@ -28147,9 +28931,9 @@ function readRoster(workspaceSlug) {
28147
28931
  }
28148
28932
 
28149
28933
  // src/lib/context/session-hook.ts
28150
- import { execFileSync } from "node:child_process";
28151
- import { accessSync, constants, realpathSync, statSync } from "node:fs";
28152
- import { basename } from "node:path";
28934
+ import { execFileSync as execFileSync2 } from "node:child_process";
28935
+ import { accessSync, constants, realpathSync, statSync as statSync3 } from "node:fs";
28936
+ import { basename as basename2 } from "node:path";
28153
28937
  var SETTINGS_REL = ".claude/settings.json";
28154
28938
  var MANAGED_KEY = "_plinth";
28155
28939
  var COMMENT_SENTINEL = 'Managed by plinth-cli. Entries tagged "_plinth" are REGENERATED on every sync:';
@@ -28157,8 +28941,32 @@ var MANAGED_COMMENT = [
28157
28941
  COMMENT_SENTINEL,
28158
28942
  "edits to them are overwritten without warning. Add your own hooks as separate",
28159
28943
  "entries in this file (they are preserved verbatim, including their matchers)",
28160
- "or in .claude/settings.local.json."
28944
+ "or in .claude/settings.local.json.",
28945
+ "The permissions.deny entries below are managed the same way. They stop an agent",
28946
+ "in this workspace sending, replying to or forwarding mail and writing a calendar;",
28947
+ "drafting and reading stay allowed. Your own allow list is untouched, and your own",
28948
+ "deny rules are kept and listed first. One exception, stated because it is",
28949
+ "visible: if you write a deny rule whose text is identical to one of ours, it is",
28950
+ "kept once, in our section, rather than twice. A deny always beats an allow,",
28951
+ "including your own."
28161
28952
  ];
28953
+ var MANAGED_DENY = [
28954
+ "mcp__claude_ai_Gmail__send_message",
28955
+ "mcp__claude_ai_Gmail__reply",
28956
+ "mcp__claude_ai_Gmail__forward",
28957
+ "mcp__claude_ai_Google_Calendar__create_event",
28958
+ "mcp__claude_ai_Google_Calendar__update_event",
28959
+ "mcp__claude_ai_Google_Calendar__delete_event",
28960
+ "mcp__claude_ai_Google_Calendar__respond_to_event",
28961
+ "mcp__*__send_message",
28962
+ "mcp__*__reply",
28963
+ "mcp__*__forward",
28964
+ "mcp__*__create_event",
28965
+ "mcp__*__update_event",
28966
+ "mcp__*__delete_event",
28967
+ "mcp__*__respond_to_event"
28968
+ ];
28969
+ var RETIRED_DENY = [];
28162
28970
  var BIN_SENTINEL = "__PLINTH_BIN__";
28163
28971
  var ALL_SENTINEL = "__PLINTH_ALL__";
28164
28972
  var RESOLVE_SCRIPT = `printf '%s
@@ -28189,7 +28997,7 @@ function resolveOverride(override, deps) {
28189
28997
  }
28190
28998
  function resolveUncached(deps) {
28191
28999
  const shell = deps.shell ?? process.env.SHELL ?? "/bin/zsh";
28192
- const exec = deps.exec ?? ((sh, args) => execFileSync(sh, args, {
29000
+ const exec = deps.exec ?? ((sh, args) => execFileSync2(sh, args, {
28193
29001
  encoding: "utf8",
28194
29002
  timeout: 5000,
28195
29003
  stdio: ["ignore", "pipe", "ignore"]
@@ -28252,13 +29060,13 @@ function probe(run, usable) {
28252
29060
  function isPlinthBinary(candidate, usable) {
28253
29061
  if (!candidate.startsWith("/"))
28254
29062
  return false;
28255
- if (basename(candidate) !== "plinth")
29063
+ if (basename2(candidate) !== "plinth")
28256
29064
  return false;
28257
29065
  return usable(candidate);
28258
29066
  }
28259
29067
  function defaultIsExecutableFile(path3) {
28260
29068
  try {
28261
- if (!statSync(path3).isFile())
29069
+ if (!statSync3(path3).isFile())
28262
29070
  return false;
28263
29071
  accessSync(path3, constants.X_OK);
28264
29072
  return true;
@@ -28269,10 +29077,6 @@ function defaultIsExecutableFile(path3) {
28269
29077
  function shellQuote(value) {
28270
29078
  return `'${value.replace(/'/g, `'\\''`)}'`;
28271
29079
  }
28272
- var VOICE_GATE_MATCHERS = [
28273
- "Bash",
28274
- "mcp__.*[Gg]mail.*__(create|update)_draft"
28275
- ];
28276
29080
  function managedEntry(id, invocation, subcommand, workspaceSlug) {
28277
29081
  return {
28278
29082
  type: "command",
@@ -28284,24 +29088,41 @@ function buildManagedBlocks(invocation, workspaceSlug) {
28284
29088
  return {
28285
29089
  SessionStart: [
28286
29090
  {
28287
- hooks: [managedEntry("grounding", invocation, "grounding", workspaceSlug)]
29091
+ hooks: [
29092
+ managedEntry("grounding", invocation, "grounding", workspaceSlug),
29093
+ managedEntry("sessions", invocation, "session register", workspaceSlug)
29094
+ ]
28288
29095
  }
28289
- ],
28290
- PreToolUse: VOICE_GATE_MATCHERS.map((matcher) => ({
28291
- matcher,
28292
- hooks: [
28293
- managedEntry("voice-gate", invocation, "voice-gate --hook", workspaceSlug)
28294
- ]
28295
- }))
29096
+ ]
28296
29097
  };
28297
29098
  }
29099
+ function mergePermissions(existing) {
29100
+ if (existing !== undefined && existing !== null && !isEntry(existing)) {
29101
+ throw new Error("`permissions` is not a JSON object");
29102
+ }
29103
+ const base2 = existing ? { ...existing } : {};
29104
+ const currentDeny = base2.deny;
29105
+ if (currentDeny !== undefined && currentDeny !== null && !Array.isArray(currentDeny)) {
29106
+ throw new Error("`permissions.deny` is not an array");
29107
+ }
29108
+ const ours = new Set([...MANAGED_DENY, ...RETIRED_DENY]);
29109
+ const kept = (Array.isArray(currentDeny) ? currentDeny : []).filter((rule) => typeof rule !== "string" || !ours.has(rule));
29110
+ base2.deny = [...kept, ...MANAGED_DENY];
29111
+ return base2;
29112
+ }
28298
29113
  var LEGACY_GROUNDING = /^'[^']*'\s+'[^']*'\s+grounding(\s|$)/;
29114
+ var OWNED_EVENTS = {
29115
+ grounding: "SessionStart",
29116
+ sessions: "SessionStart",
29117
+ "voice-gate": "PreToolUse"
29118
+ };
28299
29119
  function isManaged(entry2, event) {
28300
29120
  if (!isEntry(entry2))
28301
29121
  return false;
28302
29122
  const marker = entry2[MANAGED_KEY];
28303
- if (marker === "grounding" || marker === "voice-gate")
29123
+ if (typeof marker === "string" && OWNED_EVENTS[marker] === event) {
28304
29124
  return true;
29125
+ }
28305
29126
  if (event === "SessionStart" && typeof entry2.command === "string") {
28306
29127
  return LEGACY_GROUNDING.test(entry2.command);
28307
29128
  }
@@ -28317,17 +29138,18 @@ function mergeSettings(existing, managed) {
28317
29138
  const base2 = existing ? { ...existing } : {};
28318
29139
  const existingHooks = typeof base2.hooks === "object" && base2.hooks !== null && !Array.isArray(base2.hooks) ? base2.hooks : {};
28319
29140
  const hooks = {};
28320
- for (const [event, value] of Object.entries(existingHooks)) {
28321
- hooks[event] = asArray(value);
28322
- }
28323
- for (const [event, managedBlocks] of Object.entries(managed)) {
28324
- const kept = asArray(hooks[event]).filter(isEntry).map((block) => ({
29141
+ const events = new Set([...Object.keys(existingHooks), ...Object.keys(managed)]);
29142
+ for (const event of events) {
29143
+ const kept = asArray(existingHooks[event]).filter(isEntry).map((block) => ({
28325
29144
  ...block,
28326
29145
  hooks: asArray(block.hooks).filter((entry2) => isEntry(entry2) && !isManaged(entry2, event))
28327
29146
  })).filter((block) => block.hooks.length > 0);
28328
- hooks[event] = [...kept, ...managedBlocks];
29147
+ const merged = [...kept, ...managed[event] ?? []];
29148
+ if (merged.length > 0)
29149
+ hooks[event] = merged;
28329
29150
  }
28330
29151
  base2.hooks = hooks;
29152
+ base2.permissions = mergePermissions(base2.permissions);
28331
29153
  const current = base2.$comment;
28332
29154
  const isOurs = Array.isArray(current) && current[0] === COMMENT_SENTINEL;
28333
29155
  if (current === undefined || isOurs) {
@@ -28355,7 +29177,7 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
28355
29177
  parsed = raw;
28356
29178
  } catch (err) {
28357
29179
  const msg = err instanceof Error ? err.message : "unknown error";
28358
- out.error(`${rel} could not be parsed (${msg}), so the Plinth session-grounding ` + `and voice-gate hooks were NOT installed there. Fix or remove the ` + `file and re-run — until then this workspace is ungrounded and ` + `agent-originated drafts are UNGATED.`);
29180
+ 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.`);
28359
29181
  return "skipped";
28360
29182
  }
28361
29183
  }
@@ -28364,16 +29186,16 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
28364
29186
  generated = buildSettingsJson(resolved.invocation, workspaceSlug, parsed);
28365
29187
  } catch (err) {
28366
29188
  const msg = err instanceof Error ? err.message : "unknown error";
28367
- out.error(`${rel} has a structure the generator could not merge into (${msg}), so ` + `the Plinth session-grounding and voice-gate hooks were NOT installed ` + `there. This workspace is ungrounded and agent-originated drafts are ` + `UNGATED. Fix or remove the file and re-run.`);
29189
+ 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.`);
28368
29190
  return "skipped";
28369
29191
  }
28370
29192
  if (existing === generated)
28371
29193
  return "unchanged";
28372
29194
  writeContextFile(workspaceSlug, rel, generated);
28373
29195
  if (existing === null) {
28374
- out.info(`Generated Plinth session hooks (${rel}).`);
29196
+ out.info(`Generated Plinth session settings (${rel}).`);
28375
29197
  } else {
28376
- out.info(`Refreshed the Plinth session hooks in ${rel} (your entries kept).`);
29198
+ out.info(`Refreshed the Plinth session settings in ${rel} (your entries kept).`);
28377
29199
  }
28378
29200
  return "written";
28379
29201
  }
@@ -28390,7 +29212,7 @@ function reportResolution(out, resolved) {
28390
29212
  }
28391
29213
  }
28392
29214
  function warnUnresolvedBinary(out) {
28393
- out.error("Could not locate an installed `plinth` binary, so no Claude Code hooks " + "were written for this workspace — sessions will be UNGROUNDED and " + "agent-originated drafts UNGATED. Install the CLI globally " + "(`npm i -g @plinth-music/cli`) so `command -v plinth` resolves, then " + "re-run `plinth refresh-context`.");
29215
+ 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`.");
28394
29216
  }
28395
29217
 
28396
29218
  // src/lib/context/artist-context.ts
@@ -28441,35 +29263,249 @@ function ensurePerArtistContext(deps) {
28441
29263
  function regenerateClaudeMd(workspaceSlug, artistSlug, generated, force, out, now) {
28442
29264
  const claudeRel = `artists/${artistSlug}/${ARTIST_CLAUDE_MD}`;
28443
29265
  const existing = readContextFile(workspaceSlug, claudeRel);
28444
- const decision = decideRegen({ existing, currentVersion: "", generated, force });
29266
+ const decision = decideRegen({
29267
+ existing,
29268
+ currentVersion: "",
29269
+ generated,
29270
+ force,
29271
+ normalise: normaliseGeneratedAt
29272
+ });
28445
29273
  if (decision.action === "backup-regen" && decision.backup !== undefined) {
28446
29274
  const bakRel = `${claudeRel}.bak-${backupStamp(now())}`;
28447
29275
  writeContextFile(workspaceSlug, bakRel, decision.backup);
28448
29276
  out.warn(`artists/${artistSlug}/${ARTIST_CLAUDE_MD} had no recognisable BEGIN/END ` + `markers; backed your copy up to ${bakRel} before regenerating.`);
28449
29277
  }
28450
- if (decision.content !== null) {
28451
- writeContextFile(workspaceSlug, claudeRel, decision.content);
29278
+ if (decision.content !== null) {
29279
+ writeContextFile(workspaceSlug, claudeRel, renderGeneratedAt(decision.content, now()));
29280
+ }
29281
+ return decision.action === "create";
29282
+ }
29283
+ function ensureStub(workspaceSlug, relativePath, content) {
29284
+ if (existsSync(contextFilePath(workspaceSlug, relativePath)))
29285
+ return;
29286
+ writeContextFile(workspaceSlug, relativePath, content);
29287
+ }
29288
+ function dailyLogStub(name) {
29289
+ return `# ${name} - Daily log
29290
+
29291
+ ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
29292
+ ` + `<!-- Durable facts do not belong here - they go in ./memory.md, which syncs to this artist's Plinth record. -->
29293
+ `;
29294
+ }
29295
+ function backupStamp(d2) {
29296
+ return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
29297
+ }
29298
+
29299
+ // src/lib/context/generate.ts
29300
+ import { existsSync as existsSync2 } from "node:fs";
29301
+
29302
+ // src/lib/context/skill-commands.ts
29303
+ var COMMAND_USER_BLOCK_PLACEHOLDER = "_This workspace's own steps go here: anything specific to your team, your " + "tools or your data. Plinth preserves this block verbatim on every " + "regeneration; everything outside it is overwritten._";
29304
+ var OWNERSHIP_NOTE = "<!-- Auto-generated by Plinth into every mirror on every sync. Edit only " + "between the BEGIN/END markers at the foot; everything else is overwritten. " + "Your own commands in `.claude/commands/` are never read or touched by " + "Plinth, only this `plinth/` folder is generated. -->";
29305
+ function withUserBlock(lines) {
29306
+ return [...lines, "", USER_BLOCK_BEGIN, COMMAND_USER_BLOCK_PLACEHOLDER, USER_BLOCK_END].join(`
29307
+ `) + `
29308
+ `;
29309
+ }
29310
+ function buildStartCommandMd() {
29311
+ return withUserBlock([
29312
+ "---",
29313
+ "description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, reads the projected calendar, gives a three-line brief, then asks what you want to work on.",
29314
+ "---",
29315
+ "",
29316
+ OWNERSHIP_NOTE,
29317
+ "",
29318
+ "# Start",
29319
+ "",
29320
+ "Get oriented in this Plinth workspace in a few seconds, then load context for whatever the user names.",
29321
+ "",
29322
+ "**Do not re-run `date`, and do not restate the grounding rules.** The Plinth SessionStart hook has already injected today's date, the entity-resolution rule and the write-confirmation rule, and the workspace `CLAUDE.md` (roster and folder map) is already in context. Repeating them spends the budget this command exists to protect.",
29323
+ "",
29324
+ "## Step 1: use the two memory indexes you were already given",
29325
+ "",
29326
+ "The SessionStart hook injects **both memory indexes in full**, above: the workspace index (durable facts and conventions that hold for anyone here) and your personal index (facts true of **this member**: their email address, how they write, how they work). **Do not re-read `workspace-memory.md` or `user-memory.md`.** They are already in this context window, and re-reading them is the single most expensive thing this command could do for nothing.",
29327
+ "",
29328
+ "**Use the second one. It is the one that gets skipped**, and it is the one carrying the member's own identity, including the email address that every task assignment needs. An agent that ignores it asks the user for something the workspace already told it.",
29329
+ "",
29330
+ "If neither index appears above, the hook did not run - then, and only then, read the two files at the **mirror root**, the workspace folder holding `CLAUDE.md` (if this session started inside `artists/<slug>/`, that is the parent). Read them silently; do not dump either file into the conversation.",
29331
+ "",
29332
+ "## Step 2: what has landed",
29333
+ "",
29334
+ "Pull **unread notifications** from the Plinth MCP (`list_notifications`). If the Plinth MCP is not connected, say so in one line and carry on from the injected indexes rather than failing.",
29335
+ "",
29336
+ "**That one call plus the single file read in Step 3 are the whole budget, and the list below is exhaustive rather than indicative.** Do not sweep tasks, do not list projects, do not open a calendar, do not read an artist folder, and do not re-read the memory files. If a fact is not in the two injected indexes, in the notifications, or in that one file, it does not belong in this brief.",
29337
+ "",
29338
+ "## Step 3: what is coming up",
29339
+ "",
29340
+ "Read **one file**: `views/calendar/upcoming.md` at the mirror root. It is Plinth's projection of every artist's calendar, written into the mirror by the daemon.",
29341
+ "",
29342
+ "**This is a FILE READ, not a calendar call, and the rule in Step 2 stands unchanged: you still never open a calendar tool.** The file is here so that you do not have to.",
29343
+ "",
29344
+ '**Brief it as of its own `generated_at` stamp, and say the stamp.** A projection is a cache. It is exactly as current as the header says it is and no more, so "nothing on this week" is only ever true as of that moment.',
29345
+ "",
29346
+ "**Read its calendar-status list before you read the dates, and escalate anything that was not read.** A calendar Plinth could not reach is UNKNOWN, not empty, and the file says which. Briefing an unread calendar as a clear diary is the one failure this file exists to prevent - if a line says NOT READ, say so out loud in the brief.",
29347
+ "",
29348
+ "If the file carries `fetch_state: stale`, the last refresh failed: say how old the dates are rather than reporting them as current. If the file is absent altogether, say the projection has not run yet and carry on without it.",
29349
+ "",
29350
+ "**Dates are read here and changed by a person, in Plinth's calendar view.** If something needs moving, say what the change is and hand it over.",
29351
+ "",
29352
+ "## Step 4: brief, in three lines",
29353
+ "",
29354
+ "Then stop reading and report. **Three lines, and not a summary of what you read:**",
29355
+ "",
29356
+ "1. Today, and anything with a clock on it that the injected indexes, the notifications or the calendar file **have already named** - with the calendar's own stamp when you use it. Do not go looking for more.",
29357
+ "2. The unread count, and the one or two that actually matter.",
29358
+ "3. Anything blocked or waiting on someone.",
29359
+ "",
29360
+ 'Then ask: **"What do you want to work on?"** If the user already said so in the same message, skip the question and load that context now.',
29361
+ "",
29362
+ "## Step 5: offer the sweep, do not run it",
29363
+ "",
29364
+ `A full roster sweep, every artist's overdue and upcoming work plus calendars and project status, is useful and it is **not** part of opening a session. **Offer it in one clause ("want the roster sweep?") and stop there.** Reporting a sweep you ran anyway is the failure this step exists to prevent: it is the same cost the user did not ask for, with the offer bolted on afterwards.`,
29365
+ "",
29366
+ "**The budget is the one call in Step 2 and the one file read in Step 3, and it is a ceiling rather than a target.** Every new session pays this command before the user has asked for anything, so a second call needs a reason you would defend out loud. An opener that takes half a minute gets turned off, and an opener nobody runs banks nothing at the other end.",
29367
+ "",
29368
+ "## Loading area context",
29369
+ "",
29370
+ "Once the user names something, resolve it against the workspace before answering: search the Plinth MCP, or read the roster in `CLAUDE.md`. **Never answer from name recognition alone.** Then read the surfaces that carry it. An artist's master context is `artists/<slug>/_artist.md`, their durable facts are `artists/<slug>/memory.md`, their recent activity is `artists/<slug>/daily-log.md`. Brief on what is live, not on what you opened.",
29371
+ "",
29372
+ "If nothing resolves, say so plainly and ask. Do not invent an entity.",
29373
+ "",
29374
+ "## Write discipline",
29375
+ "",
29376
+ "This command is read-only orientation. If the session moves into changes, follow the injected grounding: prose and context through the mirror file, structured fields through the Plinth MCP, the exact write stated before the first mutation, and never the database directly.",
29377
+ "",
29378
+ `When the session is finished, \`${CLOSE_COMMAND_INVOCATION}\` is the other half of this one: it banks what was learned before the context is gone.`
29379
+ ]);
29380
+ }
29381
+ function buildCloseCommandMd() {
29382
+ return withUserBlock([
29383
+ "---",
29384
+ "description: Close a Plinth session. Verifies what you wrote landed, routes follow-ups to a durable surface, asks before anything reaches shared memory, and logs what was decided.",
29385
+ "---",
29386
+ "",
29387
+ OWNERSHIP_NOTE,
29388
+ "",
29389
+ "# Close",
29390
+ "",
29391
+ "End a working session cleanly: everything done this session is on a durable surface, nothing is half-written, and the next session can pick up cold.",
29392
+ "",
29393
+ "Trust the date the session hook injected. Do not re-derive it.",
29394
+ "",
29395
+ "## Step 1: reconstruct the session",
29396
+ "",
29397
+ "From the **transcript**, not from your memory of it. Which entities were touched, which writes were made, what was decided, what was learned. If the session was pure reading with no changes, say so and go to Step 4 anyway: a read-only close still logs, and the `--nothing-proposed` rule below is why.",
29398
+ "",
29399
+ "## Step 2: verify each write landed",
29400
+ "",
29401
+ "For every intended write, confirm it actually happened: the tool returned success, or the file on disk reflects it. Re-read the file if you are unsure. **A change that exists only in this conversation is lost the moment it ends.**",
29402
+ "",
29403
+ "Flag anything left in flight: an unsent draft, a quarantined edit awaiting `plinth confirm`, a task created with no owner or due date, something agreed out loud and written nowhere.",
29404
+ "",
29405
+ "## Step 3: route the follow-ups",
29406
+ "",
29407
+ "Each loose end goes to the surface that holds it, and nowhere else:",
29408
+ "",
29409
+ "- **Something to do** goes to a Plinth task, scoped to its artist or project, with an owner and a real due date where one exists. Fold it into the existing umbrella task rather than adding a second.",
29410
+ "- **State or context that changed** goes to the artist's master context, the body of `artists/<slug>/_artist.md`, or to the project's summary. Prose goes through the mirror file.",
29411
+ "- **What happened today** goes to `artists/<slug>/daily-log.md`, a dated entry per artist touched. Local-only and rolling, and never the place for a durable fact.",
29412
+ "",
29413
+ "## Step 4: memory, in two piles",
29414
+ "",
29415
+ "The two piles are treated differently on purpose, and the difference is not politeness. A wrong line in **shared** memory reaches every member on every machine and is injected into every future session. A wrong line in a member's **own** memory is theirs to prune.",
29416
+ "",
29417
+ "### Private: `user-memory.md` at the mirror root. Write it directly.",
29418
+ "",
29419
+ "Facts true of this member: how they write, how they sign off, what they prefer, their own identifiers. No gate. **List what you wrote in the closing summary**, so it is visible without opening the file.",
29420
+ "",
29421
+ "### Shared: `workspace-memory.md` at the mirror root. Propose. Never write unasked.",
29422
+ "",
29423
+ "Draft the candidate lines first: durable facts about this workspace that would change how a future session behaves, not this session's detail.",
29424
+ "",
29425
+ "**Each line has a required shape, and it is the file's own, not this command's.** `workspace-memory.md` is an INDEX, and the grounding block tells every future session that each line is a one-line hook pointing via a `[[wikilink]]` to the warm store where the full detail lives. So a line carries: the hook, a `[[wikilink]]` to where the detail actually is, an **absolute** date (turn \"last week\" into a real one), and what taught you it. **A line with no warm store behind it makes the grounding block lie to every session that loads it.** Read the file's header comment and follow the conventions there; where it and this command disagree, the file wins.",
29426
+ "",
29427
+ "Then put the candidates to the user with **`AskUserQuestion`**:",
29428
+ "",
29429
+ "- **One multi-select question, one option per proposed line.** Selected means bank it. Unselected means rejected.",
29430
+ '- **The free-text "Other" choice is how the user rewrites a line in their own words.** Their wording wins over yours, always.',
29431
+ "- The tool takes **four options per question and four questions**. If you have more than four candidate lines, you are banking session detail rather than durable facts: **say so and cut the list. Do not paginate.**",
29432
+ "",
29433
+ "**An accepted line that contradicts one already in the file supersedes it in place.** Edit the wrong line. Never leave two lines disagreeing.",
29434
+ "",
29435
+ "## Step 5: log every decision, then write the file",
29436
+ "",
29437
+ "**In that order.** The log is the record of what was decided; the file is the consequence.",
29438
+ "",
29439
+ "```sh",
29440
+ 'plinth memory-gate log --proposed "<the line as you put it>" --decision accept',
29441
+ 'plinth memory-gate log --proposed "<the line as you put it>" --decision reject',
29442
+ 'plinth memory-gate log --proposed "<the line as you put it>" --decision edit --final "<their rewrite>"',
29443
+ "```",
29444
+ "",
29445
+ "A close that proposed nothing still logs, because *gated and proposed nothing* has to stay distinguishable from *never gated*:",
29446
+ "",
29447
+ "```sh",
29448
+ "plinth memory-gate log --nothing-proposed",
29449
+ "```",
29450
+ "",
29451
+ "`plinth memory-gate show [--json]` reads the log back.",
29452
+ "",
29453
+ "**Four rules. Break one and a decision is lost:**",
29454
+ "",
29455
+ "1. `--decision edit` **requires** `--final`. The rewrite is the record: it is the user's own words for their own standard, and it is the most valuable row in the log.",
29456
+ "2. `--final` is **refused** on `accept` and `reject`. An accept records the proposed line; a reject records none.",
29457
+ "3. `--nothing-proposed` **cannot** be combined with `--proposed` or `--decision`.",
29458
+ '4. **`memory-gate` fails LOUD**: exit 1, with a message on stderr. **Surface it. Never swallow it.** A silently dropped decision produces exactly the "never gated" state the log exists to make visible, while looking like a clean close. If it fails, tell the user, and do not report the line as banked.',
29459
+ "",
29460
+ "**Never write this log by hand.** The CLI owns the format so it can still be read back in a year.",
29461
+ "",
29462
+ "Only once the decisions are logged, write the accepted lines into `workspace-memory.md`.",
29463
+ "",
29464
+ "## Step 6: summarise, then end the session",
29465
+ "",
29466
+ "**Four to six lines**, and no file-by-file dump: what now lives on a durable surface, what is still in flight and on whom, what was banked to memory (private and shared), and the obvious next move.",
29467
+ "",
29468
+ "Then, as the **very last step**:",
29469
+ "",
29470
+ "```sh",
29471
+ 'plinth session end --summary "<one line>"',
29472
+ "```",
29473
+ "",
29474
+ "This is the opposite of `memory-gate`: it **always exits 0 and always prints nothing**, whatever happens. It marks this session ended, and when it is running inside the Plinth desktop dock it tells the dock the session is finished so the tab can close itself. **Outside the dock it does nothing visible, and that is correct.** Nothing in this command depends on the dock; it runs the same in a bare terminal."
29475
+ ]);
29476
+ }
29477
+ function ensureWorkspaceCommands(deps) {
29478
+ const { workspaceSlug, force, out, now } = deps;
29479
+ const created = [];
29480
+ const refreshed = [];
29481
+ const targets = [
29482
+ [START_COMMAND_REL, buildStartCommandMd()],
29483
+ [CLOSE_COMMAND_REL, buildCloseCommandMd()]
29484
+ ];
29485
+ for (const [rel, generated] of targets) {
29486
+ const existing = readContextFile(workspaceSlug, rel);
29487
+ const decision = decideRegen({ existing, currentVersion: "", generated, force });
29488
+ if (decision.action === "backup-regen" && decision.backup !== undefined) {
29489
+ const bakRel = `${rel}.bak-${backupStamp2(now())}`;
29490
+ writeContextFile(workspaceSlug, bakRel, decision.backup);
29491
+ out.warn(`${rel} had no recognisable BEGIN/END markers; backed your copy up to ` + `${bakRel} before regenerating.`);
29492
+ }
29493
+ if (decision.content !== null) {
29494
+ writeContextFile(workspaceSlug, rel, decision.content);
29495
+ (decision.action === "create" ? created : refreshed).push(invocationFor(rel));
29496
+ }
29497
+ }
29498
+ if (created.length > 0) {
29499
+ out.info(`Generated ${created.join(" and ")}.`);
29500
+ }
29501
+ if (refreshed.length > 0) {
29502
+ out.info(`Refreshed ${refreshed.join(" and ")} (your edits preserved).`);
28452
29503
  }
28453
- return decision.action === "create";
28454
- }
28455
- function ensureStub(workspaceSlug, relativePath, content) {
28456
- if (existsSync(contextFilePath(workspaceSlug, relativePath)))
28457
- return;
28458
- writeContextFile(workspaceSlug, relativePath, content);
28459
- }
28460
- function dailyLogStub(name) {
28461
- return `# ${name} - Daily log
28462
-
28463
- ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
28464
- `;
28465
29504
  }
28466
- function backupStamp(d2) {
29505
+ function backupStamp2(d2) {
28467
29506
  return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
28468
29507
  }
28469
29508
 
28470
- // src/lib/context/generate.ts
28471
- import { existsSync as existsSync2 } from "node:fs";
28472
-
28473
29509
  // src/lib/context/workspace-claude-md.ts
28474
29510
  function rosterBlock(roster, activeProjectCountBySlug) {
28475
29511
  if (roster.length === 0)
@@ -28495,7 +29531,7 @@ function buildWorkspaceClaudeMd(input) {
28495
29531
  const skillCount = SKILL_LOCATIONS.length;
28496
29532
  const lines = [
28497
29533
  `<!-- plinth-schema-version: ${schemaVersion} -->`,
28498
- `<!-- Auto-generated by Plinth. Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
29534
+ `<!-- Auto-generated by Plinth. ${GENERATED_AT_PREFIX}${GENERATED_AT_PLACEHOLDER}; ` + `that stamp moves only when this file's content changes, so an unchanged file keeps an older one ` + `and is still re-checked against Plinth on every sync. Run \`plinth status\` for when the mirror last synced. ` + `Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
28499
29535
  ``,
28500
29536
  `# ${workspaceName} · Plinth workspace`,
28501
29537
  ``,
@@ -28503,17 +29539,20 @@ function buildWorkspaceClaudeMd(input) {
28503
29539
  ``,
28504
29540
  `> **Session grounding**: the current date, an entity-resolution rule, and a write-confirmation rule are injected at every session start by the Plinth hook in \`.claude/settings.json\` (and each \`artists/<slug>/.claude/settings.json\`).`,
28505
29541
  ``,
29542
+ `> **Filing a file the user hands you - BOTH halves, and the first one is unconditional**: put a copy in \`files/\`, which is how Plinth gets the text (folder map below) - do this even if everything else here fails. Then put the **original** in the user's own file storage with whatever tool you have for that, and write down where it went somewhere durable: \`artists/<slug>/memory.md\` if it is that artist's, otherwise \`workspace-memory.md\`. A pointer that exists only in this conversation is lost when it ends. If you have no tool that reaches their file storage, SAY SO - do not leave them believing the original was filed.`,
29543
+ ``,
28506
29544
  `## Folder map`,
28507
29545
  `- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread`,
28508
29546
  `- \`projects/\` \`tasks/\` \`documents/\` \`meetings/\` workspace-root flat entity files (one \`.md\` per entity, NOT nested under \`artists/\`)`,
28509
29547
  `- \`files/\` free-form document store: PDFs, executed agreements, spreadsheets, anything that is a FILE rather than an entity. Mirrored byte-for-byte both ways, folders and all`,
28510
- `- \`.claude/commands/\` local skills · at the mirror root: \`workspace-memory.md\` \`workspace-rules.md\` (workspace-wide) + \`user-memory.md\` (yours), synced to Plinth · \`CLAUDE.local.md\` private, gitignored`,
29548
+ `- \`${extractedTextRelPath("<path>")}\` where \`<path>\` is the file's path under \`files/\`: the text Plinth extracted from that file, so a PDF's contents can be searched. Not every file has one - text files are already their own text, and extraction can fail. Where it produced junk the file exists and says so instead of carrying it, so read what is there before trusting a silence. Derived: edits here are overwritten and never reach Plinth`,
29549
+ `- \`.claude/commands/\` local skills, incl. generated \`${START_COMMAND_INVOCATION}\` + \`${CLOSE_COMMAND_INVOCATION}\` · at the mirror root: \`workspace-memory.md\` \`workspace-rules.md\` (workspace-wide) + \`user-memory.md\` (yours), synced to Plinth · \`CLAUDE.local.md\` private, gitignored`,
28511
29550
  ``,
28512
29551
  `## Where key context lives: look here before hunting`,
28513
29552
  `- Each artist's **master context** (stable profile + current state) → \`artists/<slug>/_artist.md\`; edit its body and the daemon syncs both ways. Blocks labelled "rendered from Plinth" are the exception: they are overwritten on the next sync`,
28514
29553
  `- **Contracts, executed agreements & any PDF** → \`files/\` (search here first; \`documents/\` holds markdown bodies, not attachments)`,
28515
29554
  `- Workspace-wide **notes, briefs, bios & context docs** → \`documents/\` (one markdown file per doc, shared across artists)`,
28516
- `- Per-artist long-term facts → \`artists/<slug>/memory.md\` · daily activity → \`artists/<slug>/daily-log.md\``,
29555
+ `- Per-artist long-term facts → \`artists/<slug>/memory.md\`, which syncs to that artist's Plinth record; most have none yet, and creating the file is how you start one · rolling activity → \`artists/<slug>/daily-log.md\`, local-only and never the place for a durable fact`,
28517
29556
  `- ${MCP_AVAILABILITY_POINTER}`,
28518
29557
  ``,
28519
29558
  `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
@@ -28571,19 +29610,21 @@ function ensureWorkspaceContext(deps) {
28571
29610
  existing,
28572
29611
  currentVersion: "",
28573
29612
  generated,
28574
- force
29613
+ force,
29614
+ normalise: normaliseGeneratedAt
28575
29615
  });
28576
29616
  if (decision.action === "backup-regen" && decision.backup !== undefined) {
28577
- const bakRel = `${CLAUDE_MD}.bak-${backupStamp2(now())}`;
29617
+ const bakRel = `${CLAUDE_MD}.bak-${backupStamp3(now())}`;
28578
29618
  writeContextFile(workspaceSlug, bakRel, decision.backup);
28579
29619
  out.warn(`${CLAUDE_MD} had no recognisable BEGIN/END markers; backed your copy ` + `up to ${bakRel} before regenerating.`);
28580
29620
  }
28581
29621
  if (decision.content !== null) {
28582
- writeContextFile(workspaceSlug, CLAUDE_MD, decision.content);
29622
+ writeContextFile(workspaceSlug, CLAUDE_MD, renderGeneratedAt(decision.content, now()));
28583
29623
  }
28584
29624
  if (!existsSync2(contextFilePath(workspaceSlug, CLAUDE_LOCAL_MD))) {
28585
29625
  writeContextFile(workspaceSlug, CLAUDE_LOCAL_MD, CLAUDE_LOCAL_STUB);
28586
29626
  }
29627
+ ensureWorkspaceCommands({ workspaceSlug, force, out, now });
28587
29628
  ensureGitignore(workspaceSlug);
28588
29629
  const resolved = resolvePlinthBinary();
28589
29630
  reportResolution(out, resolved);
@@ -28615,7 +29656,7 @@ function ensureGitignore(workspaceSlug) {
28615
29656
  `;
28616
29657
  writeContextFile(workspaceSlug, GITIGNORE, `${existing}${lead}${block}`);
28617
29658
  }
28618
- function backupStamp2(d2) {
29659
+ function backupStamp3(d2) {
28619
29660
  return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
28620
29661
  }
28621
29662
 
@@ -28824,12 +29865,12 @@ function classify(facts) {
28824
29865
  }
28825
29866
 
28826
29867
  // src/lib/review-tier/collect.ts
28827
- import { execFileSync as execFileSync2 } from "node:child_process";
29868
+ import { execFileSync as execFileSync3 } from "node:child_process";
28828
29869
  import fs2 from "node:fs";
28829
29870
  import path3 from "node:path";
28830
29871
  function execGit(args, cwd) {
28831
29872
  try {
28832
- const stdout2 = execFileSync2("git", args, {
29873
+ const stdout2 = execFileSync3("git", args, {
28833
29874
  cwd,
28834
29875
  encoding: "utf8",
28835
29876
  maxBuffer: 64 * 1024 * 1024,
@@ -29047,16 +30088,280 @@ var reviewTierCommand = defineCommand({
29047
30088
  }
29048
30089
  });
29049
30090
 
30091
+ // src/commands/session.ts
30092
+ init_output();
30093
+ async function defaultReadStdin() {
30094
+ if (process.stdin.isTTY)
30095
+ return "";
30096
+ const chunks = [];
30097
+ for await (const chunk of process.stdin) {
30098
+ chunks.push(Buffer.from(chunk));
30099
+ }
30100
+ return Buffer.concat(chunks).toString("utf8");
30101
+ }
30102
+ function asString(value) {
30103
+ return typeof value === "string" && value.length > 0 ? value : null;
30104
+ }
30105
+ function resolveSlug(explicit) {
30106
+ let config2;
30107
+ try {
30108
+ config2 = loadConfig();
30109
+ } catch {
30110
+ return { ok: false, reason: "unconfigured" };
30111
+ }
30112
+ if (!config2)
30113
+ return { ok: false, reason: "unconfigured" };
30114
+ const slug = explicit ?? config2.active_workspace;
30115
+ if (!config2.workspaces[slug]) {
30116
+ return explicit === undefined ? { ok: false, reason: "unconfigured" } : { ok: false, reason: "unknown-workspace", slug };
30117
+ }
30118
+ return { ok: true, slug };
30119
+ }
30120
+ async function runSessionRegister(args, deps = {}) {
30121
+ const env3 = deps.env ?? process.env;
30122
+ const raw = await (deps.readStdin ?? defaultReadStdin)().catch(() => "");
30123
+ let payload = {};
30124
+ if (raw.trim().length > 0) {
30125
+ try {
30126
+ const parsed = JSON.parse(raw);
30127
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
30128
+ payload = parsed;
30129
+ }
30130
+ } catch {}
30131
+ }
30132
+ const sessionId = asString(payload.session_id) ?? asString(env3.CLAUDE_CODE_SESSION_ID);
30133
+ const cwd = asString(payload.cwd) ?? (deps.cwd ?? process.cwd)();
30134
+ const transcriptPath = asString(payload.transcript_path);
30135
+ const tabId = asString(env3.PLINTH_DOCK_TAB_ID);
30136
+ const pidRaw = asString(env3.CLAUDE_PID);
30137
+ const pid = pidRaw !== null ? Number.parseInt(pidRaw, 10) : Number.NaN;
30138
+ if (sessionId === null || !isSafeSegment(sessionId))
30139
+ return { exitCode: 0 };
30140
+ if (!Number.isInteger(pid) || pid <= 0)
30141
+ return { exitCode: 0 };
30142
+ const resolved = resolveSlug(args.workspace);
30143
+ if (!resolved.ok)
30144
+ return { exitCode: 0 };
30145
+ try {
30146
+ registerSession({
30147
+ sessionId,
30148
+ pid,
30149
+ cwd,
30150
+ workspaceSlug: resolved.slug,
30151
+ transcriptPath,
30152
+ tabId,
30153
+ now: (deps.now ?? (() => new Date))(),
30154
+ startTimeFn: deps.startTimeFn
30155
+ });
30156
+ } catch {}
30157
+ return { exitCode: 0 };
30158
+ }
30159
+ function buildSessionListJson(workspaceSlug, live, reaped, unchecked, selfPid, selfSessionId, ended = 0) {
30160
+ return {
30161
+ workspace_slug: workspaceSlug,
30162
+ self_pid: selfPid,
30163
+ self_session_id: selfSessionId,
30164
+ sessions: live.map((s2) => ({
30165
+ session_id: s2.record.session_id,
30166
+ pid: s2.record.pid,
30167
+ cwd: s2.record.cwd,
30168
+ started_at: s2.record.started_at,
30169
+ last_activity: s2.lastActivityMs === null ? null : new Date(s2.lastActivityMs).toISOString(),
30170
+ is_self: s2.record.session_id === selfSessionId || selfPid !== null && s2.record.pid === selfPid,
30171
+ tab_id: s2.record.tab_id
30172
+ })),
30173
+ reaped,
30174
+ unchecked,
30175
+ ended,
30176
+ scope: "machine-local"
30177
+ };
30178
+ }
30179
+ async function runSessionList(args, deps = {}) {
30180
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
30181
+ const env3 = deps.env ?? process.env;
30182
+ const resolved = resolveSlug(args.workspace);
30183
+ if (!resolved.ok) {
30184
+ out.error(resolved.reason === "unknown-workspace" ? `Workspace "${resolved.slug}" not found. Run \`plinth login --workspace ${resolved.slug}\` first.` : "No workspace configured. Run `plinth login --workspace <slug>` first.");
30185
+ return { exitCode: 1 };
30186
+ }
30187
+ const slug = resolved.slug;
30188
+ const { live, reaped, unknown: unknown2, ended } = listSessions(slug, deps);
30189
+ const selfPidRaw = asString(env3.CLAUDE_PID);
30190
+ const selfPid = selfPidRaw !== null ? Number.parseInt(selfPidRaw, 10) : null;
30191
+ const selfSessionId = asString(env3.CLAUDE_CODE_SESSION_ID);
30192
+ if (args.json) {
30193
+ out.info(JSON.stringify(buildSessionListJson(slug, live, reaped, unknown2, Number.isInteger(selfPid) ? selfPid : null, selfSessionId, ended), null, 2));
30194
+ return { exitCode: 0 };
30195
+ }
30196
+ const now = (deps.now ?? (() => new Date))();
30197
+ for (const line of renderSessionLines(live, now, selfPid, selfSessionId)) {
30198
+ out.info(line);
30199
+ }
30200
+ for (const line of sessionCaveats(live.length, unknown2, ended))
30201
+ out.info(line);
30202
+ return { exitCode: 0 };
30203
+ }
30204
+ var DOCK_CLOSED_ROUTE = "/closed";
30205
+ var DOCK_SIGNAL_TIMEOUT_MS = 1000;
30206
+ var SUMMARY_MAX_CHARS = 2000;
30207
+ function readDockSignalEnv(env3) {
30208
+ const port = asString(env3.PLINTH_DOCK_SIGNAL_PORT);
30209
+ const token = asString(env3.PLINTH_DOCK_SIGNAL_TOKEN);
30210
+ const tabId = asString(env3.PLINTH_DOCK_TAB_ID);
30211
+ if (port === null || token === null || tabId === null)
30212
+ return null;
30213
+ if (!/^[0-9]{1,5}$/.test(port))
30214
+ return null;
30215
+ return { port, token, tabId };
30216
+ }
30217
+ function normaliseSummary(raw) {
30218
+ if (raw === undefined)
30219
+ return null;
30220
+ const folded = raw.replace(/\s+/g, " ").trim();
30221
+ if (folded.length === 0)
30222
+ return null;
30223
+ return folded.length > SUMMARY_MAX_CHARS ? folded.slice(0, SUMMARY_MAX_CHARS) : folded;
30224
+ }
30225
+ var defaultFetch = (url2, init2) => fetch(url2, init2);
30226
+ async function runSessionEnd(args, deps = {}) {
30227
+ const env3 = deps.env ?? process.env;
30228
+ const now = (deps.now ?? (() => new Date))();
30229
+ const sessionId = asString(env3.CLAUDE_CODE_SESSION_ID);
30230
+ if (sessionId !== null && isSafeSegment(sessionId)) {
30231
+ const resolved = resolveSlug(args.workspace);
30232
+ if (resolved.ok) {
30233
+ try {
30234
+ endSession(resolved.slug, sessionId, now);
30235
+ } catch {}
30236
+ }
30237
+ }
30238
+ const dock = readDockSignalEnv(env3);
30239
+ if (dock === null)
30240
+ return { exitCode: 0 };
30241
+ const body = JSON.stringify({ summary: normaliseSummary(args.summary) });
30242
+ try {
30243
+ await (deps.fetchFn ?? defaultFetch)(`http://127.0.0.1:${dock.port}${DOCK_CLOSED_ROUTE}`, {
30244
+ method: "POST",
30245
+ headers: {
30246
+ "content-type": "application/json",
30247
+ "x-plinth-token": dock.token,
30248
+ "x-plinth-tab": dock.tabId
30249
+ },
30250
+ body,
30251
+ signal: AbortSignal.timeout(DOCK_SIGNAL_TIMEOUT_MS)
30252
+ });
30253
+ } catch {}
30254
+ return { exitCode: 0 };
30255
+ }
30256
+ function renderSessionLines(live, now, selfPid, selfSessionId, formatRelative = defaultRelative) {
30257
+ if (live.length === 0)
30258
+ return ["No Claude sessions registered on this machine."];
30259
+ return live.map((s2) => {
30260
+ const isSelf = s2.record.session_id === selfSessionId || selfPid !== null && s2.record.pid === selfPid;
30261
+ const activity = s2.lastActivityMs === null ? "activity unknown" : `active ${formatRelative(now.getTime() - s2.lastActivityMs)}`;
30262
+ const tag = isSelf ? " (this session)" : "";
30263
+ const tab = s2.record.tab_id === null ? "" : ` · tab ${s2.record.tab_id}`;
30264
+ return `${s2.record.session_id.slice(0, 8)} · pid ${s2.record.pid}${tab} · ${activity} · ${s2.record.cwd}${tag}`;
30265
+ });
30266
+ }
30267
+ function sessionCaveats(liveCount, unknown2, ended = 0) {
30268
+ const lines = [];
30269
+ if (liveCount > 1) {
30270
+ lines.push("Visibility only - nothing here prevents a conflicting write.");
30271
+ }
30272
+ if (ended > 0) {
30273
+ lines.push(`${ended} session${ended === 1 ? " has" : "s have"} run \`plinth session end\` but ${ended === 1 ? "its process is" : "their processes are"} still alive - not listed above.`);
30274
+ }
30275
+ if (unknown2 > 0) {
30276
+ lines.push(`${unknown2} record${unknown2 === 1 ? "" : "s"} could not be checked and ${unknown2 === 1 ? "is" : "are"} not listed.`);
30277
+ }
30278
+ lines.push("Machine-local - a session on another machine is not counted.");
30279
+ return lines;
30280
+ }
30281
+ function defaultRelative(deltaMs) {
30282
+ const s2 = Math.floor(Math.max(0, deltaMs) / 1000);
30283
+ if (s2 < 60)
30284
+ return `${s2}s ago`;
30285
+ const m2 = Math.floor(s2 / 60);
30286
+ if (m2 < 60)
30287
+ return `${m2}m ago`;
30288
+ const h2 = Math.floor(m2 / 60);
30289
+ if (h2 < 24)
30290
+ return `${h2}h ago`;
30291
+ return `${Math.floor(h2 / 24)}d ago`;
30292
+ }
30293
+ var sessionCommand = defineCommand({
30294
+ meta: {
30295
+ name: "session",
30296
+ description: "Concurrent Claude session visibility for this workspace"
30297
+ },
30298
+ subCommands: {
30299
+ register: defineCommand({
30300
+ meta: {
30301
+ name: "register",
30302
+ description: "Record this Claude session (SessionStart hook; fail-quiet)"
30303
+ },
30304
+ args: {
30305
+ workspace: { type: "string", description: "Workspace slug" }
30306
+ },
30307
+ async run({ args }) {
30308
+ const result = await runSessionRegister({
30309
+ workspace: args.workspace
30310
+ });
30311
+ process.exitCode = result.exitCode;
30312
+ }
30313
+ }),
30314
+ list: defineCommand({
30315
+ meta: {
30316
+ name: "list",
30317
+ description: "List live Claude sessions writing this workspace"
30318
+ },
30319
+ args: {
30320
+ workspace: { type: "string", description: "Workspace slug" },
30321
+ json: { type: "boolean", description: "Machine-readable output" },
30322
+ "no-color": { type: "boolean", description: "Disable colour" }
30323
+ },
30324
+ async run({ args }) {
30325
+ const result = await runSessionList({
30326
+ workspace: args.workspace,
30327
+ json: args.json
30328
+ }, { noColor: noColorFromArgs(args) });
30329
+ process.exitCode = result.exitCode;
30330
+ }
30331
+ }),
30332
+ end: defineCommand({
30333
+ meta: {
30334
+ name: "end",
30335
+ description: "Mark this Claude session ended and, inside the desktop dock, tell the dock (last step of /close; fail-quiet)"
30336
+ },
30337
+ args: {
30338
+ workspace: { type: "string", description: "Workspace slug" },
30339
+ summary: {
30340
+ type: "string",
30341
+ description: "One-line summary of the session, forwarded to the dock"
30342
+ }
30343
+ },
30344
+ async run({ args }) {
30345
+ const result = await runSessionEnd({
30346
+ workspace: args.workspace,
30347
+ summary: args.summary
30348
+ });
30349
+ process.exitCode = result.exitCode;
30350
+ }
30351
+ })
30352
+ }
30353
+ });
30354
+
29050
30355
  // src/commands/start.ts
29051
30356
  var import_gray_matter10 = __toESM(require_gray_matter(), 1);
29052
- import { readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
30357
+ import { readFileSync as readFileSync20, statSync as statSync12 } from "node:fs";
29053
30358
 
29054
30359
  // src/lib/daemon-log.ts
29055
- import { appendFileSync, mkdirSync as mkdirSync5, renameSync as renameSync5, statSync as statSync2 } from "node:fs";
29056
- import { dirname as dirname4, join as join6 } from "node:path";
30360
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
30361
+ import { dirname as dirname6, join as join9 } from "node:path";
29057
30362
  var DEFAULT_DAEMON_LOG_MAX_BYTES = 5000000;
29058
30363
  function daemonLogPath() {
29059
- return join6(plinthHome(), "daemon.log");
30364
+ return join9(plinthHome(), "daemon.log");
29060
30365
  }
29061
30366
  function daemonLogMaxBytes() {
29062
30367
  const raw = process.env.PLINTH_DAEMON_LOG_MAX_BYTES;
@@ -29078,10 +30383,10 @@ class DaemonLog {
29078
30383
  this.now = opts.now ?? (() => new Date);
29079
30384
  this.maxBytes = opts.maxBytes ?? daemonLogMaxBytes();
29080
30385
  try {
29081
- mkdirSync5(dirname4(this.path), { recursive: true });
30386
+ mkdirSync8(dirname6(this.path), { recursive: true });
29082
30387
  } catch {}
29083
30388
  try {
29084
- this.bytesWritten = statSync2(this.path).size;
30389
+ this.bytesWritten = statSync4(this.path).size;
29085
30390
  } catch {
29086
30391
  this.bytesWritten = 0;
29087
30392
  }
@@ -29094,7 +30399,7 @@ class DaemonLog {
29094
30399
  const bytes = Buffer.byteLength(line);
29095
30400
  try {
29096
30401
  this.maybeRotate(bytes);
29097
- appendFileSync(this.path, line);
30402
+ appendFileSync2(this.path, line);
29098
30403
  this.bytesWritten += bytes;
29099
30404
  } catch {
29100
30405
  try {
@@ -29108,97 +30413,16 @@ class DaemonLog {
29108
30413
  }
29109
30414
  rotate() {
29110
30415
  try {
29111
- renameSync5(this.path, `${this.path}.1`);
30416
+ renameSync7(this.path, `${this.path}.1`);
29112
30417
  } catch {}
29113
30418
  this.bytesWritten = 0;
29114
30419
  }
29115
30420
  }
29116
30421
 
29117
- // src/lib/daemon-pid.ts
29118
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync5 } from "node:fs";
29119
- import { dirname as dirname5, join as join7 } from "node:path";
29120
- var DaemonPidSchema = exports_external.object({
29121
- version: exports_external.literal(1),
29122
- pid: exports_external.number().int().positive(),
29123
- workspace_slug: exports_external.string().min(1),
29124
- started_at: exports_external.string().min(1)
29125
- });
29126
- function daemonPidPath(workspaceSlug) {
29127
- return join7(plinthHome(), `daemon-${workspaceSlug}.pid`);
29128
- }
29129
- function daemonPidTmpPath(workspaceSlug) {
29130
- return join7(plinthHome(), `daemon-${workspaceSlug}.pid.tmp`);
29131
- }
29132
- function writeDaemonPid(workspaceSlug, pid, startedAt) {
29133
- const file2 = {
29134
- version: 1,
29135
- pid,
29136
- workspace_slug: workspaceSlug,
29137
- started_at: startedAt.toISOString()
29138
- };
29139
- const target = daemonPidPath(workspaceSlug);
29140
- const tmp = daemonPidTmpPath(workspaceSlug);
29141
- mkdirSync6(dirname5(target), { recursive: true, mode: 448 });
29142
- writeFileSync5(tmp, JSON.stringify(file2, null, 2) + `
29143
- `, { mode: 384 });
29144
- try {
29145
- renameSync6(tmp, target);
29146
- } catch (err) {
29147
- try {
29148
- unlinkSync5(tmp);
29149
- } catch {}
29150
- throw err;
29151
- }
29152
- }
29153
- function readDaemonPid(workspaceSlug) {
29154
- let raw;
29155
- try {
29156
- raw = readFileSync8(daemonPidPath(workspaceSlug), "utf8");
29157
- } catch (err) {
29158
- if (err?.code === "ENOENT")
29159
- return null;
29160
- throw err;
29161
- }
29162
- let json2;
29163
- try {
29164
- json2 = JSON.parse(raw);
29165
- } catch {
29166
- return null;
29167
- }
29168
- const parsed = DaemonPidSchema.safeParse(json2);
29169
- return parsed.success ? parsed.data : null;
29170
- }
29171
- function clearDaemonPid(workspaceSlug) {
29172
- try {
29173
- unlinkSync5(daemonPidPath(workspaceSlug));
29174
- } catch (err) {
29175
- if (err?.code === "ENOENT")
29176
- return;
29177
- throw err;
29178
- }
29179
- }
29180
- function isPidAlive(pid) {
29181
- try {
29182
- process.kill(pid, 0);
29183
- return true;
29184
- } catch (err) {
29185
- return err?.code === "EPERM";
29186
- }
29187
- }
29188
- function readLiveDaemon(workspaceSlug, aliveFn = isPidAlive) {
29189
- const pidFile = readDaemonPid(workspaceSlug);
29190
- if (!pidFile)
29191
- return null;
29192
- if (aliveFn(pidFile.pid))
29193
- return pidFile;
29194
- clearDaemonPid(workspaceSlug);
29195
- return null;
29196
- }
29197
-
29198
30422
  // src/lib/destructive-gate.ts
29199
30423
  var import_gray_matter7 = __toESM(require_gray_matter(), 1);
29200
- import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
29201
- import { basename as basename2 } from "node:path";
30424
+ import { readFileSync as readFileSync11, statSync as statSync5 } from "node:fs";
30425
+ import { basename as basename3 } from "node:path";
29202
30426
  var GUTTING_RATIO_DEFAULT = 0.5;
29203
30427
  var GUTTING_MIN_PRIOR_BYTES = 200;
29204
30428
  function envFloat(name, def) {
@@ -29241,10 +30465,10 @@ function classifyDestructive(event, entityType, state, deps = {}) {
29241
30465
  absolutePath: event.absolutePath
29242
30466
  };
29243
30467
  }
29244
- const readFile = deps.readFile ?? ((p) => readFileSync9(p, "utf8"));
30468
+ const readFile = deps.readFile ?? ((p) => readFileSync11(p, "utf8"));
29245
30469
  const statInode = deps.statInode ?? ((p) => {
29246
30470
  try {
29247
- return statSync3(p).ino;
30471
+ return statSync5(p).ino;
29248
30472
  } catch {
29249
30473
  return;
29250
30474
  }
@@ -29263,7 +30487,7 @@ function classifyDestructive(event, entityType, state, deps = {}) {
29263
30487
  return null;
29264
30488
  }
29265
30489
  const liveInode = statInode(event.absolutePath);
29266
- const slug = basename2(event.absolutePath, ".md");
30490
+ const slug = basename3(event.absolutePath, ".md");
29267
30491
  const entry2 = (liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined) ?? getEntryBySlug(state, entityType, slug);
29268
30492
  if (!entry2)
29269
30493
  return null;
@@ -29368,40 +30592,40 @@ class DestructiveGate {
29368
30592
  }
29369
30593
 
29370
30594
  // src/lib/resolve-pending.ts
29371
- import { readFileSync as readFileSync13 } from "node:fs";
30595
+ import { readFileSync as readFileSync15 } from "node:fs";
29372
30596
 
29373
30597
  // src/lib/conflicts.ts
29374
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "node:fs";
29375
- import { dirname as dirname6, join as join8 } from "node:path";
30598
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync7 } from "node:fs";
30599
+ import { dirname as dirname7, join as join10 } from "node:path";
29376
30600
  function conflictsRoot(workspaceSlug) {
29377
- return join8(plinthHome(), "_conflicts", workspaceSlug);
30601
+ return join10(plinthHome(), "_conflicts", workspaceSlug);
29378
30602
  }
29379
30603
  function conflictFilePath(args) {
29380
30604
  const { workspaceSlug, entityType, entitySlug, timestamp } = args;
29381
30605
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
29382
- return join8(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
30606
+ return join10(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
29383
30607
  }
29384
30608
  function stashConflict(args) {
29385
30609
  const target = conflictFilePath(args);
29386
- mkdirSync7(dirname6(target), { recursive: true, mode: 448 });
29387
- writeFileSync6(target, args.contents, { mode: 384 });
30610
+ mkdirSync9(dirname7(target), { recursive: true, mode: 448 });
30611
+ writeFileSync7(target, args.contents, { mode: 384 });
29388
30612
  return target;
29389
30613
  }
29390
30614
  function fileConflictPath(args) {
29391
30615
  const { workspaceSlug, relPath, timestamp } = args;
29392
30616
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
29393
30617
  const safe = relPath.replace(/[/\\]/g, "__");
29394
- return join8(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
30618
+ return join10(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
29395
30619
  }
29396
30620
  function stashFileConflict(args) {
29397
30621
  const target = fileConflictPath(args);
29398
- mkdirSync7(dirname6(target), { recursive: true, mode: 448 });
29399
- writeFileSync6(target, args.bytes, { mode: 384 });
30622
+ mkdirSync9(dirname7(target), { recursive: true, mode: 448 });
30623
+ writeFileSync7(target, args.bytes, { mode: 384 });
29400
30624
  return target;
29401
30625
  }
29402
30626
 
29403
30627
  // src/lib/files/push.ts
29404
- import { readFileSync as readFileSync10, statSync as statSync4 } from "node:fs";
30628
+ import { readFileSync as readFileSync12, statSync as statSync6 } from "node:fs";
29405
30629
 
29406
30630
  // src/lib/log-tokens.ts
29407
30631
  function categorizeError(err) {
@@ -29472,6 +30696,17 @@ function pullNoticeLine(kind) {
29472
30696
  const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
29473
30697
  return `[${token} reason=${kind}]`;
29474
30698
  }
30699
+ function calendarProjectedLine(f3) {
30700
+ return `[CALENDAR_PROJECTED written=${f3.written} pruned=${f3.pruned} ` + `calendars=${f3.calendars} not_read=${f3.notRead}]`;
30701
+ }
30702
+ function calendarFailedLine(f3) {
30703
+ const serverPart = f3.serverMessage ? ` server_msg="${f3.serverMessage}"` : "";
30704
+ return `[CALENDAR_FAILED status=${f3.status} reason=${f3.reason} staled=${f3.staled}${serverPart}]`;
30705
+ }
30706
+ function calendarNoticeLine(kind) {
30707
+ const token = kind === "auth" ? "CALENDAR_PAUSED" : kind === "unsupported" ? "CALENDAR_UNSUPPORTED" : "CALENDAR_DEGRADED";
30708
+ return `[${token} reason=${kind}]`;
30709
+ }
29475
30710
 
29476
30711
  // src/lib/rejected.ts
29477
30712
  import { relative as relative3 } from "node:path";
@@ -29561,6 +30796,7 @@ function clearFileRejected(state, relPath) {
29561
30796
  // src/lib/files/api.ts
29562
30797
  var MAX_WORKSPACE_FILE_BYTES = 4000000;
29563
30798
  var HASH_RE = /^[0-9a-f]{64}$/;
30799
+ var TextQualitySchema = exports_external.enum(["ok", "partial", "garbled"]);
29564
30800
  var FileLiveSchema = exports_external.object({
29565
30801
  id: exports_external.string().uuid(),
29566
30802
  path: exports_external.string().min(1),
@@ -29571,8 +30807,25 @@ var FileLiveSchema = exports_external.object({
29571
30807
  project_id: exports_external.string().uuid().nullable(),
29572
30808
  updated_at: exports_external.string().datetime({ offset: true }),
29573
30809
  deleted_at: exports_external.null(),
29574
- last_edited_by: exports_external.string().uuid().nullable()
29575
- });
30810
+ last_edited_by: exports_external.string().uuid().nullable(),
30811
+ content_quality: TextQualitySchema.nullable().optional().default(null).catch(null),
30812
+ has_extracted_text: exports_external.boolean().optional().catch(undefined),
30813
+ has_stored_bytes: exports_external.boolean().optional().catch(undefined)
30814
+ });
30815
+ function storedBytesSignal(row) {
30816
+ if (row.has_stored_bytes === true)
30817
+ return "present";
30818
+ if (row.has_stored_bytes === false)
30819
+ return "absent";
30820
+ return "unknown";
30821
+ }
30822
+ function extractedTextSignal(row) {
30823
+ if (row.has_extracted_text === true)
30824
+ return "present";
30825
+ if (row.has_extracted_text === false)
30826
+ return "absent";
30827
+ return "unknown";
30828
+ }
29576
30829
  var FileTombstoneSchema = exports_external.object({
29577
30830
  id: exports_external.string().uuid(),
29578
30831
  path: exports_external.string().min(1),
@@ -29585,7 +30838,18 @@ var FilesPageSchema = exports_external.object({
29585
30838
  next_cursor: exports_external.string().nullable()
29586
30839
  });
29587
30840
  var FileWithDownloadSchema = FileLiveSchema.extend({
29588
- download_url: exports_external.string().url()
30841
+ download_url: exports_external.string().url().nullable()
30842
+ });
30843
+ var FileTextSchema = exports_external.object({
30844
+ id: exports_external.string().uuid(),
30845
+ path: exports_external.string().min(1),
30846
+ mime_type: exports_external.string(),
30847
+ updated_at: exports_external.string().datetime({ offset: true }),
30848
+ content_quality: TextQualitySchema.nullable(),
30849
+ source_uri: exports_external.string().nullable(),
30850
+ text: exports_external.string().nullable(),
30851
+ chars: exports_external.number().int().nonnegative(),
30852
+ withheld: exports_external.enum(["bytes_are_text", "no_text"]).nullable()
29589
30853
  });
29590
30854
  var PreconditionSchema = exports_external.object({
29591
30855
  code: exports_external.literal("precondition_failed"),
@@ -29624,11 +30888,39 @@ async function syncFilesPage(args) {
29624
30888
  if (!parsed.success) {
29625
30889
  throw new ApiError(res.status, `sync files response did not match the expected shape: ${parsed.error.message}`);
29626
30890
  }
29627
- return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
30891
+ return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
30892
+ }
30893
+ async function getFile(args) {
30894
+ const { baseUrl, pat, id, fetchImpl = fetch } = args;
30895
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
30896
+ let res;
30897
+ try {
30898
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
30899
+ } catch (err) {
30900
+ const msg = err instanceof Error ? err.message : "network error";
30901
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
30902
+ }
30903
+ if (res.status === 404)
30904
+ return { ok: false, code: 404 };
30905
+ if (res.status === 401) {
30906
+ throw new ApiError(401, "Token was rejected by the server.");
30907
+ }
30908
+ if (res.status === 429) {
30909
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
30910
+ }
30911
+ if (!res.ok) {
30912
+ throw new ApiError(res.status, `get file failed with HTTP ${res.status}`);
30913
+ }
30914
+ const body = await res.json();
30915
+ const parsed = FileWithDownloadSchema.safeParse(body);
30916
+ if (!parsed.success) {
30917
+ throw new ApiError(res.status, `get file response did not match the expected shape`);
30918
+ }
30919
+ return { ok: true, row: parsed.data };
29628
30920
  }
29629
- async function getFile(args) {
30921
+ async function getFileText(args) {
29630
30922
  const { baseUrl, pat, id, fetchImpl = fetch } = args;
29631
- const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
30923
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}/text`;
29632
30924
  let res;
29633
30925
  try {
29634
30926
  res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
@@ -29639,18 +30931,18 @@ async function getFile(args) {
29639
30931
  if (res.status === 404)
29640
30932
  return { ok: false, code: 404 };
29641
30933
  if (res.status === 401) {
29642
- throw new ApiError(401, "Token was rejected by the server.");
30934
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
29643
30935
  }
29644
30936
  if (res.status === 429) {
29645
30937
  throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29646
30938
  }
29647
30939
  if (!res.ok) {
29648
- throw new ApiError(res.status, `get file failed with HTTP ${res.status}`);
30940
+ throw new ApiError(res.status, `get file text failed with HTTP ${res.status}`);
29649
30941
  }
29650
30942
  const body = await res.json();
29651
- const parsed = FileWithDownloadSchema.safeParse(body);
30943
+ const parsed = FileTextSchema.safeParse(body);
29652
30944
  if (!parsed.success) {
29653
- throw new ApiError(res.status, `get file response did not match the expected shape`);
30945
+ throw new ApiError(res.status, `get file text response did not match the expected shape: ${parsed.error.message}`);
29654
30946
  }
29655
30947
  return { ok: true, row: parsed.data };
29656
30948
  }
@@ -29845,13 +31137,13 @@ function dropFiles(state, relPath) {
29845
31137
  }
29846
31138
  function safeInode(absolutePath) {
29847
31139
  try {
29848
- return statSync4(absolutePath).ino;
31140
+ return statSync6(absolutePath).ino;
29849
31141
  } catch {
29850
31142
  return;
29851
31143
  }
29852
31144
  }
29853
31145
  async function restoreFileFromCloud(args) {
29854
- const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor } = args;
31146
+ const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor, output } = args;
29855
31147
  const got = await getFile({ baseUrl, pat, id, fetchImpl });
29856
31148
  const abs = filesSubtreePath(workspaceSlug, relPath);
29857
31149
  if (!got.ok) {
@@ -29859,12 +31151,17 @@ async function restoreFileFromCloud(args) {
29859
31151
  deleteFilesFile(workspaceSlug, relPath);
29860
31152
  return dropFiles(state, relPath);
29861
31153
  }
31154
+ if (got.row.download_url === null) {
31155
+ output?.warn(`files/${relPath}: Plinth holds no bytes for this file, so there is nothing to restore from cloud — the local copy and its state were left exactly as they are.`);
31156
+ return state;
31157
+ }
29862
31158
  const bytes = await downloadBytes(got.row.download_url, fetchImpl);
29863
31159
  suppressor?.registerWrite(abs, {
29864
31160
  bodyHash: got.row.content_hash,
29865
31161
  frontmatterHash: ""
29866
31162
  });
29867
31163
  writeFilesBytes(workspaceSlug, relPath, bytes);
31164
+ deleteExtractedText(workspaceSlug, relPath);
29868
31165
  return persistFiles(state, {
29869
31166
  id: got.row.id,
29870
31167
  path: relPath,
@@ -29893,7 +31190,7 @@ async function pushFilesUpsert(args) {
29893
31190
  } else {
29894
31191
  let st;
29895
31192
  try {
29896
- st = statSync4(absolutePath);
31193
+ st = statSync6(absolutePath);
29897
31194
  } catch (err) {
29898
31195
  if (err?.code === "ENOENT") {
29899
31196
  return pushFilesDelete({ workspaceSlug, baseUrl, pat, relPath, state, deps });
@@ -29928,7 +31225,7 @@ async function pushFilesUpsert(args) {
29928
31225
  state: next
29929
31226
  };
29930
31227
  }
29931
- bytes = readFileSync10(absolutePath);
31228
+ bytes = readFileSync12(absolutePath);
29932
31229
  hash2 = sha256Bytes(bytes);
29933
31230
  inode = st.ino;
29934
31231
  }
@@ -29999,7 +31296,8 @@ async function pushFilesUpsert(args) {
29999
31296
  relPath,
30000
31297
  state,
30001
31298
  fetchImpl,
30002
- suppressor
31299
+ suppressor,
31300
+ output
30003
31301
  });
30004
31302
  }
30005
31303
  return { outcome: { kind: "conflict", relPath, stashPath }, state: nextState };
@@ -30036,7 +31334,8 @@ async function pushFilesDelete(args) {
30036
31334
  relPath,
30037
31335
  state,
30038
31336
  fetchImpl,
30039
- suppressor
31337
+ suppressor,
31338
+ output
30040
31339
  });
30041
31340
  return { outcome: { kind: "conflict", relPath, stashPath: "" }, state: nextState };
30042
31341
  }
@@ -30063,12 +31362,12 @@ class PerIdMutex {
30063
31362
 
30064
31363
  // src/lib/push.ts
30065
31364
  var import_gray_matter9 = __toESM(require_gray_matter(), 1);
30066
- import { readFileSync as readFileSync12, statSync as statSync5 } from "node:fs";
30067
- import { basename as basename3, relative as relative4 } from "node:path";
31365
+ import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
31366
+ import { basename as basename4, relative as relative4 } from "node:path";
30068
31367
 
30069
31368
  // src/lib/pull-write.ts
30070
31369
  var import_gray_matter8 = __toESM(require_gray_matter(), 1);
30071
- import { readFileSync as readFileSync11 } from "node:fs";
31370
+ import { readFileSync as readFileSync13 } from "node:fs";
30072
31371
  function computeMirrorHashes(strategy, content) {
30073
31372
  const parsed = import_gray_matter8.default(content);
30074
31373
  const fm = parsed.data ?? {};
@@ -30090,7 +31389,7 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
30090
31389
  const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
30091
31390
  let existing = null;
30092
31391
  try {
30093
- existing = readFileSync11(target, "utf8");
31392
+ existing = readFileSync13(target, "utf8");
30094
31393
  } catch (err) {
30095
31394
  if (err?.code !== "ENOENT")
30096
31395
  throw err;
@@ -30161,7 +31460,7 @@ function omitKeys(obj, keys) {
30161
31460
  }
30162
31461
  function safeInode2(absolutePath) {
30163
31462
  try {
30164
- return statSync5(absolutePath).ino;
31463
+ return statSync7(absolutePath).ino;
30165
31464
  } catch {
30166
31465
  return;
30167
31466
  }
@@ -30181,7 +31480,7 @@ function dropEntry(state, entityType, id) {
30181
31480
  function stampStateFromMirror(args) {
30182
31481
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
30183
31482
  const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
30184
- const mirrorRaw = readFileSync12(mirrorPath, "utf8");
31483
+ const mirrorRaw = readFileSync14(mirrorPath, "utf8");
30185
31484
  const mirrorParsed = import_gray_matter9.default(mirrorRaw);
30186
31485
  const mirrorFm = mirrorParsed.data ?? {};
30187
31486
  const mirrorLifted = splitUserFrontmatter(strategy, mirrorFm);
@@ -30260,7 +31559,7 @@ async function pushFile(args) {
30260
31559
  }
30261
31560
  let raw;
30262
31561
  try {
30263
- raw = readFileSync12(absolutePath, "utf8");
31562
+ raw = readFileSync14(absolutePath, "utf8");
30264
31563
  } catch (err) {
30265
31564
  if (err?.code === "ENOENT") {
30266
31565
  return runPushDelete({
@@ -30294,7 +31593,7 @@ async function pushFile(args) {
30294
31593
  const rel = relative4(workspaceMirrorPath(workspaceSlug), absolutePath);
30295
31594
  parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
30296
31595
  } catch {}
30297
- const slugFromPath = parsedSlug ?? basename3(absolutePath, ".md");
31596
+ const slugFromPath = parsedSlug ?? basename4(absolutePath, ".md");
30298
31597
  const slug = slugFromFm ?? slugFromPath;
30299
31598
  if (!slug) {
30300
31599
  return {
@@ -30377,9 +31676,9 @@ async function reconcileContextDocConflict(args) {
30377
31676
  let fileSlug;
30378
31677
  try {
30379
31678
  const rel = relative4(workspaceMirrorPath(workspaceSlug), absolutePath);
30380
- fileSlug = strategy.parseMirrorPath(rel)?.slug ?? basename3(absolutePath, ".md");
31679
+ fileSlug = strategy.parseMirrorPath(rel)?.slug ?? basename4(absolutePath, ".md");
30381
31680
  } catch {
30382
- fileSlug = basename3(absolutePath, ".md");
31681
+ fileSlug = basename4(absolutePath, ".md");
30383
31682
  }
30384
31683
  if (fileSlug !== existingSlug) {
30385
31684
  const stashPath = stashConflict({
@@ -31006,7 +32305,7 @@ async function runPushDelete(args) {
31006
32305
  const rel = relative4(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
31007
32306
  parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
31008
32307
  } catch {}
31009
- const lookupSlug = parsedSlug ?? basename3(args.absolutePath, ".md");
32308
+ const lookupSlug = parsedSlug ?? basename4(args.absolutePath, ".md");
31010
32309
  const entry2 = getEntryBySlug(args.state, strategy.type, lookupSlug);
31011
32310
  if (entry2) {
31012
32311
  id = entry2.id;
@@ -31101,9 +32400,9 @@ async function pushRename(args) {
31101
32400
  let toSlug;
31102
32401
  try {
31103
32402
  const rel = relative4(workspaceMirrorPath(workspaceSlug), toPath);
31104
- toSlug = strategy.parseMirrorPath(rel)?.slug ?? basename3(toPath, ".md");
32403
+ toSlug = strategy.parseMirrorPath(rel)?.slug ?? basename4(toPath, ".md");
31105
32404
  } catch {
31106
- toSlug = basename3(toPath, ".md");
32405
+ toSlug = basename4(toPath, ".md");
31107
32406
  }
31108
32407
  return mutex.run(id, async () => {
31109
32408
  const live = getEntry(state, entityType, id);
@@ -31133,7 +32432,7 @@ async function pushRename(args) {
31133
32432
  };
31134
32433
  if (!updated.body_hash || !updated.frontmatter_hash) {
31135
32434
  try {
31136
- const mirrorRaw = readFileSync12(toPath, "utf8");
32435
+ const mirrorRaw = readFileSync14(toPath, "utf8");
31137
32436
  const mirrorParsed = import_gray_matter9.default(mirrorRaw);
31138
32437
  const renameFm = mirrorParsed.data ?? {};
31139
32438
  const renameLifted = splitUserFrontmatter(strategy, renameFm);
@@ -31168,7 +32467,7 @@ async function pushRename(args) {
31168
32467
  if (patchResult.reason === "slug_conflict") {
31169
32468
  let contents2 = "";
31170
32469
  try {
31171
- contents2 = readFileSync12(toPath, "utf8");
32470
+ contents2 = readFileSync14(toPath, "utf8");
31172
32471
  } catch {}
31173
32472
  const stashPath2 = stashConflict({
31174
32473
  workspaceSlug,
@@ -31208,7 +32507,7 @@ async function pushRename(args) {
31208
32507
  }
31209
32508
  let contents = "";
31210
32509
  try {
31211
- contents = readFileSync12(toPath, "utf8");
32510
+ contents = readFileSync14(toPath, "utf8");
31212
32511
  } catch {}
31213
32512
  const stashPath = stashConflict({
31214
32513
  workspaceSlug,
@@ -31329,7 +32628,7 @@ async function discardBatchOps(batch, deps) {
31329
32628
  const path5 = opPath(deps.workspaceSlug, op);
31330
32629
  let local = null;
31331
32630
  try {
31332
- local = readFileSync13(path5, "utf8");
32631
+ local = readFileSync15(path5, "utf8");
31333
32632
  } catch {
31334
32633
  local = null;
31335
32634
  }
@@ -31363,7 +32662,7 @@ async function discardFileOp(op, deps) {
31363
32662
  const abs = filesSubtreePath(deps.workspaceSlug, op.path);
31364
32663
  let local = null;
31365
32664
  try {
31366
- local = readFileSync13(abs);
32665
+ local = readFileSync15(abs);
31367
32666
  } catch {
31368
32667
  local = null;
31369
32668
  }
@@ -31382,7 +32681,8 @@ async function discardFileOp(op, deps) {
31382
32681
  id: op.id,
31383
32682
  relPath: op.path,
31384
32683
  state: deps.getState(),
31385
- fetchImpl: deps.fetchImpl
32684
+ fetchImpl: deps.fetchImpl,
32685
+ output: deps.output
31386
32686
  });
31387
32687
  deps.setState(next);
31388
32688
  }
@@ -31544,10 +32844,10 @@ class FileDestructiveGate {
31544
32844
  }
31545
32845
 
31546
32846
  // src/lib/files/reconcile.ts
31547
- import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
32847
+ import { readFileSync as readFileSync16, statSync as statSync9 } from "node:fs";
31548
32848
 
31549
32849
  // src/lib/unsynced.ts
31550
- import { join as join9, relative as relative6 } from "node:path";
32850
+ import { join as join11, relative as relative6 } from "node:path";
31551
32851
  var RECORDABLE_REASONS = new Set([
31552
32852
  "rate_limit",
31553
32853
  "server",
@@ -31610,7 +32910,7 @@ async function drainUnsyncedOps(args) {
31610
32910
  attempted += 1;
31611
32911
  try {
31612
32912
  if (op.target === "entity") {
31613
- const absolutePath = join9(workspaceMirrorPath(workspaceSlug), op.path);
32913
+ const absolutePath = join11(workspaceMirrorPath(workspaceSlug), op.path);
31614
32914
  const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
31615
32915
  if (!classified) {
31616
32916
  clear("entity", op.path);
@@ -31856,10 +33156,10 @@ class ReaddirpStream extends Readable {
31856
33156
  }
31857
33157
  async _formatEntry(dirent, path5) {
31858
33158
  let entry2;
31859
- const basename4 = this._isDirent ? dirent.name : dirent;
33159
+ const basename5 = this._isDirent ? dirent.name : dirent;
31860
33160
  try {
31861
- const fullPath = presolve(pjoin(path5, basename4));
31862
- entry2 = { path: prelative(this._root, fullPath), fullPath, basename: basename4 };
33161
+ const fullPath = presolve(pjoin(path5, basename5));
33162
+ entry2 = { path: prelative(this._root, fullPath), fullPath, basename: basename5 };
31863
33163
  entry2[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
31864
33164
  } catch (err) {
31865
33165
  this._onError(err);
@@ -32386,9 +33686,9 @@ class NodeFsHandler {
32386
33686
  _watchWithNodeFs(path5, listener) {
32387
33687
  const opts = this.fsw.options;
32388
33688
  const directory = sp.dirname(path5);
32389
- const basename5 = sp.basename(path5);
33689
+ const basename6 = sp.basename(path5);
32390
33690
  const parent = this.fsw._getWatchedDir(directory);
32391
- parent.add(basename5);
33691
+ parent.add(basename6);
32392
33692
  const absolutePath = sp.resolve(path5);
32393
33693
  const options2 = {
32394
33694
  persistent: opts.persistent
@@ -32398,7 +33698,7 @@ class NodeFsHandler {
32398
33698
  let closer;
32399
33699
  if (opts.usePolling) {
32400
33700
  const enableBin = opts.interval !== opts.binaryInterval;
32401
- options2.interval = enableBin && isBinaryPath(basename5) ? opts.binaryInterval : opts.interval;
33701
+ options2.interval = enableBin && isBinaryPath(basename6) ? opts.binaryInterval : opts.interval;
32402
33702
  closer = setFsWatchFileListener(path5, absolutePath, options2, {
32403
33703
  listener,
32404
33704
  rawEmitter: this.fsw._emitRaw
@@ -32416,11 +33716,11 @@ class NodeFsHandler {
32416
33716
  if (this.fsw.closed) {
32417
33717
  return;
32418
33718
  }
32419
- const dirname8 = sp.dirname(file2);
32420
- const basename5 = sp.basename(file2);
32421
- const parent = this.fsw._getWatchedDir(dirname8);
33719
+ const dirname9 = sp.dirname(file2);
33720
+ const basename6 = sp.basename(file2);
33721
+ const parent = this.fsw._getWatchedDir(dirname9);
32422
33722
  let prevStats = stats;
32423
- if (parent.has(basename5))
33723
+ if (parent.has(basename6))
32424
33724
  return;
32425
33725
  const listener = async (path5, newStats) => {
32426
33726
  if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file2, 5))
@@ -32445,9 +33745,9 @@ class NodeFsHandler {
32445
33745
  prevStats = newStats2;
32446
33746
  }
32447
33747
  } catch (error52) {
32448
- this.fsw._remove(dirname8, basename5);
33748
+ this.fsw._remove(dirname9, basename6);
32449
33749
  }
32450
- } else if (parent.has(basename5)) {
33750
+ } else if (parent.has(basename6)) {
32451
33751
  const at = newStats.atimeMs;
32452
33752
  const mt = newStats.mtimeMs;
32453
33753
  if (!at || at <= mt || mt !== prevStats.mtimeMs) {
@@ -33301,7 +34601,7 @@ function watch(paths, options2 = {}) {
33301
34601
  var chokidar_default = { watch, FSWatcher };
33302
34602
 
33303
34603
  // src/lib/watcher.ts
33304
- import { statSync as statSync6 } from "node:fs";
34604
+ import { statSync as statSync8 } from "node:fs";
33305
34605
  import { sep as sep5 } from "node:path";
33306
34606
 
33307
34607
  // src/lib/rename-classifier.ts
@@ -33517,7 +34817,7 @@ function startWatcher(opts, handler) {
33517
34817
  pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
33518
34818
  classifyPath: opts.classifyPath,
33519
34819
  getStateEntry: opts.getStateEntry,
33520
- statSync: opts.statSync ?? ((p) => ({ ino: statSync6(p).ino }))
34820
+ statSync: opts.statSync ?? ((p) => ({ ino: statSync8(p).ino }))
33521
34821
  });
33522
34822
  const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
33523
34823
  const watcher = factory(workspaceRoot, {
@@ -33552,7 +34852,7 @@ function readBlobForPush(args) {
33552
34852
  const { absolutePath, relPath, output } = args;
33553
34853
  let st;
33554
34854
  try {
33555
- st = statSync7(absolutePath);
34855
+ st = statSync9(absolutePath);
33556
34856
  } catch (err) {
33557
34857
  if (err?.code === "ENOENT")
33558
34858
  return { kind: "gone" };
@@ -33571,7 +34871,7 @@ function readBlobForPush(args) {
33571
34871
  }
33572
34872
  let bytes;
33573
34873
  try {
33574
- bytes = readFileSync14(absolutePath);
34874
+ bytes = readFileSync16(absolutePath);
33575
34875
  } catch (err) {
33576
34876
  if (err?.code === "ENOENT")
33577
34877
  return { kind: "gone" };
@@ -34099,66 +35399,813 @@ class PullLoop {
34099
35399
  this.timer = null;
34100
35400
  }
34101
35401
  }
34102
- schedule(ms) {
34103
- if (this.stopped)
34104
- return;
34105
- this.timer = this.setTimer(() => {
34106
- this.tick();
34107
- }, ms);
35402
+ schedule(ms) {
35403
+ if (this.stopped)
35404
+ return;
35405
+ this.timer = this.setTimer(() => {
35406
+ this.tick();
35407
+ }, ms);
35408
+ }
35409
+ async tick() {
35410
+ if (this.stopped || this.inFlight)
35411
+ return;
35412
+ this.inFlight = true;
35413
+ let nextDelay;
35414
+ try {
35415
+ const result = await this.runOnce();
35416
+ nextDelay = result.exitCode === 0 ? this.onSuccess(result) : this.onFailure(result.failure);
35417
+ } catch {
35418
+ nextDelay = this.onFailure({ kind: "transient", status: 0 });
35419
+ } finally {
35420
+ this.inFlight = false;
35421
+ }
35422
+ this.schedule(nextDelay);
35423
+ }
35424
+ onSuccess(result) {
35425
+ this.failures = 0;
35426
+ this.capStreak = 0;
35427
+ this.degradedAnnounced = false;
35428
+ this.authAnnounced = false;
35429
+ if (result.applied !== undefined && result.applied > 0) {
35430
+ this.onApplied(result.applied);
35431
+ }
35432
+ this.afterPoll();
35433
+ return this.intervalMs;
35434
+ }
35435
+ onFailure(failure) {
35436
+ const kind = failure?.kind ?? "transient";
35437
+ if (kind === "auth") {
35438
+ if (!this.authAnnounced) {
35439
+ this.authAnnounced = true;
35440
+ this.notice("auth", `Pull sync paused — the server rejected the token. Run \`${this.loginHint}\`; ` + `it resumes automatically on the next retry once the token is valid, or on daemon restart.`);
35441
+ }
35442
+ return this.authRetryMs;
35443
+ }
35444
+ this.failures += 1;
35445
+ let delay;
35446
+ if (failure?.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
35447
+ delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
35448
+ } else {
35449
+ delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
35450
+ }
35451
+ this.trackCap(delay);
35452
+ return delay;
35453
+ }
35454
+ trackCap(delay) {
35455
+ this.capStreak = delay >= this.capMs ? this.capStreak + 1 : 0;
35456
+ if (this.capStreak >= DEGRADED_CAP_STREAK && !this.degradedAnnounced) {
35457
+ this.degradedAnnounced = true;
35458
+ const mins = Math.round(this.capMs / 60000);
35459
+ this.notice("degraded", `Pull sync degraded — repeated failures, retrying about every ${mins}m. ` + `Local edits still push; the mirror may be stale until the cloud is reachable again.`);
35460
+ }
35461
+ }
35462
+ }
35463
+
35464
+ // src/lib/calendar-loop.ts
35465
+ var DEFAULT_CALENDAR_INTERVAL_MS = 900000;
35466
+ var CALENDAR_BACKOFF_CAP_MS = 3600000;
35467
+ var CALENDAR_AUTH_RETRY_MS = 300000;
35468
+ function calendarIntervalMs() {
35469
+ const raw = process.env.PLINTH_CALENDAR_INTERVAL_MS;
35470
+ if (raw) {
35471
+ const n2 = Number(raw);
35472
+ if (Number.isFinite(n2) && n2 > 0)
35473
+ return n2;
35474
+ }
35475
+ return DEFAULT_CALENDAR_INTERVAL_MS;
35476
+ }
35477
+
35478
+ class CalendarLoop {
35479
+ runOnce;
35480
+ onProjected;
35481
+ notice;
35482
+ loginHint;
35483
+ intervalMs;
35484
+ capMs;
35485
+ authRetryMs;
35486
+ setTimer;
35487
+ clearTimer;
35488
+ timer = null;
35489
+ inFlight = false;
35490
+ stopped = false;
35491
+ failures = 0;
35492
+ authAnnounced = false;
35493
+ degradedAnnounced = false;
35494
+ constructor(deps) {
35495
+ this.runOnce = deps.runOnce;
35496
+ this.onProjected = deps.onProjected;
35497
+ this.notice = deps.notice;
35498
+ this.loginHint = deps.loginHint;
35499
+ this.intervalMs = deps.intervalMs ?? calendarIntervalMs();
35500
+ this.capMs = deps.capMs ?? CALENDAR_BACKOFF_CAP_MS;
35501
+ this.authRetryMs = deps.authRetryMs ?? CALENDAR_AUTH_RETRY_MS;
35502
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
35503
+ this.clearTimer = deps.clearTimer ?? ((t2) => clearTimeout(t2));
35504
+ }
35505
+ startNow() {
35506
+ this.tick();
35507
+ }
35508
+ start() {
35509
+ this.schedule(this.intervalMs);
35510
+ }
35511
+ stop() {
35512
+ this.stopped = true;
35513
+ if (this.timer !== null) {
35514
+ this.clearTimer(this.timer);
35515
+ this.timer = null;
35516
+ }
35517
+ }
35518
+ schedule(ms) {
35519
+ if (this.stopped)
35520
+ return;
35521
+ this.timer = this.setTimer(() => {
35522
+ this.tick();
35523
+ }, ms);
35524
+ }
35525
+ async tick() {
35526
+ if (this.stopped || this.inFlight)
35527
+ return;
35528
+ this.inFlight = true;
35529
+ let nextDelay;
35530
+ try {
35531
+ const result = await this.runOnce();
35532
+ nextDelay = result.ok ? this.onSuccess(result) : this.onFailure(result.failure);
35533
+ } catch (err) {
35534
+ const reason = err instanceof Error ? err.message : "unknown error";
35535
+ nextDelay = this.onFailure({ kind: "transient", reason });
35536
+ } finally {
35537
+ this.inFlight = false;
35538
+ }
35539
+ if (nextDelay !== null)
35540
+ this.schedule(nextDelay);
35541
+ }
35542
+ onSuccess(result) {
35543
+ this.failures = 0;
35544
+ this.authAnnounced = false;
35545
+ this.degradedAnnounced = false;
35546
+ if (result.written > 0 || result.pruned > 0) {
35547
+ this.onProjected(result.written, result.pruned);
35548
+ }
35549
+ return this.intervalMs;
35550
+ }
35551
+ onFailure(failure) {
35552
+ if (failure.kind === "unsupported") {
35553
+ this.stop();
35554
+ this.notice("unsupported", `Calendar projection is off: this Plinth server has no calendar route. ` + `Nothing under views/calendar/ will be written or updated. Restart the ` + `daemon once the server is up to date.`);
35555
+ return null;
35556
+ }
35557
+ if (failure.kind === "auth") {
35558
+ if (!this.authAnnounced) {
35559
+ this.authAnnounced = true;
35560
+ this.notice("auth", `Calendar refresh paused - the server rejected the token. Run ` + `\`${this.loginHint}\`, then RESTART the daemon: the token is read once ` + `at startup, so a re-login is not picked up until it does. Files ` + `under views/calendar/ keep their last good read and say they are stale.`);
35561
+ }
35562
+ return this.authRetryMs;
35563
+ }
35564
+ this.failures += 1;
35565
+ let delay;
35566
+ if (failure.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
35567
+ delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
35568
+ } else {
35569
+ delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
35570
+ }
35571
+ if (delay >= this.capMs && !this.degradedAnnounced) {
35572
+ this.degradedAnnounced = true;
35573
+ const mins = Math.round(this.capMs / 60000);
35574
+ this.notice("degraded", `Calendar refresh degraded - repeated failures, retrying about every ` + `${mins}m. Files under views/calendar/ still show the last good read ` + `and say that they are stale.`);
35575
+ }
35576
+ return delay;
35577
+ }
35578
+ }
35579
+ function classifyCalendarFailure(err) {
35580
+ if (err instanceof ApiError) {
35581
+ if (err.status === 404)
35582
+ return { kind: "unsupported", reason: err.message };
35583
+ if (err.status === 401 || err.status === 403) {
35584
+ return { kind: "auth", reason: err.message };
35585
+ }
35586
+ if (err.status === 429) {
35587
+ return {
35588
+ kind: "rate_limit",
35589
+ reason: err.message,
35590
+ retryAfterMs: err.retryAfterMs
35591
+ };
35592
+ }
35593
+ return { kind: "transient", reason: err.message };
35594
+ }
35595
+ return {
35596
+ kind: "transient",
35597
+ reason: err instanceof Error ? err.message : "unknown error"
35598
+ };
35599
+ }
35600
+
35601
+ // src/lib/calendar/api.ts
35602
+ class CalendarContractError extends ApiError {
35603
+ constructor(status, message) {
35604
+ super(status, message);
35605
+ this.name = "CalendarContractError";
35606
+ }
35607
+ }
35608
+ var CalendarEventSchema = exports_external.object({
35609
+ id: exports_external.string(),
35610
+ title: exports_external.string(),
35611
+ start: exports_external.string().min(1),
35612
+ end: exports_external.string().min(1),
35613
+ all_day: exports_external.boolean(),
35614
+ location: exports_external.string().nullish().transform((v2) => v2 ?? null),
35615
+ booking_status: exports_external.enum(["confirmed", "hold"]),
35616
+ url: exports_external.string()
35617
+ });
35618
+ var CALENDAR_STATUSES = [
35619
+ "fetched",
35620
+ "no-token",
35621
+ "token-expired",
35622
+ "google-error"
35623
+ ];
35624
+ var CalendarRowSchema = exports_external.object({
35625
+ artist_id: exports_external.string().nullable(),
35626
+ artist_slug: exports_external.string().nullable(),
35627
+ artist_name: exports_external.string().nullable(),
35628
+ calendar_id: exports_external.string(),
35629
+ calendar_name: exports_external.string(),
35630
+ status: exports_external.enum(CALENDAR_STATUSES),
35631
+ events: exports_external.array(CalendarEventSchema).nullable(),
35632
+ error: exports_external.object({ upstream_status: exports_external.number(), message: exports_external.string() }).nullish().transform((v2) => v2 ?? null),
35633
+ truncated: exports_external.boolean().optional().default(false)
35634
+ });
35635
+ var CalendarResponseSchema = exports_external.object({
35636
+ generated_at: exports_external.string().min(1),
35637
+ window: exports_external.object({
35638
+ time_min: exports_external.string().min(1),
35639
+ time_max: exports_external.string().min(1)
35640
+ }),
35641
+ artist_count: exports_external.number().int().nonnegative(),
35642
+ mapped_artist_count: exports_external.number().int().nonnegative(),
35643
+ calendars: exports_external.array(CalendarRowSchema)
35644
+ }).superRefine((body, ctx) => {
35645
+ for (const [i2, row] of body.calendars.entries()) {
35646
+ const isFetched = row.status === "fetched";
35647
+ if (isFetched && row.events === null) {
35648
+ ctx.addIssue({
35649
+ code: exports_external.ZodIssueCode.custom,
35650
+ path: ["calendars", i2, "events"],
35651
+ message: `status "fetched" must carry an events array, not null`
35652
+ });
35653
+ }
35654
+ if (!isFetched && row.events !== null) {
35655
+ ctx.addIssue({
35656
+ code: exports_external.ZodIssueCode.custom,
35657
+ path: ["calendars", i2, "events"],
35658
+ message: `status "${row.status}" must carry events: null, not an array`
35659
+ });
35660
+ }
35661
+ }
35662
+ });
35663
+ async function fetchCalendar(args) {
35664
+ const { baseUrl, pat, fetchImpl = fetch } = args;
35665
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/calendar`;
35666
+ let res;
35667
+ try {
35668
+ res = await fetchImpl(url2, {
35669
+ method: "GET",
35670
+ headers: {
35671
+ Authorization: `Bearer ${pat}`,
35672
+ Accept: "application/json"
35673
+ }
35674
+ });
35675
+ } catch (err) {
35676
+ const msg = err instanceof Error ? err.message : "network error";
35677
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
35678
+ }
35679
+ if (res.status === 401) {
35680
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
35681
+ }
35682
+ if (res.status === 404) {
35683
+ throw new ApiError(404, "This Plinth server has no /api/sync/calendar route.");
35684
+ }
35685
+ if (res.status === 429) {
35686
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
35687
+ }
35688
+ if (!res.ok) {
35689
+ throw new ApiError(res.status, `sync calendar failed with HTTP ${res.status}`);
35690
+ }
35691
+ let body;
35692
+ try {
35693
+ body = await res.json();
35694
+ } catch {
35695
+ throw new CalendarContractError(res.status, "sync calendar response was not JSON");
35696
+ }
35697
+ const parsed = CalendarResponseSchema.safeParse(body);
35698
+ if (!parsed.success) {
35699
+ const detail = parsed.error.issues.slice(0, 3).map((i2) => `${i2.path.join(".") || "(root)"}: ${i2.message}`).join("; ");
35700
+ throw new CalendarContractError(res.status, `sync calendar response did not match the contract: ${detail}`);
35701
+ }
35702
+ return parsed.data;
35703
+ }
35704
+
35705
+ // src/lib/calendar/render.ts
35706
+ var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
35707
+ var OWNERSHIP_NOTE2 = [
35708
+ "<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
35709
+ " is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
35710
+ " Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
35711
+ " person - not from this file and not by an agent. -->"
35712
+ ].join(`
35713
+ `);
35714
+ var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
35715
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
35716
+ var MONTHS = [
35717
+ "Jan",
35718
+ "Feb",
35719
+ "Mar",
35720
+ "Apr",
35721
+ "May",
35722
+ "Jun",
35723
+ "Jul",
35724
+ "Aug",
35725
+ "Sep",
35726
+ "Oct",
35727
+ "Nov",
35728
+ "Dec"
35729
+ ];
35730
+ var DAY_MS = 86400000;
35731
+ var FULL_BAND_DAYS = 14;
35732
+ function yamlString(value) {
35733
+ return JSON.stringify(value);
35734
+ }
35735
+ function oneLine(value, maxLength = 200) {
35736
+ const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
35737
+ if (flattened.length === 0)
35738
+ return "(untitled)";
35739
+ if (flattened.length <= maxLength)
35740
+ return flattened;
35741
+ return `${flattened.slice(0, maxLength - 3)}...`;
35742
+ }
35743
+ function dateParts(value) {
35744
+ const m2 = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
35745
+ if (!m2)
35746
+ return null;
35747
+ return { y: Number(m2[1]), m: Number(m2[2]), d: Number(m2[3]) };
35748
+ }
35749
+ function timeParts(value) {
35750
+ const m2 = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
35751
+ if (!m2)
35752
+ return null;
35753
+ const rawOffset = m2[3];
35754
+ const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
35755
+ return { hh: m2[1], mi: m2[2], offset };
35756
+ }
35757
+ function utcMs(parts) {
35758
+ return Date.UTC(parts.y, parts.m - 1, parts.d);
35759
+ }
35760
+ function shiftDays(parts, days) {
35761
+ const d2 = new Date(utcMs(parts) + days * DAY_MS);
35762
+ return { y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() };
35763
+ }
35764
+ function formatDay(parts, referenceYear) {
35765
+ const weekday = WEEKDAYS[new Date(utcMs(parts)).getUTCDay()] ?? "";
35766
+ const month = MONTHS[parts.m - 1] ?? "";
35767
+ const year = parts.y === referenceYear ? "" : ` ${parts.y}`;
35768
+ return `${weekday} ${parts.d} ${month}${year}`;
35769
+ }
35770
+ function sortInstant(event) {
35771
+ if (!event.all_day) {
35772
+ const ms = Date.parse(event.start);
35773
+ if (!Number.isNaN(ms))
35774
+ return ms;
35775
+ }
35776
+ const parts = dateParts(event.start);
35777
+ if (parts)
35778
+ return utcMs(parts);
35779
+ return Number.MAX_SAFE_INTEGER;
35780
+ }
35781
+ function describeWhen(event, referenceYear) {
35782
+ const start = dateParts(event.start);
35783
+ if (!start)
35784
+ return oneLine(event.start, 60);
35785
+ if (event.all_day) {
35786
+ const end2 = dateParts(event.end);
35787
+ const last = end2 ? shiftDays(end2, -1) : start;
35788
+ const single = !end2 || utcMs(last) <= utcMs(start);
35789
+ return single ? `${formatDay(start, referenceYear)} (all day)` : `${formatDay(start, referenceYear)} to ${formatDay(last, referenceYear)} (all day)`;
35790
+ }
35791
+ const startTime = timeParts(event.start);
35792
+ const end = dateParts(event.end);
35793
+ const endTime = timeParts(event.end);
35794
+ if (!startTime)
35795
+ return formatDay(start, referenceYear);
35796
+ const suffix = startTime.offset ? ` ${startTime.offset}` : "";
35797
+ if (!end || !endTime) {
35798
+ return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}${suffix}`;
35799
+ }
35800
+ if (utcMs(end) === utcMs(start)) {
35801
+ return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}-${endTime.hh}:${endTime.mi}${suffix}`;
35802
+ }
35803
+ const endSuffix = endTime.offset ? ` ${endTime.offset}` : "";
35804
+ return `${formatDay(start, referenceYear)} ${startTime.hh}:${startTime.mi}${suffix}` + ` to ${formatDay(end, referenceYear)} ${endTime.hh}:${endTime.mi}${endSuffix}`;
35805
+ }
35806
+ function fullLine(event, referenceYear, artistPrefix, includeUrl) {
35807
+ const parts = [
35808
+ `**${describeWhen(event, referenceYear)}**`,
35809
+ artistPrefix,
35810
+ `${oneLine(event.title)} (${event.booking_status})`
35811
+ ].filter((p) => p.length > 0);
35812
+ if (event.location)
35813
+ parts.push(oneLine(event.location, 80));
35814
+ if (includeUrl && event.url)
35815
+ parts.push(oneLine(event.url, 300));
35816
+ return `- ${parts.join(" - ")}`;
35817
+ }
35818
+ function compactLine(event, referenceYear, artistPrefix) {
35819
+ const parts = [
35820
+ describeWhen(event, referenceYear),
35821
+ artistPrefix,
35822
+ `${oneLine(event.title)} (${event.booking_status})`
35823
+ ].filter((p) => p.length > 0);
35824
+ return `- ${parts.join(" - ")}`;
35825
+ }
35826
+ function humanStamp(iso) {
35827
+ const ms = Date.parse(iso);
35828
+ if (Number.isNaN(ms))
35829
+ return iso;
35830
+ const d2 = new Date(ms);
35831
+ const day = formatDay({ y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() }, -1);
35832
+ const hh = String(d2.getUTCHours()).padStart(2, "0");
35833
+ const mi = String(d2.getUTCMinutes()).padStart(2, "0");
35834
+ return `${day}, ${hh}:${mi} UTC`;
35835
+ }
35836
+ function windowLine(timeMin, timeMax) {
35837
+ const from = dateParts(timeMin);
35838
+ const to = dateParts(timeMax);
35839
+ if (!from || !to)
35840
+ return `${timeMin} to ${timeMax}`;
35841
+ return `${formatDay(from, -1)} to ${formatDay(to, -1)}`;
35842
+ }
35843
+ function statusLine(args) {
35844
+ const { status, eventCount, error: error52, truncated, stamp, userLabel } = args;
35845
+ switch (status) {
35846
+ case "fetched": {
35847
+ if (eventCount === 0) {
35848
+ return `**Read fine as of ${stamp}. Nothing in this window.** The diary really ` + `is clear across the dates above. This is a successful read of an empty ` + `calendar, not a failed read - if it had failed, this line would say so.`;
35849
+ }
35850
+ const truncatedNote = truncated ? " **This list is INCOMPLETE**: Google had more entries than one page and the rest were not collected." : "";
35851
+ return `**Read fine as of ${stamp}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.${truncatedNote}`;
35852
+ }
35853
+ case "no-token":
35854
+ return `**NOT READ - Google is not connected for ${userLabel}.** Nothing is listed ` + `below because Plinth could not look, NOT because the diary is clear. ` + `Connect Google in Plinth to fill this in, and until then treat this ` + `calendar as unknown rather than empty.`;
35855
+ case "token-expired":
35856
+ return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
35857
+ case "google-error":
35858
+ return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
35859
+ }
35860
+ }
35861
+ function shortStatus(row) {
35862
+ switch (row.status) {
35863
+ case "fetched": {
35864
+ const n2 = row.events?.length ?? 0;
35865
+ if (n2 === 0)
35866
+ return "read fine, nothing in this window";
35867
+ return `read fine, ${n2} ${n2 === 1 ? "entry" : "entries"}${row.truncated ? " (INCOMPLETE - more than one page)" : ""}`;
35868
+ }
35869
+ case "no-token":
35870
+ return "NOT READ - Google is not connected; unknown, not empty";
35871
+ case "token-expired":
35872
+ return "NOT READ - the Google connection has expired; unknown, not empty";
35873
+ case "google-error":
35874
+ return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
35875
+ }
35876
+ }
35877
+ function rowFileName(row, internalName) {
35878
+ return row.artist_slug ?? internalName;
35879
+ }
35880
+ function rowDisplayName(row) {
35881
+ if (row.artist_name)
35882
+ return oneLine(row.artist_name, 80);
35883
+ return `${oneLine(row.calendar_name, 80)} (internal)`;
35884
+ }
35885
+ function splitBands(events, generatedAt) {
35886
+ const base2 = Date.parse(generatedAt);
35887
+ const boundary = Number.isNaN(base2) ? Number.MAX_SAFE_INTEGER : base2 + FULL_BAND_DAYS * DAY_MS;
35888
+ const full = [];
35889
+ const rest = [];
35890
+ for (const e2 of events) {
35891
+ (sortInstant(e2) < boundary ? full : rest).push(e2);
35892
+ }
35893
+ return { full, rest };
35894
+ }
35895
+ function renderArtistCalendar(args) {
35896
+ const { row, generatedAt, window: window2 } = args;
35897
+ const userLabel = args.userLabel ?? DEFAULT_USER_LABEL;
35898
+ const events = row.events ?? [];
35899
+ const stamp = humanStamp(generatedAt);
35900
+ const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
35901
+ const title = rowDisplayName(row);
35902
+ const header = [
35903
+ "---",
35904
+ "plinth_generated: true",
35905
+ "view: calendar",
35906
+ `artist_slug: ${row.artist_slug === null ? "null" : yamlString(row.artist_slug)}`,
35907
+ `artist_name: ${row.artist_name === null ? "null" : yamlString(row.artist_name)}`,
35908
+ `calendar_name: ${yamlString(row.calendar_name)}`,
35909
+ `generated_at: ${yamlString(generatedAt)}`,
35910
+ `window_start: ${yamlString(window2.time_min)}`,
35911
+ `window_end: ${yamlString(window2.time_max)}`,
35912
+ `status: ${row.status}`,
35913
+ "fetch_state: fresh",
35914
+ `entry_count: ${events.length}`,
35915
+ `truncated: ${row.truncated}`,
35916
+ "---",
35917
+ "",
35918
+ OWNERSHIP_NOTE2,
35919
+ "",
35920
+ `# ${title} - calendar`,
35921
+ ""
35922
+ ].join(`
35923
+ `);
35924
+ const body = [
35925
+ "",
35926
+ statusLine({
35927
+ status: row.status,
35928
+ eventCount: events.length,
35929
+ error: row.error,
35930
+ truncated: row.truncated,
35931
+ stamp,
35932
+ userLabel
35933
+ }),
35934
+ "",
35935
+ `**Window:** ${windowLine(window2.time_min, window2.time_max)}.`,
35936
+ "",
35937
+ "**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
35938
+ "",
35939
+ renderBands(events, generatedAt, referenceYear, "")
35940
+ ].join(`
35941
+ `);
35942
+ return `${header}${CALENDAR_BODY_MARKER}
35943
+ ${body}`;
35944
+ }
35945
+ function renderBands(events, generatedAt, referenceYear, artistPrefix) {
35946
+ if (events.length === 0)
35947
+ return "";
35948
+ const { full, rest } = splitBands(events, generatedAt);
35949
+ const out = [""];
35950
+ out.push(`## Next ${FULL_BAND_DAYS} days`, "");
35951
+ if (full.length === 0) {
35952
+ out.push(`_Nothing in the next ${FULL_BAND_DAYS} days._`);
35953
+ } else {
35954
+ for (const e2 of full)
35955
+ out.push(fullLine(e2, referenceYear, artistPrefix, true));
35956
+ }
35957
+ out.push("", "## Rest of the window", "");
35958
+ if (rest.length === 0) {
35959
+ out.push("_Nothing further in the window._");
35960
+ } else {
35961
+ for (const e2 of rest)
35962
+ out.push(compactLine(e2, referenceYear, artistPrefix));
35963
+ }
35964
+ out.push("");
35965
+ return out.join(`
35966
+ `);
35967
+ }
35968
+ function renderUpcoming(args) {
35969
+ const {
35970
+ rows,
35971
+ unmappedArtists,
35972
+ generatedAt,
35973
+ window: window2,
35974
+ artistCount,
35975
+ mappedArtistCount,
35976
+ internalName
35977
+ } = args;
35978
+ const stamp = humanStamp(generatedAt);
35979
+ const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
35980
+ const fetched = rows.filter((r3) => r3.status === "fetched");
35981
+ const failed = rows.filter((r3) => r3.status !== "fetched");
35982
+ const truncatedAnywhere = rows.some((r3) => r3.truncated);
35983
+ const merged = fetched.flatMap((row) => (row.events ?? []).map((event) => ({ event, row }))).sort((a2, b2) => sortInstant(a2.event) - sortInstant(b2.event));
35984
+ const { full, rest } = splitBands(merged.map((m2) => m2.event), generatedAt);
35985
+ const fullSet = new Set(full);
35986
+ const restSet = new Set(rest);
35987
+ const header = [
35988
+ "---",
35989
+ "plinth_generated: true",
35990
+ "view: calendar",
35991
+ "scope: workspace",
35992
+ `generated_at: ${yamlString(generatedAt)}`,
35993
+ `window_start: ${yamlString(window2.time_min)}`,
35994
+ `window_end: ${yamlString(window2.time_max)}`,
35995
+ `artist_count: ${artistCount}`,
35996
+ `mapped_artist_count: ${mappedArtistCount}`,
35997
+ `calendars_read: ${fetched.length}`,
35998
+ `calendars_not_read: ${failed.length}`,
35999
+ "fetch_state: fresh",
36000
+ `truncated: ${truncatedAnywhere}`,
36001
+ "---",
36002
+ "",
36003
+ OWNERSHIP_NOTE2,
36004
+ "",
36005
+ "# Upcoming - every calendar in this workspace",
36006
+ ""
36007
+ ].join(`
36008
+ `);
36009
+ const preamble = [
36010
+ "",
36011
+ `**As of ${stamp}.** Window ${windowLine(window2.time_min, window2.time_max)}.`,
36012
+ "",
36013
+ failed.length === 0 ? `**All ${fetched.length} mapped ${fetched.length === 1 ? "calendar was" : "calendars were"} read.**` : `**${fetched.length} of ${rows.length} mapped ${rows.length === 1 ? "calendar" : "calendars"} read; ${failed.length} could NOT be read.**` + ` Anything on an unread calendar is missing from the lists below - unknown, not absent.`,
36014
+ "",
36015
+ `${artistCount} ${artistCount === 1 ? "artist" : "artists"} in the roster; ` + `${mappedArtistCount} ${mappedArtistCount === 1 ? "has" : "have"} a calendar mapped in Plinth.`,
36016
+ "",
36017
+ "**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
36018
+ "",
36019
+ "## Calendar status",
36020
+ "",
36021
+ ...rows.map((row) => `- **${rowDisplayName(row)}**` + `${row.artist_slug ? ` (\`${oneLine(row.artist_slug, 80)}\`)` : ""}` + ` - ${shortStatus(row)} - \`views/calendar/${rowFileName(row, internalName)}.md\``),
36022
+ ...unmappedArtists.map((a2) => `- **${oneLine(a2.name, 80)}** (\`${oneLine(a2.slug, 80)}\`) - NO CALENDAR MAPPED in Plinth, so there is nothing to read and no file for them. Map one in Plinth if they should have dates.`),
36023
+ ""
36024
+ ].join(`
36025
+ `);
36026
+ const body = [preamble];
36027
+ body.push(`## Next ${FULL_BAND_DAYS} days`, "", "_Links are in the per-artist file named beside each calendar above._", "");
36028
+ const fullMerged = merged.filter((m2) => fullSet.has(m2.event));
36029
+ if (fullMerged.length === 0) {
36030
+ body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
36031
+ } else {
36032
+ for (const { event, row } of fullMerged) {
36033
+ body.push(fullLine(event, referenceYear, `**${rowDisplayName(row)}**`, false));
36034
+ }
36035
+ }
36036
+ body.push("", "## Rest of the window", "");
36037
+ const restMerged = merged.filter((m2) => restSet.has(m2.event));
36038
+ if (restMerged.length === 0) {
36039
+ body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : "_Nothing further in the window on any calendar that was read._");
36040
+ } else {
36041
+ for (const { event, row } of restMerged) {
36042
+ body.push(compactLine(event, referenceYear, `**${rowDisplayName(row)}**`));
36043
+ }
34108
36044
  }
34109
- async tick() {
34110
- if (this.stopped || this.inFlight)
34111
- return;
34112
- this.inFlight = true;
34113
- let nextDelay;
36045
+ body.push("");
36046
+ return `${header}${CALENDAR_BODY_MARKER}
36047
+ ${body.join(`
36048
+ `)}`;
36049
+ }
36050
+ function splitCalendarFile(content) {
36051
+ const at = content.indexOf(CALENDAR_BODY_MARKER);
36052
+ if (at === -1)
36053
+ return null;
36054
+ return {
36055
+ header: content.slice(0, at),
36056
+ body: content.slice(at + CALENDAR_BODY_MARKER.length)
36057
+ };
36058
+ }
36059
+ function headerField(header, key) {
36060
+ const re = new RegExp(`^${key}: (.*)$`, "m");
36061
+ const m2 = re.exec(header);
36062
+ if (!m2)
36063
+ return null;
36064
+ const raw = m2[1].trim();
36065
+ if (raw === "null")
36066
+ return null;
36067
+ if (raw.startsWith('"')) {
34114
36068
  try {
34115
- const result = await this.runOnce();
34116
- nextDelay = result.exitCode === 0 ? this.onSuccess(result) : this.onFailure(result.failure);
36069
+ return JSON.parse(raw);
34117
36070
  } catch {
34118
- nextDelay = this.onFailure({ kind: "transient", status: 0 });
34119
- } finally {
34120
- this.inFlight = false;
36071
+ return raw;
34121
36072
  }
34122
- this.schedule(nextDelay);
34123
36073
  }
34124
- onSuccess(result) {
34125
- this.failures = 0;
34126
- this.capStreak = 0;
34127
- this.degradedAnnounced = false;
34128
- this.authAnnounced = false;
34129
- if (result.applied !== undefined && result.applied > 0) {
34130
- this.onApplied(result.applied);
36074
+ return raw;
36075
+ }
36076
+ function renderStaleFile(existing, args) {
36077
+ const split = splitCalendarFile(existing);
36078
+ if (!split)
36079
+ return null;
36080
+ const previousGeneratedAt = headerField(split.header, "generated_at");
36081
+ const title = /^# (.+)$/m.exec(split.header)?.[1]?.trim() ?? "Calendar";
36082
+ const fmMatch = /^---\n([\s\S]*?)\n---\n/.exec(split.header);
36083
+ const carried = (fmMatch?.[1] ?? "").split(`
36084
+ `).filter((line) => !/^(fetch_state|last_refresh_attempt|last_refresh_error):/.test(line));
36085
+ const stamp = previousGeneratedAt ? humanStamp(previousGeneratedAt) : "an earlier refresh";
36086
+ const header = [
36087
+ "---",
36088
+ ...carried,
36089
+ "fetch_state: stale",
36090
+ `last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
36091
+ `last_refresh_error: ${yamlString(oneLine(args.reason, 300))}`,
36092
+ "---",
36093
+ "",
36094
+ OWNERSHIP_NOTE2,
36095
+ "",
36096
+ `# ${title}`,
36097
+ "",
36098
+ `**STALE - the last refresh FAILED and nothing below was updated.** Attempted ` + `${humanStamp(args.attemptedAt)} and it did not succeed: ` + `${oneLine(args.reason, 300)}`,
36099
+ "",
36100
+ `**What is below was last read at ${stamp}** and has not moved since. Anything ` + `changed in Google after that is not here, and something cancelled after that ` + `is still listed. Say the age out loud rather than briefing it as current.`,
36101
+ ""
36102
+ ].join(`
36103
+ `);
36104
+ return `${header}${CALENDAR_BODY_MARKER}${split.body}`;
36105
+ }
36106
+
36107
+ // src/lib/calendar/project.ts
36108
+ function writeIfChanged(workspaceSlug, name, content) {
36109
+ if (readCalendarView(workspaceSlug, name) === content)
36110
+ return false;
36111
+ writeCalendarView(workspaceSlug, name, content);
36112
+ return true;
36113
+ }
36114
+ function projectCalendar(args) {
36115
+ const { workspaceSlug, response, roster, userLabel } = args;
36116
+ const { generated_at: generatedAt, window: window2 } = response;
36117
+ let written = 0;
36118
+ let unchanged = 0;
36119
+ let collided = 0;
36120
+ const seen = new Set;
36121
+ const rows = [];
36122
+ for (const row of response.calendars) {
36123
+ const name = rowFileName(row, CALENDAR_INTERNAL_NAME);
36124
+ if (seen.has(name) || name === CALENDAR_UPCOMING_NAME) {
36125
+ collided += 1;
36126
+ continue;
34131
36127
  }
34132
- this.afterPoll();
34133
- return this.intervalMs;
36128
+ seen.add(name);
36129
+ rows.push(row);
36130
+ const content = renderArtistCalendar({
36131
+ row,
36132
+ generatedAt,
36133
+ window: window2,
36134
+ userLabel
36135
+ });
36136
+ if (writeIfChanged(workspaceSlug, name, content))
36137
+ written += 1;
36138
+ else
36139
+ unchanged += 1;
36140
+ }
36141
+ const mapped = new Set(response.calendars.map((r3) => r3.artist_slug).filter((s2) => s2 !== null));
36142
+ const unmappedArtists = roster.filter((a2) => !mapped.has(a2.slug) && a2.status !== "archived").map((a2) => ({ slug: a2.slug, name: a2.name }));
36143
+ const upcoming = renderUpcoming({
36144
+ rows,
36145
+ unmappedArtists,
36146
+ generatedAt,
36147
+ window: window2,
36148
+ artistCount: response.artist_count,
36149
+ mappedArtistCount: response.mapped_artist_count,
36150
+ internalName: CALENDAR_INTERNAL_NAME,
36151
+ userLabel
36152
+ });
36153
+ if (writeIfChanged(workspaceSlug, CALENDAR_UPCOMING_NAME, upcoming))
36154
+ written += 1;
36155
+ else
36156
+ unchanged += 1;
36157
+ const expected = new Set([...seen, CALENDAR_UPCOMING_NAME]);
36158
+ let pruned = 0;
36159
+ for (const name of listCalendarViews(workspaceSlug)) {
36160
+ if (expected.has(name))
36161
+ continue;
36162
+ if (!ownsFile(workspaceSlug, name))
36163
+ continue;
36164
+ try {
36165
+ if (deleteCalendarView(workspaceSlug, name))
36166
+ pruned += 1;
36167
+ } catch {}
34134
36168
  }
34135
- onFailure(failure) {
34136
- const kind = failure?.kind ?? "transient";
34137
- if (kind === "auth") {
34138
- if (!this.authAnnounced) {
34139
- this.authAnnounced = true;
34140
- this.notice("auth", `Pull sync paused — the server rejected the token. Run \`${this.loginHint}\`; ` + `it resumes automatically on the next retry once the token is valid, or on daemon restart.`);
34141
- }
34142
- return this.authRetryMs;
36169
+ return { written, unchanged, pruned, collided };
36170
+ }
36171
+ function ownsFile(workspaceSlug, name) {
36172
+ try {
36173
+ const existing = readCalendarView(workspaceSlug, name);
36174
+ return existing !== null && existing.includes(CALENDAR_BODY_MARKER);
36175
+ } catch {
36176
+ return false;
36177
+ }
36178
+ }
36179
+ function markCalendarStale(args) {
36180
+ const { workspaceSlug, attemptedAt, reason } = args;
36181
+ let rewritten = 0;
36182
+ let unchanged = 0;
36183
+ let skipped = 0;
36184
+ for (const name of listCalendarViews(workspaceSlug)) {
36185
+ let existing;
36186
+ try {
36187
+ existing = readCalendarView(workspaceSlug, name);
36188
+ } catch {
36189
+ skipped += 1;
36190
+ continue;
34143
36191
  }
34144
- this.failures += 1;
34145
- let delay;
34146
- if (failure?.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
34147
- delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
34148
- } else {
34149
- delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
36192
+ if (existing === null)
36193
+ continue;
36194
+ const staled = renderStaleFile(existing, { attemptedAt, reason });
36195
+ if (staled === null) {
36196
+ skipped += 1;
36197
+ continue;
34150
36198
  }
34151
- this.trackCap(delay);
34152
- return delay;
34153
- }
34154
- trackCap(delay) {
34155
- this.capStreak = delay >= this.capMs ? this.capStreak + 1 : 0;
34156
- if (this.capStreak >= DEGRADED_CAP_STREAK && !this.degradedAnnounced) {
34157
- this.degradedAnnounced = true;
34158
- const mins = Math.round(this.capMs / 60000);
34159
- this.notice("degraded", `Pull sync degraded — repeated failures, retrying about every ${mins}m. ` + `Local edits still push; the mirror may be stale until the cloud is reachable again.`);
36199
+ try {
36200
+ if (writeIfChanged(workspaceSlug, name, staled))
36201
+ rewritten += 1;
36202
+ else
36203
+ unchanged += 1;
36204
+ } catch {
36205
+ skipped += 1;
34160
36206
  }
34161
36207
  }
36208
+ return { rewritten, unchanged, skipped };
34162
36209
  }
34163
36210
 
34164
36211
  // src/lib/pull-suppression.ts
@@ -34230,7 +36277,7 @@ class PullSuppressor {
34230
36277
  }
34231
36278
 
34232
36279
  // src/lib/reconcile.ts
34233
- import { readFileSync as readFileSync15 } from "node:fs";
36280
+ import { readFileSync as readFileSync17 } from "node:fs";
34234
36281
  async function reconcileDivergedRows(args) {
34235
36282
  const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
34236
36283
  const now = args.now ?? (() => new Date);
@@ -34316,7 +36363,7 @@ async function reconcileDivergedRows(args) {
34316
36363
  }
34317
36364
  let content;
34318
36365
  try {
34319
- content = readFileSync15(absolutePath, "utf8");
36366
+ content = readFileSync17(absolutePath, "utf8");
34320
36367
  } catch (err) {
34321
36368
  if (err?.code === "ENOENT")
34322
36369
  continue;
@@ -35259,25 +37306,111 @@ function createThrottledFetch(baseFetch, opts = {}) {
35259
37306
  // src/commands/sync.ts
35260
37307
  import {
35261
37308
  existsSync as existsSync6,
35262
- readdirSync as readdirSync4,
35263
- readFileSync as readFileSync17,
35264
- statSync as statSync9,
35265
- unlinkSync as unlinkSync6
37309
+ readdirSync as readdirSync5,
37310
+ readFileSync as readFileSync19,
37311
+ statSync as statSync11,
37312
+ unlinkSync as unlinkSync7
35266
37313
  } from "node:fs";
35267
- import { join as join12 } from "node:path";
37314
+ import { join as join14 } from "node:path";
37315
+
37316
+ // src/lib/files/pull.ts
37317
+ import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
37318
+
37319
+ // src/lib/files/extracted-text.ts
37320
+ import { createHash as createHash3 } from "node:crypto";
37321
+ var DERIVED_TEXT_VERSION = 2;
37322
+ function yamlString2(value) {
37323
+ return JSON.stringify(value);
37324
+ }
37325
+ var PARTIAL_WARNING = [
37326
+ "> ⚠️ **PARTIAL EXTRACTION.** Some spans of this document came out as text and",
37327
+ "> some did not. Which spans is NOT recorded, so this could be a clean document",
37328
+ "> with mangled party names, or clean prose that degrades part-way through.",
37329
+ "> Verify anything load-bearing against the source file."
37330
+ ].join(`
37331
+ `);
37332
+ var NO_LOCAL_COPY_NOTE = [
37333
+ "> ℹ️ **NO LOCAL COPY OF THIS FILE.** Plinth does not hold this file's bytes, so",
37334
+ "> nothing was written under `files/` for it and the text below is the only copy",
37335
+ "> in this mirror. The original lives wherever `source_uri` above points."
37336
+ ].join(`
37337
+ `);
37338
+ function garbledBody(relPath, bytesInMirror) {
37339
+ return [
37340
+ "> \uD83D\uDD34 **NOT TEXT — body withheld.**",
37341
+ ">",
37342
+ "> Extraction produced characters that are not words. The usual cause is a PDF",
37343
+ "> whose embedded fonts carry no ToUnicode map, so the glyph indices come out",
37344
+ "> where the letters should be: a page reading *“Dated this 10th day of",
37345
+ "> September”* extracts as `):-,:019`.",
37346
+ ">",
37347
+ "> The characters are NOT reproduced here on purpose. They read as text to a",
37348
+ "> reader and to a search index, and they say nothing — writing them into the",
37349
+ "> mirror would put a document into your corpus that appears readable and is",
37350
+ "> not. They are excluded from Plinth's own search index for the same reason.",
37351
+ ">",
37352
+ bytesInMirror ? `> **Read the original instead:** \`files/${relPath}\`` : "> **There is no local original to read** — Plinth holds no bytes for this file." + " Follow `source_uri` above to the copy that has them."
37353
+ ].join(`
37354
+ `);
37355
+ }
37356
+ function renderExtractedText(doc2) {
37357
+ const withheld = doc2.quality === "garbled";
37358
+ const frontmatter = [
37359
+ "---",
37360
+ "plinth_generated: true",
37361
+ `source_file: ${doc2.bytesInMirror ? yamlString2(`files/${doc2.relPath}`) : "null"}`,
37362
+ `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString2(doc2.sourceUri)}`,
37363
+ `content_quality: ${doc2.quality ?? "unchecked"}`,
37364
+ `body_withheld: ${withheld}`,
37365
+ `extracted_chars: ${withheld ? 0 : doc2.text.length}`,
37366
+ "---",
37367
+ "",
37368
+ "<!-- Generated by Plinth from the source file above. This file is DERIVED:",
37369
+ " your edits here are overwritten on the next sync and are never uploaded.",
37370
+ " Edit the source file instead. -->",
37371
+ ""
37372
+ ].join(`
37373
+ `);
37374
+ if (withheld) {
37375
+ return `${frontmatter}${garbledBody(doc2.relPath, doc2.bytesInMirror)}
37376
+ `;
37377
+ }
37378
+ const notices = [
37379
+ ...doc2.quality === "partial" ? [PARTIAL_WARNING] : [],
37380
+ ...doc2.bytesInMirror ? [] : [NO_LOCAL_COPY_NOTE]
37381
+ ];
37382
+ if (notices.length > 0) {
37383
+ return `${frontmatter}${notices.join(`
37384
+
37385
+ `)}
37386
+
37387
+ ${doc2.text}
37388
+ `;
37389
+ }
37390
+ return `${frontmatter}${doc2.text}
37391
+ `;
37392
+ }
37393
+ function extractedTextHash(rendered) {
37394
+ return createHash3("sha256").update(rendered, "utf8").digest("hex");
37395
+ }
35268
37396
 
35269
37397
  // src/lib/files/pull.ts
35270
- import { existsSync as existsSync5, readFileSync as readFileSync16, statSync as statSync8 } from "node:fs";
35271
37398
  async function pullFiles(args) {
35272
37399
  const { workspaceSlug, baseUrl, pat, fetchImpl } = args;
35273
37400
  let state = args.state;
35274
37401
  let fetched = 0;
35275
37402
  let written = 0;
35276
37403
  let deleted = 0;
37404
+ let textWritten = 0;
37405
+ let textDeleted = 0;
37406
+ let textUnknown = 0;
37407
+ let pointerRows = 0;
35277
37408
  let cursor = args.cursor;
35278
37409
  let lastCursor = args.cursor;
37410
+ let cursorBlocked = false;
35279
37411
  while (true) {
35280
37412
  const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
37413
+ let pageTextFailed = false;
35281
37414
  for (const row of page.rows) {
35282
37415
  fetched++;
35283
37416
  const outcome = await applyFileRow({
@@ -35290,20 +37423,101 @@ async function pullFiles(args) {
35290
37423
  now: args.now,
35291
37424
  output: args.output,
35292
37425
  suppressor: args.suppressor,
35293
- isFileFrozen: args.isFileFrozen
37426
+ isFileFrozen: args.isFileFrozen,
37427
+ forceTextRewalk: args.forceTextRewalk
35294
37428
  });
35295
37429
  state = outcome.state;
35296
37430
  if (outcome.action === "written")
35297
37431
  written++;
35298
37432
  else if (outcome.action === "deleted")
35299
37433
  deleted++;
35300
- }
37434
+ textWritten += outcome.textWritten ?? 0;
37435
+ textDeleted += outcome.textDeleted ?? 0;
37436
+ if (outcome.textUnknown)
37437
+ textUnknown++;
37438
+ if (outcome.pointer)
37439
+ pointerRows++;
37440
+ if (outcome.textFailed)
37441
+ pageTextFailed = true;
37442
+ }
37443
+ if (pageTextFailed)
37444
+ cursorBlocked = true;
35301
37445
  if (page.next_cursor === null)
35302
37446
  break;
35303
- lastCursor = page.next_cursor;
37447
+ if (!cursorBlocked)
37448
+ lastCursor = page.next_cursor;
35304
37449
  cursor = page.next_cursor;
35305
37450
  }
35306
- return { state, fetched, written, deleted, lastCursor };
37451
+ if (textUnknown > 0) {
37452
+ args.output?.warn(`${textUnknown} file ${textUnknown === 1 ? "row" : "rows"} arrived without a readable \`has_extracted_text\` — this server predates the extracted-text contract, or the field was dropped from the payload. Derived text under views/extracted/ was left untouched rather than deleted, and is not being updated either.`);
37453
+ }
37454
+ if (pointerRows > 0) {
37455
+ args.output?.info(`${pointerRows} file ${pointerRows === 1 ? "row holds" : "rows hold"} no bytes in Plinth — nothing was written under files/ for ${pointerRows === 1 ? "it" : "them"}; where there is extracted text, views/extracted/ holds the only local copy.`);
37456
+ }
37457
+ return {
37458
+ state,
37459
+ fetched,
37460
+ written,
37461
+ deleted,
37462
+ textWritten,
37463
+ textDeleted,
37464
+ textUnknown,
37465
+ pointerRows,
37466
+ lastCursor
37467
+ };
37468
+ }
37469
+ async function applyExtractedText(args) {
37470
+ try {
37471
+ return await materialiseExtractedText(args);
37472
+ } catch (err) {
37473
+ const msg = err instanceof Error ? err.message : String(err);
37474
+ args.output?.warn(`files/${args.relPath}: could not materialise extracted text (${msg}) — mirror text left unwritten, will retry.`);
37475
+ return { textHash: undefined, written: 0, deleted: 0, failed: true };
37476
+ }
37477
+ }
37478
+ async function materialiseExtractedText(args) {
37479
+ const { workspaceSlug, baseUrl, pat, row, relPath, bytesInMirror, fetchImpl, output } = args;
37480
+ const signal = extractedTextSignal(row);
37481
+ if (signal === "absent") {
37482
+ const removed = deleteExtractedText(workspaceSlug, relPath);
37483
+ return { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
37484
+ }
37485
+ if (signal === "unknown") {
37486
+ return { textHash: undefined, written: 0, deleted: 0 };
37487
+ }
37488
+ let rendered;
37489
+ if (row.content_quality === "garbled" && bytesInMirror) {
37490
+ rendered = renderExtractedText({
37491
+ relPath,
37492
+ quality: "garbled",
37493
+ bytesInMirror,
37494
+ sourceUri: undefined,
37495
+ text: ""
37496
+ });
37497
+ } else {
37498
+ const got = await getFileText({ baseUrl, pat, id: row.id, fetchImpl });
37499
+ if (!got.ok)
37500
+ return { textHash: undefined, written: 0, deleted: 0 };
37501
+ if (got.row.text === null) {
37502
+ output?.warn(`files/${relPath}: feed reported extracted text, text route withheld it (${got.row.withheld ?? "no reason given"}) — no derived file written.`);
37503
+ const removed = deleteExtractedText(workspaceSlug, relPath);
37504
+ return { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
37505
+ }
37506
+ rendered = renderExtractedText({
37507
+ relPath,
37508
+ quality: got.row.content_quality,
37509
+ sourceUri: got.row.source_uri,
37510
+ text: got.row.text,
37511
+ bytesInMirror
37512
+ });
37513
+ }
37514
+ const hash2 = extractedTextHash(rendered);
37515
+ const onDisk = readExtractedText(workspaceSlug, relPath);
37516
+ if (onDisk !== null && extractedTextHash(onDisk) === hash2) {
37517
+ return { textHash: hash2, written: 0, deleted: 0 };
37518
+ }
37519
+ writeExtractedText(workspaceSlug, relPath, rendered);
37520
+ return { textHash: hash2, written: 1, deleted: 0 };
35307
37521
  }
35308
37522
  async function applyFileRow(args) {
35309
37523
  const { workspaceSlug, baseUrl, pat, row, state, fetchImpl, output, suppressor } = args;
@@ -35321,28 +37535,88 @@ async function applyFileRow(args) {
35321
37535
  if (row.deleted_at !== null) {
35322
37536
  suppressor?.registerDelete(abs);
35323
37537
  const didUnlink = deleteFilesFile(workspaceSlug, relPath);
37538
+ const removedText = deleteExtractedText(workspaceSlug, relPath);
37539
+ const textDeleted = removedText ? 1 : 0;
35324
37540
  if (existing) {
35325
37541
  const { state: next2 } = removeFileEntry(state, relPath);
35326
- return { state: next2, action: "deleted" };
37542
+ return { state: next2, action: "deleted", textDeleted };
35327
37543
  }
35328
- return { state, action: didUnlink ? "deleted" : "skipped" };
37544
+ return { state, action: didUnlink ? "deleted" : "skipped", textDeleted };
37545
+ }
37546
+ const textSignal = extractedTextSignal(row);
37547
+ const textUnknown = textSignal === "unknown";
37548
+ if (storedBytesSignal(row) === "absent") {
37549
+ return applyPointerRow({
37550
+ workspaceSlug,
37551
+ baseUrl,
37552
+ pat,
37553
+ row,
37554
+ relPath,
37555
+ state,
37556
+ existing,
37557
+ textSignal,
37558
+ fetchImpl,
37559
+ output
37560
+ });
35329
37561
  }
35330
37562
  if (existing && existing.content_hash === row.content_hash && existsSync5(abs)) {
35331
- return { state, action: "noop" };
37563
+ if (!args.forceTextRewalk && textIsCurrent(existing, row, workspaceSlug, relPath)) {
37564
+ return { state, action: "noop", textUnknown };
37565
+ }
37566
+ const text2 = await applyExtractedText({
37567
+ workspaceSlug,
37568
+ baseUrl,
37569
+ pat,
37570
+ row,
37571
+ relPath,
37572
+ bytesInMirror: true,
37573
+ fetchImpl,
37574
+ output
37575
+ });
37576
+ const { state: next2 } = upsertFileEntry(state, {
37577
+ ...existing,
37578
+ updated_at: row.updated_at,
37579
+ text_hash: textUnknown ? existing.text_hash : text2.textHash
37580
+ });
37581
+ return {
37582
+ state: next2,
37583
+ action: "noop",
37584
+ textWritten: text2.written,
37585
+ textDeleted: text2.deleted,
37586
+ textFailed: text2.failed,
37587
+ textUnknown
37588
+ };
37589
+ }
37590
+ const got = await getFile({ baseUrl, pat, id: row.id, fetchImpl });
37591
+ if (!got.ok) {
37592
+ return { state, action: "skipped" };
37593
+ }
37594
+ if (got.row.download_url === null) {
37595
+ if (storedBytesSignal(row) === "present") {
37596
+ output?.warn(`files/${relPath}: the file index says Plinth holds this file's bytes and the file route says it does not — treating it as having none, and leaving any local copy alone.`);
37597
+ }
37598
+ return applyPointerRow({
37599
+ workspaceSlug,
37600
+ baseUrl,
37601
+ pat,
37602
+ row,
37603
+ relPath,
37604
+ state,
37605
+ existing,
37606
+ textSignal,
37607
+ fetchImpl,
37608
+ output
37609
+ });
35332
37610
  }
35333
37611
  if (!suppressor && existing && existing.content_hash !== row.content_hash && existsSync5(abs)) {
35334
37612
  try {
35335
- const localBytes = readFileSync16(abs);
37613
+ const localBytes = readFileSync18(abs);
35336
37614
  if (sha256Bytes(localBytes) !== existing.content_hash) {
35337
37615
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
35338
37616
  output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
35339
37617
  }
35340
37618
  } catch {}
35341
37619
  }
35342
- const got = await getFile({ baseUrl, pat, id: row.id, fetchImpl });
35343
- if (!got.ok) {
35344
- return { state, action: "skipped" };
35345
- }
35346
37620
  const bytes = await downloadBytes(got.row.download_url, fetchImpl);
35347
37621
  if (sha256Bytes(bytes) !== row.content_hash) {
35348
37622
  output?.warn(`Integrity check failed for files/${relPath} (hash mismatch) — skipped this pull.`);
@@ -35350,9 +37624,28 @@ async function applyFileRow(args) {
35350
37624
  }
35351
37625
  suppressor?.registerWrite(abs, { bodyHash: row.content_hash, frontmatterHash: "" });
35352
37626
  writeFilesBytes(workspaceSlug, relPath, bytes);
37627
+ let text;
37628
+ if (textUnknown) {
37629
+ const removed = deleteExtractedText(workspaceSlug, relPath);
37630
+ if (removed) {
37631
+ output?.warn(`files/${relPath}: bytes changed while the server said nothing about extracted text — removed the derived text, which described the previous version of this file.`);
37632
+ }
37633
+ text = { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
37634
+ } else {
37635
+ text = await applyExtractedText({
37636
+ workspaceSlug,
37637
+ baseUrl,
37638
+ pat,
37639
+ row,
37640
+ relPath,
37641
+ bytesInMirror: true,
37642
+ fetchImpl,
37643
+ output
37644
+ });
37645
+ }
35353
37646
  let inode;
35354
37647
  try {
35355
- inode = statSync8(abs).ino;
37648
+ inode = statSync10(abs).ino;
35356
37649
  } catch {
35357
37650
  inode = undefined;
35358
37651
  }
@@ -35363,9 +37656,60 @@ async function applyFileRow(args) {
35363
37656
  size_bytes: row.size_bytes,
35364
37657
  mime_type: row.mime_type,
35365
37658
  updated_at: row.updated_at,
35366
- inode
37659
+ inode,
37660
+ text_hash: text.textHash
37661
+ });
37662
+ return {
37663
+ state: next,
37664
+ action: "written",
37665
+ textWritten: text.written,
37666
+ textDeleted: text.deleted,
37667
+ textFailed: text.failed,
37668
+ textUnknown
37669
+ };
37670
+ }
37671
+ async function applyPointerRow(args) {
37672
+ const { workspaceSlug, baseUrl, pat, row, relPath, state, textSignal } = args;
37673
+ if (textSignal === "absent") {
37674
+ args.output?.warn(`files/${relPath}: Plinth holds neither bytes nor text for this file — it has no local representation in the mirror.`);
37675
+ }
37676
+ const bytesInMirror = existsSync5(filesSubtreePath(workspaceSlug, relPath));
37677
+ if (bytesInMirror) {
37678
+ args.output?.warn(`files/${relPath}: Plinth holds no bytes for this file, but a local copy is present at that path — left exactly as it is. Plinth will not update it.`);
37679
+ }
37680
+ const text = await applyExtractedText({
37681
+ workspaceSlug,
37682
+ baseUrl,
37683
+ pat,
37684
+ row,
37685
+ relPath,
37686
+ bytesInMirror,
37687
+ fetchImpl: args.fetchImpl,
37688
+ output: args.output
35367
37689
  });
35368
- return { state: next, action: "written" };
37690
+ return {
37691
+ state,
37692
+ action: "noop",
37693
+ textWritten: text.written,
37694
+ textDeleted: text.deleted,
37695
+ textFailed: text.failed,
37696
+ textUnknown: textSignal === "unknown",
37697
+ pointer: true
37698
+ };
37699
+ }
37700
+ function textIsCurrent(existing, row, workspaceSlug, relPath) {
37701
+ const signal = extractedTextSignal(row);
37702
+ if (signal === "unknown")
37703
+ return true;
37704
+ if (signal === "absent") {
37705
+ return existing.text_hash === undefined && !existsSync5(extractedTextPath(workspaceSlug, relPath));
37706
+ }
37707
+ if (existing.text_hash === undefined)
37708
+ return false;
37709
+ const onDisk = readExtractedText(workspaceSlug, relPath);
37710
+ if (onDisk === null)
37711
+ return false;
37712
+ return extractedTextHash(onDisk) === existing.text_hash;
35369
37713
  }
35370
37714
 
35371
37715
  // src/commands/sync.ts
@@ -35383,11 +37727,11 @@ function hasExistingContent(workspacePath) {
35383
37727
  if (!existsSync6(workspacePath))
35384
37728
  return false;
35385
37729
  try {
35386
- return readdirSync4(workspacePath, { withFileTypes: true }).some((e2) => {
37730
+ return readdirSync5(workspacePath, { withFileTypes: true }).some((e2) => {
35387
37731
  if (!e2.isDirectory())
35388
37732
  return true;
35389
37733
  try {
35390
- return readdirSync4(join12(workspacePath, e2.name)).length > 0;
37734
+ return readdirSync5(join14(workspacePath, e2.name)).length > 0;
35391
37735
  } catch {
35392
37736
  return true;
35393
37737
  }
@@ -35407,7 +37751,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
35407
37751
  }
35408
37752
  let liveInode;
35409
37753
  try {
35410
- liveInode = statSync9(target).ino;
37754
+ liveInode = statSync11(target).ino;
35411
37755
  } catch {
35412
37756
  return { didUnlink: false, skippedReason: "absent" };
35413
37757
  }
@@ -35416,7 +37760,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
35416
37760
  }
35417
37761
  try {
35418
37762
  suppressor?.registerDelete(target);
35419
- unlinkSync6(target);
37763
+ unlinkSync7(target);
35420
37764
  return { didUnlink: true };
35421
37765
  } catch (err) {
35422
37766
  if (err?.code === "ENOENT") {
@@ -35503,7 +37847,11 @@ async function runSync(workspaceSlug, deps = {}) {
35503
37847
  updateSyncCursor(slug, entityType, lastAdvancedCursor);
35504
37848
  }
35505
37849
  }
35506
- const filesStartCursor = entry2.sync_cursors?.["files"] ?? null;
37850
+ const needsDerivedTextRewalk = state.derived_text_version !== DERIVED_TEXT_VERSION;
37851
+ const filesStartCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
37852
+ if (needsDerivedTextRewalk) {
37853
+ out.info("Re-reading the file index once to refresh extracted text (template changed).");
37854
+ }
35507
37855
  const filesResult = await pullFiles({
35508
37856
  workspaceSlug: slug,
35509
37857
  baseUrl: entry2.api_base_url,
@@ -35514,16 +37862,21 @@ async function runSync(workspaceSlug, deps = {}) {
35514
37862
  now,
35515
37863
  output: out,
35516
37864
  suppressor: deps.suppressor,
35517
- isFileFrozen: deps.isFileFrozen
37865
+ isFileFrozen: deps.isFileFrozen,
37866
+ forceTextRewalk: needsDerivedTextRewalk
35518
37867
  });
35519
37868
  if (filesResult.state !== state) {
35520
37869
  state = filesResult.state;
35521
37870
  stateDirty = true;
35522
37871
  }
37872
+ if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
37873
+ state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
37874
+ stateDirty = true;
37875
+ }
35523
37876
  counters["files"] = {
35524
37877
  fetched: filesResult.fetched,
35525
- written: filesResult.written,
35526
- deleted: filesResult.deleted
37878
+ written: filesResult.written + filesResult.textWritten,
37879
+ deleted: filesResult.deleted + filesResult.textDeleted
35527
37880
  };
35528
37881
  if (filesResult.lastCursor !== filesStartCursor) {
35529
37882
  updateSyncCursor(slug, "files", filesResult.lastCursor);
@@ -35705,7 +38058,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
35705
38058
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
35706
38059
  let localRaw = null;
35707
38060
  try {
35708
- localRaw = readFileSync17(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
38061
+ localRaw = readFileSync19(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
35709
38062
  } catch {
35710
38063
  localRaw = null;
35711
38064
  }
@@ -35728,7 +38081,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
35728
38081
  const hashes = computeMirrorHashes(strategy, content);
35729
38082
  let inode;
35730
38083
  try {
35731
- inode = statSync9(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38084
+ inode = statSync11(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
35732
38085
  } catch {
35733
38086
  inode = undefined;
35734
38087
  }
@@ -35785,7 +38138,7 @@ var syncCommand = defineCommand({
35785
38138
  // src/commands/start.ts
35786
38139
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
35787
38140
  var PULL_HEARTBEAT_MS = 900000;
35788
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync10(p)) {
38141
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync12(p)) {
35789
38142
  let changed = false;
35790
38143
  const nextEntries = {};
35791
38144
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -35818,7 +38171,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync10(p)
35818
38171
  return { state, changed: false };
35819
38172
  return { state: { ...state, entries: nextEntries }, changed: true };
35820
38173
  }
35821
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync18(p, "utf8")) {
38174
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync20(p, "utf8")) {
35822
38175
  let changed = false;
35823
38176
  const nextEntries = {};
35824
38177
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -35915,7 +38268,7 @@ function makeWatchHandler(deps) {
35915
38268
  }
35916
38269
  let st;
35917
38270
  try {
35918
- st = statSync10(absolutePath);
38271
+ st = statSync12(absolutePath);
35919
38272
  } catch (err) {
35920
38273
  if (err?.code === "ENOENT")
35921
38274
  return;
@@ -35946,7 +38299,7 @@ function makeWatchHandler(deps) {
35946
38299
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
35947
38300
  return;
35948
38301
  }
35949
- const bytes = readFileSync18(absolutePath);
38302
+ const bytes = readFileSync20(absolutePath);
35950
38303
  const hash2 = sha256Bytes(bytes);
35951
38304
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
35952
38305
  bodyHash: hash2,
@@ -36004,7 +38357,7 @@ function makeWatchHandler(deps) {
36004
38357
  if (suppressor) {
36005
38358
  let raw = null;
36006
38359
  try {
36007
- raw = readFileSync18(absolutePath, "utf8");
38360
+ raw = readFileSync20(absolutePath, "utf8");
36008
38361
  } catch {
36009
38362
  raw = null;
36010
38363
  }
@@ -36042,7 +38395,7 @@ function makeWatchHandler(deps) {
36042
38395
  if (suppressor) {
36043
38396
  let raw = null;
36044
38397
  try {
36045
- raw = readFileSync18(absolutePath, "utf8");
38398
+ raw = readFileSync20(absolutePath, "utf8");
36046
38399
  } catch {
36047
38400
  raw = null;
36048
38401
  }
@@ -36080,7 +38433,7 @@ function makeWatchHandler(deps) {
36080
38433
  if (suppressor) {
36081
38434
  let raw = null;
36082
38435
  try {
36083
- raw = readFileSync18(absolutePath, "utf8");
38436
+ raw = readFileSync20(absolutePath, "utf8");
36084
38437
  } catch {
36085
38438
  raw = null;
36086
38439
  }
@@ -36118,7 +38471,7 @@ function makeWatchHandler(deps) {
36118
38471
  if (suppressor) {
36119
38472
  let raw = null;
36120
38473
  try {
36121
- raw = readFileSync18(absolutePath, "utf8");
38474
+ raw = readFileSync20(absolutePath, "utf8");
36122
38475
  } catch {
36123
38476
  raw = null;
36124
38477
  }
@@ -36211,7 +38564,7 @@ function makeWatchHandler(deps) {
36211
38564
  if (strategy) {
36212
38565
  let raw = null;
36213
38566
  try {
36214
- raw = readFileSync18(absolutePath, "utf8");
38567
+ raw = readFileSync20(absolutePath, "utf8");
36215
38568
  } catch {
36216
38569
  raw = null;
36217
38570
  }
@@ -36819,9 +39172,76 @@ async function runStart(workspaceSlug, deps = {}) {
36819
39172
  loginHint: `plinth login --workspace ${slug}`
36820
39173
  });
36821
39174
  pullLoop.start();
39175
+ let contractMismatchAnnounced = false;
39176
+ let collisionAnnounced = false;
39177
+ const calendarLoop = new CalendarLoop({
39178
+ runOnce: async () => {
39179
+ const attemptedAt = (deps.now?.() ?? new Date).toISOString();
39180
+ try {
39181
+ const response = await fetchCalendar({
39182
+ baseUrl: entry2.api_base_url,
39183
+ pat,
39184
+ fetchImpl
39185
+ });
39186
+ const result = projectCalendar({
39187
+ workspaceSlug: slug,
39188
+ response,
39189
+ roster: readRoster(slug)
39190
+ });
39191
+ const notRead = response.calendars.filter((c3) => c3.status !== "fetched").length;
39192
+ daemonLog.log(calendarProjectedLine({
39193
+ written: result.written,
39194
+ pruned: result.pruned,
39195
+ calendars: response.calendars.length,
39196
+ notRead
39197
+ }));
39198
+ if (result.collided > 0 && !collisionAnnounced) {
39199
+ collisionAnnounced = true;
39200
+ spinner.warn(`${result.collided} calendar row${result.collided === 1 ? "" : "s"} ` + `shared a file name with another and ${result.collided === 1 ? "was" : "were"} skipped.`);
39201
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39202
+ }
39203
+ contractMismatchAnnounced = false;
39204
+ return { ok: true, written: result.written, pruned: result.pruned };
39205
+ } catch (err) {
39206
+ const failure = classifyCalendarFailure(err);
39207
+ let staled = 0;
39208
+ try {
39209
+ staled = markCalendarStale({
39210
+ workspaceSlug: slug,
39211
+ attemptedAt,
39212
+ reason: failure.reason
39213
+ }).rewritten;
39214
+ } catch (staleErr) {
39215
+ out.log(`[CALENDAR_STALE_FAILED ${categorizeError(staleErr).reason}]`);
39216
+ }
39217
+ daemonLog.log(calendarFailedLine({ ...categorizeError(err), staled }));
39218
+ if (err instanceof CalendarContractError && !contractMismatchAnnounced) {
39219
+ contractMismatchAnnounced = true;
39220
+ spinner.warn(`Calendar refresh: the server's response did not match the contract ` + `this daemon was built against. ${err.message}`);
39221
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39222
+ }
39223
+ return { ok: false, failure };
39224
+ }
39225
+ },
39226
+ onProjected: (written, pruned) => {
39227
+ const parts = [`${written} calendar file${written === 1 ? "" : "s"}`];
39228
+ if (pruned > 0)
39229
+ parts.push(`${pruned} pruned`);
39230
+ spinner.success(`↻ ${parts.join(", ")}`);
39231
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39232
+ },
39233
+ notice: (kind, message) => {
39234
+ daemonLog.log(calendarNoticeLine(kind));
39235
+ spinner.warn(message);
39236
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39237
+ },
39238
+ loginHint: `plinth login --workspace ${slug}`
39239
+ });
39240
+ calendarLoop.startNow();
36822
39241
  const shutdown = (signal) => {
36823
39242
  (async () => {
36824
39243
  pullLoop.stop();
39244
+ calendarLoop.stop();
36825
39245
  spinner.warn(`Received ${signal}, draining pending pushes…`);
36826
39246
  try {
36827
39247
  await Promise.race([
@@ -37032,27 +39452,27 @@ var startCommand = defineCommand({
37032
39452
  });
37033
39453
 
37034
39454
  // src/commands/status.ts
37035
- import { statSync as statSync11 } from "node:fs";
39455
+ import { statSync as statSync13 } from "node:fs";
37036
39456
 
37037
39457
  // src/lib/build-stamp.ts
37038
- import { execFileSync as execFileSync3 } from "node:child_process";
37039
- import { existsSync as existsSync7, mkdirSync as mkdirSync8, readFileSync as readFileSync19, writeFileSync as writeFileSync7 } from "node:fs";
37040
- import { dirname as dirname9, join as join13 } from "node:path";
39458
+ import { execFileSync as execFileSync4 } from "node:child_process";
39459
+ import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync21, writeFileSync as writeFileSync8 } from "node:fs";
39460
+ import { dirname as dirname10, join as join15 } from "node:path";
37041
39461
  import { fileURLToPath } from "node:url";
37042
39462
  var BUILD_STAMP_FILENAME = "build-stamp.json";
37043
39463
  function stampPath(distDir) {
37044
- return join13(distDir, BUILD_STAMP_FILENAME);
39464
+ return join15(distDir, BUILD_STAMP_FILENAME);
37045
39465
  }
37046
39466
  function readHead(repoDir) {
37047
39467
  try {
37048
- const sha = execFileSync3("git", ["rev-parse", "HEAD"], {
39468
+ const sha = execFileSync4("git", ["rev-parse", "HEAD"], {
37049
39469
  cwd: repoDir,
37050
39470
  encoding: "utf8",
37051
39471
  stdio: ["ignore", "pipe", "ignore"]
37052
39472
  }).trim();
37053
39473
  if (!sha)
37054
39474
  return null;
37055
- const porcelain = execFileSync3("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
39475
+ const porcelain = execFileSync4("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
37056
39476
  return { sha, dirty: porcelain.trim().length > 0 };
37057
39477
  } catch {
37058
39478
  return null;
@@ -37060,7 +39480,7 @@ function readHead(repoDir) {
37060
39480
  }
37061
39481
  function readBuildStamp(distDir) {
37062
39482
  try {
37063
- const parsed = JSON.parse(readFileSync19(stampPath(distDir), "utf8"));
39483
+ const parsed = JSON.parse(readFileSync21(stampPath(distDir), "utf8"));
37064
39484
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
37065
39485
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
37066
39486
  }
@@ -37072,9 +39492,9 @@ function readBuildStamp(distDir) {
37072
39492
  function findRepoRoot(fromDir) {
37073
39493
  let dir = fromDir;
37074
39494
  for (;; ) {
37075
- if (existsSync7(join13(dir, ".git")))
39495
+ if (existsSync7(join15(dir, ".git")))
37076
39496
  return dir;
37077
- const parent = dirname9(dir);
39497
+ const parent = dirname10(dir);
37078
39498
  if (parent === dir)
37079
39499
  return null;
37080
39500
  dir = parent;
@@ -37082,7 +39502,7 @@ function findRepoRoot(fromDir) {
37082
39502
  }
37083
39503
  function probeRepo(repoDir, stampSha) {
37084
39504
  try {
37085
- execFileSync3("git", ["cat-file", "-e", `${stampSha}^{commit}`], {
39505
+ execFileSync4("git", ["cat-file", "-e", `${stampSha}^{commit}`], {
37086
39506
  cwd: repoDir,
37087
39507
  stdio: "ignore"
37088
39508
  });
@@ -37098,7 +39518,7 @@ function checkBuildStaleness(deps = {}) {
37098
39518
  const metaUrl = deps.metaUrl ?? import.meta.url;
37099
39519
  let distDir;
37100
39520
  try {
37101
- distDir = dirname9(fileURLToPath(metaUrl));
39521
+ distDir = dirname10(fileURLToPath(metaUrl));
37102
39522
  } catch {
37103
39523
  return { kind: "unknown" };
37104
39524
  }
@@ -37144,8 +39564,8 @@ function formatRelative(deltaMs) {
37144
39564
  return `${Math.floor(h2 / 24)}d ago`;
37145
39565
  }
37146
39566
  function formatAbsolute(date5) {
37147
- const pad2 = (n2) => String(n2).padStart(2, "0");
37148
- return `${date5.getFullYear()}-${pad2(date5.getMonth() + 1)}-${pad2(date5.getDate())} ` + `${pad2(date5.getHours())}:${pad2(date5.getMinutes())}:${pad2(date5.getSeconds())}`;
39567
+ const pad3 = (n2) => String(n2).padStart(2, "0");
39568
+ return `${date5.getFullYear()}-${pad3(date5.getMonth() + 1)}-${pad3(date5.getDate())} ` + `${pad3(date5.getHours())}:${pad3(date5.getMinutes())}:${pad3(date5.getSeconds())}`;
37149
39569
  }
37150
39570
  function short(sha) {
37151
39571
  return sha.slice(0, 7);
@@ -37168,7 +39588,7 @@ function renderBuild(out, staleness) {
37168
39588
  }
37169
39589
  function lastActivity(now) {
37170
39590
  try {
37171
- const mtimeMs = statSync11(daemonLogPath()).mtimeMs;
39591
+ const mtimeMs = statSync13(daemonLogPath()).mtimeMs;
37172
39592
  return formatRelative(now.getTime() - mtimeMs);
37173
39593
  } catch {
37174
39594
  return null;
@@ -37203,6 +39623,27 @@ async function runStatus(args, deps = {}) {
37203
39623
  if (activity)
37204
39624
  out.info(cont(`last activity ${activity}`));
37205
39625
  out.info("");
39626
+ const sessions = listSessions(slug, {
39627
+ aliveFn: deps.aliveFn,
39628
+ startTimeFn: deps.sessionStartTimeFn,
39629
+ mtimeFn: deps.sessionMtimeFn
39630
+ });
39631
+ const selfPidRaw = process.env.CLAUDE_PID;
39632
+ const selfPid = selfPidRaw ? Number.parseInt(selfPidRaw, 10) : null;
39633
+ const selfSessionId = process.env.CLAUDE_CODE_SESSION_ID ?? null;
39634
+ const sessionLines = renderSessionLines(sessions.live, now, Number.isInteger(selfPid) ? selfPid : null, selfSessionId, formatRelative);
39635
+ const count = sessions.live.length;
39636
+ const reapNote = sessions.reaped > 0 ? ` (cleared ${sessions.reaped} ended session${sessions.reaped === 1 ? "" : "s"})` : "";
39637
+ out.info(row("Sessions", count === 0 ? `None registered on this machine.${reapNote}` : `${count} live on this machine${reapNote}`));
39638
+ if (count > 0 || sessions.unknown > 0 || sessions.ended > 0) {
39639
+ if (count > 0)
39640
+ for (const line of sessionLines)
39641
+ out.info(cont(line));
39642
+ for (const line of sessionCaveats(count, sessions.unknown, sessions.ended)) {
39643
+ out.info(cont(line));
39644
+ }
39645
+ }
39646
+ out.info("");
37206
39647
  let localState;
37207
39648
  try {
37208
39649
  localState = loadState(slug);
@@ -37291,7 +39732,7 @@ var versionCommand = defineCommand({
37291
39732
  });
37292
39733
 
37293
39734
  // src/commands/voice-gate.ts
37294
- import { readFileSync as readFileSync22 } from "node:fs";
39735
+ import { readFileSync as readFileSync24 } from "node:fs";
37295
39736
 
37296
39737
  // src/lib/voice-gate/api.ts
37297
39738
  var VoiceGateSurfaces = [
@@ -37393,15 +39834,15 @@ async function callVoiceGate(req) {
37393
39834
  }
37394
39835
 
37395
39836
  // src/lib/voice-gate/attempts.ts
37396
- import { createHash as createHash3 } from "node:crypto";
39837
+ import { createHash as createHash4 } from "node:crypto";
37397
39838
  import {
37398
- mkdirSync as mkdirSync9,
37399
- readFileSync as readFileSync20,
37400
- renameSync as renameSync7,
37401
- unlinkSync as unlinkSync7,
37402
- writeFileSync as writeFileSync8
39839
+ mkdirSync as mkdirSync11,
39840
+ readFileSync as readFileSync22,
39841
+ renameSync as renameSync8,
39842
+ unlinkSync as unlinkSync8,
39843
+ writeFileSync as writeFileSync9
37403
39844
  } from "node:fs";
37404
- import { join as join14 } from "node:path";
39845
+ import { join as join16 } from "node:path";
37405
39846
  var MAX_REGENS = 2;
37406
39847
  var ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
37407
39848
  var AttemptEntrySchema = exports_external.object({
@@ -37414,20 +39855,20 @@ var AttemptsFileSchema = exports_external.object({
37414
39855
  entries: exports_external.record(exports_external.string(), AttemptEntrySchema)
37415
39856
  });
37416
39857
  function attemptsDir(workspaceSlug) {
37417
- return join14(plinthHome(), "_voice_gate", workspaceSlug);
39858
+ return join16(plinthHome(), "_voice_gate", workspaceSlug);
37418
39859
  }
37419
39860
  function attemptsPath(workspaceSlug) {
37420
- return join14(attemptsDir(workspaceSlug), "attempts.json");
39861
+ return join16(attemptsDir(workspaceSlug), "attempts.json");
37421
39862
  }
37422
39863
  function attemptKey(identity) {
37423
- return createHash3("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
39864
+ return createHash4("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
37424
39865
  }
37425
39866
  function emptyFile() {
37426
39867
  return { version: 1, entries: {} };
37427
39868
  }
37428
39869
  function load2(workspaceSlug) {
37429
39870
  try {
37430
- const raw = readFileSync20(attemptsPath(workspaceSlug), "utf8");
39871
+ const raw = readFileSync22(attemptsPath(workspaceSlug), "utf8");
37431
39872
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
37432
39873
  return parsed.success ? parsed.data : emptyFile();
37433
39874
  } catch {
@@ -37442,19 +39883,19 @@ function prune(file2, now) {
37442
39883
  }));
37443
39884
  return { version: 1, entries };
37444
39885
  }
37445
- var tmpCounter = 0;
39886
+ var tmpCounter2 = 0;
37446
39887
  function save(workspaceSlug, file2) {
37447
39888
  const dir = attemptsDir(workspaceSlug);
37448
39889
  const target = attemptsPath(workspaceSlug);
37449
- const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
37450
- mkdirSync9(dir, { recursive: true, mode: 448 });
37451
- writeFileSync8(tmp, `${JSON.stringify(file2, null, 2)}
39890
+ const tmp = `${target}.${process.pid}.${tmpCounter2++}.tmp`;
39891
+ mkdirSync11(dir, { recursive: true, mode: 448 });
39892
+ writeFileSync9(tmp, `${JSON.stringify(file2, null, 2)}
37452
39893
  `, { mode: 384 });
37453
39894
  try {
37454
- renameSync7(tmp, target);
39895
+ renameSync8(tmp, target);
37455
39896
  } catch (err) {
37456
39897
  try {
37457
- unlinkSync7(tmp);
39898
+ unlinkSync8(tmp);
37458
39899
  } catch {}
37459
39900
  throw err;
37460
39901
  }
@@ -37590,7 +40031,7 @@ function denyUngated(detail) {
37590
40031
  }
37591
40032
 
37592
40033
  // src/lib/voice-gate/payload.ts
37593
- import { readFileSync as readFileSync21, statSync as statSync12 } from "node:fs";
40034
+ import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
37594
40035
  import { homedir as homedir2 } from "node:os";
37595
40036
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
37596
40037
  var MAX_BODY_BYTES = 256 * 1024;
@@ -37600,7 +40041,7 @@ function isRawGwsDraftCreate(tokens) {
37600
40041
  return tokens[0] === "gws" && tokens.includes("drafts") && tokens.includes("create");
37601
40042
  }
37602
40043
  var MCP_GMAIL_DRAFT_PATTERN = /^mcp__.*gmail.*__(create|update)_draft$/i;
37603
- function asString(value) {
40044
+ function asString2(value) {
37604
40045
  return typeof value === "string" ? value : null;
37605
40046
  }
37606
40047
  function asNonEmptyString(value) {
@@ -37747,11 +40188,11 @@ function isHelperInvocation(tokens) {
37747
40188
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
37748
40189
  }
37749
40190
  function defaultReadBodyFile(path5) {
37750
- const stat4 = statSync12(path5);
40191
+ const stat4 = statSync14(path5);
37751
40192
  if (stat4.size > MAX_BODY_BYTES) {
37752
40193
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
37753
40194
  }
37754
- return readFileSync21(path5, "utf8");
40195
+ return readFileSync23(path5, "utf8");
37755
40196
  }
37756
40197
  function parseRecipients(...raw) {
37757
40198
  const all = raw.filter((v2) => typeof v2 === "string").flatMap((v2) => v2.split(",")).map((v2) => v2.trim().toLowerCase()).filter((v2) => v2.length > 0);
@@ -37839,10 +40280,10 @@ function classifyHelperSegment(tokens, dirState, readBodyFile) {
37839
40280
  }
37840
40281
  function classifyBash(payload, readBodyFile) {
37841
40282
  const input = asRecord(payload.tool_input);
37842
- const command = asString(input.command);
40283
+ const command = asString2(input.command);
37843
40284
  if (!command)
37844
40285
  return { gated: false };
37845
- const cwd = asString(payload.cwd);
40286
+ const cwd = asString2(payload.cwd);
37846
40287
  let dirState = cwd ? { dir: cwd, known: true } : { dir: process.cwd(), known: true };
37847
40288
  const items = [];
37848
40289
  for (const segment of splitCommandSegments(command)) {
@@ -37883,7 +40324,7 @@ function classifyMcp(payload) {
37883
40324
  ]
37884
40325
  };
37885
40326
  }
37886
- const subject = asString(input.subject) ?? "";
40327
+ const subject = asString2(input.subject) ?? "";
37887
40328
  return {
37888
40329
  gated: true,
37889
40330
  items: [
@@ -37891,7 +40332,7 @@ function classifyMcp(payload) {
37891
40332
  draft: {
37892
40333
  site: "mcp-gmail",
37893
40334
  recipients: parseRecipients(asStringArray(input.to).join(","), asStringArray(input.cc).join(","), asStringArray(input.bcc).join(",")),
37894
- threadId: asString(input.replyToMessageId) || asString(input.draftId) || null,
40335
+ threadId: asString2(input.replyToMessageId) || asString2(input.draftId) || null,
37895
40336
  subject,
37896
40337
  text: composeDraftText(subject, body)
37897
40338
  }
@@ -37902,7 +40343,7 @@ function classifyMcp(payload) {
37902
40343
  function classifyPayload(payload, deps = {}) {
37903
40344
  const readBodyFile = deps.readBodyFile ?? defaultReadBodyFile;
37904
40345
  try {
37905
- const toolName = asString(payload.tool_name);
40346
+ const toolName = asString2(payload.tool_name);
37906
40347
  if (!toolName)
37907
40348
  return { gated: false };
37908
40349
  if (toolName === "Bash")
@@ -37928,7 +40369,7 @@ var diskAttempts = {
37928
40369
  recordFailure: (slug, key, reason) => recordFailure(slug, key, reason),
37929
40370
  clear: clearAttempt
37930
40371
  };
37931
- async function defaultReadStdin() {
40372
+ async function defaultReadStdin2() {
37932
40373
  if (process.stdin.isTTY)
37933
40374
  return "";
37934
40375
  const chunks = [];
@@ -38037,7 +40478,7 @@ async function gateOneDraft(draft, sessionId, auth, deps) {
38037
40478
  }
38038
40479
  async function runVoiceGateHook(args, deps = {}) {
38039
40480
  const sink = deps.sink ?? { stdout: process.stdout, stderr: process.stderr };
38040
- const readStdin = deps.readStdin ?? defaultReadStdin;
40481
+ const readStdin = deps.readStdin ?? defaultReadStdin2;
38041
40482
  const raw = await readStdin().catch(() => "");
38042
40483
  let payload;
38043
40484
  try {
@@ -38102,7 +40543,7 @@ function isSurface(value) {
38102
40543
  async function runVoiceGateDirect(args, deps = {}) {
38103
40544
  const { Output: Output2 } = await Promise.resolve().then(() => (init_output(), exports_output));
38104
40545
  const out = new Output2({ sink: deps.sink, noColor: deps.noColor });
38105
- const readStdin = deps.readStdin ?? defaultReadStdin;
40546
+ const readStdin = deps.readStdin ?? defaultReadStdin2;
38106
40547
  const surfaceArg = args.surface ?? "email_draft";
38107
40548
  if (!isSurface(surfaceArg)) {
38108
40549
  out.error(`Unknown surface "${surfaceArg}". Expected one of: ${VoiceGateSurfaces.join(", ")}.`);
@@ -38111,7 +40552,7 @@ async function runVoiceGateDirect(args, deps = {}) {
38111
40552
  let text;
38112
40553
  if (args.file) {
38113
40554
  try {
38114
- text = readFileSync22(args.file, "utf8");
40555
+ text = readFileSync24(args.file, "utf8");
38115
40556
  } catch (err) {
38116
40557
  const msg = err instanceof Error ? err.message : "unknown error";
38117
40558
  out.error(`Could not read ${args.file}: ${msg}`);
@@ -38256,7 +40697,9 @@ var main = defineCommand({
38256
40697
  "voice-gate": voiceGateCommand,
38257
40698
  backlinks: backlinksCommand,
38258
40699
  declare: declareCommand,
38259
- "review-tier": reviewTierCommand
40700
+ "review-tier": reviewTierCommand,
40701
+ session: sessionCommand,
40702
+ "memory-gate": memoryGateCommand
38260
40703
  }
38261
40704
  });
38262
40705
  runMain(main);