@plinth-music/cli 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.8.0 — 2026-08-23
6
+
7
+ Two PRs and two direct commits since `0.7.0` (`v0.7.0..af83c59`). The daemon
8
+ survives a failed confirm gate, and the map it writes tells an agent which
9
+ command answers which question. No server prerequisite: both changes are
10
+ client-side, and a 0.7.0 client is unaffected.
11
+
12
+ ### A failing confirm gate no longer takes the daemon down with it
13
+
14
+ Both destructive gates fire their settle window fire-and-forget, so a rejecting
15
+ `onSettle` surfaced as an unhandled rejection and **terminated the long-running
16
+ daemon** — with the batch already lifted out of the gate's pending buffer, so its
17
+ remaining operations were neither pushed nor quarantined. Work disappeared behind
18
+ a process that had simply stopped.
19
+
20
+ Three layers now catch it. The gate reports a settle failure through a new
21
+ `onError` callback instead of rejecting into the void. The under-threshold flush
22
+ catches per-operation failures, logs the same `[PUSH_FAILED]` / `[FILE_PUSH_FAILED]`
23
+ lines the watch path already used, and hands recordable ones to the unsynced net
24
+ so the poll tick re-drives them — one operation failing no longer skips its
25
+ siblings. And a failed quarantine write warns on the banner rather than rejecting.
26
+
27
+ ### Every mirror's `CLAUDE.md` says how to traverse the corpus
28
+
29
+ A new **Navigating the corpus** section: three lookup lines mapping a traversal
30
+ question to the surface that answers it — what points at X (`plinth backlinks`),
31
+ what this file links to (grep `[[`), and how to find a file by name, text inside a
32
+ PDF, or an entity by field.
33
+
34
+ `plinth backlinks` shipped in `0.6.0` and appeared in no generated context.
35
+ Measured on the live mirror, the generated text carried no `backlinks`, no
36
+ `wikilink` and no `[[`, while the command answered a real query with 7 files and
37
+ 16 occurrences. A/B on the live mirror, same question in two fresh sessions: with
38
+ the section, the first tool call was `plinth backlinks`; without it, `grep -ril`.
39
+
40
+ The generated file's line bound moves 51 → 56. Its schema-version stamp does not —
41
+ the section is inline literal text, and none of the five constants the stamp hashes
42
+ changed.
43
+
5
44
  ## 0.7.0 — 2026-08-23
6
45
 
7
46
  One PR and one docs commit since `0.6.0` (`v0.6.0..71c775c`). The mirror gains a
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "c8ca82b2d16c0ed618813268f475e63ba0c4d26d",
2
+ "sha": "4a3be5d977539351a0cc5c0adc5617cb706b676b",
3
3
  "dirty": false,
4
- "built_at": "2026-08-23T13:01:01.515Z"
4
+ "built_at": "2026-08-23T20:04:35.148Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -9193,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
9193
9193
  // package.json
9194
9194
  var package_default = {
9195
9195
  name: "@plinth-music/cli",
9196
- version: "0.7.0",
9196
+ version: "0.8.0",
9197
9197
  description: "Plinth capstone sync daemon and CLI",
9198
9198
  type: "module",
9199
9199
  private: false,
@@ -29507,6 +29507,7 @@ function backupStamp2(d2) {
29507
29507
  }
29508
29508
 
29509
29509
  // src/lib/context/workspace-claude-md.ts
29510
+ var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
29510
29511
  function rosterBlock(roster, activeProjectCountBySlug) {
29511
29512
  if (roster.length === 0)
29512
29513
  return "_No artists synced yet._";
@@ -29555,6 +29556,11 @@ function buildWorkspaceClaudeMd(input) {
29555
29556
  `- 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`,
29556
29557
  `- ${MCP_AVAILABILITY_POINTER}`,
29557
29558
  ``,
29559
+ `## Navigating the corpus`,
29560
+ `- **What points at X?** → \`plinth backlinks "<name>"\`, a mirror path, or a wikilink target as written. Render-only blocks, code spans and comments are narrowed out; \`--unfiltered\` shows them. A bare name several files answer to is refused with the candidates listed, so re-run with the path it names`,
29561
+ `- **What does this file link to?** → grep \`[[\` in it`,
29562
+ `- **Find a file by name** → glob under \`files/\` \`documents/\` \`artists/\` · **text inside a PDF** → \`${EXTRACTED_TEXT_DIR}\` (folder map above) · **a task, project, artist or contact by field** → the \`mcp__plinth__*\` \`search_entities\` / \`list_*\` tools`,
29563
+ ``,
29558
29564
  `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
29559
29565
  ...entityLines,
29560
29566
  ``,
@@ -30555,10 +30561,12 @@ class DestructiveGate {
30555
30561
  settleMs;
30556
30562
  maxBatch;
30557
30563
  onSettle;
30564
+ onError;
30558
30565
  constructor(opts) {
30559
30566
  this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
30560
30567
  this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH;
30561
30568
  this.onSettle = opts.onSettle;
30569
+ this.onError = opts.onError;
30562
30570
  }
30563
30571
  consider(op) {
30564
30572
  this.pending.push(op);
@@ -30587,7 +30595,11 @@ class DestructiveGate {
30587
30595
  return;
30588
30596
  const batch = this.pending;
30589
30597
  this.pending = [];
30590
- await this.onSettle(batch);
30598
+ try {
30599
+ await this.onSettle(batch);
30600
+ } catch (err) {
30601
+ this.onError?.(err);
30602
+ }
30591
30603
  }
30592
30604
  }
30593
30605
 
@@ -32807,10 +32819,12 @@ class FileDestructiveGate {
32807
32819
  settleMs;
32808
32820
  maxBatch;
32809
32821
  onSettle;
32822
+ onError;
32810
32823
  constructor(opts) {
32811
32824
  this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS2;
32812
32825
  this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH2;
32813
32826
  this.onSettle = opts.onSettle;
32827
+ this.onError = opts.onError;
32814
32828
  }
32815
32829
  consider(op) {
32816
32830
  this.pending.push(op);
@@ -32839,7 +32853,11 @@ class FileDestructiveGate {
32839
32853
  return;
32840
32854
  const batch = this.pending;
32841
32855
  this.pending = [];
32842
- await this.onSettle(batch);
32856
+ try {
32857
+ await this.onSettle(batch);
32858
+ } catch (err) {
32859
+ this.onError?.(err);
32860
+ }
32843
32861
  }
32844
32862
  }
32845
32863
 
@@ -38644,6 +38662,97 @@ function discardSink() {
38644
38662
  const stream = { write: () => true };
38645
38663
  return { stdout: stream, stderr: stream };
38646
38664
  }
38665
+ async function flushHeldEntityOp(deps) {
38666
+ const now = deps.now ?? (() => new Date);
38667
+ try {
38668
+ const result = await pushFile({
38669
+ workspaceSlug: deps.workspaceSlug,
38670
+ baseUrl: deps.baseUrl,
38671
+ pat: deps.pat,
38672
+ absolutePath: deps.op.absolutePath,
38673
+ entityType: deps.op.entityType,
38674
+ state: deps.getState(),
38675
+ exists: deps.op.kind === "overwrite",
38676
+ unlinkContext: deps.op.kind === "delete" ? { id: deps.op.id, slug: deps.op.slug, updated_at: deps.op.updatedAt } : null,
38677
+ deps: {
38678
+ fetchImpl: deps.fetchImpl,
38679
+ now,
38680
+ mutex: deps.mutex,
38681
+ output: deps.output
38682
+ }
38683
+ });
38684
+ deps.setState(result.state);
38685
+ reportOutcome(deps.spinner, deps.workspaceSlug, deps.getState(), result.outcome);
38686
+ } catch (err) {
38687
+ const msg = err instanceof Error ? err.message : "unknown push error";
38688
+ deps.output?.log(pushFailedLine({
38689
+ entityType: deps.op.entityType,
38690
+ slug: deps.op.slug,
38691
+ id: deps.op.id,
38692
+ ...categorizeError(err)
38693
+ }));
38694
+ deps.spinner.warn(`Push failed for ${deps.op.entityType}/${deps.op.slug}: ${msg}`);
38695
+ if (isRecordableFailure(err)) {
38696
+ deps.setState(recordEntityUnsynced({
38697
+ state: deps.getState(),
38698
+ workspaceSlug: deps.workspaceSlug,
38699
+ absolutePath: deps.op.absolutePath,
38700
+ op: deps.op.kind === "delete" ? "delete" : "upsert",
38701
+ err,
38702
+ now
38703
+ }));
38704
+ }
38705
+ }
38706
+ }
38707
+ async function flushHeldFileOpImpl(deps) {
38708
+ const now = deps.now ?? (() => new Date);
38709
+ const fileDeps = {
38710
+ fetchImpl: deps.fetchImpl,
38711
+ now,
38712
+ output: deps.output,
38713
+ suppressor: deps.suppressor
38714
+ };
38715
+ const run = async () => {
38716
+ try {
38717
+ const result = deps.op.kind === "delete" ? await pushFilesDelete({
38718
+ workspaceSlug: deps.workspaceSlug,
38719
+ baseUrl: deps.baseUrl,
38720
+ pat: deps.pat,
38721
+ relPath: deps.op.relPath,
38722
+ state: deps.getState(),
38723
+ deps: fileDeps
38724
+ }) : await pushFilesUpsert({
38725
+ workspaceSlug: deps.workspaceSlug,
38726
+ baseUrl: deps.baseUrl,
38727
+ pat: deps.pat,
38728
+ absolutePath: deps.op.absolutePath,
38729
+ relPath: deps.op.relPath,
38730
+ state: deps.getState(),
38731
+ deps: fileDeps
38732
+ });
38733
+ deps.setState(result.state);
38734
+ reportFileOutcome(deps.spinner, deps.workspaceSlug, deps.getState(), result.outcome);
38735
+ } catch (err) {
38736
+ const msg = err instanceof Error ? err.message : "unknown push error";
38737
+ deps.output?.log(fileFailedLine({ path: deps.op.relPath, ...categorizeError(err) }));
38738
+ deps.spinner.warn(`File push failed for files/${deps.op.relPath}: ${msg}`);
38739
+ if (isRecordableFailure(err)) {
38740
+ deps.setState(recordFileUnsynced({
38741
+ state: deps.getState(),
38742
+ relPath: deps.op.relPath,
38743
+ op: deps.op.kind === "delete" ? "delete" : "upsert",
38744
+ err,
38745
+ now
38746
+ }));
38747
+ }
38748
+ }
38749
+ };
38750
+ if (deps.mutex) {
38751
+ await deps.mutex.run(`files:${deps.op.relPath}`, run);
38752
+ } else {
38753
+ await run();
38754
+ }
38755
+ }
38647
38756
  async function runStart(workspaceSlug, deps = {}) {
38648
38757
  const daemonLog = deps.logSink ?? new DaemonLog({ path: daemonLogPath(), now: deps.now });
38649
38758
  const out = new Output({
@@ -38897,22 +39006,25 @@ async function runStart(workspaceSlug, deps = {}) {
38897
39006
  return r3 ? { entityType: r3.entityType, slug: r3.entitySlug } : null;
38898
39007
  };
38899
39008
  const suppressor = new PullSuppressor;
38900
- const flushHeldOp = async (op) => {
38901
- const result = await pushFile({
38902
- workspaceSlug: slug,
38903
- baseUrl: entry2.api_base_url,
38904
- pat,
38905
- absolutePath: op.absolutePath,
38906
- entityType: op.entityType,
38907
- state,
38908
- exists: op.kind === "overwrite",
38909
- unlinkContext: op.kind === "delete" ? { id: op.id, slug: op.slug, updated_at: op.updatedAt } : null,
38910
- deps: { fetchImpl, now: deps.now, mutex, output: out }
38911
- });
38912
- state = result.state;
38913
- reportOutcome(spinner, slug, state, result.outcome);
38914
- };
39009
+ const flushHeldOp = (op) => flushHeldEntityOp({
39010
+ workspaceSlug: slug,
39011
+ baseUrl: entry2.api_base_url,
39012
+ pat,
39013
+ op,
39014
+ getState: () => state,
39015
+ setState: (s2) => {
39016
+ state = s2;
39017
+ },
39018
+ mutex,
39019
+ fetchImpl,
39020
+ now: deps.now,
39021
+ output: out,
39022
+ spinner
39023
+ });
38915
39024
  const gate = new DestructiveGate({
39025
+ onError: (err) => {
39026
+ out.log(`[DESTRUCTIVE_SETTLE_FAILED source=entity-watch reason=${categorizeError(err).reason}]`);
39027
+ },
38916
39028
  onSettle: async (ops) => {
38917
39029
  const batch = dedupeOps(ops);
38918
39030
  if (batch.length === 0)
@@ -38935,43 +39047,49 @@ async function runStart(workspaceSlug, deps = {}) {
38935
39047
  prior_len: op.kind === "overwrite" ? op.priorLen : undefined,
38936
39048
  new_len: op.kind === "overwrite" ? op.newLen : undefined
38937
39049
  }));
38938
- const stashPath = stashPendingBatch({
38939
- workspaceSlug: slug,
38940
- batchId,
38941
- timestamp: ts,
38942
- tripReason: result.reason,
38943
- ops: pendingOps
38944
- });
39050
+ let stashPath;
39051
+ try {
39052
+ stashPath = stashPendingBatch({
39053
+ workspaceSlug: slug,
39054
+ batchId,
39055
+ timestamp: ts,
39056
+ tripReason: result.reason,
39057
+ ops: pendingOps
39058
+ });
39059
+ } catch (err) {
39060
+ const msg = err instanceof Error ? err.message : "unknown error";
39061
+ out.log(`[DESTRUCTIVE_HELD_STASH_FAILED source=entity-watch reason=${categorizeError(err).reason}]`);
39062
+ spinner.warn(`Could not quarantine ${batch.length} destructive op${batch.length === 1 ? "" : "s"} (${msg}); they were dropped and stay local-only.`);
39063
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39064
+ refreshFrozen();
39065
+ return;
39066
+ }
38945
39067
  refreshFrozen();
38946
39068
  out.log(`[DESTRUCTIVE_HELD batch=${batchId} ops=${batch.length} reason=${result.reason.kind}]`);
38947
39069
  spinner.warn(`Held ${batch.length} destructive op${batch.length === 1 ? "" : "s"} pending confirm → ${stashPath}. Run \`plinth confirm\` to review.`);
38948
39070
  spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
38949
39071
  }
38950
39072
  });
38951
- const flushHeldFileOp = async (op) => {
38952
- const fileDeps = { fetchImpl, now: deps.now, output: out, suppressor };
38953
- await mutex.run(`files:${op.relPath}`, async () => {
38954
- const result = op.kind === "delete" ? await pushFilesDelete({
38955
- workspaceSlug: slug,
38956
- baseUrl: entry2.api_base_url,
38957
- pat,
38958
- relPath: op.relPath,
38959
- state,
38960
- deps: fileDeps
38961
- }) : await pushFilesUpsert({
38962
- workspaceSlug: slug,
38963
- baseUrl: entry2.api_base_url,
38964
- pat,
38965
- absolutePath: op.absolutePath,
38966
- relPath: op.relPath,
38967
- state,
38968
- deps: fileDeps
38969
- });
38970
- state = result.state;
38971
- reportFileOutcome(spinner, slug, state, result.outcome);
38972
- });
38973
- };
39073
+ const flushHeldFileOp = (op) => flushHeldFileOpImpl({
39074
+ workspaceSlug: slug,
39075
+ baseUrl: entry2.api_base_url,
39076
+ pat,
39077
+ op,
39078
+ getState: () => state,
39079
+ setState: (s2) => {
39080
+ state = s2;
39081
+ },
39082
+ mutex,
39083
+ fetchImpl,
39084
+ now: deps.now,
39085
+ output: out,
39086
+ spinner,
39087
+ suppressor
39088
+ });
38974
39089
  const fileGate = new FileDestructiveGate({
39090
+ onError: (err) => {
39091
+ out.log(`[DESTRUCTIVE_SETTLE_FAILED source=files-watch reason=${categorizeError(err).reason}]`);
39092
+ },
38975
39093
  onSettle: async (ops) => {
38976
39094
  const batch = dedupeFileOps(ops);
38977
39095
  if (batch.length === 0)
@@ -38985,13 +39103,23 @@ async function runStart(workspaceSlug, deps = {}) {
38985
39103
  const ts = (deps.now ?? (() => new Date))();
38986
39104
  const batchId = batchIdFromTimestamp(ts);
38987
39105
  const pendingOps = batch.map(fileOpToPendingOp);
38988
- const stashPath = stashPendingBatch({
38989
- workspaceSlug: slug,
38990
- batchId,
38991
- timestamp: ts,
38992
- tripReason: result.reason,
38993
- ops: pendingOps
38994
- });
39106
+ let stashPath;
39107
+ try {
39108
+ stashPath = stashPendingBatch({
39109
+ workspaceSlug: slug,
39110
+ batchId,
39111
+ timestamp: ts,
39112
+ tripReason: result.reason,
39113
+ ops: pendingOps
39114
+ });
39115
+ } catch (err) {
39116
+ const msg = err instanceof Error ? err.message : "unknown error";
39117
+ out.log(`[DESTRUCTIVE_HELD_STASH_FAILED source=files-watch reason=${categorizeError(err).reason}]`);
39118
+ spinner.warn(`Could not quarantine ${batch.length} destructive file op${batch.length === 1 ? "" : "s"} (${msg}); they were dropped and stay local-only.`);
39119
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39120
+ refreshFrozen();
39121
+ return;
39122
+ }
38995
39123
  refreshFrozen();
38996
39124
  out.log(`[DESTRUCTIVE_HELD batch=${batchId} ops=${batch.length} reason=${result.reason.kind} source=files-watch]`);
38997
39125
  spinner.warn(`Held ${batch.length} destructive file op${batch.length === 1 ? "" : "s"} pending confirm → ${stashPath}. Run \`plinth confirm\` to review.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,