@plinth-music/cli 0.7.0 → 0.9.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,89 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.9.0 — 2026-08-24
6
+
7
+ Two PRs since `0.8.0` (`v0.8.0..4842d40`). An agent is now told to read a draft
8
+ back from the tool that staged it before calling it ready. No server prerequisite:
9
+ the change is client-side, and a 0.8.0 client is unaffected.
10
+
11
+ ### An agent reads its own draft back before saying it is ready
12
+
13
+ Primitive (e)'s outbound group gains a paragraph. The voice guidance added on
14
+ 20 Aug already told the agent that nothing inspects a draft between it and the
15
+ reader, then aimed that observation only at the WORDS. This is the other half:
16
+ inspect the artefact. Fetch the staged draft, look at its body, its recipients
17
+ and the thread it landed on, and only then say it is ready. A tool's own
18
+ description of how it handles your text is a claim like any other, and the first
19
+ use in a session is checked against what actually came out.
20
+
21
+ **The gap was the trigger, not the rule.** The workspace `CLAUDE.md` already
22
+ carried "where a document and the running system disagree, the system wins", and
23
+ it did not fire — a line describing tool usage does not read like a claim needing
24
+ verification. So this ships as a concrete read-back step rather than as another
25
+ principle the agent grades itself against.
26
+
27
+ Found by ordinary management work rather than by a sweep. An agent staged a mail
28
+ reply whose body was visible `<p>` and `<b>` markup and reported it staged; the
29
+ user found it in his own mailbox. The cause was not the tool but prose describing
30
+ it — a rule said a local helper "handles `text/html`", true of what that script
31
+ emits and false of what it takes. Two more defects of the same shape surfaced
32
+ while repairing it: an update path that silently strips `In-Reply-To` and
33
+ `References`, and a re-run that duplicates a draft rather than replacing it.
34
+
35
+ The prose deliberately names no email tool, and a test pins that: Plinth ships
36
+ none and cannot know what the user has. Grounding budget 9,050 → 9,944 bytes,
37
+ all of it in (e); primitive (j) held at 1,266 for the seventh time.
38
+
39
+ Honest limit, recorded rather than argued away: this is prose, and prose gets
40
+ skipped. The durable fix is a Plinth-owned outbound draft path that owns escaping
41
+ and threading in code for every user, at which point this paragraph becomes a
42
+ backstop rather than the surface.
43
+
44
+ ### Housekeeping
45
+
46
+ The test baseline in `CLAUDE.md` now reads 2052 pass / 0 fail / 107 files at
47
+ `b4d8ed3`, re-measured on a clean checkout rather than carried forward.
48
+
49
+ ## 0.8.0 — 2026-08-23
50
+
51
+ Two PRs and two direct commits since `0.7.0` (`v0.7.0..af83c59`). The daemon
52
+ survives a failed confirm gate, and the map it writes tells an agent which
53
+ command answers which question. No server prerequisite: both changes are
54
+ client-side, and a 0.7.0 client is unaffected.
55
+
56
+ ### A failing confirm gate no longer takes the daemon down with it
57
+
58
+ Both destructive gates fire their settle window fire-and-forget, so a rejecting
59
+ `onSettle` surfaced as an unhandled rejection and **terminated the long-running
60
+ daemon** — with the batch already lifted out of the gate's pending buffer, so its
61
+ remaining operations were neither pushed nor quarantined. Work disappeared behind
62
+ a process that had simply stopped.
63
+
64
+ Three layers now catch it. The gate reports a settle failure through a new
65
+ `onError` callback instead of rejecting into the void. The under-threshold flush
66
+ catches per-operation failures, logs the same `[PUSH_FAILED]` / `[FILE_PUSH_FAILED]`
67
+ lines the watch path already used, and hands recordable ones to the unsynced net
68
+ so the poll tick re-drives them — one operation failing no longer skips its
69
+ siblings. And a failed quarantine write warns on the banner rather than rejecting.
70
+
71
+ ### Every mirror's `CLAUDE.md` says how to traverse the corpus
72
+
73
+ A new **Navigating the corpus** section: three lookup lines mapping a traversal
74
+ question to the surface that answers it — what points at X (`plinth backlinks`),
75
+ what this file links to (grep `[[`), and how to find a file by name, text inside a
76
+ PDF, or an entity by field.
77
+
78
+ `plinth backlinks` shipped in `0.6.0` and appeared in no generated context.
79
+ Measured on the live mirror, the generated text carried no `backlinks`, no
80
+ `wikilink` and no `[[`, while the command answered a real query with 7 files and
81
+ 16 occurrences. A/B on the live mirror, same question in two fresh sessions: with
82
+ the section, the first tool call was `plinth backlinks`; without it, `grep -ril`.
83
+
84
+ The generated file's line bound moves 51 → 56. Its schema-version stamp does not —
85
+ the section is inline literal text, and none of the five constants the stamp hashes
86
+ changed.
87
+
5
88
  ## 0.7.0 — 2026-08-23
6
89
 
7
90
  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": "fb065c13301d65750f1a98761148486678da5b6d",
3
3
  "dirty": false,
4
- "built_at": "2026-08-23T13:01:01.515Z"
4
+ "built_at": "2026-08-24T19:03:09.765Z"
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.9.0",
9197
9197
  description: "Plinth capstone sync daemon and CLI",
9198
9198
  type: "module",
9199
9199
  private: false,
@@ -26916,7 +26916,9 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
26916
26916
  ``,
26917
26917
  `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying and forwarding are not yours, and neither is any shell command line ` + `that does the same thing. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, nothing changes ` + `except that you are the only thing holding it. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`,
26918
26918
  ``,
26919
- `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and the Voice section of your own \`user-memory.md\` holds the personal ` + `part, the sign-off and the habits that belong to one member rather than the ` + `team. Read them before you write and follow them as written, rather than ` + `drafting in your own register and correcting after. Nothing inspects a draft ` + `between you and the person reading it, so the rules in those files are the ` + `whole of it.`
26919
+ `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and the Voice section of your own \`user-memory.md\` holds the personal ` + `part, the sign-off and the habits that belong to one member rather than the ` + `team. Read them before you write and follow them as written, rather than ` + `drafting in your own register and correcting after. Nothing inspects a draft ` + `between you and the person reading it, so the rules in those files are the ` + `whole of it.`,
26920
+ ``,
26921
+ `Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`
26920
26922
  ];
26921
26923
  if (workspaceRules !== null) {
26922
26924
  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:
@@ -29507,6 +29509,7 @@ function backupStamp2(d2) {
29507
29509
  }
29508
29510
 
29509
29511
  // src/lib/context/workspace-claude-md.ts
29512
+ var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
29510
29513
  function rosterBlock(roster, activeProjectCountBySlug) {
29511
29514
  if (roster.length === 0)
29512
29515
  return "_No artists synced yet._";
@@ -29555,6 +29558,11 @@ function buildWorkspaceClaudeMd(input) {
29555
29558
  `- 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
29559
  `- ${MCP_AVAILABILITY_POINTER}`,
29557
29560
  ``,
29561
+ `## Navigating the corpus`,
29562
+ `- **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`,
29563
+ `- **What does this file link to?** → grep \`[[\` in it`,
29564
+ `- **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`,
29565
+ ``,
29558
29566
  `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
29559
29567
  ...entityLines,
29560
29568
  ``,
@@ -30555,10 +30563,12 @@ class DestructiveGate {
30555
30563
  settleMs;
30556
30564
  maxBatch;
30557
30565
  onSettle;
30566
+ onError;
30558
30567
  constructor(opts) {
30559
30568
  this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
30560
30569
  this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH;
30561
30570
  this.onSettle = opts.onSettle;
30571
+ this.onError = opts.onError;
30562
30572
  }
30563
30573
  consider(op) {
30564
30574
  this.pending.push(op);
@@ -30587,7 +30597,11 @@ class DestructiveGate {
30587
30597
  return;
30588
30598
  const batch = this.pending;
30589
30599
  this.pending = [];
30590
- await this.onSettle(batch);
30600
+ try {
30601
+ await this.onSettle(batch);
30602
+ } catch (err) {
30603
+ this.onError?.(err);
30604
+ }
30591
30605
  }
30592
30606
  }
30593
30607
 
@@ -32807,10 +32821,12 @@ class FileDestructiveGate {
32807
32821
  settleMs;
32808
32822
  maxBatch;
32809
32823
  onSettle;
32824
+ onError;
32810
32825
  constructor(opts) {
32811
32826
  this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS2;
32812
32827
  this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH2;
32813
32828
  this.onSettle = opts.onSettle;
32829
+ this.onError = opts.onError;
32814
32830
  }
32815
32831
  consider(op) {
32816
32832
  this.pending.push(op);
@@ -32839,7 +32855,11 @@ class FileDestructiveGate {
32839
32855
  return;
32840
32856
  const batch = this.pending;
32841
32857
  this.pending = [];
32842
- await this.onSettle(batch);
32858
+ try {
32859
+ await this.onSettle(batch);
32860
+ } catch (err) {
32861
+ this.onError?.(err);
32862
+ }
32843
32863
  }
32844
32864
  }
32845
32865
 
@@ -38644,6 +38664,97 @@ function discardSink() {
38644
38664
  const stream = { write: () => true };
38645
38665
  return { stdout: stream, stderr: stream };
38646
38666
  }
38667
+ async function flushHeldEntityOp(deps) {
38668
+ const now = deps.now ?? (() => new Date);
38669
+ try {
38670
+ const result = await pushFile({
38671
+ workspaceSlug: deps.workspaceSlug,
38672
+ baseUrl: deps.baseUrl,
38673
+ pat: deps.pat,
38674
+ absolutePath: deps.op.absolutePath,
38675
+ entityType: deps.op.entityType,
38676
+ state: deps.getState(),
38677
+ exists: deps.op.kind === "overwrite",
38678
+ unlinkContext: deps.op.kind === "delete" ? { id: deps.op.id, slug: deps.op.slug, updated_at: deps.op.updatedAt } : null,
38679
+ deps: {
38680
+ fetchImpl: deps.fetchImpl,
38681
+ now,
38682
+ mutex: deps.mutex,
38683
+ output: deps.output
38684
+ }
38685
+ });
38686
+ deps.setState(result.state);
38687
+ reportOutcome(deps.spinner, deps.workspaceSlug, deps.getState(), result.outcome);
38688
+ } catch (err) {
38689
+ const msg = err instanceof Error ? err.message : "unknown push error";
38690
+ deps.output?.log(pushFailedLine({
38691
+ entityType: deps.op.entityType,
38692
+ slug: deps.op.slug,
38693
+ id: deps.op.id,
38694
+ ...categorizeError(err)
38695
+ }));
38696
+ deps.spinner.warn(`Push failed for ${deps.op.entityType}/${deps.op.slug}: ${msg}`);
38697
+ if (isRecordableFailure(err)) {
38698
+ deps.setState(recordEntityUnsynced({
38699
+ state: deps.getState(),
38700
+ workspaceSlug: deps.workspaceSlug,
38701
+ absolutePath: deps.op.absolutePath,
38702
+ op: deps.op.kind === "delete" ? "delete" : "upsert",
38703
+ err,
38704
+ now
38705
+ }));
38706
+ }
38707
+ }
38708
+ }
38709
+ async function flushHeldFileOpImpl(deps) {
38710
+ const now = deps.now ?? (() => new Date);
38711
+ const fileDeps = {
38712
+ fetchImpl: deps.fetchImpl,
38713
+ now,
38714
+ output: deps.output,
38715
+ suppressor: deps.suppressor
38716
+ };
38717
+ const run = async () => {
38718
+ try {
38719
+ const result = deps.op.kind === "delete" ? await pushFilesDelete({
38720
+ workspaceSlug: deps.workspaceSlug,
38721
+ baseUrl: deps.baseUrl,
38722
+ pat: deps.pat,
38723
+ relPath: deps.op.relPath,
38724
+ state: deps.getState(),
38725
+ deps: fileDeps
38726
+ }) : await pushFilesUpsert({
38727
+ workspaceSlug: deps.workspaceSlug,
38728
+ baseUrl: deps.baseUrl,
38729
+ pat: deps.pat,
38730
+ absolutePath: deps.op.absolutePath,
38731
+ relPath: deps.op.relPath,
38732
+ state: deps.getState(),
38733
+ deps: fileDeps
38734
+ });
38735
+ deps.setState(result.state);
38736
+ reportFileOutcome(deps.spinner, deps.workspaceSlug, deps.getState(), result.outcome);
38737
+ } catch (err) {
38738
+ const msg = err instanceof Error ? err.message : "unknown push error";
38739
+ deps.output?.log(fileFailedLine({ path: deps.op.relPath, ...categorizeError(err) }));
38740
+ deps.spinner.warn(`File push failed for files/${deps.op.relPath}: ${msg}`);
38741
+ if (isRecordableFailure(err)) {
38742
+ deps.setState(recordFileUnsynced({
38743
+ state: deps.getState(),
38744
+ relPath: deps.op.relPath,
38745
+ op: deps.op.kind === "delete" ? "delete" : "upsert",
38746
+ err,
38747
+ now
38748
+ }));
38749
+ }
38750
+ }
38751
+ };
38752
+ if (deps.mutex) {
38753
+ await deps.mutex.run(`files:${deps.op.relPath}`, run);
38754
+ } else {
38755
+ await run();
38756
+ }
38757
+ }
38647
38758
  async function runStart(workspaceSlug, deps = {}) {
38648
38759
  const daemonLog = deps.logSink ?? new DaemonLog({ path: daemonLogPath(), now: deps.now });
38649
38760
  const out = new Output({
@@ -38897,22 +39008,25 @@ async function runStart(workspaceSlug, deps = {}) {
38897
39008
  return r3 ? { entityType: r3.entityType, slug: r3.entitySlug } : null;
38898
39009
  };
38899
39010
  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
- };
39011
+ const flushHeldOp = (op) => flushHeldEntityOp({
39012
+ workspaceSlug: slug,
39013
+ baseUrl: entry2.api_base_url,
39014
+ pat,
39015
+ op,
39016
+ getState: () => state,
39017
+ setState: (s2) => {
39018
+ state = s2;
39019
+ },
39020
+ mutex,
39021
+ fetchImpl,
39022
+ now: deps.now,
39023
+ output: out,
39024
+ spinner
39025
+ });
38915
39026
  const gate = new DestructiveGate({
39027
+ onError: (err) => {
39028
+ out.log(`[DESTRUCTIVE_SETTLE_FAILED source=entity-watch reason=${categorizeError(err).reason}]`);
39029
+ },
38916
39030
  onSettle: async (ops) => {
38917
39031
  const batch = dedupeOps(ops);
38918
39032
  if (batch.length === 0)
@@ -38935,43 +39049,49 @@ async function runStart(workspaceSlug, deps = {}) {
38935
39049
  prior_len: op.kind === "overwrite" ? op.priorLen : undefined,
38936
39050
  new_len: op.kind === "overwrite" ? op.newLen : undefined
38937
39051
  }));
38938
- const stashPath = stashPendingBatch({
38939
- workspaceSlug: slug,
38940
- batchId,
38941
- timestamp: ts,
38942
- tripReason: result.reason,
38943
- ops: pendingOps
38944
- });
39052
+ let stashPath;
39053
+ try {
39054
+ stashPath = stashPendingBatch({
39055
+ workspaceSlug: slug,
39056
+ batchId,
39057
+ timestamp: ts,
39058
+ tripReason: result.reason,
39059
+ ops: pendingOps
39060
+ });
39061
+ } catch (err) {
39062
+ const msg = err instanceof Error ? err.message : "unknown error";
39063
+ out.log(`[DESTRUCTIVE_HELD_STASH_FAILED source=entity-watch reason=${categorizeError(err).reason}]`);
39064
+ spinner.warn(`Could not quarantine ${batch.length} destructive op${batch.length === 1 ? "" : "s"} (${msg}); they were dropped and stay local-only.`);
39065
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39066
+ refreshFrozen();
39067
+ return;
39068
+ }
38945
39069
  refreshFrozen();
38946
39070
  out.log(`[DESTRUCTIVE_HELD batch=${batchId} ops=${batch.length} reason=${result.reason.kind}]`);
38947
39071
  spinner.warn(`Held ${batch.length} destructive op${batch.length === 1 ? "" : "s"} pending confirm → ${stashPath}. Run \`plinth confirm\` to review.`);
38948
39072
  spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
38949
39073
  }
38950
39074
  });
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
- };
39075
+ const flushHeldFileOp = (op) => flushHeldFileOpImpl({
39076
+ workspaceSlug: slug,
39077
+ baseUrl: entry2.api_base_url,
39078
+ pat,
39079
+ op,
39080
+ getState: () => state,
39081
+ setState: (s2) => {
39082
+ state = s2;
39083
+ },
39084
+ mutex,
39085
+ fetchImpl,
39086
+ now: deps.now,
39087
+ output: out,
39088
+ spinner,
39089
+ suppressor
39090
+ });
38974
39091
  const fileGate = new FileDestructiveGate({
39092
+ onError: (err) => {
39093
+ out.log(`[DESTRUCTIVE_SETTLE_FAILED source=files-watch reason=${categorizeError(err).reason}]`);
39094
+ },
38975
39095
  onSettle: async (ops) => {
38976
39096
  const batch = dedupeFileOps(ops);
38977
39097
  if (batch.length === 0)
@@ -38985,13 +39105,23 @@ async function runStart(workspaceSlug, deps = {}) {
38985
39105
  const ts = (deps.now ?? (() => new Date))();
38986
39106
  const batchId = batchIdFromTimestamp(ts);
38987
39107
  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
- });
39108
+ let stashPath;
39109
+ try {
39110
+ stashPath = stashPendingBatch({
39111
+ workspaceSlug: slug,
39112
+ batchId,
39113
+ timestamp: ts,
39114
+ tripReason: result.reason,
39115
+ ops: pendingOps
39116
+ });
39117
+ } catch (err) {
39118
+ const msg = err instanceof Error ? err.message : "unknown error";
39119
+ out.log(`[DESTRUCTIVE_HELD_STASH_FAILED source=files-watch reason=${categorizeError(err).reason}]`);
39120
+ spinner.warn(`Could not quarantine ${batch.length} destructive file op${batch.length === 1 ? "" : "s"} (${msg}); they were dropped and stay local-only.`);
39121
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39122
+ refreshFrozen();
39123
+ return;
39124
+ }
38995
39125
  refreshFrozen();
38996
39126
  out.log(`[DESTRUCTIVE_HELD batch=${batchId} ops=${batch.length} reason=${result.reason.kind} source=files-watch]`);
38997
39127
  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.9.0",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,