@plinth-music/cli 0.5.0 → 0.6.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.6.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,59 @@ 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
+ }
25177
25240
  function contextFilePath(workspaceSlug, relativePath) {
25178
25241
  const ws = workspaceMirrorPath(workspaceSlug);
25179
25242
  const resolved = resolve(join2(ws, relativePath));
@@ -26772,9 +26835,15 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
26772
26835
  ``,
26773
26836
  `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
26837
  ``,
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.`,
26838
+ `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.`,
26839
+ ``,
26840
+ `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 (\`list_key_dates\`, \`list_artist_calendars\`).`,
26776
26841
  ``,
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.`
26842
+ `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.`,
26843
+ ``,
26844
+ `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.`,
26845
+ ``,
26846
+ `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
26847
  ];
26779
26848
  if (workspaceRules !== null) {
26780
26849
  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 +26920,8 @@ var FileStateEntrySchema = exports_external.object({
26851
26920
  size_bytes: exports_external.number().int().nonnegative(),
26852
26921
  mime_type: exports_external.string().min(1),
26853
26922
  updated_at: exports_external.string().datetime({ offset: true }),
26854
- inode: exports_external.number().int().nonnegative().optional()
26923
+ inode: exports_external.number().int().nonnegative().optional(),
26924
+ text_hash: exports_external.string().min(1).optional()
26855
26925
  });
26856
26926
  var FileEntriesSchema = exports_external.record(exports_external.string(), FileStateEntrySchema);
26857
26927
  var WorkspaceRulesStateSchema = exports_external.object({
@@ -26895,7 +26965,8 @@ var WorkspaceStateV2Schema = exports_external.object({
26895
26965
  rejected: exports_external.array(RejectedWriteSchema).optional(),
26896
26966
  workspace_rules: WorkspaceRulesStateSchema.optional(),
26897
26967
  workspace_memory: WorkspaceMemoryStateSchema.optional(),
26898
- user_memory: UserMemoryStateSchema.optional()
26968
+ user_memory: UserMemoryStateSchema.optional(),
26969
+ derived_text_version: exports_external.number().int().nonnegative().optional()
26899
26970
  });
26900
26971
  var WorkspaceStateV1Schema = exports_external.object({
26901
26972
  version: exports_external.literal(1),
@@ -27745,148 +27816,787 @@ var loginCommand = defineCommand({
27745
27816
  }
27746
27817
  });
27747
27818
 
27748
- // src/lib/context/artist-context.ts
27749
- import { existsSync } from "node:fs";
27819
+ // src/lib/memory-gate/log.ts
27820
+ import {
27821
+ appendFileSync,
27822
+ closeSync,
27823
+ mkdirSync as mkdirSync7,
27824
+ openSync,
27825
+ readFileSync as readFileSync8,
27826
+ readSync,
27827
+ statSync as statSync2
27828
+ } from "node:fs";
27829
+ import { join as join7 } from "node:path";
27750
27830
 
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.";
27831
+ // src/lib/sessions/registry.ts
27832
+ import {
27833
+ mkdirSync as mkdirSync6,
27834
+ readdirSync as readdirSync3,
27835
+ readFileSync as readFileSync7,
27836
+ renameSync as renameSync6,
27837
+ statSync,
27838
+ unlinkSync as unlinkSync6,
27839
+ writeFileSync as writeFileSync6
27840
+ } from "node:fs";
27841
+ import { execFileSync } from "node:child_process";
27842
+ import { basename, dirname as dirname5, join as join6 } from "node:path";
27786
27843
 
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)
27844
+ // src/lib/daemon-pid.ts
27845
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync6, renameSync as renameSync5, unlinkSync as unlinkSync5, writeFileSync as writeFileSync5 } from "node:fs";
27846
+ import { dirname as dirname4, join as join5 } from "node:path";
27847
+ var DaemonPidSchema = exports_external.object({
27848
+ version: exports_external.literal(1),
27849
+ pid: exports_external.number().int().positive(),
27850
+ workspace_slug: exports_external.string().min(1),
27851
+ started_at: exports_external.string().min(1)
27852
+ });
27853
+ function daemonPidPath(workspaceSlug) {
27854
+ return join5(plinthHome(), `daemon-${workspaceSlug}.pid`);
27855
+ }
27856
+ function daemonPidTmpPath(workspaceSlug) {
27857
+ return join5(plinthHome(), `daemon-${workspaceSlug}.pid.tmp`);
27858
+ }
27859
+ function writeDaemonPid(workspaceSlug, pid, startedAt) {
27860
+ const file2 = {
27861
+ version: 1,
27862
+ pid,
27863
+ workspace_slug: workspaceSlug,
27864
+ started_at: startedAt.toISOString()
27865
+ };
27866
+ const target = daemonPidPath(workspaceSlug);
27867
+ const tmp = daemonPidTmpPath(workspaceSlug);
27868
+ mkdirSync5(dirname4(target), { recursive: true, mode: 448 });
27869
+ writeFileSync5(tmp, JSON.stringify(file2, null, 2) + `
27870
+ `, { mode: 384 });
27871
+ try {
27872
+ renameSync5(tmp, target);
27873
+ } catch (err) {
27874
+ try {
27875
+ unlinkSync5(tmp);
27876
+ } catch {}
27877
+ throw err;
27878
+ }
27879
+ }
27880
+ function readDaemonPid(workspaceSlug) {
27881
+ let raw;
27882
+ try {
27883
+ raw = readFileSync6(daemonPidPath(workspaceSlug), "utf8");
27884
+ } catch (err) {
27885
+ if (err?.code === "ENOENT")
27886
+ return null;
27887
+ throw err;
27888
+ }
27889
+ let json2;
27890
+ try {
27891
+ json2 = JSON.parse(raw);
27892
+ } catch {
27798
27893
  return null;
27799
- return content.slice(afterBegin, endIdx);
27894
+ }
27895
+ const parsed = DaemonPidSchema.safeParse(json2);
27896
+ return parsed.success ? parsed.data : null;
27800
27897
  }
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");
27898
+ function clearDaemonPid(workspaceSlug) {
27899
+ try {
27900
+ unlinkSync5(daemonPidPath(workspaceSlug));
27901
+ } catch (err) {
27902
+ if (err?.code === "ENOENT")
27903
+ return;
27904
+ throw err;
27805
27905
  }
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");
27906
+ }
27907
+ function isPidAlive(pid) {
27908
+ try {
27909
+ process.kill(pid, 0);
27910
+ return true;
27911
+ } catch (err) {
27912
+ return err?.code === "EPERM";
27810
27913
  }
27811
- return generated.slice(0, afterBegin) + preserved + generated.slice(endIdx);
27914
+ }
27915
+ function readLiveDaemon(workspaceSlug, aliveFn = isPidAlive) {
27916
+ const pidFile = readDaemonPid(workspaceSlug);
27917
+ if (!pidFile)
27918
+ return null;
27919
+ if (aliveFn(pidFile.pid))
27920
+ return pidFile;
27921
+ clearDaemonPid(workspaceSlug);
27922
+ return null;
27812
27923
  }
27813
27924
 
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}`;
27925
+ // src/lib/sessions/registry.ts
27926
+ var SessionRecordSchema = exports_external.object({
27927
+ version: exports_external.literal(1),
27928
+ session_id: exports_external.string().min(1),
27929
+ pid: exports_external.number().int().positive(),
27930
+ pid_start_time: exports_external.string().min(1),
27931
+ cwd: exports_external.string().min(1),
27932
+ workspace_slug: exports_external.string().min(1),
27933
+ started_at: exports_external.string().min(1),
27934
+ transcript_path: exports_external.string().min(1).nullable(),
27935
+ tab_id: exports_external.string().min(1).nullable().default(null),
27936
+ ended_at: exports_external.string().min(1).nullable().default(null)
27937
+ });
27938
+ var SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
27939
+ function isSafeSegment(value) {
27940
+ return SAFE_SEGMENT.test(value) && !value.includes("..");
27828
27941
  }
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("");
27942
+ function sessionsDir(workspaceSlug) {
27943
+ if (!isSafeSegment(workspaceSlug)) {
27944
+ throw new Error(`Refusing unsafe workspace slug: ${workspaceSlug}`);
27846
27945
  }
27847
- const sortedProjects = sortProjects(projects);
27848
- const names = sortedProjects.map((p) => p.name).join(", ");
27849
- lines.push(`## Active projects (${sortedProjects.length})${names ? `: ${names}` : ""}`);
27850
- if (sortedProjects.length === 0) {
27851
- lines.push(`_None active._`);
27852
- } else {
27853
- for (const p of sortedProjects) {
27854
- const meta3 = [p.status, formatDateRange(p.startDate, p.endDate)].filter((s2) => s2 !== null && s2.length > 0).join(", ");
27855
- lines.push(`- **${p.name}** (project \`${p.id}\`)${meta3 ? ` · ${meta3}` : ""}`);
27856
- }
27946
+ return join6(plinthHome(), workspaceSlug, "sessions");
27947
+ }
27948
+ function sessionRecordPath(workspaceSlug, sessionId) {
27949
+ if (!isSafeSegment(sessionId)) {
27950
+ throw new Error(`Refusing unsafe session id: ${sessionId}`);
27857
27951
  }
27858
- lines.push("");
27859
- const sortedTasks = sortTasks(tasks);
27860
- const shown = sortedTasks.slice(0, TASK_DISPLAY_CAP);
27861
- lines.push(`## Open tasks (${shown.length}/${sortedTasks.length})`);
27862
- if (sortedTasks.length === 0) {
27863
- lines.push(`_None open._`);
27864
- } else {
27865
- for (const t2 of shown) {
27866
- const due = t2.dueDate ? `due ${datePart(t2.dueDate)}` : "no due date";
27867
- const pri = t2.priority ? ` (${t2.priority})` : "";
27868
- lines.push(`- ${t2.name} · ${due}${pri}`);
27869
- }
27870
- const remaining = sortedTasks.length - shown.length;
27871
- if (remaining > 0) {
27872
- lines.push(`- _+${remaining} more via \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}_`);
27873
- }
27952
+ return join6(sessionsDir(workspaceSlug), `${sessionId}.json`);
27953
+ }
27954
+ function readPidStartTime(pid) {
27955
+ try {
27956
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
27957
+ encoding: "utf8",
27958
+ timeout: 5000,
27959
+ stdio: ["ignore", "pipe", "ignore"],
27960
+ env: { ...process.env, TZ: "UTC" }
27961
+ });
27962
+ const collapsed = out.trim().replace(/\s+/g, " ");
27963
+ return collapsed.length > 0 ? collapsed : null;
27964
+ } catch {
27965
+ return null;
27874
27966
  }
27875
- lines.push("");
27876
- lines.push(`## More & companions`);
27877
- 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
- lines.push(`- Closed/archived, meetings & full lists: \`mcp__plinth__list_projects\` / \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}`);
27879
- lines.push(`- Schema: ${MCP_INTROSPECTION_POINTER}`);
27880
- lines.push(`- Long-term facts → \`./memory.md\` · daily activity → \`./daily-log.md\``);
27881
- lines.push("");
27882
- lines.push(USER_BLOCK_BEGIN);
27883
- lines.push(USER_BLOCK_PLACEHOLDER);
27884
- lines.push(USER_BLOCK_END);
27885
- return lines.join(`
27886
- `) + `
27887
- `;
27888
27967
  }
27889
- function artistIdHint(uuid3) {
27968
+ function registerSession(input) {
27969
+ const startTime = (input.startTimeFn ?? readPidStartTime)(input.pid);
27970
+ if (startTime === null)
27971
+ return null;
27972
+ const record2 = {
27973
+ version: 1,
27974
+ session_id: input.sessionId,
27975
+ pid: input.pid,
27976
+ pid_start_time: startTime,
27977
+ cwd: input.cwd,
27978
+ workspace_slug: input.workspaceSlug,
27979
+ started_at: (input.now ?? new Date).toISOString(),
27980
+ transcript_path: input.transcriptPath,
27981
+ tab_id: input.tabId ?? null,
27982
+ ended_at: null
27983
+ };
27984
+ writeRecordAtomic(sessionRecordPath(input.workspaceSlug, input.sessionId), record2);
27985
+ return record2;
27986
+ }
27987
+ var tmpCounter = 0;
27988
+ function writeRecordAtomic(target, record2) {
27989
+ const dir = dirname5(target);
27990
+ mkdirSync6(dir, { recursive: true, mode: 448 });
27991
+ const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
27992
+ sweepDeadTmpFiles(dir, target);
27993
+ writeFileSync6(tmp, JSON.stringify(record2, null, 2) + `
27994
+ `, { mode: 384 });
27995
+ try {
27996
+ renameSync6(tmp, target);
27997
+ } catch (err) {
27998
+ try {
27999
+ unlinkSync6(tmp);
28000
+ } catch {}
28001
+ throw err;
28002
+ }
28003
+ }
28004
+ function sweepDeadTmpFiles(dir, target) {
28005
+ const prefix = `${basename(target)}.`;
28006
+ let names;
28007
+ try {
28008
+ names = readdirSync3(dir);
28009
+ } catch {
28010
+ return;
28011
+ }
28012
+ for (const name of names) {
28013
+ if (!name.startsWith(prefix) || !name.endsWith(".tmp"))
28014
+ continue;
28015
+ const parts = name.slice(0, -".tmp".length).split(".");
28016
+ const pid = Number.parseInt(parts[parts.length - 2] ?? "", 10);
28017
+ if (!Number.isInteger(pid) || pid <= 0)
28018
+ continue;
28019
+ if (pid === process.pid || isPidAlive(pid))
28020
+ continue;
28021
+ try {
28022
+ unlinkSync6(join6(dir, name));
28023
+ } catch {}
28024
+ }
28025
+ }
28026
+ function endSession(workspaceSlug, sessionId, now = new Date, deps = {}) {
28027
+ const read = deps.readFn ?? readSessionRecord;
28028
+ const path3 = sessionRecordPath(workspaceSlug, sessionId);
28029
+ const current = read(path3);
28030
+ if (current === null)
28031
+ return null;
28032
+ const latest = read(path3);
28033
+ if (latest === null || latest.pid !== current.pid || latest.pid_start_time !== current.pid_start_time) {
28034
+ return null;
28035
+ }
28036
+ const record2 = { ...latest, ended_at: now.toISOString() };
28037
+ writeRecordAtomic(path3, record2);
28038
+ return record2;
28039
+ }
28040
+ function readSessionRecord(path3) {
28041
+ let raw;
28042
+ try {
28043
+ raw = readFileSync7(path3, "utf8");
28044
+ } catch {
28045
+ return null;
28046
+ }
28047
+ let json2;
28048
+ try {
28049
+ json2 = JSON.parse(raw);
28050
+ } catch {
28051
+ return null;
28052
+ }
28053
+ const parsed = SessionRecordSchema.safeParse(json2);
28054
+ return parsed.success ? parsed.data : null;
28055
+ }
28056
+ function sessionLiveness(record2, deps = {}) {
28057
+ if (!(deps.aliveFn ?? isPidAlive)(record2.pid))
28058
+ return "dead";
28059
+ const startTime = (deps.startTimeFn ?? readPidStartTime)(record2.pid);
28060
+ if (startTime === null)
28061
+ return "unknown";
28062
+ return startTime === record2.pid_start_time ? "live" : "dead";
28063
+ }
28064
+ function transcriptMtime(path3) {
28065
+ try {
28066
+ return statSync(path3).mtimeMs;
28067
+ } catch {
28068
+ return null;
28069
+ }
28070
+ }
28071
+ function listSessions(workspaceSlug, deps = {}) {
28072
+ let names;
28073
+ try {
28074
+ names = readdirSync3(sessionsDir(workspaceSlug));
28075
+ } catch {
28076
+ return { live: [], reaped: 0, unknown: 0, ended: 0 };
28077
+ }
28078
+ const live = [];
28079
+ let reaped = 0;
28080
+ let unknown2 = 0;
28081
+ let ended = 0;
28082
+ for (const name of names) {
28083
+ if (!name.endsWith(".json"))
28084
+ continue;
28085
+ const path3 = join6(sessionsDir(workspaceSlug), name);
28086
+ const record2 = readSessionRecord(path3);
28087
+ if (!record2) {
28088
+ continue;
28089
+ }
28090
+ const verdict = sessionLiveness(record2, deps);
28091
+ if (verdict === "unknown") {
28092
+ unknown2 += 1;
28093
+ continue;
28094
+ }
28095
+ if (verdict === "dead") {
28096
+ const current = readSessionRecord(path3);
28097
+ const sameRecord = current !== null && current.pid === record2.pid && current.pid_start_time === record2.pid_start_time;
28098
+ if (sameRecord) {
28099
+ try {
28100
+ unlinkSync6(path3);
28101
+ reaped += 1;
28102
+ } catch {}
28103
+ }
28104
+ continue;
28105
+ }
28106
+ if (record2.ended_at !== null) {
28107
+ ended += 1;
28108
+ continue;
28109
+ }
28110
+ const mtime = record2.transcript_path ? (deps.mtimeFn ?? transcriptMtime)(record2.transcript_path) : null;
28111
+ live.push({ record: record2, lastActivityMs: mtime });
28112
+ }
28113
+ live.sort((a2, b2) => (b2.lastActivityMs ?? -1) - (a2.lastActivityMs ?? -1));
28114
+ return { live, reaped, unknown: unknown2, ended };
28115
+ }
28116
+
28117
+ // src/lib/memory-gate/log.ts
28118
+ var MEMORY_GATE_DECISIONS = ["accept", "reject", "edit"];
28119
+ var Common = {
28120
+ version: exports_external.literal(1),
28121
+ at: exports_external.string().min(1),
28122
+ session_id: exports_external.string().min(1).nullable(),
28123
+ member: exports_external.string().min(1).nullable()
28124
+ };
28125
+ var DecisionRecordSchema = exports_external.object({
28126
+ ...Common,
28127
+ kind: exports_external.literal("decision"),
28128
+ proposed: exports_external.string().min(1),
28129
+ decision: exports_external.enum(MEMORY_GATE_DECISIONS),
28130
+ final: exports_external.string().min(1).nullable()
28131
+ });
28132
+ var NothingProposedRecordSchema = exports_external.object({
28133
+ ...Common,
28134
+ kind: exports_external.literal("nothing-proposed")
28135
+ });
28136
+ var MemoryGateRecordSchema = exports_external.discriminatedUnion("kind", [
28137
+ DecisionRecordSchema,
28138
+ NothingProposedRecordSchema
28139
+ ]);
28140
+ function memoryGateDir(workspaceSlug) {
28141
+ if (!isSafeSegment(workspaceSlug)) {
28142
+ throw new Error(`Refusing unsafe workspace slug: ${workspaceSlug}`);
28143
+ }
28144
+ return join7(plinthHome(), "_memory_gate", workspaceSlug);
28145
+ }
28146
+ function memoryGateLogPath(workspaceSlug) {
28147
+ return join7(memoryGateDir(workspaceSlug), "log.jsonl");
28148
+ }
28149
+ function buildDecisionRecord(input) {
28150
+ const final = input.decision === "accept" ? input.proposed : input.decision === "reject" ? null : input.final ?? null;
28151
+ return {
28152
+ version: 1,
28153
+ kind: "decision",
28154
+ at: (input.now ?? new Date).toISOString(),
28155
+ session_id: input.sessionId,
28156
+ member: input.member,
28157
+ proposed: input.proposed,
28158
+ decision: input.decision,
28159
+ final
28160
+ };
28161
+ }
28162
+ function buildNothingProposedRecord(input) {
28163
+ return {
28164
+ version: 1,
28165
+ kind: "nothing-proposed",
28166
+ at: (input.now ?? new Date).toISOString(),
28167
+ session_id: input.sessionId,
28168
+ member: input.member
28169
+ };
28170
+ }
28171
+ function endsMidLine(path3) {
28172
+ let size;
28173
+ try {
28174
+ size = statSync2(path3).size;
28175
+ } catch {
28176
+ return false;
28177
+ }
28178
+ if (size === 0)
28179
+ return false;
28180
+ const fd = openSync(path3, "r");
28181
+ try {
28182
+ const last = Buffer.alloc(1);
28183
+ readSync(fd, last, 0, 1, size - 1);
28184
+ return last[0] !== 10;
28185
+ } finally {
28186
+ closeSync(fd);
28187
+ }
28188
+ }
28189
+ function appendMemoryGateRecord(workspaceSlug, record2) {
28190
+ const checked = MemoryGateRecordSchema.parse(record2);
28191
+ const path3 = memoryGateLogPath(workspaceSlug);
28192
+ mkdirSync7(memoryGateDir(workspaceSlug), { recursive: true, mode: 448 });
28193
+ const prefix = endsMidLine(path3) ? `
28194
+ ` : "";
28195
+ appendFileSync(path3, `${prefix}${JSON.stringify(checked)}
28196
+ `, { mode: 384 });
28197
+ }
28198
+ function readMemoryGateLog(workspaceSlug) {
28199
+ let raw;
28200
+ try {
28201
+ raw = readFileSync8(memoryGateLogPath(workspaceSlug), "utf8");
28202
+ } catch {
28203
+ return { records: [], skipped: 0 };
28204
+ }
28205
+ const records = [];
28206
+ let skipped = 0;
28207
+ for (const line of raw.split(`
28208
+ `)) {
28209
+ if (line.trim().length === 0)
28210
+ continue;
28211
+ let json2;
28212
+ try {
28213
+ json2 = JSON.parse(line);
28214
+ } catch {
28215
+ skipped += 1;
28216
+ continue;
28217
+ }
28218
+ const parsed = MemoryGateRecordSchema.safeParse(json2);
28219
+ if (parsed.success)
28220
+ records.push(parsed.data);
28221
+ else
28222
+ skipped += 1;
28223
+ }
28224
+ return { records, skipped };
28225
+ }
28226
+
28227
+ // src/commands/memory-gate.ts
28228
+ init_output();
28229
+ function resolveTarget2(explicitSlug, explicitMember, loadConfigFn) {
28230
+ let config2;
28231
+ try {
28232
+ config2 = loadConfigFn();
28233
+ } catch {
28234
+ return { ok: false, message: "Config at ~/.plinth/config.json could not be read. Run `plinth login --workspace <slug>` first." };
28235
+ }
28236
+ if (!config2) {
28237
+ return { ok: false, message: "No workspace configured. Run `plinth login --workspace <slug>` first." };
28238
+ }
28239
+ const slug = explicitSlug ?? config2.active_workspace;
28240
+ const entry2 = config2.workspaces[slug];
28241
+ if (!entry2) {
28242
+ return {
28243
+ ok: false,
28244
+ message: explicitSlug === undefined ? "No workspace configured. Run `plinth login --workspace <slug>` first." : `Workspace "${slug}" not found. Run \`plinth login --workspace ${slug}\` first.`
28245
+ };
28246
+ }
28247
+ return { ok: true, slug, member: explicitMember ?? entry2.user_id ?? null };
28248
+ }
28249
+ function isDecision(value) {
28250
+ return MEMORY_GATE_DECISIONS.includes(value);
28251
+ }
28252
+ async function runMemoryGateLog(args, deps = {}) {
28253
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
28254
+ const env3 = deps.env ?? process.env;
28255
+ const now = (deps.now ?? (() => new Date))();
28256
+ const nothing = args.nothingProposed === true;
28257
+ const hasDecisionArgs = args.proposed !== undefined || args.decision !== undefined;
28258
+ if (nothing && hasDecisionArgs) {
28259
+ out.error("--nothing-proposed records a close that proposed nothing; it cannot be combined with --proposed/--decision.");
28260
+ return { exitCode: 1 };
28261
+ }
28262
+ if (!nothing && !hasDecisionArgs) {
28263
+ out.error("Nothing to log. Pass --proposed and --decision for one decision, or --nothing-proposed for a close that had nothing to bank.");
28264
+ return { exitCode: 1 };
28265
+ }
28266
+ const target = resolveTarget2(args.workspace, args.member, deps.loadConfigFn ?? loadConfig);
28267
+ if (!target.ok) {
28268
+ out.error(target.message);
28269
+ return { exitCode: 1 };
28270
+ }
28271
+ const sessionId = typeof env3.CLAUDE_CODE_SESSION_ID === "string" && env3.CLAUDE_CODE_SESSION_ID.length > 0 ? env3.CLAUDE_CODE_SESSION_ID : null;
28272
+ const append = deps.appendFn ?? appendMemoryGateRecord;
28273
+ if (nothing) {
28274
+ try {
28275
+ append(target.slug, buildNothingProposedRecord({ sessionId, member: target.member, now }));
28276
+ } catch (err) {
28277
+ out.error(`Could not write the memory-gate log: ${err.message}`);
28278
+ return { exitCode: 1 };
28279
+ }
28280
+ out.info("Logged: this close proposed nothing for shared memory.");
28281
+ return { exitCode: 0 };
28282
+ }
28283
+ const proposed = args.proposed?.trim() ?? "";
28284
+ if (proposed.length === 0) {
28285
+ out.error("--proposed must carry the line as it was put to the user.");
28286
+ return { exitCode: 1 };
28287
+ }
28288
+ const decisionRaw = args.decision?.trim() ?? "";
28289
+ if (!isDecision(decisionRaw)) {
28290
+ out.error(`--decision must be one of ${MEMORY_GATE_DECISIONS.join(", ")} (got ${decisionRaw === "" ? "nothing" : `"${decisionRaw}"`}).`);
28291
+ return { exitCode: 1 };
28292
+ }
28293
+ const finalArg = args.final?.trim() ?? "";
28294
+ if (decisionRaw === "edit" && finalArg.length === 0) {
28295
+ out.error("--decision edit needs --final: the user's rewritten line is the record.");
28296
+ return { exitCode: 1 };
28297
+ }
28298
+ if (decisionRaw !== "edit" && finalArg.length > 0) {
28299
+ out.error(`--final applies to --decision edit only (accept records the proposed line, reject records none).`);
28300
+ return { exitCode: 1 };
28301
+ }
28302
+ try {
28303
+ append(target.slug, buildDecisionRecord({
28304
+ proposed,
28305
+ decision: decisionRaw,
28306
+ final: finalArg.length > 0 ? finalArg : null,
28307
+ sessionId,
28308
+ member: target.member,
28309
+ now
28310
+ }));
28311
+ } catch (err) {
28312
+ out.error(`Could not write the memory-gate log: ${err.message}`);
28313
+ return { exitCode: 1 };
28314
+ }
28315
+ out.info(`Logged ${decisionRaw} for shared memory (${target.slug}).`);
28316
+ return { exitCode: 0 };
28317
+ }
28318
+ async function runMemoryGateShow(args, deps = {}) {
28319
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
28320
+ const target = resolveTarget2(args.workspace, args.member, deps.loadConfigFn ?? loadConfig);
28321
+ if (!target.ok) {
28322
+ out.error(target.message);
28323
+ return { exitCode: 1 };
28324
+ }
28325
+ const { records, skipped } = (deps.readFn ?? readMemoryGateLog)(target.slug);
28326
+ const limit = args.limit !== undefined && args.limit > 0 ? args.limit : undefined;
28327
+ const shown = limit === undefined ? records : records.slice(-limit);
28328
+ if (args.json) {
28329
+ out.info(JSON.stringify({
28330
+ workspace_slug: target.slug,
28331
+ path: memoryGateLogPath(target.slug),
28332
+ records: shown,
28333
+ withheld: records.length - shown.length,
28334
+ unreadable: skipped
28335
+ }, null, 2));
28336
+ return { exitCode: 0 };
28337
+ }
28338
+ if (shown.length === 0) {
28339
+ out.info(`No memory-gate records for ${target.slug}.`);
28340
+ }
28341
+ for (const record2 of shown) {
28342
+ if (record2.kind === "nothing-proposed") {
28343
+ out.info(`${record2.at} · nothing proposed`);
28344
+ continue;
28345
+ }
28346
+ const tail = record2.final === null ? "" : ` → ${record2.final}`;
28347
+ out.info(`${record2.at} · ${record2.decision} · ${record2.proposed}${tail}`);
28348
+ }
28349
+ if (records.length - shown.length > 0) {
28350
+ out.info(`${records.length - shown.length} earlier record(s) not shown (--limit).`);
28351
+ }
28352
+ if (skipped > 0) {
28353
+ out.info(`${skipped} line(s) could not be read and are not counted above.`);
28354
+ }
28355
+ return { exitCode: 0 };
28356
+ }
28357
+ var memoryGateCommand = defineCommand({
28358
+ meta: {
28359
+ name: "memory-gate",
28360
+ description: "Record what a /close ritual proposed for shared memory, and what the user decided"
28361
+ },
28362
+ subCommands: {
28363
+ log: defineCommand({
28364
+ meta: {
28365
+ name: "log",
28366
+ description: "Append one decision (or a nothing-proposed close) to the local memory-gate log. Machine-local: nothing under ~/.plinth syncs."
28367
+ },
28368
+ args: {
28369
+ workspace: { type: "string", description: "Workspace slug" },
28370
+ proposed: { type: "string", description: "The line as it was put to the user" },
28371
+ decision: { type: "string", description: `One of ${MEMORY_GATE_DECISIONS.join(", ")}` },
28372
+ final: { type: "string", description: "The user's rewritten line (--decision edit only)" },
28373
+ "nothing-proposed": {
28374
+ type: "boolean",
28375
+ description: "Record a close that proposed nothing for shared memory"
28376
+ },
28377
+ member: { type: "string", description: "Override the member id (defaults to this login's user)" },
28378
+ "no-color": { type: "boolean", description: "Disable colour" }
28379
+ },
28380
+ async run({ args }) {
28381
+ const result = await runMemoryGateLog({
28382
+ workspace: args.workspace,
28383
+ proposed: args.proposed,
28384
+ decision: args.decision,
28385
+ final: args.final,
28386
+ nothingProposed: args["nothing-proposed"],
28387
+ member: args.member
28388
+ }, { noColor: noColorFromArgs(args) });
28389
+ process.exitCode = result.exitCode;
28390
+ }
28391
+ }),
28392
+ show: defineCommand({
28393
+ meta: {
28394
+ name: "show",
28395
+ description: "Read the local memory-gate log back. Machine-local: nothing under ~/.plinth syncs."
28396
+ },
28397
+ args: {
28398
+ workspace: { type: "string", description: "Workspace slug" },
28399
+ json: { type: "boolean", description: "Machine-readable output" },
28400
+ limit: { type: "string", description: "Show only the most recent N records" },
28401
+ "no-color": { type: "boolean", description: "Disable colour" }
28402
+ },
28403
+ async run({ args }) {
28404
+ const rawLimit = args.limit;
28405
+ const parsed = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
28406
+ const result = await runMemoryGateShow({
28407
+ workspace: args.workspace,
28408
+ json: args.json,
28409
+ limit: parsed !== undefined && Number.isInteger(parsed) ? parsed : undefined
28410
+ }, { noColor: noColorFromArgs(args) });
28411
+ process.exitCode = result.exitCode;
28412
+ }
28413
+ })
28414
+ }
28415
+ });
28416
+
28417
+ // src/lib/context/artist-context.ts
28418
+ import { existsSync } from "node:fs";
28419
+
28420
+ // src/lib/context/command-paths.ts
28421
+ var WORKSPACE_COMMANDS_DIR = ".claude/commands/plinth";
28422
+ var WORKSPACE_COMMANDS_SUBFOLDER = WORKSPACE_COMMANDS_DIR.split("/").pop() ?? "";
28423
+ var START_COMMAND_REL = `${WORKSPACE_COMMANDS_DIR}/start.md`;
28424
+ var CLOSE_COMMAND_REL = `${WORKSPACE_COMMANDS_DIR}/close.md`;
28425
+ function invocationFor(relPath) {
28426
+ const parts = relPath.split("/");
28427
+ const name = (parts.pop() ?? "").replace(/\.md$/, "");
28428
+ const namespace = parts.pop() ?? "";
28429
+ return `/${namespace}:${name}`;
28430
+ }
28431
+ var START_COMMAND_INVOCATION = invocationFor(START_COMMAND_REL);
28432
+ var CLOSE_COMMAND_INVOCATION = invocationFor(CLOSE_COMMAND_REL);
28433
+
28434
+ // src/lib/context/entity-catalog.ts
28435
+ var ENTITY_CATALOG = [
28436
+ { label: "artists", blurb: "roster acts; one folder per artist under `artists/<slug>/`", file: true },
28437
+ { label: "projects", blurb: "campaigns, releases, and workstreams", file: true },
28438
+ { label: "tasks", blurb: "actionable to-dos, optionally tied to an artist or project", file: true },
28439
+ { label: "meetings + meeting_notes", blurb: "meeting records and their attached notes", file: true },
28440
+ { label: "documents", blurb: "notes, briefs, bios, transcripts, context docs (markdown bodies, not attachments)", file: true },
28441
+ {
28442
+ label: "workspace_files",
28443
+ 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",
28444
+ file: true
28445
+ },
28446
+ {
28447
+ label: "threads + thread_messages",
28448
+ 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",
28449
+ file: true
28450
+ },
28451
+ { label: "key_dates", blurb: "calendar milestones and release dates", file: false },
28452
+ { label: "comments", blurb: "comments anchored to any entity", file: false },
28453
+ { label: "tracks", blurb: "track and release metadata", file: false },
28454
+ { label: "social_posts", blurb: "planned and published social content", file: false },
28455
+ { label: "useful_links", blurb: "bookmarked links per artist or workspace", file: false },
28456
+ { label: "financial_entries", blurb: "income and cost ledger rows", file: false },
28457
+ { label: "commissions", blurb: "agent, sync, streaming, publishing & other statements", file: false }
28458
+ ];
28459
+ var MCP_INTROSPECTION_POINTER = "ask the `mcp__plinth__*` tools for exact fields & enum values (the " + "platform is the schema source of truth)";
28460
+ 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.";
28461
+ var SKILL_LOCATIONS = [
28462
+ {
28463
+ label: "workspace",
28464
+ 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"
28465
+ },
28466
+ { label: "personal", detail: "`~/.claude/commands/`; your own, available everywhere" }
28467
+ ];
28468
+ 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.";
28469
+
28470
+ // src/lib/context/generated-at.ts
28471
+ var GENERATED_AT_PLACEHOLDER = "{{plinth-generated-at}}";
28472
+ var GENERATED_AT_PREFIX = "Content built ";
28473
+ var STAMP_RE = /\d{4}-\d{2}-\d{2} \d{2}:\d{2} [+-]\d{2}:\d{2}/;
28474
+ function escapeRegExp(literal2) {
28475
+ return literal2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28476
+ }
28477
+ var STAMP_SLOT_RE = new RegExp(escapeRegExp(GENERATED_AT_PREFIX) + "(?:" + STAMP_RE.source + "|" + escapeRegExp(GENERATED_AT_PLACEHOLDER) + ")");
28478
+ function pad2(n2) {
28479
+ return String(n2).padStart(2, "0");
28480
+ }
28481
+ function formatGeneratedAt(d2) {
28482
+ const date5 = `${d2.getFullYear()}-${pad2(d2.getMonth() + 1)}-${pad2(d2.getDate())}`;
28483
+ const time3 = `${pad2(d2.getHours())}:${pad2(d2.getMinutes())}`;
28484
+ const offsetMinutes = -d2.getTimezoneOffset();
28485
+ const sign = offsetMinutes < 0 ? "-" : "+";
28486
+ const abs = Math.abs(offsetMinutes);
28487
+ return `${date5} ${time3} ${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
28488
+ }
28489
+ function renderGeneratedAt(content, at) {
28490
+ return content.replace(GENERATED_AT_PLACEHOLDER, formatGeneratedAt(at));
28491
+ }
28492
+ function normaliseGeneratedAt(content) {
28493
+ return content.replace(STAMP_SLOT_RE, GENERATED_AT_PREFIX + GENERATED_AT_PLACEHOLDER);
28494
+ }
28495
+
28496
+ // src/lib/context/markers.ts
28497
+ var USER_BLOCK_BEGIN = "<!-- BEGIN: user-customisable -->";
28498
+ var USER_BLOCK_END = "<!-- END -->";
28499
+ var USER_BLOCK_PLACEHOLDER = "_Add workspace-specific guidance here. This block is the only part of " + "the file Plinth preserves across regenerations._";
28500
+ function extractUserBlock(content) {
28501
+ const beginIdx = content.indexOf(USER_BLOCK_BEGIN);
28502
+ if (beginIdx === -1)
28503
+ return null;
28504
+ const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
28505
+ const endIdx = content.indexOf(USER_BLOCK_END, afterBegin);
28506
+ if (endIdx === -1)
28507
+ return null;
28508
+ return content.slice(afterBegin, endIdx);
28509
+ }
28510
+ function injectUserBlock(generated, preserved) {
28511
+ const beginIdx = generated.indexOf(USER_BLOCK_BEGIN);
28512
+ if (beginIdx === -1) {
28513
+ throw new Error("injectUserBlock: generated content has no BEGIN marker");
28514
+ }
28515
+ const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
28516
+ const endIdx = generated.indexOf(USER_BLOCK_END, afterBegin);
28517
+ if (endIdx === -1) {
28518
+ throw new Error("injectUserBlock: generated content has no END marker");
28519
+ }
28520
+ return generated.slice(0, afterBegin) + preserved + generated.slice(endIdx);
28521
+ }
28522
+
28523
+ // src/lib/context/artist-claude-md.ts
28524
+ var CONTEXT_VERSION_HEADER = "plinth-context-version";
28525
+ var TASK_DISPLAY_CAP = 10;
28526
+ var PRIORITY_RANK = {
28527
+ urgent: 0,
28528
+ high: 1,
28529
+ medium: 2,
28530
+ low: 3
28531
+ };
28532
+ function buildArtistClaudeMd(input) {
28533
+ const body = buildBody(input);
28534
+ const version2 = stableHash(body).slice(0, 12);
28535
+ return `<!-- ${CONTEXT_VERSION_HEADER}: ${version2} -->
28536
+ ${body}`;
28537
+ }
28538
+ function buildBody(input) {
28539
+ const { slug, name, status, uuid: uuid3, profile, projects, tasks } = input;
28540
+ const lines = [];
28541
+ 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. -->`);
28542
+ lines.push("");
28543
+ lines.push(`# ${name} · Plinth artist`);
28544
+ lines.push("");
28545
+ const statusPart = status ? `, status ${status}` : "";
28546
+ const bioPart = profile.bio ? ` ${profile.bio}` : "";
28547
+ lines.push(`${name} (artist \`${slug}\`${statusPart}).${bioPart}`);
28548
+ lines.push(`Working dir for ${name}-scoped AI sessions. ${SECURITY_NOTE}`);
28549
+ lines.push("");
28550
+ const links = buildLinks(profile);
28551
+ if (links) {
28552
+ lines.push(`## Links`);
28553
+ lines.push(links);
28554
+ lines.push("");
28555
+ }
28556
+ const sortedProjects = sortProjects(projects);
28557
+ const names = sortedProjects.map((p) => p.name).join(", ");
28558
+ lines.push(`## Active projects (${sortedProjects.length})${names ? `: ${names}` : ""}`);
28559
+ if (sortedProjects.length === 0) {
28560
+ lines.push(`_None active._`);
28561
+ } else {
28562
+ for (const p of sortedProjects) {
28563
+ const meta3 = [p.status, formatDateRange(p.startDate, p.endDate)].filter((s2) => s2 !== null && s2.length > 0).join(", ");
28564
+ lines.push(`- **${p.name}** (project \`${p.id}\`)${meta3 ? ` · ${meta3}` : ""}`);
28565
+ }
28566
+ }
28567
+ lines.push("");
28568
+ const sortedTasks = sortTasks(tasks);
28569
+ const shown = sortedTasks.slice(0, TASK_DISPLAY_CAP);
28570
+ lines.push(`## Open tasks (${shown.length}/${sortedTasks.length})`);
28571
+ if (sortedTasks.length === 0) {
28572
+ lines.push(`_None open._`);
28573
+ } else {
28574
+ for (const t2 of shown) {
28575
+ const due = t2.dueDate ? `due ${datePart(t2.dueDate)}` : "no due date";
28576
+ const pri = t2.priority ? ` (${t2.priority})` : "";
28577
+ lines.push(`- ${t2.name} · ${due}${pri}`);
28578
+ }
28579
+ const remaining = sortedTasks.length - shown.length;
28580
+ if (remaining > 0) {
28581
+ lines.push(`- _+${remaining} more via \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}_`);
28582
+ }
28583
+ }
28584
+ lines.push("");
28585
+ lines.push(`## More & companions`);
28586
+ 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`);
28587
+ lines.push(`- Closed/archived, meetings & full lists: \`mcp__plinth__list_projects\` / \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}`);
28588
+ lines.push(`- Schema: ${MCP_INTROSPECTION_POINTER}`);
28589
+ 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`);
28590
+ 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`);
28591
+ lines.push("");
28592
+ lines.push(USER_BLOCK_BEGIN);
28593
+ lines.push(USER_BLOCK_PLACEHOLDER);
28594
+ lines.push(USER_BLOCK_END);
28595
+ return lines.join(`
28596
+ `) + `
28597
+ `;
28598
+ }
28599
+ function artistIdHint(uuid3) {
27890
28600
  return uuid3 ? ` (artist_id \`${uuid3}\`)` : "";
27891
28601
  }
27892
28602
  function buildLinks(p) {
@@ -27950,7 +28660,7 @@ function datePart(value) {
27950
28660
 
27951
28661
  // src/lib/context/artist-data.ts
27952
28662
  var import_gray_matter5 = __toESM(require_gray_matter(), 1);
27953
- import { readFileSync as readFileSync6 } from "node:fs";
28663
+ import { readFileSync as readFileSync9 } from "node:fs";
27954
28664
  function artistUuidBySlug(state) {
27955
28665
  const map2 = new Map;
27956
28666
  for (const entry2 of Object.values(state.entries.artists ?? {})) {
@@ -28062,7 +28772,7 @@ function readEntityFrontmatter(workspaceSlug, entityType, slug) {
28062
28772
  }
28063
28773
  let raw;
28064
28774
  try {
28065
- raw = readFileSync6(path3, "utf8");
28775
+ raw = readFileSync9(path3, "utf8");
28066
28776
  } catch {
28067
28777
  return null;
28068
28778
  }
@@ -28090,6 +28800,7 @@ function readSchemaVersion(content) {
28090
28800
  // src/lib/context/regen.ts
28091
28801
  function decideRegen(input) {
28092
28802
  const { existing, currentVersion, generated, force } = input;
28803
+ const normalise = input.normalise ?? ((c3) => c3);
28093
28804
  if (existing === null || existing.trim() === "") {
28094
28805
  return { action: "create", content: generated };
28095
28806
  }
@@ -28103,7 +28814,7 @@ function decideRegen(input) {
28103
28814
  return { action: "backup-regen", content: generated, backup: existing };
28104
28815
  }
28105
28816
  const merged = injectUserBlock(generated, preserved);
28106
- if (merged === existing) {
28817
+ if (normalise(merged) === normalise(existing)) {
28107
28818
  return { action: "skip", content: null };
28108
28819
  }
28109
28820
  return { action: "regen", content: merged };
@@ -28111,18 +28822,18 @@ function decideRegen(input) {
28111
28822
 
28112
28823
  // src/lib/context/roster.ts
28113
28824
  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";
28825
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
28826
+ import { join as join8 } from "node:path";
28116
28827
  function readRoster(workspaceSlug) {
28117
28828
  let artistsDir;
28118
28829
  try {
28119
- artistsDir = join5(workspaceMirrorPath(workspaceSlug), "artists");
28830
+ artistsDir = join8(workspaceMirrorPath(workspaceSlug), "artists");
28120
28831
  } catch {
28121
28832
  return [];
28122
28833
  }
28123
28834
  let slugs;
28124
28835
  try {
28125
- slugs = readdirSync3(artistsDir, { withFileTypes: true }).filter((d2) => d2.isDirectory()).map((d2) => d2.name).sort();
28836
+ slugs = readdirSync4(artistsDir, { withFileTypes: true }).filter((d2) => d2.isDirectory()).map((d2) => d2.name).sort();
28126
28837
  } catch {
28127
28838
  return [];
28128
28839
  }
@@ -28130,7 +28841,7 @@ function readRoster(workspaceSlug) {
28130
28841
  for (const slug of slugs) {
28131
28842
  let raw;
28132
28843
  try {
28133
- raw = readFileSync7(join5(artistsDir, slug, "_artist.md"), "utf8");
28844
+ raw = readFileSync10(join8(artistsDir, slug, "_artist.md"), "utf8");
28134
28845
  } catch {
28135
28846
  continue;
28136
28847
  }
@@ -28147,9 +28858,9 @@ function readRoster(workspaceSlug) {
28147
28858
  }
28148
28859
 
28149
28860
  // 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";
28861
+ import { execFileSync as execFileSync2 } from "node:child_process";
28862
+ import { accessSync, constants, realpathSync, statSync as statSync3 } from "node:fs";
28863
+ import { basename as basename2 } from "node:path";
28153
28864
  var SETTINGS_REL = ".claude/settings.json";
28154
28865
  var MANAGED_KEY = "_plinth";
28155
28866
  var COMMENT_SENTINEL = 'Managed by plinth-cli. Entries tagged "_plinth" are REGENERATED on every sync:';
@@ -28157,8 +28868,32 @@ var MANAGED_COMMENT = [
28157
28868
  COMMENT_SENTINEL,
28158
28869
  "edits to them are overwritten without warning. Add your own hooks as separate",
28159
28870
  "entries in this file (they are preserved verbatim, including their matchers)",
28160
- "or in .claude/settings.local.json."
28871
+ "or in .claude/settings.local.json.",
28872
+ "The permissions.deny entries below are managed the same way. They stop an agent",
28873
+ "in this workspace sending, replying to or forwarding mail and writing a calendar;",
28874
+ "drafting and reading stay allowed. Your own allow list is untouched, and your own",
28875
+ "deny rules are kept and listed first. One exception, stated because it is",
28876
+ "visible: if you write a deny rule whose text is identical to one of ours, it is",
28877
+ "kept once, in our section, rather than twice. A deny always beats an allow,",
28878
+ "including your own."
28161
28879
  ];
28880
+ var MANAGED_DENY = [
28881
+ "mcp__claude_ai_Gmail__send_message",
28882
+ "mcp__claude_ai_Gmail__reply",
28883
+ "mcp__claude_ai_Gmail__forward",
28884
+ "mcp__claude_ai_Google_Calendar__create_event",
28885
+ "mcp__claude_ai_Google_Calendar__update_event",
28886
+ "mcp__claude_ai_Google_Calendar__delete_event",
28887
+ "mcp__claude_ai_Google_Calendar__respond_to_event",
28888
+ "mcp__*__send_message",
28889
+ "mcp__*__reply",
28890
+ "mcp__*__forward",
28891
+ "mcp__*__create_event",
28892
+ "mcp__*__update_event",
28893
+ "mcp__*__delete_event",
28894
+ "mcp__*__respond_to_event"
28895
+ ];
28896
+ var RETIRED_DENY = [];
28162
28897
  var BIN_SENTINEL = "__PLINTH_BIN__";
28163
28898
  var ALL_SENTINEL = "__PLINTH_ALL__";
28164
28899
  var RESOLVE_SCRIPT = `printf '%s
@@ -28189,7 +28924,7 @@ function resolveOverride(override, deps) {
28189
28924
  }
28190
28925
  function resolveUncached(deps) {
28191
28926
  const shell = deps.shell ?? process.env.SHELL ?? "/bin/zsh";
28192
- const exec = deps.exec ?? ((sh, args) => execFileSync(sh, args, {
28927
+ const exec = deps.exec ?? ((sh, args) => execFileSync2(sh, args, {
28193
28928
  encoding: "utf8",
28194
28929
  timeout: 5000,
28195
28930
  stdio: ["ignore", "pipe", "ignore"]
@@ -28252,13 +28987,13 @@ function probe(run, usable) {
28252
28987
  function isPlinthBinary(candidate, usable) {
28253
28988
  if (!candidate.startsWith("/"))
28254
28989
  return false;
28255
- if (basename(candidate) !== "plinth")
28990
+ if (basename2(candidate) !== "plinth")
28256
28991
  return false;
28257
28992
  return usable(candidate);
28258
28993
  }
28259
28994
  function defaultIsExecutableFile(path3) {
28260
28995
  try {
28261
- if (!statSync(path3).isFile())
28996
+ if (!statSync3(path3).isFile())
28262
28997
  return false;
28263
28998
  accessSync(path3, constants.X_OK);
28264
28999
  return true;
@@ -28269,10 +29004,6 @@ function defaultIsExecutableFile(path3) {
28269
29004
  function shellQuote(value) {
28270
29005
  return `'${value.replace(/'/g, `'\\''`)}'`;
28271
29006
  }
28272
- var VOICE_GATE_MATCHERS = [
28273
- "Bash",
28274
- "mcp__.*[Gg]mail.*__(create|update)_draft"
28275
- ];
28276
29007
  function managedEntry(id, invocation, subcommand, workspaceSlug) {
28277
29008
  return {
28278
29009
  type: "command",
@@ -28284,24 +29015,41 @@ function buildManagedBlocks(invocation, workspaceSlug) {
28284
29015
  return {
28285
29016
  SessionStart: [
28286
29017
  {
28287
- hooks: [managedEntry("grounding", invocation, "grounding", workspaceSlug)]
29018
+ hooks: [
29019
+ managedEntry("grounding", invocation, "grounding", workspaceSlug),
29020
+ managedEntry("sessions", invocation, "session register", workspaceSlug)
29021
+ ]
28288
29022
  }
28289
- ],
28290
- PreToolUse: VOICE_GATE_MATCHERS.map((matcher) => ({
28291
- matcher,
28292
- hooks: [
28293
- managedEntry("voice-gate", invocation, "voice-gate --hook", workspaceSlug)
28294
- ]
28295
- }))
29023
+ ]
28296
29024
  };
28297
29025
  }
29026
+ function mergePermissions(existing) {
29027
+ if (existing !== undefined && existing !== null && !isEntry(existing)) {
29028
+ throw new Error("`permissions` is not a JSON object");
29029
+ }
29030
+ const base2 = existing ? { ...existing } : {};
29031
+ const currentDeny = base2.deny;
29032
+ if (currentDeny !== undefined && currentDeny !== null && !Array.isArray(currentDeny)) {
29033
+ throw new Error("`permissions.deny` is not an array");
29034
+ }
29035
+ const ours = new Set([...MANAGED_DENY, ...RETIRED_DENY]);
29036
+ const kept = (Array.isArray(currentDeny) ? currentDeny : []).filter((rule) => typeof rule !== "string" || !ours.has(rule));
29037
+ base2.deny = [...kept, ...MANAGED_DENY];
29038
+ return base2;
29039
+ }
28298
29040
  var LEGACY_GROUNDING = /^'[^']*'\s+'[^']*'\s+grounding(\s|$)/;
29041
+ var OWNED_EVENTS = {
29042
+ grounding: "SessionStart",
29043
+ sessions: "SessionStart",
29044
+ "voice-gate": "PreToolUse"
29045
+ };
28299
29046
  function isManaged(entry2, event) {
28300
29047
  if (!isEntry(entry2))
28301
29048
  return false;
28302
29049
  const marker = entry2[MANAGED_KEY];
28303
- if (marker === "grounding" || marker === "voice-gate")
29050
+ if (typeof marker === "string" && OWNED_EVENTS[marker] === event) {
28304
29051
  return true;
29052
+ }
28305
29053
  if (event === "SessionStart" && typeof entry2.command === "string") {
28306
29054
  return LEGACY_GROUNDING.test(entry2.command);
28307
29055
  }
@@ -28317,17 +29065,18 @@ function mergeSettings(existing, managed) {
28317
29065
  const base2 = existing ? { ...existing } : {};
28318
29066
  const existingHooks = typeof base2.hooks === "object" && base2.hooks !== null && !Array.isArray(base2.hooks) ? base2.hooks : {};
28319
29067
  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) => ({
29068
+ const events = new Set([...Object.keys(existingHooks), ...Object.keys(managed)]);
29069
+ for (const event of events) {
29070
+ const kept = asArray(existingHooks[event]).filter(isEntry).map((block) => ({
28325
29071
  ...block,
28326
29072
  hooks: asArray(block.hooks).filter((entry2) => isEntry(entry2) && !isManaged(entry2, event))
28327
29073
  })).filter((block) => block.hooks.length > 0);
28328
- hooks[event] = [...kept, ...managedBlocks];
29074
+ const merged = [...kept, ...managed[event] ?? []];
29075
+ if (merged.length > 0)
29076
+ hooks[event] = merged;
28329
29077
  }
28330
29078
  base2.hooks = hooks;
29079
+ base2.permissions = mergePermissions(base2.permissions);
28331
29080
  const current = base2.$comment;
28332
29081
  const isOurs = Array.isArray(current) && current[0] === COMMENT_SENTINEL;
28333
29082
  if (current === undefined || isOurs) {
@@ -28355,7 +29104,7 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
28355
29104
  parsed = raw;
28356
29105
  } catch (err) {
28357
29106
  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.`);
29107
+ 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
29108
  return "skipped";
28360
29109
  }
28361
29110
  }
@@ -28364,16 +29113,16 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
28364
29113
  generated = buildSettingsJson(resolved.invocation, workspaceSlug, parsed);
28365
29114
  } catch (err) {
28366
29115
  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.`);
29116
+ 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
29117
  return "skipped";
28369
29118
  }
28370
29119
  if (existing === generated)
28371
29120
  return "unchanged";
28372
29121
  writeContextFile(workspaceSlug, rel, generated);
28373
29122
  if (existing === null) {
28374
- out.info(`Generated Plinth session hooks (${rel}).`);
29123
+ out.info(`Generated Plinth session settings (${rel}).`);
28375
29124
  } else {
28376
- out.info(`Refreshed the Plinth session hooks in ${rel} (your entries kept).`);
29125
+ out.info(`Refreshed the Plinth session settings in ${rel} (your entries kept).`);
28377
29126
  }
28378
29127
  return "written";
28379
29128
  }
@@ -28390,7 +29139,7 @@ function reportResolution(out, resolved) {
28390
29139
  }
28391
29140
  }
28392
29141
  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`.");
29142
+ 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
29143
  }
28395
29144
 
28396
29145
  // src/lib/context/artist-context.ts
@@ -28441,14 +29190,20 @@ function ensurePerArtistContext(deps) {
28441
29190
  function regenerateClaudeMd(workspaceSlug, artistSlug, generated, force, out, now) {
28442
29191
  const claudeRel = `artists/${artistSlug}/${ARTIST_CLAUDE_MD}`;
28443
29192
  const existing = readContextFile(workspaceSlug, claudeRel);
28444
- const decision = decideRegen({ existing, currentVersion: "", generated, force });
29193
+ const decision = decideRegen({
29194
+ existing,
29195
+ currentVersion: "",
29196
+ generated,
29197
+ force,
29198
+ normalise: normaliseGeneratedAt
29199
+ });
28445
29200
  if (decision.action === "backup-regen" && decision.backup !== undefined) {
28446
29201
  const bakRel = `${claudeRel}.bak-${backupStamp(now())}`;
28447
29202
  writeContextFile(workspaceSlug, bakRel, decision.backup);
28448
29203
  out.warn(`artists/${artistSlug}/${ARTIST_CLAUDE_MD} had no recognisable BEGIN/END ` + `markers; backed your copy up to ${bakRel} before regenerating.`);
28449
29204
  }
28450
29205
  if (decision.content !== null) {
28451
- writeContextFile(workspaceSlug, claudeRel, decision.content);
29206
+ writeContextFile(workspaceSlug, claudeRel, renderGeneratedAt(decision.content, now()));
28452
29207
  }
28453
29208
  return decision.action === "create";
28454
29209
  }
@@ -28461,6 +29216,7 @@ function dailyLogStub(name) {
28461
29216
  return `# ${name} - Daily log
28462
29217
 
28463
29218
  ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
29219
+ ` + `<!-- Durable facts do not belong here - they go in ./memory.md, which syncs to this artist's Plinth record. -->
28464
29220
  `;
28465
29221
  }
28466
29222
  function backupStamp(d2) {
@@ -28470,6 +29226,199 @@ function backupStamp(d2) {
28470
29226
  // src/lib/context/generate.ts
28471
29227
  import { existsSync as existsSync2 } from "node:fs";
28472
29228
 
29229
+ // src/lib/context/skill-commands.ts
29230
+ 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._";
29231
+ 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. -->";
29232
+ function withUserBlock(lines) {
29233
+ return [...lines, "", USER_BLOCK_BEGIN, COMMAND_USER_BLOCK_PLACEHOLDER, USER_BLOCK_END].join(`
29234
+ `) + `
29235
+ `;
29236
+ }
29237
+ function buildStartCommandMd() {
29238
+ return withUserBlock([
29239
+ "---",
29240
+ "description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, gives a three-line brief, then asks what you want to work on.",
29241
+ "---",
29242
+ "",
29243
+ OWNERSHIP_NOTE,
29244
+ "",
29245
+ "# Start",
29246
+ "",
29247
+ "Get oriented in this Plinth workspace in a few seconds, then load context for whatever the user names.",
29248
+ "",
29249
+ "**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.",
29250
+ "",
29251
+ "## Step 1: use the two memory indexes you were already given",
29252
+ "",
29253
+ "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.",
29254
+ "",
29255
+ "**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.",
29256
+ "",
29257
+ "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.",
29258
+ "",
29259
+ "## Step 2: what has landed",
29260
+ "",
29261
+ "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.",
29262
+ "",
29263
+ "**That one call is 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 or in the notifications, it does not belong in this brief.",
29264
+ "",
29265
+ "## Step 3: brief, in three lines",
29266
+ "",
29267
+ "Then stop reading and report. **Three lines, and not a summary of what you read:**",
29268
+ "",
29269
+ "1. Today, and anything with a clock on it that the injected indexes or the notifications **have already named**. Do not go looking for more.",
29270
+ "2. The unread count, and the one or two that actually matter.",
29271
+ "3. Anything blocked or waiting on someone.",
29272
+ "",
29273
+ '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.',
29274
+ "",
29275
+ "## Step 4: offer the sweep, do not run it",
29276
+ "",
29277
+ `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.`,
29278
+ "",
29279
+ "**The budget is the one call in Step 2, 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.",
29280
+ "",
29281
+ "## Loading area context",
29282
+ "",
29283
+ "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.",
29284
+ "",
29285
+ "If nothing resolves, say so plainly and ask. Do not invent an entity.",
29286
+ "",
29287
+ "## Write discipline",
29288
+ "",
29289
+ "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.",
29290
+ "",
29291
+ `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.`
29292
+ ]);
29293
+ }
29294
+ function buildCloseCommandMd() {
29295
+ return withUserBlock([
29296
+ "---",
29297
+ "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.",
29298
+ "---",
29299
+ "",
29300
+ OWNERSHIP_NOTE,
29301
+ "",
29302
+ "# Close",
29303
+ "",
29304
+ "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.",
29305
+ "",
29306
+ "Trust the date the session hook injected. Do not re-derive it.",
29307
+ "",
29308
+ "## Step 1: reconstruct the session",
29309
+ "",
29310
+ "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.",
29311
+ "",
29312
+ "## Step 2: verify each write landed",
29313
+ "",
29314
+ "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.**",
29315
+ "",
29316
+ "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.",
29317
+ "",
29318
+ "## Step 3: route the follow-ups",
29319
+ "",
29320
+ "Each loose end goes to the surface that holds it, and nowhere else:",
29321
+ "",
29322
+ "- **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.",
29323
+ "- **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.",
29324
+ "- **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.",
29325
+ "",
29326
+ "## Step 4: memory, in two piles",
29327
+ "",
29328
+ "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.",
29329
+ "",
29330
+ "### Private: `user-memory.md` at the mirror root. Write it directly.",
29331
+ "",
29332
+ "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.",
29333
+ "",
29334
+ "### Shared: `workspace-memory.md` at the mirror root. Propose. Never write unasked.",
29335
+ "",
29336
+ "Draft the candidate lines first: durable facts about this workspace that would change how a future session behaves, not this session's detail.",
29337
+ "",
29338
+ "**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.",
29339
+ "",
29340
+ "Then put the candidates to the user with **`AskUserQuestion`**:",
29341
+ "",
29342
+ "- **One multi-select question, one option per proposed line.** Selected means bank it. Unselected means rejected.",
29343
+ '- **The free-text "Other" choice is how the user rewrites a line in their own words.** Their wording wins over yours, always.',
29344
+ "- 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.**",
29345
+ "",
29346
+ "**An accepted line that contradicts one already in the file supersedes it in place.** Edit the wrong line. Never leave two lines disagreeing.",
29347
+ "",
29348
+ "## Step 5: log every decision, then write the file",
29349
+ "",
29350
+ "**In that order.** The log is the record of what was decided; the file is the consequence.",
29351
+ "",
29352
+ "```sh",
29353
+ 'plinth memory-gate log --proposed "<the line as you put it>" --decision accept',
29354
+ 'plinth memory-gate log --proposed "<the line as you put it>" --decision reject',
29355
+ 'plinth memory-gate log --proposed "<the line as you put it>" --decision edit --final "<their rewrite>"',
29356
+ "```",
29357
+ "",
29358
+ "A close that proposed nothing still logs, because *gated and proposed nothing* has to stay distinguishable from *never gated*:",
29359
+ "",
29360
+ "```sh",
29361
+ "plinth memory-gate log --nothing-proposed",
29362
+ "```",
29363
+ "",
29364
+ "`plinth memory-gate show [--json]` reads the log back.",
29365
+ "",
29366
+ "**Four rules. Break one and a decision is lost:**",
29367
+ "",
29368
+ "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.",
29369
+ "2. `--final` is **refused** on `accept` and `reject`. An accept records the proposed line; a reject records none.",
29370
+ "3. `--nothing-proposed` **cannot** be combined with `--proposed` or `--decision`.",
29371
+ '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.',
29372
+ "",
29373
+ "**Never write this log by hand.** The CLI owns the format so it can still be read back in a year.",
29374
+ "",
29375
+ "Only once the decisions are logged, write the accepted lines into `workspace-memory.md`.",
29376
+ "",
29377
+ "## Step 6: summarise, then end the session",
29378
+ "",
29379
+ "**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.",
29380
+ "",
29381
+ "Then, as the **very last step**:",
29382
+ "",
29383
+ "```sh",
29384
+ 'plinth session end --summary "<one line>"',
29385
+ "```",
29386
+ "",
29387
+ "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."
29388
+ ]);
29389
+ }
29390
+ function ensureWorkspaceCommands(deps) {
29391
+ const { workspaceSlug, force, out, now } = deps;
29392
+ const created = [];
29393
+ const refreshed = [];
29394
+ const targets = [
29395
+ [START_COMMAND_REL, buildStartCommandMd()],
29396
+ [CLOSE_COMMAND_REL, buildCloseCommandMd()]
29397
+ ];
29398
+ for (const [rel, generated] of targets) {
29399
+ const existing = readContextFile(workspaceSlug, rel);
29400
+ const decision = decideRegen({ existing, currentVersion: "", generated, force });
29401
+ if (decision.action === "backup-regen" && decision.backup !== undefined) {
29402
+ const bakRel = `${rel}.bak-${backupStamp2(now())}`;
29403
+ writeContextFile(workspaceSlug, bakRel, decision.backup);
29404
+ out.warn(`${rel} had no recognisable BEGIN/END markers; backed your copy up to ` + `${bakRel} before regenerating.`);
29405
+ }
29406
+ if (decision.content !== null) {
29407
+ writeContextFile(workspaceSlug, rel, decision.content);
29408
+ (decision.action === "create" ? created : refreshed).push(invocationFor(rel));
29409
+ }
29410
+ }
29411
+ if (created.length > 0) {
29412
+ out.info(`Generated ${created.join(" and ")}.`);
29413
+ }
29414
+ if (refreshed.length > 0) {
29415
+ out.info(`Refreshed ${refreshed.join(" and ")} (your edits preserved).`);
29416
+ }
29417
+ }
29418
+ function backupStamp2(d2) {
29419
+ return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
29420
+ }
29421
+
28473
29422
  // src/lib/context/workspace-claude-md.ts
28474
29423
  function rosterBlock(roster, activeProjectCountBySlug) {
28475
29424
  if (roster.length === 0)
@@ -28495,7 +29444,7 @@ function buildWorkspaceClaudeMd(input) {
28495
29444
  const skillCount = SKILL_LOCATIONS.length;
28496
29445
  const lines = [
28497
29446
  `<!-- plinth-schema-version: ${schemaVersion} -->`,
28498
- `<!-- Auto-generated by Plinth. Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
29447
+ `<!-- 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
29448
  ``,
28500
29449
  `# ${workspaceName} · Plinth workspace`,
28501
29450
  ``,
@@ -28503,17 +29452,20 @@ function buildWorkspaceClaudeMd(input) {
28503
29452
  ``,
28504
29453
  `> **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
29454
  ``,
29455
+ `> **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.`,
29456
+ ``,
28506
29457
  `## Folder map`,
28507
29458
  `- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread`,
28508
29459
  `- \`projects/\` \`tasks/\` \`documents/\` \`meetings/\` workspace-root flat entity files (one \`.md\` per entity, NOT nested under \`artists/\`)`,
28509
29460
  `- \`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`,
29461
+ `- \`${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`,
29462
+ `- \`.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
29463
  ``,
28512
29464
  `## Where key context lives: look here before hunting`,
28513
29465
  `- 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
29466
  `- **Contracts, executed agreements & any PDF** → \`files/\` (search here first; \`documents/\` holds markdown bodies, not attachments)`,
28515
29467
  `- 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\``,
29468
+ `- 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
29469
  `- ${MCP_AVAILABILITY_POINTER}`,
28518
29470
  ``,
28519
29471
  `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
@@ -28571,19 +29523,21 @@ function ensureWorkspaceContext(deps) {
28571
29523
  existing,
28572
29524
  currentVersion: "",
28573
29525
  generated,
28574
- force
29526
+ force,
29527
+ normalise: normaliseGeneratedAt
28575
29528
  });
28576
29529
  if (decision.action === "backup-regen" && decision.backup !== undefined) {
28577
- const bakRel = `${CLAUDE_MD}.bak-${backupStamp2(now())}`;
29530
+ const bakRel = `${CLAUDE_MD}.bak-${backupStamp3(now())}`;
28578
29531
  writeContextFile(workspaceSlug, bakRel, decision.backup);
28579
29532
  out.warn(`${CLAUDE_MD} had no recognisable BEGIN/END markers; backed your copy ` + `up to ${bakRel} before regenerating.`);
28580
29533
  }
28581
29534
  if (decision.content !== null) {
28582
- writeContextFile(workspaceSlug, CLAUDE_MD, decision.content);
29535
+ writeContextFile(workspaceSlug, CLAUDE_MD, renderGeneratedAt(decision.content, now()));
28583
29536
  }
28584
29537
  if (!existsSync2(contextFilePath(workspaceSlug, CLAUDE_LOCAL_MD))) {
28585
29538
  writeContextFile(workspaceSlug, CLAUDE_LOCAL_MD, CLAUDE_LOCAL_STUB);
28586
29539
  }
29540
+ ensureWorkspaceCommands({ workspaceSlug, force, out, now });
28587
29541
  ensureGitignore(workspaceSlug);
28588
29542
  const resolved = resolvePlinthBinary();
28589
29543
  reportResolution(out, resolved);
@@ -28615,7 +29569,7 @@ function ensureGitignore(workspaceSlug) {
28615
29569
  `;
28616
29570
  writeContextFile(workspaceSlug, GITIGNORE, `${existing}${lead}${block}`);
28617
29571
  }
28618
- function backupStamp2(d2) {
29572
+ function backupStamp3(d2) {
28619
29573
  return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
28620
29574
  }
28621
29575
 
@@ -28824,12 +29778,12 @@ function classify(facts) {
28824
29778
  }
28825
29779
 
28826
29780
  // src/lib/review-tier/collect.ts
28827
- import { execFileSync as execFileSync2 } from "node:child_process";
29781
+ import { execFileSync as execFileSync3 } from "node:child_process";
28828
29782
  import fs2 from "node:fs";
28829
29783
  import path3 from "node:path";
28830
29784
  function execGit(args, cwd) {
28831
29785
  try {
28832
- const stdout2 = execFileSync2("git", args, {
29786
+ const stdout2 = execFileSync3("git", args, {
28833
29787
  cwd,
28834
29788
  encoding: "utf8",
28835
29789
  maxBuffer: 64 * 1024 * 1024,
@@ -29047,16 +30001,280 @@ var reviewTierCommand = defineCommand({
29047
30001
  }
29048
30002
  });
29049
30003
 
30004
+ // src/commands/session.ts
30005
+ init_output();
30006
+ async function defaultReadStdin() {
30007
+ if (process.stdin.isTTY)
30008
+ return "";
30009
+ const chunks = [];
30010
+ for await (const chunk of process.stdin) {
30011
+ chunks.push(Buffer.from(chunk));
30012
+ }
30013
+ return Buffer.concat(chunks).toString("utf8");
30014
+ }
30015
+ function asString(value) {
30016
+ return typeof value === "string" && value.length > 0 ? value : null;
30017
+ }
30018
+ function resolveSlug(explicit) {
30019
+ let config2;
30020
+ try {
30021
+ config2 = loadConfig();
30022
+ } catch {
30023
+ return { ok: false, reason: "unconfigured" };
30024
+ }
30025
+ if (!config2)
30026
+ return { ok: false, reason: "unconfigured" };
30027
+ const slug = explicit ?? config2.active_workspace;
30028
+ if (!config2.workspaces[slug]) {
30029
+ return explicit === undefined ? { ok: false, reason: "unconfigured" } : { ok: false, reason: "unknown-workspace", slug };
30030
+ }
30031
+ return { ok: true, slug };
30032
+ }
30033
+ async function runSessionRegister(args, deps = {}) {
30034
+ const env3 = deps.env ?? process.env;
30035
+ const raw = await (deps.readStdin ?? defaultReadStdin)().catch(() => "");
30036
+ let payload = {};
30037
+ if (raw.trim().length > 0) {
30038
+ try {
30039
+ const parsed = JSON.parse(raw);
30040
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
30041
+ payload = parsed;
30042
+ }
30043
+ } catch {}
30044
+ }
30045
+ const sessionId = asString(payload.session_id) ?? asString(env3.CLAUDE_CODE_SESSION_ID);
30046
+ const cwd = asString(payload.cwd) ?? (deps.cwd ?? process.cwd)();
30047
+ const transcriptPath = asString(payload.transcript_path);
30048
+ const tabId = asString(env3.PLINTH_DOCK_TAB_ID);
30049
+ const pidRaw = asString(env3.CLAUDE_PID);
30050
+ const pid = pidRaw !== null ? Number.parseInt(pidRaw, 10) : Number.NaN;
30051
+ if (sessionId === null || !isSafeSegment(sessionId))
30052
+ return { exitCode: 0 };
30053
+ if (!Number.isInteger(pid) || pid <= 0)
30054
+ return { exitCode: 0 };
30055
+ const resolved = resolveSlug(args.workspace);
30056
+ if (!resolved.ok)
30057
+ return { exitCode: 0 };
30058
+ try {
30059
+ registerSession({
30060
+ sessionId,
30061
+ pid,
30062
+ cwd,
30063
+ workspaceSlug: resolved.slug,
30064
+ transcriptPath,
30065
+ tabId,
30066
+ now: (deps.now ?? (() => new Date))(),
30067
+ startTimeFn: deps.startTimeFn
30068
+ });
30069
+ } catch {}
30070
+ return { exitCode: 0 };
30071
+ }
30072
+ function buildSessionListJson(workspaceSlug, live, reaped, unchecked, selfPid, selfSessionId, ended = 0) {
30073
+ return {
30074
+ workspace_slug: workspaceSlug,
30075
+ self_pid: selfPid,
30076
+ self_session_id: selfSessionId,
30077
+ sessions: live.map((s2) => ({
30078
+ session_id: s2.record.session_id,
30079
+ pid: s2.record.pid,
30080
+ cwd: s2.record.cwd,
30081
+ started_at: s2.record.started_at,
30082
+ last_activity: s2.lastActivityMs === null ? null : new Date(s2.lastActivityMs).toISOString(),
30083
+ is_self: s2.record.session_id === selfSessionId || selfPid !== null && s2.record.pid === selfPid,
30084
+ tab_id: s2.record.tab_id
30085
+ })),
30086
+ reaped,
30087
+ unchecked,
30088
+ ended,
30089
+ scope: "machine-local"
30090
+ };
30091
+ }
30092
+ async function runSessionList(args, deps = {}) {
30093
+ const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
30094
+ const env3 = deps.env ?? process.env;
30095
+ const resolved = resolveSlug(args.workspace);
30096
+ if (!resolved.ok) {
30097
+ 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.");
30098
+ return { exitCode: 1 };
30099
+ }
30100
+ const slug = resolved.slug;
30101
+ const { live, reaped, unknown: unknown2, ended } = listSessions(slug, deps);
30102
+ const selfPidRaw = asString(env3.CLAUDE_PID);
30103
+ const selfPid = selfPidRaw !== null ? Number.parseInt(selfPidRaw, 10) : null;
30104
+ const selfSessionId = asString(env3.CLAUDE_CODE_SESSION_ID);
30105
+ if (args.json) {
30106
+ out.info(JSON.stringify(buildSessionListJson(slug, live, reaped, unknown2, Number.isInteger(selfPid) ? selfPid : null, selfSessionId, ended), null, 2));
30107
+ return { exitCode: 0 };
30108
+ }
30109
+ const now = (deps.now ?? (() => new Date))();
30110
+ for (const line of renderSessionLines(live, now, selfPid, selfSessionId)) {
30111
+ out.info(line);
30112
+ }
30113
+ for (const line of sessionCaveats(live.length, unknown2, ended))
30114
+ out.info(line);
30115
+ return { exitCode: 0 };
30116
+ }
30117
+ var DOCK_CLOSED_ROUTE = "/closed";
30118
+ var DOCK_SIGNAL_TIMEOUT_MS = 1000;
30119
+ var SUMMARY_MAX_CHARS = 2000;
30120
+ function readDockSignalEnv(env3) {
30121
+ const port = asString(env3.PLINTH_DOCK_SIGNAL_PORT);
30122
+ const token = asString(env3.PLINTH_DOCK_SIGNAL_TOKEN);
30123
+ const tabId = asString(env3.PLINTH_DOCK_TAB_ID);
30124
+ if (port === null || token === null || tabId === null)
30125
+ return null;
30126
+ if (!/^[0-9]{1,5}$/.test(port))
30127
+ return null;
30128
+ return { port, token, tabId };
30129
+ }
30130
+ function normaliseSummary(raw) {
30131
+ if (raw === undefined)
30132
+ return null;
30133
+ const folded = raw.replace(/\s+/g, " ").trim();
30134
+ if (folded.length === 0)
30135
+ return null;
30136
+ return folded.length > SUMMARY_MAX_CHARS ? folded.slice(0, SUMMARY_MAX_CHARS) : folded;
30137
+ }
30138
+ var defaultFetch = (url2, init2) => fetch(url2, init2);
30139
+ async function runSessionEnd(args, deps = {}) {
30140
+ const env3 = deps.env ?? process.env;
30141
+ const now = (deps.now ?? (() => new Date))();
30142
+ const sessionId = asString(env3.CLAUDE_CODE_SESSION_ID);
30143
+ if (sessionId !== null && isSafeSegment(sessionId)) {
30144
+ const resolved = resolveSlug(args.workspace);
30145
+ if (resolved.ok) {
30146
+ try {
30147
+ endSession(resolved.slug, sessionId, now);
30148
+ } catch {}
30149
+ }
30150
+ }
30151
+ const dock = readDockSignalEnv(env3);
30152
+ if (dock === null)
30153
+ return { exitCode: 0 };
30154
+ const body = JSON.stringify({ summary: normaliseSummary(args.summary) });
30155
+ try {
30156
+ await (deps.fetchFn ?? defaultFetch)(`http://127.0.0.1:${dock.port}${DOCK_CLOSED_ROUTE}`, {
30157
+ method: "POST",
30158
+ headers: {
30159
+ "content-type": "application/json",
30160
+ "x-plinth-token": dock.token,
30161
+ "x-plinth-tab": dock.tabId
30162
+ },
30163
+ body,
30164
+ signal: AbortSignal.timeout(DOCK_SIGNAL_TIMEOUT_MS)
30165
+ });
30166
+ } catch {}
30167
+ return { exitCode: 0 };
30168
+ }
30169
+ function renderSessionLines(live, now, selfPid, selfSessionId, formatRelative = defaultRelative) {
30170
+ if (live.length === 0)
30171
+ return ["No Claude sessions registered on this machine."];
30172
+ return live.map((s2) => {
30173
+ const isSelf = s2.record.session_id === selfSessionId || selfPid !== null && s2.record.pid === selfPid;
30174
+ const activity = s2.lastActivityMs === null ? "activity unknown" : `active ${formatRelative(now.getTime() - s2.lastActivityMs)}`;
30175
+ const tag = isSelf ? " (this session)" : "";
30176
+ const tab = s2.record.tab_id === null ? "" : ` · tab ${s2.record.tab_id}`;
30177
+ return `${s2.record.session_id.slice(0, 8)} · pid ${s2.record.pid}${tab} · ${activity} · ${s2.record.cwd}${tag}`;
30178
+ });
30179
+ }
30180
+ function sessionCaveats(liveCount, unknown2, ended = 0) {
30181
+ const lines = [];
30182
+ if (liveCount > 1) {
30183
+ lines.push("Visibility only - nothing here prevents a conflicting write.");
30184
+ }
30185
+ if (ended > 0) {
30186
+ 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.`);
30187
+ }
30188
+ if (unknown2 > 0) {
30189
+ lines.push(`${unknown2} record${unknown2 === 1 ? "" : "s"} could not be checked and ${unknown2 === 1 ? "is" : "are"} not listed.`);
30190
+ }
30191
+ lines.push("Machine-local - a session on another machine is not counted.");
30192
+ return lines;
30193
+ }
30194
+ function defaultRelative(deltaMs) {
30195
+ const s2 = Math.floor(Math.max(0, deltaMs) / 1000);
30196
+ if (s2 < 60)
30197
+ return `${s2}s ago`;
30198
+ const m2 = Math.floor(s2 / 60);
30199
+ if (m2 < 60)
30200
+ return `${m2}m ago`;
30201
+ const h2 = Math.floor(m2 / 60);
30202
+ if (h2 < 24)
30203
+ return `${h2}h ago`;
30204
+ return `${Math.floor(h2 / 24)}d ago`;
30205
+ }
30206
+ var sessionCommand = defineCommand({
30207
+ meta: {
30208
+ name: "session",
30209
+ description: "Concurrent Claude session visibility for this workspace"
30210
+ },
30211
+ subCommands: {
30212
+ register: defineCommand({
30213
+ meta: {
30214
+ name: "register",
30215
+ description: "Record this Claude session (SessionStart hook; fail-quiet)"
30216
+ },
30217
+ args: {
30218
+ workspace: { type: "string", description: "Workspace slug" }
30219
+ },
30220
+ async run({ args }) {
30221
+ const result = await runSessionRegister({
30222
+ workspace: args.workspace
30223
+ });
30224
+ process.exitCode = result.exitCode;
30225
+ }
30226
+ }),
30227
+ list: defineCommand({
30228
+ meta: {
30229
+ name: "list",
30230
+ description: "List live Claude sessions writing this workspace"
30231
+ },
30232
+ args: {
30233
+ workspace: { type: "string", description: "Workspace slug" },
30234
+ json: { type: "boolean", description: "Machine-readable output" },
30235
+ "no-color": { type: "boolean", description: "Disable colour" }
30236
+ },
30237
+ async run({ args }) {
30238
+ const result = await runSessionList({
30239
+ workspace: args.workspace,
30240
+ json: args.json
30241
+ }, { noColor: noColorFromArgs(args) });
30242
+ process.exitCode = result.exitCode;
30243
+ }
30244
+ }),
30245
+ end: defineCommand({
30246
+ meta: {
30247
+ name: "end",
30248
+ description: "Mark this Claude session ended and, inside the desktop dock, tell the dock (last step of /close; fail-quiet)"
30249
+ },
30250
+ args: {
30251
+ workspace: { type: "string", description: "Workspace slug" },
30252
+ summary: {
30253
+ type: "string",
30254
+ description: "One-line summary of the session, forwarded to the dock"
30255
+ }
30256
+ },
30257
+ async run({ args }) {
30258
+ const result = await runSessionEnd({
30259
+ workspace: args.workspace,
30260
+ summary: args.summary
30261
+ });
30262
+ process.exitCode = result.exitCode;
30263
+ }
30264
+ })
30265
+ }
30266
+ });
30267
+
29050
30268
  // src/commands/start.ts
29051
30269
  var import_gray_matter10 = __toESM(require_gray_matter(), 1);
29052
- import { readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
30270
+ import { readFileSync as readFileSync20, statSync as statSync12 } from "node:fs";
29053
30271
 
29054
30272
  // 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";
30273
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
30274
+ import { dirname as dirname6, join as join9 } from "node:path";
29057
30275
  var DEFAULT_DAEMON_LOG_MAX_BYTES = 5000000;
29058
30276
  function daemonLogPath() {
29059
- return join6(plinthHome(), "daemon.log");
30277
+ return join9(plinthHome(), "daemon.log");
29060
30278
  }
29061
30279
  function daemonLogMaxBytes() {
29062
30280
  const raw = process.env.PLINTH_DAEMON_LOG_MAX_BYTES;
@@ -29078,10 +30296,10 @@ class DaemonLog {
29078
30296
  this.now = opts.now ?? (() => new Date);
29079
30297
  this.maxBytes = opts.maxBytes ?? daemonLogMaxBytes();
29080
30298
  try {
29081
- mkdirSync5(dirname4(this.path), { recursive: true });
30299
+ mkdirSync8(dirname6(this.path), { recursive: true });
29082
30300
  } catch {}
29083
30301
  try {
29084
- this.bytesWritten = statSync2(this.path).size;
30302
+ this.bytesWritten = statSync4(this.path).size;
29085
30303
  } catch {
29086
30304
  this.bytesWritten = 0;
29087
30305
  }
@@ -29094,7 +30312,7 @@ class DaemonLog {
29094
30312
  const bytes = Buffer.byteLength(line);
29095
30313
  try {
29096
30314
  this.maybeRotate(bytes);
29097
- appendFileSync(this.path, line);
30315
+ appendFileSync2(this.path, line);
29098
30316
  this.bytesWritten += bytes;
29099
30317
  } catch {
29100
30318
  try {
@@ -29108,97 +30326,16 @@ class DaemonLog {
29108
30326
  }
29109
30327
  rotate() {
29110
30328
  try {
29111
- renameSync5(this.path, `${this.path}.1`);
30329
+ renameSync7(this.path, `${this.path}.1`);
29112
30330
  } catch {}
29113
30331
  this.bytesWritten = 0;
29114
30332
  }
29115
30333
  }
29116
30334
 
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
30335
  // src/lib/destructive-gate.ts
29199
30336
  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";
30337
+ import { readFileSync as readFileSync11, statSync as statSync5 } from "node:fs";
30338
+ import { basename as basename3 } from "node:path";
29202
30339
  var GUTTING_RATIO_DEFAULT = 0.5;
29203
30340
  var GUTTING_MIN_PRIOR_BYTES = 200;
29204
30341
  function envFloat(name, def) {
@@ -29241,10 +30378,10 @@ function classifyDestructive(event, entityType, state, deps = {}) {
29241
30378
  absolutePath: event.absolutePath
29242
30379
  };
29243
30380
  }
29244
- const readFile = deps.readFile ?? ((p) => readFileSync9(p, "utf8"));
30381
+ const readFile = deps.readFile ?? ((p) => readFileSync11(p, "utf8"));
29245
30382
  const statInode = deps.statInode ?? ((p) => {
29246
30383
  try {
29247
- return statSync3(p).ino;
30384
+ return statSync5(p).ino;
29248
30385
  } catch {
29249
30386
  return;
29250
30387
  }
@@ -29263,7 +30400,7 @@ function classifyDestructive(event, entityType, state, deps = {}) {
29263
30400
  return null;
29264
30401
  }
29265
30402
  const liveInode = statInode(event.absolutePath);
29266
- const slug = basename2(event.absolutePath, ".md");
30403
+ const slug = basename3(event.absolutePath, ".md");
29267
30404
  const entry2 = (liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined) ?? getEntryBySlug(state, entityType, slug);
29268
30405
  if (!entry2)
29269
30406
  return null;
@@ -29368,40 +30505,40 @@ class DestructiveGate {
29368
30505
  }
29369
30506
 
29370
30507
  // src/lib/resolve-pending.ts
29371
- import { readFileSync as readFileSync13 } from "node:fs";
30508
+ import { readFileSync as readFileSync15 } from "node:fs";
29372
30509
 
29373
30510
  // 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";
30511
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync7 } from "node:fs";
30512
+ import { dirname as dirname7, join as join10 } from "node:path";
29376
30513
  function conflictsRoot(workspaceSlug) {
29377
- return join8(plinthHome(), "_conflicts", workspaceSlug);
30514
+ return join10(plinthHome(), "_conflicts", workspaceSlug);
29378
30515
  }
29379
30516
  function conflictFilePath(args) {
29380
30517
  const { workspaceSlug, entityType, entitySlug, timestamp } = args;
29381
30518
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
29382
- return join8(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
30519
+ return join10(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
29383
30520
  }
29384
30521
  function stashConflict(args) {
29385
30522
  const target = conflictFilePath(args);
29386
- mkdirSync7(dirname6(target), { recursive: true, mode: 448 });
29387
- writeFileSync6(target, args.contents, { mode: 384 });
30523
+ mkdirSync9(dirname7(target), { recursive: true, mode: 448 });
30524
+ writeFileSync7(target, args.contents, { mode: 384 });
29388
30525
  return target;
29389
30526
  }
29390
30527
  function fileConflictPath(args) {
29391
30528
  const { workspaceSlug, relPath, timestamp } = args;
29392
30529
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
29393
30530
  const safe = relPath.replace(/[/\\]/g, "__");
29394
- return join8(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
30531
+ return join10(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
29395
30532
  }
29396
30533
  function stashFileConflict(args) {
29397
30534
  const target = fileConflictPath(args);
29398
- mkdirSync7(dirname6(target), { recursive: true, mode: 448 });
29399
- writeFileSync6(target, args.bytes, { mode: 384 });
30535
+ mkdirSync9(dirname7(target), { recursive: true, mode: 448 });
30536
+ writeFileSync7(target, args.bytes, { mode: 384 });
29400
30537
  return target;
29401
30538
  }
29402
30539
 
29403
30540
  // src/lib/files/push.ts
29404
- import { readFileSync as readFileSync10, statSync as statSync4 } from "node:fs";
30541
+ import { readFileSync as readFileSync12, statSync as statSync6 } from "node:fs";
29405
30542
 
29406
30543
  // src/lib/log-tokens.ts
29407
30544
  function categorizeError(err) {
@@ -29561,6 +30698,7 @@ function clearFileRejected(state, relPath) {
29561
30698
  // src/lib/files/api.ts
29562
30699
  var MAX_WORKSPACE_FILE_BYTES = 4000000;
29563
30700
  var HASH_RE = /^[0-9a-f]{64}$/;
30701
+ var TextQualitySchema = exports_external.enum(["ok", "partial", "garbled"]);
29564
30702
  var FileLiveSchema = exports_external.object({
29565
30703
  id: exports_external.string().uuid(),
29566
30704
  path: exports_external.string().min(1),
@@ -29571,8 +30709,25 @@ var FileLiveSchema = exports_external.object({
29571
30709
  project_id: exports_external.string().uuid().nullable(),
29572
30710
  updated_at: exports_external.string().datetime({ offset: true }),
29573
30711
  deleted_at: exports_external.null(),
29574
- last_edited_by: exports_external.string().uuid().nullable()
29575
- });
30712
+ last_edited_by: exports_external.string().uuid().nullable(),
30713
+ content_quality: TextQualitySchema.nullable().optional().default(null).catch(null),
30714
+ has_extracted_text: exports_external.boolean().optional().catch(undefined),
30715
+ has_stored_bytes: exports_external.boolean().optional().catch(undefined)
30716
+ });
30717
+ function storedBytesSignal(row) {
30718
+ if (row.has_stored_bytes === true)
30719
+ return "present";
30720
+ if (row.has_stored_bytes === false)
30721
+ return "absent";
30722
+ return "unknown";
30723
+ }
30724
+ function extractedTextSignal(row) {
30725
+ if (row.has_extracted_text === true)
30726
+ return "present";
30727
+ if (row.has_extracted_text === false)
30728
+ return "absent";
30729
+ return "unknown";
30730
+ }
29576
30731
  var FileTombstoneSchema = exports_external.object({
29577
30732
  id: exports_external.string().uuid(),
29578
30733
  path: exports_external.string().min(1),
@@ -29585,7 +30740,18 @@ var FilesPageSchema = exports_external.object({
29585
30740
  next_cursor: exports_external.string().nullable()
29586
30741
  });
29587
30742
  var FileWithDownloadSchema = FileLiveSchema.extend({
29588
- download_url: exports_external.string().url()
30743
+ download_url: exports_external.string().url().nullable()
30744
+ });
30745
+ var FileTextSchema = exports_external.object({
30746
+ id: exports_external.string().uuid(),
30747
+ path: exports_external.string().min(1),
30748
+ mime_type: exports_external.string(),
30749
+ updated_at: exports_external.string().datetime({ offset: true }),
30750
+ content_quality: TextQualitySchema.nullable(),
30751
+ source_uri: exports_external.string().nullable(),
30752
+ text: exports_external.string().nullable(),
30753
+ chars: exports_external.number().int().nonnegative(),
30754
+ withheld: exports_external.enum(["bytes_are_text", "no_text"]).nullable()
29589
30755
  });
29590
30756
  var PreconditionSchema = exports_external.object({
29591
30757
  code: exports_external.literal("precondition_failed"),
@@ -29654,6 +30820,34 @@ async function getFile(args) {
29654
30820
  }
29655
30821
  return { ok: true, row: parsed.data };
29656
30822
  }
30823
+ async function getFileText(args) {
30824
+ const { baseUrl, pat, id, fetchImpl = fetch } = args;
30825
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}/text`;
30826
+ let res;
30827
+ try {
30828
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
30829
+ } catch (err) {
30830
+ const msg = err instanceof Error ? err.message : "network error";
30831
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
30832
+ }
30833
+ if (res.status === 404)
30834
+ return { ok: false, code: 404 };
30835
+ if (res.status === 401) {
30836
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
30837
+ }
30838
+ if (res.status === 429) {
30839
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
30840
+ }
30841
+ if (!res.ok) {
30842
+ throw new ApiError(res.status, `get file text failed with HTTP ${res.status}`);
30843
+ }
30844
+ const body = await res.json();
30845
+ const parsed = FileTextSchema.safeParse(body);
30846
+ if (!parsed.success) {
30847
+ throw new ApiError(res.status, `get file text response did not match the expected shape: ${parsed.error.message}`);
30848
+ }
30849
+ return { ok: true, row: parsed.data };
30850
+ }
29657
30851
  async function downloadBytes(downloadUrl, fetchImpl = fetch) {
29658
30852
  let res;
29659
30853
  try {
@@ -29845,13 +31039,13 @@ function dropFiles(state, relPath) {
29845
31039
  }
29846
31040
  function safeInode(absolutePath) {
29847
31041
  try {
29848
- return statSync4(absolutePath).ino;
31042
+ return statSync6(absolutePath).ino;
29849
31043
  } catch {
29850
31044
  return;
29851
31045
  }
29852
31046
  }
29853
31047
  async function restoreFileFromCloud(args) {
29854
- const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor } = args;
31048
+ const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor, output } = args;
29855
31049
  const got = await getFile({ baseUrl, pat, id, fetchImpl });
29856
31050
  const abs = filesSubtreePath(workspaceSlug, relPath);
29857
31051
  if (!got.ok) {
@@ -29859,12 +31053,17 @@ async function restoreFileFromCloud(args) {
29859
31053
  deleteFilesFile(workspaceSlug, relPath);
29860
31054
  return dropFiles(state, relPath);
29861
31055
  }
31056
+ if (got.row.download_url === null) {
31057
+ 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.`);
31058
+ return state;
31059
+ }
29862
31060
  const bytes = await downloadBytes(got.row.download_url, fetchImpl);
29863
31061
  suppressor?.registerWrite(abs, {
29864
31062
  bodyHash: got.row.content_hash,
29865
31063
  frontmatterHash: ""
29866
31064
  });
29867
31065
  writeFilesBytes(workspaceSlug, relPath, bytes);
31066
+ deleteExtractedText(workspaceSlug, relPath);
29868
31067
  return persistFiles(state, {
29869
31068
  id: got.row.id,
29870
31069
  path: relPath,
@@ -29893,7 +31092,7 @@ async function pushFilesUpsert(args) {
29893
31092
  } else {
29894
31093
  let st;
29895
31094
  try {
29896
- st = statSync4(absolutePath);
31095
+ st = statSync6(absolutePath);
29897
31096
  } catch (err) {
29898
31097
  if (err?.code === "ENOENT") {
29899
31098
  return pushFilesDelete({ workspaceSlug, baseUrl, pat, relPath, state, deps });
@@ -29928,7 +31127,7 @@ async function pushFilesUpsert(args) {
29928
31127
  state: next
29929
31128
  };
29930
31129
  }
29931
- bytes = readFileSync10(absolutePath);
31130
+ bytes = readFileSync12(absolutePath);
29932
31131
  hash2 = sha256Bytes(bytes);
29933
31132
  inode = st.ino;
29934
31133
  }
@@ -29999,7 +31198,8 @@ async function pushFilesUpsert(args) {
29999
31198
  relPath,
30000
31199
  state,
30001
31200
  fetchImpl,
30002
- suppressor
31201
+ suppressor,
31202
+ output
30003
31203
  });
30004
31204
  }
30005
31205
  return { outcome: { kind: "conflict", relPath, stashPath }, state: nextState };
@@ -30036,7 +31236,8 @@ async function pushFilesDelete(args) {
30036
31236
  relPath,
30037
31237
  state,
30038
31238
  fetchImpl,
30039
- suppressor
31239
+ suppressor,
31240
+ output
30040
31241
  });
30041
31242
  return { outcome: { kind: "conflict", relPath, stashPath: "" }, state: nextState };
30042
31243
  }
@@ -30063,12 +31264,12 @@ class PerIdMutex {
30063
31264
 
30064
31265
  // src/lib/push.ts
30065
31266
  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";
31267
+ import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
31268
+ import { basename as basename4, relative as relative4 } from "node:path";
30068
31269
 
30069
31270
  // src/lib/pull-write.ts
30070
31271
  var import_gray_matter8 = __toESM(require_gray_matter(), 1);
30071
- import { readFileSync as readFileSync11 } from "node:fs";
31272
+ import { readFileSync as readFileSync13 } from "node:fs";
30072
31273
  function computeMirrorHashes(strategy, content) {
30073
31274
  const parsed = import_gray_matter8.default(content);
30074
31275
  const fm = parsed.data ?? {};
@@ -30090,7 +31291,7 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
30090
31291
  const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
30091
31292
  let existing = null;
30092
31293
  try {
30093
- existing = readFileSync11(target, "utf8");
31294
+ existing = readFileSync13(target, "utf8");
30094
31295
  } catch (err) {
30095
31296
  if (err?.code !== "ENOENT")
30096
31297
  throw err;
@@ -30161,7 +31362,7 @@ function omitKeys(obj, keys) {
30161
31362
  }
30162
31363
  function safeInode2(absolutePath) {
30163
31364
  try {
30164
- return statSync5(absolutePath).ino;
31365
+ return statSync7(absolutePath).ino;
30165
31366
  } catch {
30166
31367
  return;
30167
31368
  }
@@ -30181,7 +31382,7 @@ function dropEntry(state, entityType, id) {
30181
31382
  function stampStateFromMirror(args) {
30182
31383
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
30183
31384
  const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
30184
- const mirrorRaw = readFileSync12(mirrorPath, "utf8");
31385
+ const mirrorRaw = readFileSync14(mirrorPath, "utf8");
30185
31386
  const mirrorParsed = import_gray_matter9.default(mirrorRaw);
30186
31387
  const mirrorFm = mirrorParsed.data ?? {};
30187
31388
  const mirrorLifted = splitUserFrontmatter(strategy, mirrorFm);
@@ -30260,7 +31461,7 @@ async function pushFile(args) {
30260
31461
  }
30261
31462
  let raw;
30262
31463
  try {
30263
- raw = readFileSync12(absolutePath, "utf8");
31464
+ raw = readFileSync14(absolutePath, "utf8");
30264
31465
  } catch (err) {
30265
31466
  if (err?.code === "ENOENT") {
30266
31467
  return runPushDelete({
@@ -30294,7 +31495,7 @@ async function pushFile(args) {
30294
31495
  const rel = relative4(workspaceMirrorPath(workspaceSlug), absolutePath);
30295
31496
  parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
30296
31497
  } catch {}
30297
- const slugFromPath = parsedSlug ?? basename3(absolutePath, ".md");
31498
+ const slugFromPath = parsedSlug ?? basename4(absolutePath, ".md");
30298
31499
  const slug = slugFromFm ?? slugFromPath;
30299
31500
  if (!slug) {
30300
31501
  return {
@@ -30377,9 +31578,9 @@ async function reconcileContextDocConflict(args) {
30377
31578
  let fileSlug;
30378
31579
  try {
30379
31580
  const rel = relative4(workspaceMirrorPath(workspaceSlug), absolutePath);
30380
- fileSlug = strategy.parseMirrorPath(rel)?.slug ?? basename3(absolutePath, ".md");
31581
+ fileSlug = strategy.parseMirrorPath(rel)?.slug ?? basename4(absolutePath, ".md");
30381
31582
  } catch {
30382
- fileSlug = basename3(absolutePath, ".md");
31583
+ fileSlug = basename4(absolutePath, ".md");
30383
31584
  }
30384
31585
  if (fileSlug !== existingSlug) {
30385
31586
  const stashPath = stashConflict({
@@ -31006,7 +32207,7 @@ async function runPushDelete(args) {
31006
32207
  const rel = relative4(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
31007
32208
  parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
31008
32209
  } catch {}
31009
- const lookupSlug = parsedSlug ?? basename3(args.absolutePath, ".md");
32210
+ const lookupSlug = parsedSlug ?? basename4(args.absolutePath, ".md");
31010
32211
  const entry2 = getEntryBySlug(args.state, strategy.type, lookupSlug);
31011
32212
  if (entry2) {
31012
32213
  id = entry2.id;
@@ -31101,9 +32302,9 @@ async function pushRename(args) {
31101
32302
  let toSlug;
31102
32303
  try {
31103
32304
  const rel = relative4(workspaceMirrorPath(workspaceSlug), toPath);
31104
- toSlug = strategy.parseMirrorPath(rel)?.slug ?? basename3(toPath, ".md");
32305
+ toSlug = strategy.parseMirrorPath(rel)?.slug ?? basename4(toPath, ".md");
31105
32306
  } catch {
31106
- toSlug = basename3(toPath, ".md");
32307
+ toSlug = basename4(toPath, ".md");
31107
32308
  }
31108
32309
  return mutex.run(id, async () => {
31109
32310
  const live = getEntry(state, entityType, id);
@@ -31133,7 +32334,7 @@ async function pushRename(args) {
31133
32334
  };
31134
32335
  if (!updated.body_hash || !updated.frontmatter_hash) {
31135
32336
  try {
31136
- const mirrorRaw = readFileSync12(toPath, "utf8");
32337
+ const mirrorRaw = readFileSync14(toPath, "utf8");
31137
32338
  const mirrorParsed = import_gray_matter9.default(mirrorRaw);
31138
32339
  const renameFm = mirrorParsed.data ?? {};
31139
32340
  const renameLifted = splitUserFrontmatter(strategy, renameFm);
@@ -31168,7 +32369,7 @@ async function pushRename(args) {
31168
32369
  if (patchResult.reason === "slug_conflict") {
31169
32370
  let contents2 = "";
31170
32371
  try {
31171
- contents2 = readFileSync12(toPath, "utf8");
32372
+ contents2 = readFileSync14(toPath, "utf8");
31172
32373
  } catch {}
31173
32374
  const stashPath2 = stashConflict({
31174
32375
  workspaceSlug,
@@ -31208,7 +32409,7 @@ async function pushRename(args) {
31208
32409
  }
31209
32410
  let contents = "";
31210
32411
  try {
31211
- contents = readFileSync12(toPath, "utf8");
32412
+ contents = readFileSync14(toPath, "utf8");
31212
32413
  } catch {}
31213
32414
  const stashPath = stashConflict({
31214
32415
  workspaceSlug,
@@ -31329,7 +32530,7 @@ async function discardBatchOps(batch, deps) {
31329
32530
  const path5 = opPath(deps.workspaceSlug, op);
31330
32531
  let local = null;
31331
32532
  try {
31332
- local = readFileSync13(path5, "utf8");
32533
+ local = readFileSync15(path5, "utf8");
31333
32534
  } catch {
31334
32535
  local = null;
31335
32536
  }
@@ -31363,7 +32564,7 @@ async function discardFileOp(op, deps) {
31363
32564
  const abs = filesSubtreePath(deps.workspaceSlug, op.path);
31364
32565
  let local = null;
31365
32566
  try {
31366
- local = readFileSync13(abs);
32567
+ local = readFileSync15(abs);
31367
32568
  } catch {
31368
32569
  local = null;
31369
32570
  }
@@ -31382,7 +32583,8 @@ async function discardFileOp(op, deps) {
31382
32583
  id: op.id,
31383
32584
  relPath: op.path,
31384
32585
  state: deps.getState(),
31385
- fetchImpl: deps.fetchImpl
32586
+ fetchImpl: deps.fetchImpl,
32587
+ output: deps.output
31386
32588
  });
31387
32589
  deps.setState(next);
31388
32590
  }
@@ -31544,10 +32746,10 @@ class FileDestructiveGate {
31544
32746
  }
31545
32747
 
31546
32748
  // src/lib/files/reconcile.ts
31547
- import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
32749
+ import { readFileSync as readFileSync16, statSync as statSync9 } from "node:fs";
31548
32750
 
31549
32751
  // src/lib/unsynced.ts
31550
- import { join as join9, relative as relative6 } from "node:path";
32752
+ import { join as join11, relative as relative6 } from "node:path";
31551
32753
  var RECORDABLE_REASONS = new Set([
31552
32754
  "rate_limit",
31553
32755
  "server",
@@ -31610,7 +32812,7 @@ async function drainUnsyncedOps(args) {
31610
32812
  attempted += 1;
31611
32813
  try {
31612
32814
  if (op.target === "entity") {
31613
- const absolutePath = join9(workspaceMirrorPath(workspaceSlug), op.path);
32815
+ const absolutePath = join11(workspaceMirrorPath(workspaceSlug), op.path);
31614
32816
  const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
31615
32817
  if (!classified) {
31616
32818
  clear("entity", op.path);
@@ -31856,10 +33058,10 @@ class ReaddirpStream extends Readable {
31856
33058
  }
31857
33059
  async _formatEntry(dirent, path5) {
31858
33060
  let entry2;
31859
- const basename4 = this._isDirent ? dirent.name : dirent;
33061
+ const basename5 = this._isDirent ? dirent.name : dirent;
31860
33062
  try {
31861
- const fullPath = presolve(pjoin(path5, basename4));
31862
- entry2 = { path: prelative(this._root, fullPath), fullPath, basename: basename4 };
33063
+ const fullPath = presolve(pjoin(path5, basename5));
33064
+ entry2 = { path: prelative(this._root, fullPath), fullPath, basename: basename5 };
31863
33065
  entry2[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
31864
33066
  } catch (err) {
31865
33067
  this._onError(err);
@@ -32386,9 +33588,9 @@ class NodeFsHandler {
32386
33588
  _watchWithNodeFs(path5, listener) {
32387
33589
  const opts = this.fsw.options;
32388
33590
  const directory = sp.dirname(path5);
32389
- const basename5 = sp.basename(path5);
33591
+ const basename6 = sp.basename(path5);
32390
33592
  const parent = this.fsw._getWatchedDir(directory);
32391
- parent.add(basename5);
33593
+ parent.add(basename6);
32392
33594
  const absolutePath = sp.resolve(path5);
32393
33595
  const options2 = {
32394
33596
  persistent: opts.persistent
@@ -32398,7 +33600,7 @@ class NodeFsHandler {
32398
33600
  let closer;
32399
33601
  if (opts.usePolling) {
32400
33602
  const enableBin = opts.interval !== opts.binaryInterval;
32401
- options2.interval = enableBin && isBinaryPath(basename5) ? opts.binaryInterval : opts.interval;
33603
+ options2.interval = enableBin && isBinaryPath(basename6) ? opts.binaryInterval : opts.interval;
32402
33604
  closer = setFsWatchFileListener(path5, absolutePath, options2, {
32403
33605
  listener,
32404
33606
  rawEmitter: this.fsw._emitRaw
@@ -32416,11 +33618,11 @@ class NodeFsHandler {
32416
33618
  if (this.fsw.closed) {
32417
33619
  return;
32418
33620
  }
32419
- const dirname8 = sp.dirname(file2);
32420
- const basename5 = sp.basename(file2);
32421
- const parent = this.fsw._getWatchedDir(dirname8);
33621
+ const dirname9 = sp.dirname(file2);
33622
+ const basename6 = sp.basename(file2);
33623
+ const parent = this.fsw._getWatchedDir(dirname9);
32422
33624
  let prevStats = stats;
32423
- if (parent.has(basename5))
33625
+ if (parent.has(basename6))
32424
33626
  return;
32425
33627
  const listener = async (path5, newStats) => {
32426
33628
  if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file2, 5))
@@ -32445,9 +33647,9 @@ class NodeFsHandler {
32445
33647
  prevStats = newStats2;
32446
33648
  }
32447
33649
  } catch (error52) {
32448
- this.fsw._remove(dirname8, basename5);
33650
+ this.fsw._remove(dirname9, basename6);
32449
33651
  }
32450
- } else if (parent.has(basename5)) {
33652
+ } else if (parent.has(basename6)) {
32451
33653
  const at = newStats.atimeMs;
32452
33654
  const mt = newStats.mtimeMs;
32453
33655
  if (!at || at <= mt || mt !== prevStats.mtimeMs) {
@@ -33301,7 +34503,7 @@ function watch(paths, options2 = {}) {
33301
34503
  var chokidar_default = { watch, FSWatcher };
33302
34504
 
33303
34505
  // src/lib/watcher.ts
33304
- import { statSync as statSync6 } from "node:fs";
34506
+ import { statSync as statSync8 } from "node:fs";
33305
34507
  import { sep as sep5 } from "node:path";
33306
34508
 
33307
34509
  // src/lib/rename-classifier.ts
@@ -33517,7 +34719,7 @@ function startWatcher(opts, handler) {
33517
34719
  pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
33518
34720
  classifyPath: opts.classifyPath,
33519
34721
  getStateEntry: opts.getStateEntry,
33520
- statSync: opts.statSync ?? ((p) => ({ ino: statSync6(p).ino }))
34722
+ statSync: opts.statSync ?? ((p) => ({ ino: statSync8(p).ino }))
33521
34723
  });
33522
34724
  const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
33523
34725
  const watcher = factory(workspaceRoot, {
@@ -33552,7 +34754,7 @@ function readBlobForPush(args) {
33552
34754
  const { absolutePath, relPath, output } = args;
33553
34755
  let st;
33554
34756
  try {
33555
- st = statSync7(absolutePath);
34757
+ st = statSync9(absolutePath);
33556
34758
  } catch (err) {
33557
34759
  if (err?.code === "ENOENT")
33558
34760
  return { kind: "gone" };
@@ -33571,7 +34773,7 @@ function readBlobForPush(args) {
33571
34773
  }
33572
34774
  let bytes;
33573
34775
  try {
33574
- bytes = readFileSync14(absolutePath);
34776
+ bytes = readFileSync16(absolutePath);
33575
34777
  } catch (err) {
33576
34778
  if (err?.code === "ENOENT")
33577
34779
  return { kind: "gone" };
@@ -34230,7 +35432,7 @@ class PullSuppressor {
34230
35432
  }
34231
35433
 
34232
35434
  // src/lib/reconcile.ts
34233
- import { readFileSync as readFileSync15 } from "node:fs";
35435
+ import { readFileSync as readFileSync17 } from "node:fs";
34234
35436
  async function reconcileDivergedRows(args) {
34235
35437
  const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
34236
35438
  const now = args.now ?? (() => new Date);
@@ -34316,7 +35518,7 @@ async function reconcileDivergedRows(args) {
34316
35518
  }
34317
35519
  let content;
34318
35520
  try {
34319
- content = readFileSync15(absolutePath, "utf8");
35521
+ content = readFileSync17(absolutePath, "utf8");
34320
35522
  } catch (err) {
34321
35523
  if (err?.code === "ENOENT")
34322
35524
  continue;
@@ -35259,25 +36461,111 @@ function createThrottledFetch(baseFetch, opts = {}) {
35259
36461
  // src/commands/sync.ts
35260
36462
  import {
35261
36463
  existsSync as existsSync6,
35262
- readdirSync as readdirSync4,
35263
- readFileSync as readFileSync17,
35264
- statSync as statSync9,
35265
- unlinkSync as unlinkSync6
36464
+ readdirSync as readdirSync5,
36465
+ readFileSync as readFileSync19,
36466
+ statSync as statSync11,
36467
+ unlinkSync as unlinkSync7
35266
36468
  } from "node:fs";
35267
- import { join as join12 } from "node:path";
36469
+ import { join as join14 } from "node:path";
36470
+
36471
+ // src/lib/files/pull.ts
36472
+ import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
36473
+
36474
+ // src/lib/files/extracted-text.ts
36475
+ import { createHash as createHash3 } from "node:crypto";
36476
+ var DERIVED_TEXT_VERSION = 2;
36477
+ function yamlString(value) {
36478
+ return JSON.stringify(value);
36479
+ }
36480
+ var PARTIAL_WARNING = [
36481
+ "> ⚠️ **PARTIAL EXTRACTION.** Some spans of this document came out as text and",
36482
+ "> some did not. Which spans is NOT recorded, so this could be a clean document",
36483
+ "> with mangled party names, or clean prose that degrades part-way through.",
36484
+ "> Verify anything load-bearing against the source file."
36485
+ ].join(`
36486
+ `);
36487
+ var NO_LOCAL_COPY_NOTE = [
36488
+ "> ℹ️ **NO LOCAL COPY OF THIS FILE.** Plinth does not hold this file's bytes, so",
36489
+ "> nothing was written under `files/` for it and the text below is the only copy",
36490
+ "> in this mirror. The original lives wherever `source_uri` above points."
36491
+ ].join(`
36492
+ `);
36493
+ function garbledBody(relPath, bytesInMirror) {
36494
+ return [
36495
+ "> \uD83D\uDD34 **NOT TEXT — body withheld.**",
36496
+ ">",
36497
+ "> Extraction produced characters that are not words. The usual cause is a PDF",
36498
+ "> whose embedded fonts carry no ToUnicode map, so the glyph indices come out",
36499
+ "> where the letters should be: a page reading *“Dated this 10th day of",
36500
+ "> September”* extracts as `):-,:019`.",
36501
+ ">",
36502
+ "> The characters are NOT reproduced here on purpose. They read as text to a",
36503
+ "> reader and to a search index, and they say nothing — writing them into the",
36504
+ "> mirror would put a document into your corpus that appears readable and is",
36505
+ "> not. They are excluded from Plinth's own search index for the same reason.",
36506
+ ">",
36507
+ 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."
36508
+ ].join(`
36509
+ `);
36510
+ }
36511
+ function renderExtractedText(doc2) {
36512
+ const withheld = doc2.quality === "garbled";
36513
+ const frontmatter = [
36514
+ "---",
36515
+ "plinth_generated: true",
36516
+ `source_file: ${doc2.bytesInMirror ? yamlString(`files/${doc2.relPath}`) : "null"}`,
36517
+ `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString(doc2.sourceUri)}`,
36518
+ `content_quality: ${doc2.quality ?? "unchecked"}`,
36519
+ `body_withheld: ${withheld}`,
36520
+ `extracted_chars: ${withheld ? 0 : doc2.text.length}`,
36521
+ "---",
36522
+ "",
36523
+ "<!-- Generated by Plinth from the source file above. This file is DERIVED:",
36524
+ " your edits here are overwritten on the next sync and are never uploaded.",
36525
+ " Edit the source file instead. -->",
36526
+ ""
36527
+ ].join(`
36528
+ `);
36529
+ if (withheld) {
36530
+ return `${frontmatter}${garbledBody(doc2.relPath, doc2.bytesInMirror)}
36531
+ `;
36532
+ }
36533
+ const notices = [
36534
+ ...doc2.quality === "partial" ? [PARTIAL_WARNING] : [],
36535
+ ...doc2.bytesInMirror ? [] : [NO_LOCAL_COPY_NOTE]
36536
+ ];
36537
+ if (notices.length > 0) {
36538
+ return `${frontmatter}${notices.join(`
36539
+
36540
+ `)}
36541
+
36542
+ ${doc2.text}
36543
+ `;
36544
+ }
36545
+ return `${frontmatter}${doc2.text}
36546
+ `;
36547
+ }
36548
+ function extractedTextHash(rendered) {
36549
+ return createHash3("sha256").update(rendered, "utf8").digest("hex");
36550
+ }
35268
36551
 
35269
36552
  // src/lib/files/pull.ts
35270
- import { existsSync as existsSync5, readFileSync as readFileSync16, statSync as statSync8 } from "node:fs";
35271
36553
  async function pullFiles(args) {
35272
36554
  const { workspaceSlug, baseUrl, pat, fetchImpl } = args;
35273
36555
  let state = args.state;
35274
36556
  let fetched = 0;
35275
36557
  let written = 0;
35276
36558
  let deleted = 0;
36559
+ let textWritten = 0;
36560
+ let textDeleted = 0;
36561
+ let textUnknown = 0;
36562
+ let pointerRows = 0;
35277
36563
  let cursor = args.cursor;
35278
36564
  let lastCursor = args.cursor;
36565
+ let cursorBlocked = false;
35279
36566
  while (true) {
35280
36567
  const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
36568
+ let pageTextFailed = false;
35281
36569
  for (const row of page.rows) {
35282
36570
  fetched++;
35283
36571
  const outcome = await applyFileRow({
@@ -35290,20 +36578,101 @@ async function pullFiles(args) {
35290
36578
  now: args.now,
35291
36579
  output: args.output,
35292
36580
  suppressor: args.suppressor,
35293
- isFileFrozen: args.isFileFrozen
36581
+ isFileFrozen: args.isFileFrozen,
36582
+ forceTextRewalk: args.forceTextRewalk
35294
36583
  });
35295
36584
  state = outcome.state;
35296
36585
  if (outcome.action === "written")
35297
36586
  written++;
35298
36587
  else if (outcome.action === "deleted")
35299
36588
  deleted++;
35300
- }
36589
+ textWritten += outcome.textWritten ?? 0;
36590
+ textDeleted += outcome.textDeleted ?? 0;
36591
+ if (outcome.textUnknown)
36592
+ textUnknown++;
36593
+ if (outcome.pointer)
36594
+ pointerRows++;
36595
+ if (outcome.textFailed)
36596
+ pageTextFailed = true;
36597
+ }
36598
+ if (pageTextFailed)
36599
+ cursorBlocked = true;
35301
36600
  if (page.next_cursor === null)
35302
36601
  break;
35303
- lastCursor = page.next_cursor;
36602
+ if (!cursorBlocked)
36603
+ lastCursor = page.next_cursor;
35304
36604
  cursor = page.next_cursor;
35305
36605
  }
35306
- return { state, fetched, written, deleted, lastCursor };
36606
+ if (textUnknown > 0) {
36607
+ 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.`);
36608
+ }
36609
+ if (pointerRows > 0) {
36610
+ 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.`);
36611
+ }
36612
+ return {
36613
+ state,
36614
+ fetched,
36615
+ written,
36616
+ deleted,
36617
+ textWritten,
36618
+ textDeleted,
36619
+ textUnknown,
36620
+ pointerRows,
36621
+ lastCursor
36622
+ };
36623
+ }
36624
+ async function applyExtractedText(args) {
36625
+ try {
36626
+ return await materialiseExtractedText(args);
36627
+ } catch (err) {
36628
+ const msg = err instanceof Error ? err.message : String(err);
36629
+ args.output?.warn(`files/${args.relPath}: could not materialise extracted text (${msg}) — mirror text left unwritten, will retry.`);
36630
+ return { textHash: undefined, written: 0, deleted: 0, failed: true };
36631
+ }
36632
+ }
36633
+ async function materialiseExtractedText(args) {
36634
+ const { workspaceSlug, baseUrl, pat, row, relPath, bytesInMirror, fetchImpl, output } = args;
36635
+ const signal = extractedTextSignal(row);
36636
+ if (signal === "absent") {
36637
+ const removed = deleteExtractedText(workspaceSlug, relPath);
36638
+ return { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
36639
+ }
36640
+ if (signal === "unknown") {
36641
+ return { textHash: undefined, written: 0, deleted: 0 };
36642
+ }
36643
+ let rendered;
36644
+ if (row.content_quality === "garbled" && bytesInMirror) {
36645
+ rendered = renderExtractedText({
36646
+ relPath,
36647
+ quality: "garbled",
36648
+ bytesInMirror,
36649
+ sourceUri: undefined,
36650
+ text: ""
36651
+ });
36652
+ } else {
36653
+ const got = await getFileText({ baseUrl, pat, id: row.id, fetchImpl });
36654
+ if (!got.ok)
36655
+ return { textHash: undefined, written: 0, deleted: 0 };
36656
+ if (got.row.text === null) {
36657
+ output?.warn(`files/${relPath}: feed reported extracted text, text route withheld it (${got.row.withheld ?? "no reason given"}) — no derived file written.`);
36658
+ const removed = deleteExtractedText(workspaceSlug, relPath);
36659
+ return { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
36660
+ }
36661
+ rendered = renderExtractedText({
36662
+ relPath,
36663
+ quality: got.row.content_quality,
36664
+ sourceUri: got.row.source_uri,
36665
+ text: got.row.text,
36666
+ bytesInMirror
36667
+ });
36668
+ }
36669
+ const hash2 = extractedTextHash(rendered);
36670
+ const onDisk = readExtractedText(workspaceSlug, relPath);
36671
+ if (onDisk !== null && extractedTextHash(onDisk) === hash2) {
36672
+ return { textHash: hash2, written: 0, deleted: 0 };
36673
+ }
36674
+ writeExtractedText(workspaceSlug, relPath, rendered);
36675
+ return { textHash: hash2, written: 1, deleted: 0 };
35307
36676
  }
35308
36677
  async function applyFileRow(args) {
35309
36678
  const { workspaceSlug, baseUrl, pat, row, state, fetchImpl, output, suppressor } = args;
@@ -35321,28 +36690,88 @@ async function applyFileRow(args) {
35321
36690
  if (row.deleted_at !== null) {
35322
36691
  suppressor?.registerDelete(abs);
35323
36692
  const didUnlink = deleteFilesFile(workspaceSlug, relPath);
36693
+ const removedText = deleteExtractedText(workspaceSlug, relPath);
36694
+ const textDeleted = removedText ? 1 : 0;
35324
36695
  if (existing) {
35325
36696
  const { state: next2 } = removeFileEntry(state, relPath);
35326
- return { state: next2, action: "deleted" };
36697
+ return { state: next2, action: "deleted", textDeleted };
35327
36698
  }
35328
- return { state, action: didUnlink ? "deleted" : "skipped" };
36699
+ return { state, action: didUnlink ? "deleted" : "skipped", textDeleted };
36700
+ }
36701
+ const textSignal = extractedTextSignal(row);
36702
+ const textUnknown = textSignal === "unknown";
36703
+ if (storedBytesSignal(row) === "absent") {
36704
+ return applyPointerRow({
36705
+ workspaceSlug,
36706
+ baseUrl,
36707
+ pat,
36708
+ row,
36709
+ relPath,
36710
+ state,
36711
+ existing,
36712
+ textSignal,
36713
+ fetchImpl,
36714
+ output
36715
+ });
35329
36716
  }
35330
36717
  if (existing && existing.content_hash === row.content_hash && existsSync5(abs)) {
35331
- return { state, action: "noop" };
36718
+ if (!args.forceTextRewalk && textIsCurrent(existing, row, workspaceSlug, relPath)) {
36719
+ return { state, action: "noop", textUnknown };
36720
+ }
36721
+ const text2 = await applyExtractedText({
36722
+ workspaceSlug,
36723
+ baseUrl,
36724
+ pat,
36725
+ row,
36726
+ relPath,
36727
+ bytesInMirror: true,
36728
+ fetchImpl,
36729
+ output
36730
+ });
36731
+ const { state: next2 } = upsertFileEntry(state, {
36732
+ ...existing,
36733
+ updated_at: row.updated_at,
36734
+ text_hash: textUnknown ? existing.text_hash : text2.textHash
36735
+ });
36736
+ return {
36737
+ state: next2,
36738
+ action: "noop",
36739
+ textWritten: text2.written,
36740
+ textDeleted: text2.deleted,
36741
+ textFailed: text2.failed,
36742
+ textUnknown
36743
+ };
36744
+ }
36745
+ const got = await getFile({ baseUrl, pat, id: row.id, fetchImpl });
36746
+ if (!got.ok) {
36747
+ return { state, action: "skipped" };
36748
+ }
36749
+ if (got.row.download_url === null) {
36750
+ if (storedBytesSignal(row) === "present") {
36751
+ 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.`);
36752
+ }
36753
+ return applyPointerRow({
36754
+ workspaceSlug,
36755
+ baseUrl,
36756
+ pat,
36757
+ row,
36758
+ relPath,
36759
+ state,
36760
+ existing,
36761
+ textSignal,
36762
+ fetchImpl,
36763
+ output
36764
+ });
35332
36765
  }
35333
36766
  if (!suppressor && existing && existing.content_hash !== row.content_hash && existsSync5(abs)) {
35334
36767
  try {
35335
- const localBytes = readFileSync16(abs);
36768
+ const localBytes = readFileSync18(abs);
35336
36769
  if (sha256Bytes(localBytes) !== existing.content_hash) {
35337
36770
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
35338
36771
  output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
35339
36772
  }
35340
36773
  } catch {}
35341
36774
  }
35342
- const got = await getFile({ baseUrl, pat, id: row.id, fetchImpl });
35343
- if (!got.ok) {
35344
- return { state, action: "skipped" };
35345
- }
35346
36775
  const bytes = await downloadBytes(got.row.download_url, fetchImpl);
35347
36776
  if (sha256Bytes(bytes) !== row.content_hash) {
35348
36777
  output?.warn(`Integrity check failed for files/${relPath} (hash mismatch) — skipped this pull.`);
@@ -35350,9 +36779,28 @@ async function applyFileRow(args) {
35350
36779
  }
35351
36780
  suppressor?.registerWrite(abs, { bodyHash: row.content_hash, frontmatterHash: "" });
35352
36781
  writeFilesBytes(workspaceSlug, relPath, bytes);
36782
+ let text;
36783
+ if (textUnknown) {
36784
+ const removed = deleteExtractedText(workspaceSlug, relPath);
36785
+ if (removed) {
36786
+ 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.`);
36787
+ }
36788
+ text = { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
36789
+ } else {
36790
+ text = await applyExtractedText({
36791
+ workspaceSlug,
36792
+ baseUrl,
36793
+ pat,
36794
+ row,
36795
+ relPath,
36796
+ bytesInMirror: true,
36797
+ fetchImpl,
36798
+ output
36799
+ });
36800
+ }
35353
36801
  let inode;
35354
36802
  try {
35355
- inode = statSync8(abs).ino;
36803
+ inode = statSync10(abs).ino;
35356
36804
  } catch {
35357
36805
  inode = undefined;
35358
36806
  }
@@ -35363,9 +36811,60 @@ async function applyFileRow(args) {
35363
36811
  size_bytes: row.size_bytes,
35364
36812
  mime_type: row.mime_type,
35365
36813
  updated_at: row.updated_at,
35366
- inode
36814
+ inode,
36815
+ text_hash: text.textHash
36816
+ });
36817
+ return {
36818
+ state: next,
36819
+ action: "written",
36820
+ textWritten: text.written,
36821
+ textDeleted: text.deleted,
36822
+ textFailed: text.failed,
36823
+ textUnknown
36824
+ };
36825
+ }
36826
+ async function applyPointerRow(args) {
36827
+ const { workspaceSlug, baseUrl, pat, row, relPath, state, textSignal } = args;
36828
+ if (textSignal === "absent") {
36829
+ args.output?.warn(`files/${relPath}: Plinth holds neither bytes nor text for this file — it has no local representation in the mirror.`);
36830
+ }
36831
+ const bytesInMirror = existsSync5(filesSubtreePath(workspaceSlug, relPath));
36832
+ if (bytesInMirror) {
36833
+ 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.`);
36834
+ }
36835
+ const text = await applyExtractedText({
36836
+ workspaceSlug,
36837
+ baseUrl,
36838
+ pat,
36839
+ row,
36840
+ relPath,
36841
+ bytesInMirror,
36842
+ fetchImpl: args.fetchImpl,
36843
+ output: args.output
35367
36844
  });
35368
- return { state: next, action: "written" };
36845
+ return {
36846
+ state,
36847
+ action: "noop",
36848
+ textWritten: text.written,
36849
+ textDeleted: text.deleted,
36850
+ textFailed: text.failed,
36851
+ textUnknown: textSignal === "unknown",
36852
+ pointer: true
36853
+ };
36854
+ }
36855
+ function textIsCurrent(existing, row, workspaceSlug, relPath) {
36856
+ const signal = extractedTextSignal(row);
36857
+ if (signal === "unknown")
36858
+ return true;
36859
+ if (signal === "absent") {
36860
+ return existing.text_hash === undefined && !existsSync5(extractedTextPath(workspaceSlug, relPath));
36861
+ }
36862
+ if (existing.text_hash === undefined)
36863
+ return false;
36864
+ const onDisk = readExtractedText(workspaceSlug, relPath);
36865
+ if (onDisk === null)
36866
+ return false;
36867
+ return extractedTextHash(onDisk) === existing.text_hash;
35369
36868
  }
35370
36869
 
35371
36870
  // src/commands/sync.ts
@@ -35383,11 +36882,11 @@ function hasExistingContent(workspacePath) {
35383
36882
  if (!existsSync6(workspacePath))
35384
36883
  return false;
35385
36884
  try {
35386
- return readdirSync4(workspacePath, { withFileTypes: true }).some((e2) => {
36885
+ return readdirSync5(workspacePath, { withFileTypes: true }).some((e2) => {
35387
36886
  if (!e2.isDirectory())
35388
36887
  return true;
35389
36888
  try {
35390
- return readdirSync4(join12(workspacePath, e2.name)).length > 0;
36889
+ return readdirSync5(join14(workspacePath, e2.name)).length > 0;
35391
36890
  } catch {
35392
36891
  return true;
35393
36892
  }
@@ -35407,7 +36906,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
35407
36906
  }
35408
36907
  let liveInode;
35409
36908
  try {
35410
- liveInode = statSync9(target).ino;
36909
+ liveInode = statSync11(target).ino;
35411
36910
  } catch {
35412
36911
  return { didUnlink: false, skippedReason: "absent" };
35413
36912
  }
@@ -35416,7 +36915,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
35416
36915
  }
35417
36916
  try {
35418
36917
  suppressor?.registerDelete(target);
35419
- unlinkSync6(target);
36918
+ unlinkSync7(target);
35420
36919
  return { didUnlink: true };
35421
36920
  } catch (err) {
35422
36921
  if (err?.code === "ENOENT") {
@@ -35503,7 +37002,11 @@ async function runSync(workspaceSlug, deps = {}) {
35503
37002
  updateSyncCursor(slug, entityType, lastAdvancedCursor);
35504
37003
  }
35505
37004
  }
35506
- const filesStartCursor = entry2.sync_cursors?.["files"] ?? null;
37005
+ const needsDerivedTextRewalk = state.derived_text_version !== DERIVED_TEXT_VERSION;
37006
+ const filesStartCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
37007
+ if (needsDerivedTextRewalk) {
37008
+ out.info("Re-reading the file index once to refresh extracted text (template changed).");
37009
+ }
35507
37010
  const filesResult = await pullFiles({
35508
37011
  workspaceSlug: slug,
35509
37012
  baseUrl: entry2.api_base_url,
@@ -35514,16 +37017,21 @@ async function runSync(workspaceSlug, deps = {}) {
35514
37017
  now,
35515
37018
  output: out,
35516
37019
  suppressor: deps.suppressor,
35517
- isFileFrozen: deps.isFileFrozen
37020
+ isFileFrozen: deps.isFileFrozen,
37021
+ forceTextRewalk: needsDerivedTextRewalk
35518
37022
  });
35519
37023
  if (filesResult.state !== state) {
35520
37024
  state = filesResult.state;
35521
37025
  stateDirty = true;
35522
37026
  }
37027
+ if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
37028
+ state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
37029
+ stateDirty = true;
37030
+ }
35523
37031
  counters["files"] = {
35524
37032
  fetched: filesResult.fetched,
35525
- written: filesResult.written,
35526
- deleted: filesResult.deleted
37033
+ written: filesResult.written + filesResult.textWritten,
37034
+ deleted: filesResult.deleted + filesResult.textDeleted
35527
37035
  };
35528
37036
  if (filesResult.lastCursor !== filesStartCursor) {
35529
37037
  updateSyncCursor(slug, "files", filesResult.lastCursor);
@@ -35705,7 +37213,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
35705
37213
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
35706
37214
  let localRaw = null;
35707
37215
  try {
35708
- localRaw = readFileSync17(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
37216
+ localRaw = readFileSync19(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
35709
37217
  } catch {
35710
37218
  localRaw = null;
35711
37219
  }
@@ -35728,7 +37236,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
35728
37236
  const hashes = computeMirrorHashes(strategy, content);
35729
37237
  let inode;
35730
37238
  try {
35731
- inode = statSync9(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
37239
+ inode = statSync11(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
35732
37240
  } catch {
35733
37241
  inode = undefined;
35734
37242
  }
@@ -35785,7 +37293,7 @@ var syncCommand = defineCommand({
35785
37293
  // src/commands/start.ts
35786
37294
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
35787
37295
  var PULL_HEARTBEAT_MS = 900000;
35788
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync10(p)) {
37296
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync12(p)) {
35789
37297
  let changed = false;
35790
37298
  const nextEntries = {};
35791
37299
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -35818,7 +37326,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync10(p)
35818
37326
  return { state, changed: false };
35819
37327
  return { state: { ...state, entries: nextEntries }, changed: true };
35820
37328
  }
35821
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync18(p, "utf8")) {
37329
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync20(p, "utf8")) {
35822
37330
  let changed = false;
35823
37331
  const nextEntries = {};
35824
37332
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -35915,7 +37423,7 @@ function makeWatchHandler(deps) {
35915
37423
  }
35916
37424
  let st;
35917
37425
  try {
35918
- st = statSync10(absolutePath);
37426
+ st = statSync12(absolutePath);
35919
37427
  } catch (err) {
35920
37428
  if (err?.code === "ENOENT")
35921
37429
  return;
@@ -35946,7 +37454,7 @@ function makeWatchHandler(deps) {
35946
37454
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
35947
37455
  return;
35948
37456
  }
35949
- const bytes = readFileSync18(absolutePath);
37457
+ const bytes = readFileSync20(absolutePath);
35950
37458
  const hash2 = sha256Bytes(bytes);
35951
37459
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
35952
37460
  bodyHash: hash2,
@@ -36004,7 +37512,7 @@ function makeWatchHandler(deps) {
36004
37512
  if (suppressor) {
36005
37513
  let raw = null;
36006
37514
  try {
36007
- raw = readFileSync18(absolutePath, "utf8");
37515
+ raw = readFileSync20(absolutePath, "utf8");
36008
37516
  } catch {
36009
37517
  raw = null;
36010
37518
  }
@@ -36042,7 +37550,7 @@ function makeWatchHandler(deps) {
36042
37550
  if (suppressor) {
36043
37551
  let raw = null;
36044
37552
  try {
36045
- raw = readFileSync18(absolutePath, "utf8");
37553
+ raw = readFileSync20(absolutePath, "utf8");
36046
37554
  } catch {
36047
37555
  raw = null;
36048
37556
  }
@@ -36080,7 +37588,7 @@ function makeWatchHandler(deps) {
36080
37588
  if (suppressor) {
36081
37589
  let raw = null;
36082
37590
  try {
36083
- raw = readFileSync18(absolutePath, "utf8");
37591
+ raw = readFileSync20(absolutePath, "utf8");
36084
37592
  } catch {
36085
37593
  raw = null;
36086
37594
  }
@@ -36118,7 +37626,7 @@ function makeWatchHandler(deps) {
36118
37626
  if (suppressor) {
36119
37627
  let raw = null;
36120
37628
  try {
36121
- raw = readFileSync18(absolutePath, "utf8");
37629
+ raw = readFileSync20(absolutePath, "utf8");
36122
37630
  } catch {
36123
37631
  raw = null;
36124
37632
  }
@@ -36211,7 +37719,7 @@ function makeWatchHandler(deps) {
36211
37719
  if (strategy) {
36212
37720
  let raw = null;
36213
37721
  try {
36214
- raw = readFileSync18(absolutePath, "utf8");
37722
+ raw = readFileSync20(absolutePath, "utf8");
36215
37723
  } catch {
36216
37724
  raw = null;
36217
37725
  }
@@ -37032,27 +38540,27 @@ var startCommand = defineCommand({
37032
38540
  });
37033
38541
 
37034
38542
  // src/commands/status.ts
37035
- import { statSync as statSync11 } from "node:fs";
38543
+ import { statSync as statSync13 } from "node:fs";
37036
38544
 
37037
38545
  // 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";
38546
+ import { execFileSync as execFileSync4 } from "node:child_process";
38547
+ import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync21, writeFileSync as writeFileSync8 } from "node:fs";
38548
+ import { dirname as dirname10, join as join15 } from "node:path";
37041
38549
  import { fileURLToPath } from "node:url";
37042
38550
  var BUILD_STAMP_FILENAME = "build-stamp.json";
37043
38551
  function stampPath(distDir) {
37044
- return join13(distDir, BUILD_STAMP_FILENAME);
38552
+ return join15(distDir, BUILD_STAMP_FILENAME);
37045
38553
  }
37046
38554
  function readHead(repoDir) {
37047
38555
  try {
37048
- const sha = execFileSync3("git", ["rev-parse", "HEAD"], {
38556
+ const sha = execFileSync4("git", ["rev-parse", "HEAD"], {
37049
38557
  cwd: repoDir,
37050
38558
  encoding: "utf8",
37051
38559
  stdio: ["ignore", "pipe", "ignore"]
37052
38560
  }).trim();
37053
38561
  if (!sha)
37054
38562
  return null;
37055
- const porcelain = execFileSync3("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
38563
+ const porcelain = execFileSync4("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
37056
38564
  return { sha, dirty: porcelain.trim().length > 0 };
37057
38565
  } catch {
37058
38566
  return null;
@@ -37060,7 +38568,7 @@ function readHead(repoDir) {
37060
38568
  }
37061
38569
  function readBuildStamp(distDir) {
37062
38570
  try {
37063
- const parsed = JSON.parse(readFileSync19(stampPath(distDir), "utf8"));
38571
+ const parsed = JSON.parse(readFileSync21(stampPath(distDir), "utf8"));
37064
38572
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
37065
38573
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
37066
38574
  }
@@ -37072,9 +38580,9 @@ function readBuildStamp(distDir) {
37072
38580
  function findRepoRoot(fromDir) {
37073
38581
  let dir = fromDir;
37074
38582
  for (;; ) {
37075
- if (existsSync7(join13(dir, ".git")))
38583
+ if (existsSync7(join15(dir, ".git")))
37076
38584
  return dir;
37077
- const parent = dirname9(dir);
38585
+ const parent = dirname10(dir);
37078
38586
  if (parent === dir)
37079
38587
  return null;
37080
38588
  dir = parent;
@@ -37082,7 +38590,7 @@ function findRepoRoot(fromDir) {
37082
38590
  }
37083
38591
  function probeRepo(repoDir, stampSha) {
37084
38592
  try {
37085
- execFileSync3("git", ["cat-file", "-e", `${stampSha}^{commit}`], {
38593
+ execFileSync4("git", ["cat-file", "-e", `${stampSha}^{commit}`], {
37086
38594
  cwd: repoDir,
37087
38595
  stdio: "ignore"
37088
38596
  });
@@ -37098,7 +38606,7 @@ function checkBuildStaleness(deps = {}) {
37098
38606
  const metaUrl = deps.metaUrl ?? import.meta.url;
37099
38607
  let distDir;
37100
38608
  try {
37101
- distDir = dirname9(fileURLToPath(metaUrl));
38609
+ distDir = dirname10(fileURLToPath(metaUrl));
37102
38610
  } catch {
37103
38611
  return { kind: "unknown" };
37104
38612
  }
@@ -37144,8 +38652,8 @@ function formatRelative(deltaMs) {
37144
38652
  return `${Math.floor(h2 / 24)}d ago`;
37145
38653
  }
37146
38654
  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())}`;
38655
+ const pad3 = (n2) => String(n2).padStart(2, "0");
38656
+ return `${date5.getFullYear()}-${pad3(date5.getMonth() + 1)}-${pad3(date5.getDate())} ` + `${pad3(date5.getHours())}:${pad3(date5.getMinutes())}:${pad3(date5.getSeconds())}`;
37149
38657
  }
37150
38658
  function short(sha) {
37151
38659
  return sha.slice(0, 7);
@@ -37168,7 +38676,7 @@ function renderBuild(out, staleness) {
37168
38676
  }
37169
38677
  function lastActivity(now) {
37170
38678
  try {
37171
- const mtimeMs = statSync11(daemonLogPath()).mtimeMs;
38679
+ const mtimeMs = statSync13(daemonLogPath()).mtimeMs;
37172
38680
  return formatRelative(now.getTime() - mtimeMs);
37173
38681
  } catch {
37174
38682
  return null;
@@ -37203,6 +38711,27 @@ async function runStatus(args, deps = {}) {
37203
38711
  if (activity)
37204
38712
  out.info(cont(`last activity ${activity}`));
37205
38713
  out.info("");
38714
+ const sessions = listSessions(slug, {
38715
+ aliveFn: deps.aliveFn,
38716
+ startTimeFn: deps.sessionStartTimeFn,
38717
+ mtimeFn: deps.sessionMtimeFn
38718
+ });
38719
+ const selfPidRaw = process.env.CLAUDE_PID;
38720
+ const selfPid = selfPidRaw ? Number.parseInt(selfPidRaw, 10) : null;
38721
+ const selfSessionId = process.env.CLAUDE_CODE_SESSION_ID ?? null;
38722
+ const sessionLines = renderSessionLines(sessions.live, now, Number.isInteger(selfPid) ? selfPid : null, selfSessionId, formatRelative);
38723
+ const count = sessions.live.length;
38724
+ const reapNote = sessions.reaped > 0 ? ` (cleared ${sessions.reaped} ended session${sessions.reaped === 1 ? "" : "s"})` : "";
38725
+ out.info(row("Sessions", count === 0 ? `None registered on this machine.${reapNote}` : `${count} live on this machine${reapNote}`));
38726
+ if (count > 0 || sessions.unknown > 0 || sessions.ended > 0) {
38727
+ if (count > 0)
38728
+ for (const line of sessionLines)
38729
+ out.info(cont(line));
38730
+ for (const line of sessionCaveats(count, sessions.unknown, sessions.ended)) {
38731
+ out.info(cont(line));
38732
+ }
38733
+ }
38734
+ out.info("");
37206
38735
  let localState;
37207
38736
  try {
37208
38737
  localState = loadState(slug);
@@ -37291,7 +38820,7 @@ var versionCommand = defineCommand({
37291
38820
  });
37292
38821
 
37293
38822
  // src/commands/voice-gate.ts
37294
- import { readFileSync as readFileSync22 } from "node:fs";
38823
+ import { readFileSync as readFileSync24 } from "node:fs";
37295
38824
 
37296
38825
  // src/lib/voice-gate/api.ts
37297
38826
  var VoiceGateSurfaces = [
@@ -37393,15 +38922,15 @@ async function callVoiceGate(req) {
37393
38922
  }
37394
38923
 
37395
38924
  // src/lib/voice-gate/attempts.ts
37396
- import { createHash as createHash3 } from "node:crypto";
38925
+ import { createHash as createHash4 } from "node:crypto";
37397
38926
  import {
37398
- mkdirSync as mkdirSync9,
37399
- readFileSync as readFileSync20,
37400
- renameSync as renameSync7,
37401
- unlinkSync as unlinkSync7,
37402
- writeFileSync as writeFileSync8
38927
+ mkdirSync as mkdirSync11,
38928
+ readFileSync as readFileSync22,
38929
+ renameSync as renameSync8,
38930
+ unlinkSync as unlinkSync8,
38931
+ writeFileSync as writeFileSync9
37403
38932
  } from "node:fs";
37404
- import { join as join14 } from "node:path";
38933
+ import { join as join16 } from "node:path";
37405
38934
  var MAX_REGENS = 2;
37406
38935
  var ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
37407
38936
  var AttemptEntrySchema = exports_external.object({
@@ -37414,20 +38943,20 @@ var AttemptsFileSchema = exports_external.object({
37414
38943
  entries: exports_external.record(exports_external.string(), AttemptEntrySchema)
37415
38944
  });
37416
38945
  function attemptsDir(workspaceSlug) {
37417
- return join14(plinthHome(), "_voice_gate", workspaceSlug);
38946
+ return join16(plinthHome(), "_voice_gate", workspaceSlug);
37418
38947
  }
37419
38948
  function attemptsPath(workspaceSlug) {
37420
- return join14(attemptsDir(workspaceSlug), "attempts.json");
38949
+ return join16(attemptsDir(workspaceSlug), "attempts.json");
37421
38950
  }
37422
38951
  function attemptKey(identity) {
37423
- return createHash3("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
38952
+ return createHash4("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
37424
38953
  }
37425
38954
  function emptyFile() {
37426
38955
  return { version: 1, entries: {} };
37427
38956
  }
37428
38957
  function load2(workspaceSlug) {
37429
38958
  try {
37430
- const raw = readFileSync20(attemptsPath(workspaceSlug), "utf8");
38959
+ const raw = readFileSync22(attemptsPath(workspaceSlug), "utf8");
37431
38960
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
37432
38961
  return parsed.success ? parsed.data : emptyFile();
37433
38962
  } catch {
@@ -37442,19 +38971,19 @@ function prune(file2, now) {
37442
38971
  }));
37443
38972
  return { version: 1, entries };
37444
38973
  }
37445
- var tmpCounter = 0;
38974
+ var tmpCounter2 = 0;
37446
38975
  function save(workspaceSlug, file2) {
37447
38976
  const dir = attemptsDir(workspaceSlug);
37448
38977
  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)}
38978
+ const tmp = `${target}.${process.pid}.${tmpCounter2++}.tmp`;
38979
+ mkdirSync11(dir, { recursive: true, mode: 448 });
38980
+ writeFileSync9(tmp, `${JSON.stringify(file2, null, 2)}
37452
38981
  `, { mode: 384 });
37453
38982
  try {
37454
- renameSync7(tmp, target);
38983
+ renameSync8(tmp, target);
37455
38984
  } catch (err) {
37456
38985
  try {
37457
- unlinkSync7(tmp);
38986
+ unlinkSync8(tmp);
37458
38987
  } catch {}
37459
38988
  throw err;
37460
38989
  }
@@ -37590,7 +39119,7 @@ function denyUngated(detail) {
37590
39119
  }
37591
39120
 
37592
39121
  // src/lib/voice-gate/payload.ts
37593
- import { readFileSync as readFileSync21, statSync as statSync12 } from "node:fs";
39122
+ import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
37594
39123
  import { homedir as homedir2 } from "node:os";
37595
39124
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
37596
39125
  var MAX_BODY_BYTES = 256 * 1024;
@@ -37600,7 +39129,7 @@ function isRawGwsDraftCreate(tokens) {
37600
39129
  return tokens[0] === "gws" && tokens.includes("drafts") && tokens.includes("create");
37601
39130
  }
37602
39131
  var MCP_GMAIL_DRAFT_PATTERN = /^mcp__.*gmail.*__(create|update)_draft$/i;
37603
- function asString(value) {
39132
+ function asString2(value) {
37604
39133
  return typeof value === "string" ? value : null;
37605
39134
  }
37606
39135
  function asNonEmptyString(value) {
@@ -37747,11 +39276,11 @@ function isHelperInvocation(tokens) {
37747
39276
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
37748
39277
  }
37749
39278
  function defaultReadBodyFile(path5) {
37750
- const stat4 = statSync12(path5);
39279
+ const stat4 = statSync14(path5);
37751
39280
  if (stat4.size > MAX_BODY_BYTES) {
37752
39281
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
37753
39282
  }
37754
- return readFileSync21(path5, "utf8");
39283
+ return readFileSync23(path5, "utf8");
37755
39284
  }
37756
39285
  function parseRecipients(...raw) {
37757
39286
  const all = raw.filter((v2) => typeof v2 === "string").flatMap((v2) => v2.split(",")).map((v2) => v2.trim().toLowerCase()).filter((v2) => v2.length > 0);
@@ -37839,10 +39368,10 @@ function classifyHelperSegment(tokens, dirState, readBodyFile) {
37839
39368
  }
37840
39369
  function classifyBash(payload, readBodyFile) {
37841
39370
  const input = asRecord(payload.tool_input);
37842
- const command = asString(input.command);
39371
+ const command = asString2(input.command);
37843
39372
  if (!command)
37844
39373
  return { gated: false };
37845
- const cwd = asString(payload.cwd);
39374
+ const cwd = asString2(payload.cwd);
37846
39375
  let dirState = cwd ? { dir: cwd, known: true } : { dir: process.cwd(), known: true };
37847
39376
  const items = [];
37848
39377
  for (const segment of splitCommandSegments(command)) {
@@ -37883,7 +39412,7 @@ function classifyMcp(payload) {
37883
39412
  ]
37884
39413
  };
37885
39414
  }
37886
- const subject = asString(input.subject) ?? "";
39415
+ const subject = asString2(input.subject) ?? "";
37887
39416
  return {
37888
39417
  gated: true,
37889
39418
  items: [
@@ -37891,7 +39420,7 @@ function classifyMcp(payload) {
37891
39420
  draft: {
37892
39421
  site: "mcp-gmail",
37893
39422
  recipients: parseRecipients(asStringArray(input.to).join(","), asStringArray(input.cc).join(","), asStringArray(input.bcc).join(",")),
37894
- threadId: asString(input.replyToMessageId) || asString(input.draftId) || null,
39423
+ threadId: asString2(input.replyToMessageId) || asString2(input.draftId) || null,
37895
39424
  subject,
37896
39425
  text: composeDraftText(subject, body)
37897
39426
  }
@@ -37902,7 +39431,7 @@ function classifyMcp(payload) {
37902
39431
  function classifyPayload(payload, deps = {}) {
37903
39432
  const readBodyFile = deps.readBodyFile ?? defaultReadBodyFile;
37904
39433
  try {
37905
- const toolName = asString(payload.tool_name);
39434
+ const toolName = asString2(payload.tool_name);
37906
39435
  if (!toolName)
37907
39436
  return { gated: false };
37908
39437
  if (toolName === "Bash")
@@ -37928,7 +39457,7 @@ var diskAttempts = {
37928
39457
  recordFailure: (slug, key, reason) => recordFailure(slug, key, reason),
37929
39458
  clear: clearAttempt
37930
39459
  };
37931
- async function defaultReadStdin() {
39460
+ async function defaultReadStdin2() {
37932
39461
  if (process.stdin.isTTY)
37933
39462
  return "";
37934
39463
  const chunks = [];
@@ -38037,7 +39566,7 @@ async function gateOneDraft(draft, sessionId, auth, deps) {
38037
39566
  }
38038
39567
  async function runVoiceGateHook(args, deps = {}) {
38039
39568
  const sink = deps.sink ?? { stdout: process.stdout, stderr: process.stderr };
38040
- const readStdin = deps.readStdin ?? defaultReadStdin;
39569
+ const readStdin = deps.readStdin ?? defaultReadStdin2;
38041
39570
  const raw = await readStdin().catch(() => "");
38042
39571
  let payload;
38043
39572
  try {
@@ -38102,7 +39631,7 @@ function isSurface(value) {
38102
39631
  async function runVoiceGateDirect(args, deps = {}) {
38103
39632
  const { Output: Output2 } = await Promise.resolve().then(() => (init_output(), exports_output));
38104
39633
  const out = new Output2({ sink: deps.sink, noColor: deps.noColor });
38105
- const readStdin = deps.readStdin ?? defaultReadStdin;
39634
+ const readStdin = deps.readStdin ?? defaultReadStdin2;
38106
39635
  const surfaceArg = args.surface ?? "email_draft";
38107
39636
  if (!isSurface(surfaceArg)) {
38108
39637
  out.error(`Unknown surface "${surfaceArg}". Expected one of: ${VoiceGateSurfaces.join(", ")}.`);
@@ -38111,7 +39640,7 @@ async function runVoiceGateDirect(args, deps = {}) {
38111
39640
  let text;
38112
39641
  if (args.file) {
38113
39642
  try {
38114
- text = readFileSync22(args.file, "utf8");
39643
+ text = readFileSync24(args.file, "utf8");
38115
39644
  } catch (err) {
38116
39645
  const msg = err instanceof Error ? err.message : "unknown error";
38117
39646
  out.error(`Could not read ${args.file}: ${msg}`);
@@ -38256,7 +39785,9 @@ var main = defineCommand({
38256
39785
  "voice-gate": voiceGateCommand,
38257
39786
  backlinks: backlinksCommand,
38258
39787
  declare: declareCommand,
38259
- "review-tier": reviewTierCommand
39788
+ "review-tier": reviewTierCommand,
39789
+ session: sessionCommand,
39790
+ "memory-gate": memoryGateCommand
38260
39791
  }
38261
39792
  });
38262
39793
  runMain(main);