dsh-git-badge 0.18.0 → 0.19.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
@@ -10,6 +10,40 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.19.0] - 2026-10-12
14
+
15
+ ### Fixed
16
+
17
+ - **`revert` refuses a backup from a different upstream generation.** `apply`
18
+ patches whichever `SEAM_DATA_DIR` it is pointed at, so the shipped default
19
+ (`$DSH_HOME/git-badge-seam`) and this repo's wrapper (`seam/`) each keep their
20
+ own backup — and applying through one leaves the other's behind. After a DSH
21
+ update that stale backup holds an OLDER upstream, and `revert` used to restore
22
+ it blindly: a silent downgrade of the installed package. `revert` now validates
23
+ the backup before restoring it — the current anchors must resolve against it —
24
+ and refuses with a clear message, changing nothing.
25
+ - **`status` reports what `revert` would do**, under a `revert:` line: restore,
26
+ no-op, or REFUSE and why. SEAM.md had documented that line from the beginning;
27
+ it was never implemented, so the refusal could arrive as a surprise at the one
28
+ moment it was least welcome.
29
+ - **A worktree registration that cannot be written now says WHY.** The store
30
+ swallowed the cause, so `npx dsh-git-badge checkout <path>` reported only
31
+ "could not write the registration" — no path, no reason, no recovery — and the
32
+ next reader had to rediscover that a file sandbox or a read-only home can
33
+ refuse the write. It now names the target file, the real errno, and the three
34
+ ways out.
35
+
36
+ ### Added
37
+
38
+ - **The chip's hover card names the linked worktrees a session is NOT
39
+ following.** That state used to be invisible: a registration the sandbox
40
+ refused left a badge describing the session's own checkout, which looks exactly
41
+ like a session with nothing to follow, while the agent's work sat in a tree the
42
+ UI never mentioned. The card now reads
43
+ `2 linked · none registered — npx dsh-git-badge checkout <path>`. It rides
44
+ `detail=1` like the rest of the card's extras, so nothing at rest pays for the
45
+ extra `git worktree list`.
46
+
13
47
  ## [0.18.0] - 2026-10-12
14
48
 
15
49
  ### Fixed
package/lib/client.js CHANGED
@@ -760,6 +760,24 @@ window.__ModuleLoader__.load({
760
760
  return name;
761
761
  }
762
762
 
763
+ /**
764
+ * The `worktrees` row: how many LINKED worktrees this repository has while
765
+ * the badge follows none of them.
766
+ *
767
+ * It earns its place on one thing only — the failure it makes visible. A
768
+ * registration is written outside the repository, so a file sandbox or a
769
+ * read-only home can refuse it. When that happens the badge silently keeps
770
+ * describing the session's own checkout, and the agent's work sits in a tree
771
+ * the UI never names. That state is indistinguishable from a session with
772
+ * nothing to follow, so the card is the only place it can be said. The node
773
+ * half computes it under `detail=1` only, so nothing at rest pays for it.
774
+ */
775
+ function linkedWorktreesDetail(info) {
776
+ const linked = info?.linkedWorktrees;
777
+ if (typeof linked !== "number" || linked <= 0) return void 0;
778
+ return linked + " linked \u00B7 none registered \u2014 npx dsh-git-badge checkout <path>";
779
+ }
780
+
763
781
  /**
764
782
  * ONE commit line, shared by the branch hover and the PR token's hover so
765
783
  * the two cannot drift apart again: a fixed-width monospace hash cell, so
@@ -1076,6 +1094,11 @@ window.__ModuleLoader__.load({
1076
1094
  // WHICH checkout the badge describes, when it is a worktree. The chip shows
1077
1095
  // the branch alone, so this row is the only place the tree is named.
1078
1096
  add("worktree", worktreeDetail(data));
1097
+ // The linked-but-unregistered state — the on-screen difference between
1098
+ // "nothing to follow" and "the registration could not be written". Rides
1099
+ // the detail payload, so it appears once the hover has fetched, exactly
1100
+ // like the `checkout` row below it.
1101
+ add("worktrees", linkedWorktreesDetail(data));
1079
1102
  // The conversation's OWN directory, present only when the badge is
1080
1103
  // describing a followed worktree — the chip's branch and counts are the
1081
1104
  // tree's in that case, so this is where the checkout's own state stays
@@ -1870,7 +1893,7 @@ window.__ModuleLoader__.load({
1870
1893
  // Additive; the host reads apply/inject and ignores the rest. The suite
1871
1894
  // drives these to assert the REQUEST contract — which surface asks for the
1872
1895
  // expensive extras — without a browser, a fetch or a network.
1873
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
1896
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, linkedWorktreesDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
1874
1897
  return module.exports;
1875
1898
  }
1876
1899
  });
package/lib/index.js CHANGED
@@ -1686,6 +1686,17 @@ function apply(ctx) {
1686
1686
  }
1687
1687
  }
1688
1688
  }
1689
+ if (detail && info.git === true && session !== null && effective.worktree === void 0) {
1690
+ // No registration is in effect, so the badge describes the session's OWN
1691
+ // checkout. When this repository also has LINKED worktrees, that is the one
1692
+ // state worth naming: a registration write that failed (a file sandbox, a
1693
+ // read-only home) looks exactly like a session with nothing to follow, and
1694
+ // the agent's work then sits in a tree the badge never mentions. Gated on
1695
+ // detail=1 (a pointer resting on the chip), so nothing at rest pays for the
1696
+ // extra `git worktree list`.
1697
+ const { worktrees } = await worktreesFor(target.path);
1698
+ if (worktrees.length > 1) body.linkedWorktrees = worktrees.length - 1;
1699
+ }
1689
1700
  // Echo the resolved workspace id. SSE events identify the workspace,
1690
1701
  // not the session, so a session-targeted client has no other way to
1691
1702
  // tell whether an event belongs to it — without this the input chip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Git status badges for DeepSeek Harness: an input-row chip on every install, plus sidebar session-row badges (git status, PR/CI action tokens) after one manual step: `npx dsh-git-badge apply`, then restart dsh web. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/seam/apply.js CHANGED
@@ -47,9 +47,9 @@
47
47
  * Usage: npx dsh-git-badge apply | revert | status | checkout <path|--clear>
48
48
  * (equivalently apply.js <verb>; checkout delegates to checkout.js)
49
49
  */
50
- import { copyFileSync, existsSync, readFileSync } from "node:fs";
50
+ import { copyFileSync, existsSync } from "node:fs";
51
51
  import { MARKER } from "./anchors.js";
52
- import { backupState, doPatch, doRestore, installPaths, inspect, KNOWN_GOOD_HASH, sha256 } from "./patch.js";
52
+ import { backupState, doPatch, doRestore, installPaths, inspect, KNOWN_GOOD_HASH, revertPlan } from "./patch.js";
53
53
  import { installSkill } from "./skill.js";
54
54
  import { writeRestartMarker } from "./store.js";
55
55
 
@@ -115,6 +115,17 @@ if (verb === "status") {
115
115
  default:
116
116
  console.log(`verdict: ${r.state.toUpperCase()} — see error above.`);
117
117
  }
118
+ // Whether `revert` would act or refuse, reported BEFORE anyone needs it. The
119
+ // refusal can be about a backup that lives in a different store than the one
120
+ // being inspected, so revert time is the wrong moment to learn about it.
121
+ const plan = revertPlan(paths);
122
+ const revertLine =
123
+ plan.action === "refuse"
124
+ ? `would REFUSE — ${plan.reason}`
125
+ : plan.action === "noop"
126
+ ? `nothing to do — ${plan.reason}`
127
+ : `would restore the bytes as found — ${plan.reason}`;
128
+ console.log(`revert: ${revertLine}`);
118
129
  process.exit(VERDICT_EXIT_1.has(r.state) ? 1 : 0);
119
130
  }
120
131
 
@@ -182,40 +193,48 @@ if (verb === "apply") {
182
193
  }
183
194
  console.log(`now install the plugin: dsh plugin --profile web add dsh-git-badge`);
184
195
  } else {
185
- // revert
186
- if (existsSync(paths.backupClient)) {
187
- const r = inspect(paths);
188
- const installedHash = r.hash;
189
- const backupHash = sha256(readFileSync(paths.backupClient, "utf8"));
190
- if (installedHash === backupHash) {
191
- // Idempotent re-run: the installed bytes already ARE the backup.
192
- console.log("already reverted — installed client.js matches the backup; nothing to do.");
193
- } else if (r.state === "ours-current" || r.state === "ours-stale" || r.state === "ours-corrupt") {
194
- doRestore(paths);
195
- console.log("reverted client.js/index.js to the bytes as found before patching.");
196
- // A revert also changes bytes the running bundles were composed from.
197
- writeRestartMarker("revert");
198
- } else {
199
- // Installed file is not ours => upstream replaced it since patching.
200
- console.error("REFUSING to revert: the installed client.js is not this repo's");
201
- console.error(`artifact (state: ${r.state}), so upstream replaced it since the patch was`);
202
- console.error("applied. Restoring the backup would DOWNGRADE the package.");
203
- console.error("Nothing was changed. If the downgrade is really what you want, restore");
204
- console.error(`${paths.backupClient} by hand.`);
205
- process.exit(1);
206
- }
196
+ // revert — ONE decision (revertPlan), shared with the `status` report, so the
197
+ // refusal a user is warned about is the refusal they actually get.
198
+ const plan = revertPlan(paths);
199
+ const r = inspect(paths);
200
+ const ours = r.state === "ours-current" || r.state === "ours-stale" || r.state === "ours-corrupt";
201
+ const hasBackup = existsSync(paths.backupClient);
202
+
203
+ if (plan.action === "restore") {
204
+ doRestore(paths);
205
+ console.log("reverted client.js/index.js to the bytes as found before patching.");
206
+ // A revert also changes bytes the running bundles were composed from.
207
+ writeRestartMarker("revert");
208
+ } else if (plan.action === "noop") {
209
+ // Idempotent re-run: the installed bytes already ARE the backup.
210
+ console.log(`already reverted — ${plan.reason}; nothing to do.`);
211
+ } else if (hasBackup && ours) {
212
+ // The dangerous case: WE patched this build, but the backup beside it is
213
+ // from another upstream generation. Restoring it downgrades the package.
214
+ console.error("REFUSING to revert: the backup in this store is not a build this patch");
215
+ console.error(`targets — ${plan.reason}.`);
216
+ console.error("Restoring it would replace the installed build with an older upstream that the");
217
+ console.error("current anchors cannot re-apply. Nothing was changed.");
218
+ console.error("Revert from the store that patched this build instead — its `status` prints");
219
+ console.error("`revert: would restore ...` — or reinstall the DSH package.");
220
+ process.exit(1);
221
+ } else if (hasBackup) {
222
+ // Installed file is not ours => upstream replaced it since patching.
223
+ console.error("REFUSING to revert: the installed client.js is not this repo's");
224
+ console.error(`artifact (state: ${r.state}). Restoring the backup would DOWNGRADE the`);
225
+ console.error("package. Nothing was changed. If the downgrade is really what you want,");
226
+ console.error(`restore ${paths.backupClient} by hand.`);
227
+ process.exit(1);
228
+ } else if (ours) {
229
+ console.error("REFUSING: the installed file is patched but no backup exists in");
230
+ console.error("the seam data dir, so the original bytes are unknown here. Options:");
231
+ console.error(" 1. If you applied from a clone of the plugin's repository, run its");
232
+ console.error(" wrapper instead — it keeps its own backup:");
233
+ console.error(" <clone>/seam/apply.sh revert");
234
+ console.error(" 2. Otherwise reinstall the package — pristine files, guaranteed:");
235
+ console.error(" npm i -g @deepseek-ai/dsh@latest");
236
+ process.exit(1);
207
237
  } else {
208
- const r = inspect(paths);
209
- if (r.state === "ours-current" || r.state === "ours-stale" || r.state === "ours-corrupt") {
210
- console.error("REFUSING: the installed file is patched but no backup exists in");
211
- console.error("the seam data dir, so the original bytes are unknown here. Options:");
212
- console.error(" 1. If you applied from a clone of the plugin's repository, run its");
213
- console.error(" wrapper instead — it keeps its own backup:");
214
- console.error(" <clone>/seam/apply.sh revert");
215
- console.error(" 2. Otherwise reinstall the package — pristine files, guaranteed:");
216
- console.error(" npm i -g @deepseek-ai/dsh@latest");
217
- process.exit(1);
218
- }
219
238
  console.log("no backup present and nothing of ours installed; client.js left untouched.");
220
239
  }
221
240
  // restore the no-op host half if no backup of the original exists
package/seam/checkout.js CHANGED
@@ -77,8 +77,19 @@ if (!paths.includes(path)) {
77
77
  process.exit(1);
78
78
  }
79
79
 
80
- if (!writeSessionCheckout(sessionId, path)) {
81
- console.error("[dsh-git-badge] could not write the registration; the badge keeps its inferred checkout.");
80
+ const written = writeSessionCheckout(sessionId, path);
81
+ if (written.ok !== true) {
82
+ // The failure the badge cannot show. Say the CAUSE and the three ways out, so
83
+ // this never reads as "the worktree has no changes" — which is exactly what a
84
+ // failed registration looks like from the UI.
85
+ console.error(`[dsh-git-badge] could not write the registration to ${written.file}`);
86
+ console.error(` reason: ${written.error}${written.code === "" ? "" : ` (${written.code})`}`);
87
+ console.error(" The badge therefore keeps describing THIS session's own checkout, so the");
88
+ console.error(" worktree's state will not appear. Pick one:");
89
+ console.error(" 1. give this process write access to that directory, then re-run this command;");
90
+ console.error(" 2. run this command from a shell that already has it;");
91
+ console.error(" 3. add the worktree as its own dsh workspace — a session opened there needs no");
92
+ console.error(" registration at all, because its working directory IS the worktree.");
82
93
  process.exit(1);
83
94
  }
84
95
  console.log(`[dsh-git-badge] this session's checkout is now ${basename(path)} (${branchOf(path)}).`);
package/seam/patch.js CHANGED
@@ -120,6 +120,45 @@ export function backupState(paths) {
120
120
  : "drift";
121
121
  }
122
122
 
123
+ /**
124
+ * What `revert` WOULD do, without doing it — the ONE decision the `status`
125
+ * report and the revert branch both read, so they cannot drift apart. They did:
126
+ * `revert: would REFUSE` was documented in SEAM.md and never implemented, which
127
+ * is the same shape of bug as the hole this closes.
128
+ *
129
+ * A legitimate backup is the bytes as found before patching, so the CURRENT
130
+ * anchors must still resolve against it (`backupState` is "patchable"). A store
131
+ * whose backup predates a DSH update satisfies neither condition: it holds an
132
+ * OLDER upstream, so restoring it would downgrade the package AND leave a build
133
+ * this patch cannot re-apply.
134
+ *
135
+ * That store is easy to acquire by accident, because `apply` patches whichever
136
+ * store it is pointed at: the shipped patcher defaults to
137
+ * `$DSH_HOME/git-badge-seam` while this repo's wrapper keeps its own under
138
+ * `seam/`. Apply through one and the other's backup stays behind.
139
+ *
140
+ * @returns {{ action: "restore" | "noop" | "refuse", reason: string }}
141
+ */
142
+ export function revertPlan(paths) {
143
+ if (!existsSync(paths.backupClient))
144
+ return { action: "refuse", reason: "no backup in this store, so the original bytes are unknown" };
145
+ const r = inspect(paths);
146
+ if (r.hash === sha256(readText(paths.backupClient)))
147
+ return { action: "noop", reason: "installed client.js already matches the backup" };
148
+ if (r.state !== "ours-current" && r.state !== "ours-stale" && r.state !== "ours-corrupt")
149
+ return {
150
+ action: "refuse",
151
+ reason: `the installed client.js is not this repo's artifact (state: ${r.state})`
152
+ };
153
+ const bstate = backupState(paths);
154
+ if (bstate !== "patchable")
155
+ return {
156
+ action: "refuse",
157
+ reason: `the backup is '${bstate}' — a different upstream generation, not a build this patch targets`
158
+ };
159
+ return { action: "restore", reason: "the backup is a build this patch targets" };
160
+ }
161
+
123
162
  /**
124
163
  * Backup the bytes AS FOUND, patch client.js in place, install the no-op host
125
164
  * half, and record the restart marker (`reason` names the actor: apply |
package/seam/store.js CHANGED
@@ -105,20 +105,24 @@ export function readSessionCheckouts(file = sessionCheckoutsPath()) {
105
105
  /**
106
106
  * Register the checkout a session is working in. Written by the agent (the
107
107
  * git-worktree and /gh checkout paths), read by the node half.
108
+ *
109
+ * Returns the OUTCOME rather than a boolean, because the reason matters here more
110
+ * than anywhere else in this file. A registration that cannot be written is the
111
+ * one failure the badge CANNOT show: the session quietly keeps its inferred
112
+ * checkout, which looks identical to a session with nothing to follow. The caller
113
+ * gets `error`/`code` so it can say what went wrong and where, instead of
114
+ * reporting a bare failure that the next reader has to rediscover the hard way.
108
115
  */
109
116
  export function writeSessionCheckout(sessionId, path) {
117
+ const file = sessionCheckoutsPath();
110
118
  try {
111
119
  mkdirSync(dataDir(), { recursive: true });
112
120
  const sessions = readSessionCheckouts();
113
121
  sessions[sessionId] = { path, at: new Date().toISOString() };
114
- writeFileSync(
115
- sessionCheckoutsPath(),
116
- JSON.stringify({ schema: "dsh-git-badge/session-checkout/v1", sessions }),
117
- "utf8",
118
- );
119
- return true;
120
- } catch {
121
- return false;
122
+ writeFileSync(file, JSON.stringify({ schema: "dsh-git-badge/session-checkout/v1", sessions }), "utf8");
123
+ return { ok: true, file };
124
+ } catch (error) {
125
+ return { ok: false, file, error: String(error?.message ?? error), code: String(error?.code ?? "") };
122
126
  }
123
127
  }
124
128