@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 +83 -0
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +186 -56
- package/package.json +1 -1
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
|
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.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
|
-
|
|
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
|
-
|
|
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 =
|
|
38901
|
-
|
|
38902
|
-
|
|
38903
|
-
|
|
38904
|
-
|
|
38905
|
-
|
|
38906
|
-
|
|
38907
|
-
state
|
|
38908
|
-
|
|
38909
|
-
|
|
38910
|
-
|
|
38911
|
-
|
|
38912
|
-
|
|
38913
|
-
|
|
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
|
-
|
|
38939
|
-
|
|
38940
|
-
|
|
38941
|
-
|
|
38942
|
-
|
|
38943
|
-
|
|
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 =
|
|
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
|
-
};
|
|
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
|
-
|
|
38989
|
-
|
|
38990
|
-
|
|
38991
|
-
|
|
38992
|
-
|
|
38993
|
-
|
|
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.`);
|