@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 +39 -0
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +183 -55
- package/package.json +1 -1
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
|
package/dist/build-stamp.json
CHANGED
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
38901
|
-
|
|
38902
|
-
|
|
38903
|
-
|
|
38904
|
-
|
|
38905
|
-
|
|
38906
|
-
|
|
38907
|
-
state
|
|
38908
|
-
|
|
38909
|
-
|
|
38910
|
-
|
|
38911
|
-
|
|
38912
|
-
|
|
38913
|
-
|
|
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
|
-
|
|
38939
|
-
|
|
38940
|
-
|
|
38941
|
-
|
|
38942
|
-
|
|
38943
|
-
|
|
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 =
|
|
38952
|
-
|
|
38953
|
-
|
|
38954
|
-
|
|
38955
|
-
|
|
38956
|
-
|
|
38957
|
-
|
|
38958
|
-
|
|
38959
|
-
|
|
38960
|
-
|
|
38961
|
-
|
|
38962
|
-
|
|
38963
|
-
|
|
38964
|
-
|
|
38965
|
-
|
|
38966
|
-
|
|
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
|
-
|
|
38989
|
-
|
|
38990
|
-
|
|
38991
|
-
|
|
38992
|
-
|
|
38993
|
-
|
|
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.`);
|