dsh-git-badge 0.15.0 → 0.16.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,47 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.0] - 2026-09-16
14
+
15
+ ### Added
16
+
17
+ - **A session now tells the badge which checkout it is working in.** dsh cannot
18
+ supply that fact — a session's `cwd` is immutable creation metadata and always
19
+ the main checkout — so the agent registers it explicitly:
20
+ `npx dsh-git-badge-checkout <path>` (shipped bin), run by the `git-worktree`
21
+ skill after `git worktree add` and by `/gh checkout` when a switch lands in or
22
+ out of a linked worktree. The node half prefers the registration, and
23
+ validates it: git's own worktree list must know the path, the entry expires
24
+ after 24h, and the name/branch come from git, never from the file. The
25
+ registration is self-clearing — removing it (or letting it expire) retires the
26
+ extra watcher and the badge returns to the session's own directory.
27
+
28
+ - **A settings card for the ranking order: Settings → Plugins → Git Badge.**
29
+ The order now lives in this plugin's own `git-badge` settings namespace —
30
+ declared with a schema, validated and stored by the host, observed live — and
31
+ the card reorders the seven categories with up/down controls, Save/Discard,
32
+ and Reset to default. No file polling, no restart: a change applies to the
33
+ next status read. A pre-existing `~/.dsh/git-badge-next.json` seeds the
34
+ namespace as its base (and remains the fallback on hosts without the settings
35
+ stack), so an order configured before the card existed survives the upgrade.
36
+ `/gh order` still works for agent-driven changes.
37
+
38
+ ### Changed
39
+
40
+ - **The ranking card sits in an expander, like the host's other plugin cards.**
41
+ Collapsed by default with the title and a one-line description; the list,
42
+ reorder controls, and Save/Discard/Reset live in the body, which auto-collapses
43
+ once a save settles clean. The card chrome reproduces the host's plugin-card
44
+ values inline (its CSS module is not exported to plugins), so it reads as one
45
+ of the set rather than loose content between it.
46
+
47
+ - **PR inference is gone.** The badge no longer guesses a worktree from the
48
+ branch with an open pull request. The guess was invisible to the session that
49
+ made it and silently wrong whenever two trees were in play; a `worktreeInferred`
50
+ marker, the repository-wide `gh pr list` read that powered it, and the
51
+ inference wording in the hover all go with it. A session follows its
52
+ registration or stays on its own checkout — nothing else.
53
+
13
54
  ## [0.15.0] - 2026-09-15
14
55
 
15
56
  ### Changed
package/README.md CHANGED
@@ -5,13 +5,13 @@ Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek
5
5
  - **Input-row chip** — `● main ↑0 ↓2 ✎3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
6
6
  - **Hover card** — rest a pointer on the chip for what won't fit: the file breakdown (`✎3` split into staged / unstaged / untracked), the last three commits, and the stash. Fetched only on hover, so the everyday badge pays nothing for it. Works on every install.
7
7
  - **PR / CI token** — `● main ↑0 ↓2 PR#142 ✗` when the branch has a GitHub pull request, read through your own `gh` CLI (the plugin never handles a credential). The token **links to the PR** — it opens in a new tab, and underlines when you hover or focus it. Invisible when `gh` cannot answer: not installed, not logged in, no GitHub remote, offline or rate-limited all render the unchanged chip, and a token with no URL to link to stays plain text.
8
- - **Worktree-aware** — a conversation's directory is fixed when it is created, so a conversation that does its work in a linked `git worktree` would otherwise keep reporting the main checkout. When exactly one linked worktree of that repository has an open pull request, the badge describes **that worktree** instead — its branch, counts, tree mark and PR/CI token (`🌳 chore/global-skills-tiering ↑0 ↓2 PR#391 ✓` rather than `● main`). The hover card names the tree in a `worktree` row, says it was inferred, and adds a `checkout` row with the conversation's own directory, so the main checkout's branch and dirty state stay one hover away. One candidate only — two open PRs across worktrees is a reason to say nothing rather than guess — and a session row always reports the checkout that session is working in.
8
+ - **Worktree-aware** — a conversation's directory is fixed when it is created, so a conversation that does its work in a linked `git worktree` would otherwise keep reporting the main checkout. The session registers its checkout (`npx dsh-git-badge-checkout <path>`, run by the git-worktree and `/gh checkout` paths), and the badge describes **that worktree** instead — its branch, counts, tree mark and PR/CI token (`🌳 chore/global-skills-tiering ↑0 ↓2 PR#391 ✓` rather than `● main`). The hover card names the tree in a `worktree` row and adds a `checkout` row with the conversation's own directory, so the main checkout's branch and dirty state stay one hover away. Registrations are advisory: git's own worktree list validates the path, entries expire after 24h, and a workspace-targeted row always reports the checkout the registry owns.
9
9
  - **Sidebar session-row action token** — each session row shows the ONE thing that conversation needs from you, or nothing at all: `merge`, `fix CI`, `review`, `resolve`, `pull`, `push`. It is triage rather than status: `merge` appears only when GitHub itself reports `mergeStateStatus: CLEAN`, and every waiting state (draft, blocked, behind, checks still running) stays silent. Hovering the row adds a line naming the checkout and the pull request the action refers to. The token floats to the right of the row, is set heavier than the timestamp beside it, and is coloured by how much it needs you — red for a conflict or a failing build, amber for a requested review, green for `merge`, quiet grey for routine sync. Requires the `sidebar.workspaces.sessionRow` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
10
10
  - **Nothing on workspace rows.** A workspace cannot know which worktree its conversations are using, so a per-workspace badge could only guess or report the main checkout. The seam is on the session row for that reason.
11
11
 
12
12
  The leading **mark** belongs to the input chip: a circle filled with the status colour, or a **tree** when the checkout is a linked `git worktree`. The sidebar row deliberately carries no mark — see the row bullet above, where one word does the job a colour cannot. (`●`/`🌳` stand in for it here — it is a drawn SVG so that it can take the theme's status colours.)
13
13
 
14
- Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit, including inside a linked worktree that is only inferred. One `git status --porcelain=v2` per sample, shared by every surface pointing at the same checkout, plus at most one `gh` read per repository-and-branch per ~90s and one repository-wide open-PR list per ~90s. The status API takes **no path**: the caller passes a session id (chip and sidebar rows) or a workspace id, and the server resolves the directory itself.
14
+ Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit, including inside a linked worktree the session registered. One `git status --porcelain=v2` per sample, shared by every surface pointing at the same checkout, plus at most one `gh` read per repository-and-branch per ~90s. The status API takes **no path**: the caller passes a session id (chip and sidebar rows) or a workspace id, and the server resolves the directory itself.
15
15
 
16
16
  Diagrams, the hover card's full field list and a walkthrough for git newcomers live
17
17
  in the [repo README](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) —
package/lib/client.js CHANGED
@@ -436,9 +436,9 @@ window.__ModuleLoader__.load({
436
436
  "aria-label":
437
437
  (isWorktree ? "git worktree: " : "git: ") +
438
438
  STATUS_LABEL[status] +
439
- // an inferred checkout is not where the conversation lives, so the
440
- // accessible name says where the fact came from
441
- (info.worktreeInferred === true ? " (inferred from an open pull request in this repository)" : ""),
439
+ // a followed checkout is not where the conversation lives, and the
440
+ // accessible name says so — the session registered it explicitly
441
+ (info.worktreeFollowed === true ? " (registered for this session)" : ""),
442
442
  focusable: "false",
443
443
  style: { flex: "none", display: "block", color: META_STYLE.color }
444
444
  };
@@ -585,7 +585,7 @@ window.__ModuleLoader__.load({
585
585
  const checkout = [name === "" ? String(info.branch) : name + " on " + info.branch];
586
586
  // the node half followed this tree because it is the one whose branch
587
587
  // has the open PR — say so, because nothing on the row can
588
- if (info.worktreeInferred === true) checkout.push("its branch has the open pull request");
588
+
589
589
  bits.push("checkout: " + checkout.join(" \u00B7 "));
590
590
  }
591
591
  const pr = formatPrDetail(info.pr);
@@ -623,7 +623,7 @@ window.__ModuleLoader__.load({
623
623
 
624
624
  /**
625
625
  * The `checkout` row: the directory the CONVERSATION itself names, present
626
- * only when the badge is describing an inferred worktree instead. The chip's
626
+ * only when the badge is describing a FOLLOWED worktree instead. The chip's
627
627
  * branch and counts are the tree's in that case, so without this row the
628
628
  * checkout's own state is nowhere on screen. Branch, then files (or "clean"),
629
629
  * then sync counts only when they are nonzero — the chip's own rule.
@@ -642,15 +642,13 @@ window.__ModuleLoader__.load({
642
642
  * The `worktree` row: WHICH linked worktree the badge is describing. The chip
643
643
  * names only the branch (see the note above formatOperationToken), so the
644
644
  * card is the one place a tree is identified — the mark's shape can only say
645
- * *that* the checkout is a worktree, never which. An inferred follow also says
646
- * why, because "which tree" and "why this tree" are different questions and
647
- * the second is the one a reader will ask.
645
+ * *that* the checkout is a worktree, never which.
648
646
  */
649
647
  function worktreeDetail(info) {
650
648
  if (info === void 0 || info === null || info.isWorktree !== true) return void 0;
651
649
  const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
652
650
  if (name === "") return void 0;
653
- return info.worktreeInferred === true ? name + " (inferred from its open pull request)" : name;
651
+ return name;
654
652
  }
655
653
 
656
654
  const CARD_CONTAINER = {
@@ -820,6 +818,51 @@ window.__ModuleLoader__.load({
820
818
  const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
821
819
  const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
822
820
 
821
+ // The host's plugin-card chrome, reproduced inline: its CSS module is not
822
+ // exported to plugins, so these values mirror PluginCard.module.css so our
823
+ // card sits in the same list as its siblings.
824
+ const CARD_BASE = {
825
+ border: ".5px solid var(--dsw-alias-border-l4, #e4e8eb)",
826
+ borderRadius: "16px",
827
+ listStyle: "none",
828
+ transition: "border-color .16s, background .16s"
829
+ };
830
+ const CARD_CLOSED = { ...CARD_BASE, background: "var(--dsw-alias-bg-layer-3, #ffffff)" };
831
+ const CARD_OPEN = {
832
+ ...CARD_BASE,
833
+ background: "var(--dsw-alias-bg-layer-2, #f7f8f9)",
834
+ borderColor: "var(--dsw-alias-label-dimmed, #b8bfc4)"
835
+ };
836
+ const CARD_HEADER = {
837
+ appearance: "none",
838
+ width: "100%",
839
+ font: "inherit",
840
+ color: "inherit",
841
+ textAlign: "left",
842
+ cursor: "pointer",
843
+ background: "none",
844
+ border: 0,
845
+ borderRadius: "12px",
846
+ display: "flex",
847
+ alignItems: "center",
848
+ gap: "12px",
849
+ padding: "14px 16px"
850
+ };
851
+ const CARD_NAME = { color: "var(--dsw-alias-label-primary, #1f2328)", fontSize: "15px", fontWeight: "600", lineHeight: "1.4" };
852
+ const CARD_DESCRIPTION = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", fontSize: "13px", lineHeight: "1.5" };
853
+
854
+ const CARD_BUTTON = {
855
+ cursor: "pointer",
856
+ color: "inherit",
857
+ background: "none",
858
+ border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
859
+ borderRadius: "4px",
860
+ fontSize: "11px",
861
+ lineHeight: "18px",
862
+ padding: "0 6px",
863
+ flex: "none"
864
+ };
865
+
823
866
  /** One label/value row — the shape every hover body shares. */
824
867
  function cardRow(label, value) {
825
868
  return react_jsx_runtime.jsxs("div", {
@@ -868,10 +911,10 @@ window.__ModuleLoader__.load({
868
911
  // the branch alone, so this row is the only place the tree is named.
869
912
  add("worktree", worktreeDetail(data));
870
913
  // The conversation's OWN directory, present only when the badge is
871
- // describing an inferred worktree — the chip's branch and counts are the
914
+ // describing a followed worktree — the chip's branch and counts are the
872
915
  // tree's in that case, so this is where the checkout's own state stays
873
916
  // visible. Absent until the `detail=1` response lands, like commits/stash.
874
- if (data.worktreeInferred === true) add("checkout", formatCheckoutDetail(data.checkout));
917
+ if (data.worktreeFollowed === true) add("checkout", formatCheckoutDetail(data.checkout));
875
918
  add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
876
919
  return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
877
920
  }
@@ -1172,6 +1215,162 @@ window.__ModuleLoader__.load({
1172
1215
  // `workspaces` is no longer required: both surfaces target an id and the
1173
1216
  // node half resolves the workspace, so the client never needs a service
1174
1217
  // lookup. Fewer declared services also means fewer ways to fail to load.
1218
+ /** The built-in ranking order — what "Reset to default" restores. */
1219
+ const DEFAULT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
1220
+
1221
+ /**
1222
+ * Move one category within the ranking order — the card's only reorder
1223
+ * primitive, pure so the suite can drive it without a browser. Out-of-range
1224
+ * moves are no-ops (the buttons disable at the ends, this is the belt).
1225
+ */
1226
+ function moveCategory(order, index, delta) {
1227
+ const next = [...order];
1228
+ const to = index + delta;
1229
+ if (index < 0 || index >= next.length || to < 0 || to >= next.length) return next;
1230
+ const [moved] = next.splice(index, 1);
1231
+ next.splice(to, 0, moved);
1232
+ return next;
1233
+ }
1234
+
1235
+ /** Human labels for the ranking categories, in default order. */
1236
+ const CATEGORY_LABELS = {
1237
+ operation: ["operation", "resume a paused merge/rebase"],
1238
+ unmerged: ["unmerged", "resolve conflict markers"],
1239
+ sync: ["sync", "pull, or rebase + confirmed force-push"],
1240
+ publish: ["publish", "push local commits"],
1241
+ merge: ["merge", "a merge-ready pull request"],
1242
+ commit: ["commit", "stage and commit working changes"],
1243
+ checks: ["checks", "watch failing CI"]
1244
+ };
1245
+
1246
+ /**
1247
+ * The settings card's body — Settings → Plugins → Git Badge. Reads the
1248
+ * bound settings scope through the store the registration injects and
1249
+ * writes back one field ("order") via the scope's mutate path. Styled like
1250
+ * this plugin's other surfaces rather than the host's card primitives,
1251
+ * which are not exported to plugins.
1252
+ */
1253
+ function GitBadgeOrderCard(props) {
1254
+ const state = props.useGitBadgeOrderCard((snapshot) => snapshot);
1255
+ const t = props.t;
1256
+ // Local expander state, mirroring the host's plugin cards: collapsed by
1257
+ // default, and auto-collapsed again once a save settles clean (so the
1258
+ // card confirms itself the way its siblings do).
1259
+ const [open, setOpen] = react.useState(false);
1260
+ const saveStarted = react.useRef(false);
1261
+ react.useEffect(() => {
1262
+ if (state.saving) {
1263
+ saveStarted.current = true;
1264
+ return;
1265
+ }
1266
+ if (!saveStarted.current) return;
1267
+ saveStarted.current = false;
1268
+ if (!state.dirty && !state.failed) setOpen(false);
1269
+ }, [state.dirty, state.failed, state.saving]);
1270
+ if (!state.available) return null;
1271
+ const rows = state.order.map((cat, index) => {
1272
+ const [label, hint] = CATEGORY_LABELS[cat] ?? [cat, ""];
1273
+ return react_jsx_runtime.jsxs("div", {
1274
+ style: { display: "flex", alignItems: "center", gap: "8px", padding: "2px 0" },
1275
+ children: [
1276
+ react_jsx_runtime.jsx("span", {
1277
+ style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", fontFamily: "monospace", minWidth: "16px", textAlign: "right" },
1278
+ children: String(index + 1)
1279
+ }),
1280
+ react_jsx_runtime.jsx("span", { style: { minWidth: "84px" }, children: label }),
1281
+ react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "1", minWidth: 0 }, children: hint }),
1282
+ react_jsx_runtime.jsx("button", {
1283
+ type: "button",
1284
+ "aria-label": t("moveUp") + " " + label,
1285
+ disabled: state.saving || !state.writable || index === 0,
1286
+ onClick: () => props.move(index, -1),
1287
+ style: CARD_BUTTON,
1288
+ children: "\u2191"
1289
+ }),
1290
+ react_jsx_runtime.jsx("button", {
1291
+ type: "button",
1292
+ "aria-label": t("moveDown") + " " + label,
1293
+ disabled: state.saving || !state.writable || index === state.order.length - 1,
1294
+ onClick: () => props.move(index, 1),
1295
+ style: CARD_BUTTON,
1296
+ children: "\u2193"
1297
+ })
1298
+ ]
1299
+ }, cat);
1300
+ });
1301
+ // The card chrome matches the host's own plugin cards (its CSS module is
1302
+ // not exported to plugins, so the values are reproduced inline): an <li>
1303
+ // with the bordered card, a header button that expands, and a body that
1304
+ // carries the list and the footer actions.
1305
+ return react_jsx_runtime.jsxs("li", {
1306
+ style: open ? CARD_OPEN : CARD_CLOSED,
1307
+ children: [
1308
+ react_jsx_runtime.jsxs("button", {
1309
+ type: "button",
1310
+ "aria-expanded": open,
1311
+ "aria-label": t(open ? "collapse" : "expand") + ": " + t("title"),
1312
+ onClick: () => setOpen(!open),
1313
+ style: CARD_HEADER,
1314
+ children: [
1315
+ react_jsx_runtime.jsxs("span", {
1316
+ style: { display: "flex", flexDirection: "column", flex: "1", gap: "4px", minWidth: 0 },
1317
+ children: [
1318
+ react_jsx_runtime.jsx("span", { style: CARD_NAME, children: t("title") }),
1319
+ react_jsx_runtime.jsx("span", { style: CARD_DESCRIPTION, children: t("description") })
1320
+ ]
1321
+ }),
1322
+ react_jsx_runtime.jsx("span", {
1323
+ "aria-hidden": true,
1324
+ style: open
1325
+ ? { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", transform: "rotate(180deg)", transition: "transform .16s" }
1326
+ : { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", transition: "transform .16s" },
1327
+ children: (primitives !== null && primitives.IconChevronDownOutline14 !== void 0
1328
+ ? react_jsx_runtime.jsx(primitives.IconChevronDownOutline14, {})
1329
+ : "\u25BE")
1330
+ })
1331
+ ]
1332
+ }),
1333
+ open
1334
+ ? react_jsx_runtime.jsxs("div", {
1335
+ style: { borderTop: ".5px solid var(--dsw-alias-border-l2, #e4e8eb)", margin: "0 16px", paddingBottom: "8px" },
1336
+ children: [
1337
+ react_jsx_runtime.jsx("div", { style: { padding: "10px 0 6px" }, children: t("intro") }),
1338
+ react_jsx_runtime.jsx("div", { style: { display: "flex", flexDirection: "column" }, children: rows }),
1339
+ react_jsx_runtime.jsxs("div", {
1340
+ style: { display: "flex", gap: "8px", alignItems: "center", justifyContent: "flex-end", padding: "12px 0 4px" },
1341
+ children: [
1342
+ state.failed ? react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-error, #d13438)", flex: "1", fontSize: "12px" }, children: t("failed") }) : null,
1343
+ state.writable ? null : react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "1", fontSize: "12px" }, children: t("readOnly") }),
1344
+ react_jsx_runtime.jsx("button", {
1345
+ type: "button",
1346
+ disabled: !state.dirty || state.saving,
1347
+ onClick: props.discard,
1348
+ style: CARD_BUTTON,
1349
+ children: t("discard")
1350
+ }),
1351
+ react_jsx_runtime.jsx("button", {
1352
+ type: "button",
1353
+ disabled: !state.dirty || state.saving || !state.writable,
1354
+ onClick: props.save,
1355
+ style: CARD_BUTTON,
1356
+ children: state.saving ? t("saving") : t("save")
1357
+ }),
1358
+ react_jsx_runtime.jsx("button", {
1359
+ type: "button",
1360
+ disabled: state.saving || !state.writable,
1361
+ onClick: props.reset,
1362
+ style: CARD_BUTTON,
1363
+ children: t("reset")
1364
+ })
1365
+ ]
1366
+ })
1367
+ ]
1368
+ })
1369
+ : null
1370
+ ]
1371
+ });
1372
+ }
1373
+
1175
1374
  const inject = ["slots", "inputTriggers"];
1176
1375
 
1177
1376
  /**
@@ -1267,6 +1466,126 @@ window.__ModuleLoader__.load({
1267
1466
  });
1268
1467
  }
1269
1468
 
1469
+ // The settings card (Settings → Plugins → Git Badge): the order lives
1470
+ // in this plugin's `git-badge` settings namespace, which the node half
1471
+ // registers; this half renders the card that edits it through the bound
1472
+ // scope — reads from the scope snapshot, writes one field via mutate.
1473
+ // Guarded: a host without settingsScope loses the card, never the
1474
+ // badges.
1475
+ if (typeof ctx.inject === "function") {
1476
+ try {
1477
+ ctx.inject(["settingsScope", "slots", "locale"], (scopeCtx) => {
1478
+ const NS = "dsh-git-badge";
1479
+ scopeCtx.effect(() => scopeCtx.locale.register(NS, {
1480
+ en: {
1481
+ title: "Git Badge",
1482
+ description: "Which action class the badge suggests first.",
1483
+ expand: "Expand",
1484
+ collapse: "Collapse",
1485
+ intro: "The badge suggests one next action; this is the order it considers the classes in.",
1486
+ save: "Save",
1487
+ discard: "Discard",
1488
+ reset: "Reset to default",
1489
+ saving: "Saving\u2026",
1490
+ failed: "save failed \u2014 try again",
1491
+ readOnly: "read-only on this host",
1492
+ moveUp: "Move up",
1493
+ moveDown: "Move down"
1494
+ }
1495
+ }), "dsh-git-badge: settings locale");
1496
+ const scope = scopeCtx.settingsScope.bind({ namespace: "git-badge" });
1497
+ // draft === null means "mirror the stored order"; any reorder
1498
+ // stages a copy, and only a confirmed write clears it — a
1499
+ // rejected write leaves the draft for a retry rather than
1500
+ // pretending it saved.
1501
+ let draft = null;
1502
+ let saving = false;
1503
+ let failed = false;
1504
+ const subscribers = new Set();
1505
+ let snapshot = null;
1506
+ const storedOrder = () => {
1507
+ const view = scope.getSnapshot();
1508
+ return Array.isArray(view?.value?.order) ? view.value.order : DEFAULT_ORDER;
1509
+ };
1510
+ const build = () => {
1511
+ const view = scope.getSnapshot();
1512
+ const stored = storedOrder();
1513
+ const order = draft ?? stored;
1514
+ return {
1515
+ available: view?.status === "ready",
1516
+ writable: view?.writable === true,
1517
+ order,
1518
+ dirty: draft !== null && draft.join() !== stored.join(),
1519
+ saving,
1520
+ failed
1521
+ };
1522
+ };
1523
+ const publish = () => {
1524
+ snapshot = build();
1525
+ for (const listener of subscribers) listener();
1526
+ };
1527
+ scopeCtx.effect(() => {
1528
+ const off = scope.subscribe(publish);
1529
+ return () => {
1530
+ if (typeof off === "function") off();
1531
+ };
1532
+ }, "dsh-git-badge: settings scope");
1533
+ publish();
1534
+ const store = {
1535
+ getSnapshot: () => snapshot ?? build(),
1536
+ subscribe: (listener) => {
1537
+ subscribers.add(listener);
1538
+ return () => subscribers.delete(listener);
1539
+ }
1540
+ };
1541
+ const save = async () => {
1542
+ if (draft === null || saving) return;
1543
+ const wanted = [...draft];
1544
+ saving = true;
1545
+ failed = false;
1546
+ publish();
1547
+ try {
1548
+ await scope.mutate([{ op: "set", path: ["order"], value: wanted }]);
1549
+ } catch (error) {
1550
+ console.error("[dsh-git-badge] order save failed:", error);
1551
+ }
1552
+ saving = false;
1553
+ if (storedOrder().join() === wanted.join()) draft = null;
1554
+ else failed = true;
1555
+ publish();
1556
+ };
1557
+ scopeCtx.slots.inject("settings.plugin.item", function* () {
1558
+ yield scopeCtx.slots.register({
1559
+ name: "settings.plugin.item",
1560
+ key: "git-badge",
1561
+ locale: NS,
1562
+ inject: () => ({
1563
+ hooks: { gitBadgeOrderCard: store },
1564
+ move: (index, delta) => {
1565
+ draft = moveCategory(draft ?? [...storedOrder()], index, delta);
1566
+ failed = false;
1567
+ publish();
1568
+ },
1569
+ save: () => void save(),
1570
+ discard: () => {
1571
+ draft = null;
1572
+ failed = false;
1573
+ publish();
1574
+ },
1575
+ reset: () => {
1576
+ draft = [...DEFAULT_ORDER];
1577
+ failed = false;
1578
+ publish();
1579
+ }
1580
+ })
1581
+ }, GitBadgeOrderCard);
1582
+ });
1583
+ });
1584
+ } catch (error) {
1585
+ console.error("[dsh-git-badge] settings card unavailable:", error);
1586
+ }
1587
+ }
1588
+
1270
1589
  // inject() re-evaluates when a seam's declaration appears, so boot
1271
1590
  // order relative to the workspace browser does not matter. On an
1272
1591
  // unpatched install the seam is never declared, so this callback never
@@ -1348,7 +1667,7 @@ window.__ModuleLoader__.load({
1348
1667
  // Additive; the host reads apply/inject and ignores the rest. The suite
1349
1668
  // drives these to assert the REQUEST contract — which surface asks for the
1350
1669
  // expensive extras — without a browser, a fetch or a network.
1351
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions };
1670
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, moveCategory };
1352
1671
  return module.exports;
1353
1672
  }
1354
1673
  });
package/lib/index.js CHANGED
@@ -69,7 +69,7 @@
69
69
  import { execFile } from "node:child_process";
70
70
  import { watch, existsSync, readFileSync } from "node:fs";
71
71
  import { basename, isAbsolute, join, resolve } from "node:path";
72
- import { clearRestartMarker, readRestartMarker } from "../seam/store.js";
72
+ import { SESSION_CHECKOUT_TTL_MS, clearRestartMarker, readRestartMarker, readSessionCheckouts } from "../seam/store.js";
73
73
 
74
74
  const inject = ["webServer", "workspaceRegistry"];
75
75
  const name = "dsh-git-badge";
@@ -82,6 +82,13 @@ const name = "dsh-git-badge";
82
82
  * single documented seam.
83
83
  */
84
84
  const config = {
85
+ /**
86
+ * Test seam: the session-checkout registrations file. Null (the default)
87
+ * resolves it under the plugin's data dir (SEAM_DATA_DIR-aware store.js).
88
+ */
89
+ sessionCheckoutsFile: null,
90
+ /** Test seam: resolves the schema lib the settings namespace is built from. */
91
+ settingsSchemaLoader: async () => (await import("@deepseek-ai/schemastery")).default,
85
92
  /** budget for every git invocation; an expired call is killed */
86
93
  gitTimeoutMs: 3000,
87
94
  /** network fetch budget; worst-case route latency, paid once per TTL */
@@ -380,43 +387,6 @@ async function readPrStatus(toplevel, branch) {
380
387
  return summarizePr(json);
381
388
  }
382
389
 
383
- /** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
384
- const prListState = new Map();
385
-
386
- /**
387
- * Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
388
- * ONE `gh pr list` answers the whole repository, which is what makes worktree
389
- * selection affordable: N worktrees cost one forge call, not N.
390
- *
391
- * Degrades exactly like readPrStatus, and for the same reason: a non-array body,
392
- * unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
393
- * resolve an empty map. "No PRs" and "no forge" are the same answer here — both
394
- * mean no worktree can be shown to be the one the session is working in.
395
- */
396
- async function readOpenPrs(toplevel) {
397
- if (!(await originIsGitHub(toplevel))) return new Map();
398
- const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
399
- cwd: toplevel,
400
- timeout: config.prTimeoutMs,
401
- env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
402
- });
403
- if (out.stdout === null) return new Map();
404
- let json;
405
- try {
406
- json = JSON.parse(out.stdout);
407
- } catch {
408
- return new Map();
409
- }
410
- if (!Array.isArray(json)) return new Map();
411
- const byBranch = new Map();
412
- for (const row of json) {
413
- const branch = row?.headRefName;
414
- if (typeof branch !== "string" || branch === "") continue;
415
- const pr = summarizePr(row);
416
- if (pr !== void 0) byBranch.set(branch, pr);
417
- }
418
- return byBranch;
419
- }
420
390
 
421
391
  /** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
422
392
  function prListKey(byBranch) {
@@ -424,63 +394,6 @@ function prListKey(byBranch) {
424
394
  return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
425
395
  }
426
396
 
427
- /**
428
- * Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
429
- * out-of-band refresh when the TTL has lapsed — the same shape and the same
430
- * reasoning as prStatusFor, because the forge is never route latency.
431
- *
432
- * `notify` fires only when the set CHANGES: a changed set can change which
433
- * worktree a session badge follows, so the client has to be told to refetch
434
- * rather than waiting for its 60s safety-net poll.
435
- */
436
- function openPrsFor(toplevel, notify) {
437
- if (config.prStatus === "off") return void 0;
438
- const now = Date.now();
439
- const state = prListState.get(toplevel);
440
- if (state !== void 0) {
441
- // Fresh, or a refresh is already running: serve what we have either way.
442
- if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
443
- }
444
- const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
445
- const inFlight = (async () => {
446
- const value = await readOpenPrs(toplevel).catch(() => void 0);
447
- if (value !== void 0) {
448
- const changed = prListKey(record.value) !== prListKey(value);
449
- record.value = value;
450
- if (changed) notify(toplevel);
451
- }
452
- record.lastAttemptAt = Date.now();
453
- record.inFlight = null;
454
- return value;
455
- })();
456
- record.inFlight = inFlight;
457
- prListState.set(toplevel, record);
458
- return record.value;
459
- }
460
-
461
- /**
462
- * The one linked worktree a session badge may follow: exactly one entry other
463
- * than the checkout itself that is checked out on a branch with an OPEN PR.
464
- *
465
- * Exactly one, because the plugin cannot know which tree a session uses — DSH
466
- * records no session→worktree link at all — so an ambiguous repository stays on
467
- * its own checkout rather than guessing. A second open PR is a reason to say
468
- * nothing, not a reason to pick one. Detached, bare and prunable entries are
469
- * never candidates: they have no branch a PR could belong to.
470
- */
471
- function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
472
- if (openPrs === void 0 || openPrs.size === 0) return void 0;
473
- const candidates = worktrees.filter((wt) =>
474
- wt.path !== checkoutPath &&
475
- wt.detached !== true &&
476
- wt.bare !== true &&
477
- wt.prunable !== true &&
478
- typeof wt.branch === "string" &&
479
- openPrs.has(wt.branch)
480
- );
481
- return candidates.length === 1 ? candidates[0] : void 0;
482
- }
483
-
484
397
  /**
485
398
  * PR state for toplevel: the value to serve NOW (possibly undefined, possibly
486
399
  * stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
@@ -741,19 +654,57 @@ const OPERATION_NEXT = {
741
654
  */
742
655
  const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
743
656
 
744
- /** The ranking override file — read per call (one tiny stat+read per status). */
745
- function nextOrder() {
657
+ /** Normalise any order list: known categories first, then the rest in default order. */
658
+ function normalizeOrder(order) {
659
+ if (!Array.isArray(order)) return DEFAULT_NEXT_ORDER;
660
+ const known = order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
661
+ return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
662
+ }
663
+
664
+ /** The settings namespace this plugin owns — its order lives there, live. */
665
+ const SETTINGS_NS = "git-badge";
666
+
667
+ /**
668
+ * The namespace schema, built from the host-provided schema lib — imported
669
+ * LAZILY because this plugin's module graph must load without it (the test
670
+ * suite runs from a bare checkout, and a host without the settings stack should
671
+ * lose only the settings card, never the badge).
672
+ */
673
+ function orderSettingsSchema(Schema) {
674
+ return Schema.object({
675
+ order: Schema.array(Schema.union(DEFAULT_NEXT_ORDER)).default(DEFAULT_NEXT_ORDER)
676
+ });
677
+ }
678
+
679
+ /**
680
+ * The pre-settings override file. Still honoured as the namespace's BASE (a
681
+ * composition default, so a user who configured the JSON keeps their order
682
+ * after the upgrade) and as a fallback when the settings service is absent.
683
+ */
684
+ function legacyOrderFile() {
746
685
  try {
747
686
  const home = process.env.DSH_HOME || join(process.env.HOME || "", ".dsh");
748
687
  const raw = JSON.parse(readFileSync(join(home, "git-badge-next.json"), "utf8"));
749
- if (!Array.isArray(raw?.order)) return DEFAULT_NEXT_ORDER;
750
- const known = raw.order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
751
- return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
688
+ return normalizeOrder(raw?.order);
752
689
  } catch {
753
690
  return DEFAULT_NEXT_ORDER;
754
691
  }
755
692
  }
756
693
 
694
+ /**
695
+ * The live order — one in-memory value, updated by the settings scope. It is
696
+ * authoritative only once the namespace actually registered; until then (and on
697
+ * a host without the settings stack, or in a unit test that never calls apply)
698
+ * the legacy file is read per call, which is the pre-settings behaviour.
699
+ */
700
+ let currentOrder = DEFAULT_NEXT_ORDER;
701
+ let settingsOrderActive = false;
702
+
703
+ /** The ranking order every status read uses. */
704
+ function nextOrder() {
705
+ return settingsOrderActive ? currentOrder : legacyOrderFile();
706
+ }
707
+
757
708
  /**
758
709
  * The hover card's "action" row and the (+) menu's picker: every sub-action the
759
710
  * checkout justifies, each as a `gh`-skill invocation ({ args, why, what } —
@@ -1333,10 +1284,10 @@ function watchWorkspace(root, key, workspaceId) {
1333
1284
  }
1334
1285
 
1335
1286
  /**
1336
- * Workspace path -> { worktreePath, workspaceId, owned } for the inferred
1337
- * worktree a session badge is currently following. One per workspace: selection
1338
- * is a single choice by construction (selectSessionWorktree), so following a
1339
- * different tree retires the previous one here.
1287
+ * Workspace path -> { worktreePath, workspaceId, owned } for the worktree a
1288
+ * session badge is currently following. One per workspace: a session follows at
1289
+ * most one tree, so following a different tree (or none) retires the previous
1290
+ * entry here.
1340
1291
  */
1341
1292
  const selectedWorktrees = new Map();
1342
1293
 
@@ -1450,48 +1401,117 @@ async function resolveWorkspace(ctx, params) {
1450
1401
  return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
1451
1402
  }
1452
1403
 
1404
+ /**
1405
+ * The checkout a session registered for itself, or undefined.
1406
+ *
1407
+ * This is the ONLY source for "which worktree is this session using", and it has
1408
+ * to be explicit: DSH records no session→worktree link (a session's cwd is
1409
+ * immutable creation metadata and always the main checkout), so nothing in the
1410
+ * host can answer it. The agent writes the registration with
1411
+ * `dsh-git-badge-checkout <path>`; the file is advisory, so it is validated
1412
+ * here before it is believed — the path must be a worktree git itself lists,
1413
+ * the entry must be fresher than SESSION_CHECKOUT_TTL_MS, and the name/branch
1414
+ * come from git rather than from the file. A stale, hand-edited or removed
1415
+ * registration can therefore only fail to resolve, never point the badge at a
1416
+ * directory that is not a worktree of this repository.
1417
+ */
1418
+ function sessionCheckouts() {
1419
+ if (typeof config.sessionCheckoutsFile === "string") {
1420
+ try {
1421
+ const raw = JSON.parse(readFileSync(config.sessionCheckoutsFile, "utf8"));
1422
+ return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
1423
+ ? raw.sessions
1424
+ : {};
1425
+ } catch {
1426
+ return {};
1427
+ }
1428
+ }
1429
+ return readSessionCheckouts();
1430
+ }
1431
+
1432
+ async function registeredCheckout(target, sessionId) {
1433
+ if (typeof sessionId !== "string" || sessionId === "") return void 0;
1434
+ const entry = sessionCheckouts()[sessionId];
1435
+ if (entry === void 0 || typeof entry?.path !== "string") return void 0;
1436
+ const at = Date.parse(String(entry.at ?? ""));
1437
+ if (!Number.isFinite(at) || Date.now() - at > SESSION_CHECKOUT_TTL_MS) return void 0;
1438
+ const { toplevel, worktrees } = await worktreesFor(target.path);
1439
+ // Git lists the main worktree first, and a registration only means something
1440
+ // when the session's own directory IS that main checkout: a session already
1441
+ // inside a linked worktree is its own answer and is never swapped sideways.
1442
+ const main = worktrees[0];
1443
+ if (toplevel === "" || main === void 0 || main.path !== toplevel || target.path !== toplevel || worktrees.length <= 1) return void 0;
1444
+ const found = worktrees.find((tree) => tree.path === entry.path);
1445
+ if (found === void 0 || found.path === toplevel) return void 0;
1446
+ return { path: found.path, name: found.name, branch: found.branch };
1447
+ }
1448
+
1453
1449
  /**
1454
1450
  * The directory a request is ABOUT.
1455
1451
  *
1456
- * A SESSION-targeted chip may follow the one linked worktree its repository can be
1457
- * shown to be working in (selectSessionWorktree): DSH records no session→worktree
1458
- * link at all — session cwd is immutable creation metadata and `attachSession`
1459
- * requires it to equal the workspace path — so a conversation started in the main
1460
- * checkout would otherwise report `main` while its work, and its pull request,
1461
- * live in a tree. The tree's watcher is reconciled HERE, because this is the only
1462
- * place that knows which tree was chosen.
1452
+ * A SESSION-targeted chip follows the checkout that session REGISTERED — the one
1453
+ * signal that knows where the agent is actually working. Without a registration
1454
+ * the request stays on the session's own directory: the plugin never guesses a
1455
+ * worktree from pull requests (an earlier build inferred one from the branch
1456
+ * with the open PR; the guess was invisible to the session that made it and
1457
+ * silently wrong whenever two trees were in play).
1463
1458
  *
1464
1459
  * A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
1465
1460
  * the registry owns, and substituting a tree would make the row lie about the
1466
1461
  * branch it is on.
1467
- *
1468
- * Gated three ways so the common case costs nothing: the caller must name a
1469
- * session, must be asking about PR state at all (selection is defined by an open
1470
- * PR, so a caller not asking cannot benefit), and the repository must actually
1471
- * have a linked worktree. Only then is the forge consulted.
1472
1462
  */
1473
- async function effectiveTarget(target, bySession, wantPr, notify) {
1474
- if (bySession !== true || wantPr !== true || config.worktreeStatus === "off") return { id: target.id, path: target.path };
1475
- const { toplevel, worktrees } = await worktreesFor(target.path);
1476
- // Git lists the main worktree first, so `main.path === toplevel` is the exact
1477
- // test for "the session's own directory is the main checkout". A session whose
1478
- // directory IS a linked worktree is its own answer and is never swapped
1479
- // sideways onto a sibling.
1480
- const main = worktrees[0];
1481
- if (toplevel === "" || main === void 0 || main.path !== toplevel || worktrees.length <= 1) {
1482
- selectWorktreeWatch(target.path, target.id, void 0);
1483
- return { id: target.id, path: target.path };
1463
+ async function effectiveTarget(target, sessionId) {
1464
+ const bySession = typeof sessionId === "string" && sessionId !== "";
1465
+ if (config.worktreeStatus !== "off") {
1466
+ const registered = bySession ? await registeredCheckout(target, sessionId) : void 0;
1467
+ if (registered !== void 0) {
1468
+ selectWorktreeWatch(target.path, target.id, registered.path);
1469
+ // The name and branch travel; the PATH never does (AGENTS.md rule 7).
1470
+ return { id: target.id, path: registered.path, worktree: { name: registered.name, branch: registered.branch } };
1471
+ }
1484
1472
  }
1485
- const openPrs = openPrsFor(toplevel, notify);
1486
- const chosen = selectSessionWorktree(worktrees, openPrs, toplevel);
1487
- selectWorktreeWatch(target.path, target.id, chosen === void 0 ? void 0 : chosen.path);
1488
- if (chosen === void 0) return { id: target.id, path: target.path };
1489
- // The name and branch travel; the PATH never does (AGENTS.md rule 7).
1490
- return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
1473
+ // No registration (or a stale one): retire any follow this workspace had, so
1474
+ // clearing the registration actually stops the extra watcher.
1475
+ selectWorktreeWatch(target.path, target.id, void 0);
1476
+ return { id: target.id, path: target.path };
1491
1477
  }
1492
1478
 
1493
1479
  /** Host plugin body — register the status route and the SSE change feed. */
1494
1480
  function apply(ctx) {
1481
+ // The ranking order lives in this plugin's own settings namespace: declared
1482
+ // with a schema so the host validates it, stored in the user's settings
1483
+ // document, and observed live — a change applies to the next status read
1484
+ // with no file poll and no restart. The pre-settings JSON file, when it
1485
+ // exists, seeds the namespace as its composition BASE so an order configured
1486
+ // before this existed survives the upgrade; it is also the fallback when the
1487
+ // settings service is absent (a host composed without dsh-settings).
1488
+ currentOrder = legacyOrderFile();
1489
+ if (typeof ctx.inject === "function") {
1490
+ try {
1491
+ ctx.inject(["settings"], async (settingsCtx) => {
1492
+ let Schema;
1493
+ try {
1494
+ Schema = await config.settingsSchemaLoader();
1495
+ } catch (error) {
1496
+ console.error("[dsh-git-badge] schema lib unavailable; keeping the legacy order file:", error);
1497
+ return;
1498
+ }
1499
+ const scope = settingsCtx.settings.register(SETTINGS_NS, orderSettingsSchema(Schema), {
1500
+ base: { order: legacyOrderFile() }
1501
+ });
1502
+ currentOrder = normalizeOrder(scope.get()?.order);
1503
+ settingsOrderActive = true;
1504
+ settingsCtx.effect(() => scope.watch((next) => {
1505
+ currentOrder = normalizeOrder(next?.order);
1506
+ }), "dsh-git-badge: order settings");
1507
+ settingsCtx.effect(() => () => {
1508
+ settingsOrderActive = false;
1509
+ }, "dsh-git-badge: order settings teardown");
1510
+ });
1511
+ } catch (error) {
1512
+ console.error("[dsh-git-badge] settings namespace unavailable, using the legacy order file:", error);
1513
+ }
1514
+ }
1495
1515
  // A fresh boot composed the files on disk into the new bundles — any
1496
1516
  // restart-pending marker was written by an install/apply against the PREVIOUS
1497
1517
  // boot and its change is now live. Clearing here is what makes the marker
@@ -1516,7 +1536,7 @@ function apply(ctx) {
1516
1536
  // session's own directory — see effectiveTarget. A workspace-targeted
1517
1537
  // row never leaves the checkout the registry owns.
1518
1538
  const session = url.searchParams.get("session");
1519
- const effective = await effectiveTarget(target, session !== null && session !== "", pr, notifyChange);
1539
+ const effective = await effectiveTarget(target, session ?? void 0);
1520
1540
  const info = await gitStatus(effective.path, detail, pr);
1521
1541
  const body = { ...info, workspace: target.id };
1522
1542
  if (effective.worktree !== void 0 && info.git === true) {
@@ -1524,7 +1544,7 @@ function apply(ctx) {
1524
1544
  // name. Saying so is the whole honesty of the feature: the chip's
1525
1545
  // mark says "worktree", its name says which, and this flag tells the
1526
1546
  // card why the checkout below differs from the branch beside it.
1527
- body.worktreeInferred = true;
1547
+ body.worktreeFollowed = true;
1528
1548
  if (detail) {
1529
1549
  // The session's OWN directory, for the card's `checkout` row, so the
1530
1550
  // main checkout stays visible once the badge follows a tree. Gated
@@ -1645,10 +1665,6 @@ export {
1645
1665
  summarizeChecks,
1646
1666
  prStatusFor,
1647
1667
  prState,
1648
- readOpenPrs,
1649
- openPrsFor,
1650
- prListState,
1651
- selectSessionWorktree,
1652
1668
  selectWorktreeWatch,
1653
1669
  selectedWorktrees,
1654
1670
  statusInFlight,
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
7
7
  "bin": {
8
- "dsh-git-badge-seam": "seam/apply.js"
8
+ "dsh-git-badge-seam": "seam/apply.js",
9
+ "dsh-git-badge-checkout": "seam/checkout.js"
9
10
  },
10
11
  "exports": {
11
12
  ".": "./lib/index.js",
@@ -56,10 +57,14 @@
56
57
  "package.json",
57
58
  "seam/anchors.js",
58
59
  "seam/apply.js",
60
+ "seam/checkout.js",
59
61
  "seam/postinstall.js",
60
62
  "seam/skill.js",
61
63
  "seam/store.js",
62
64
  "seam/stub-index.js",
63
65
  "skill/gh/SKILL.md"
64
- ]
66
+ ],
67
+ "dependencies": {
68
+ "@deepseek-ai/schemastery": "^3.18.2"
69
+ }
65
70
  }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-git-badge-checkout — tell the badge which checkout THIS session is working
4
+ * in.
5
+ *
6
+ * WHY THIS EXISTS: a dsh session's cwd is immutable creation metadata — it is
7
+ * the main checkout forever, even after the agent does its work in a linked
8
+ * worktree. The badge therefore cannot know, on its own, that a session moved
9
+ * into `.wt/<name>`; it can only infer a worktree from an open pull request.
10
+ * This command closes that gap: the agent registers the checkout explicitly, and
11
+ * the node half prefers the registration over inference.
12
+ *
13
+ * The registration is advisory and self-expiring (24h) — never let it fail the
14
+ * work that earned it:
15
+ *
16
+ * usage: npx dsh-git-badge-checkout <path> register this session
17
+ * npx dsh-git-badge-checkout --clear forget this session
18
+ *
19
+ * The path is validated as a real worktree of its repository before it is
20
+ * written, so a typo cannot point the badge at an unrelated directory.
21
+ */
22
+ import { execFileSync } from "node:child_process";
23
+ import { basename, resolve } from "node:path";
24
+ import { clearSessionCheckout, writeSessionCheckout } from "./store.js";
25
+
26
+ function usage(message) {
27
+ if (message !== void 0) console.error(message);
28
+ console.error("usage: dsh-git-badge-checkout <path> | --clear");
29
+ }
30
+
31
+ /** Worktree paths git lists for the repo containing `dir`. */
32
+ function worktreePaths(dir) {
33
+ const out = execFileSync("git", ["-C", dir, "worktree", "list", "--porcelain"], { encoding: "utf8" });
34
+ return out
35
+ .split("\n")
36
+ .filter((line) => line.startsWith("worktree "))
37
+ .map((line) => resolve(line.slice("worktree ".length).trim()));
38
+ }
39
+
40
+ function branchOf(dir) {
41
+ try {
42
+ return execFileSync("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
43
+ } catch {
44
+ return "unknown";
45
+ }
46
+ }
47
+
48
+ const sessionId = process.env.DSH_SESSION_ID;
49
+ if (sessionId === void 0 || sessionId === "") {
50
+ usage("no DSH_SESSION_ID in the environment — run this from inside a dsh session.");
51
+ process.exit(2);
52
+ }
53
+
54
+ const arg = process.argv[2];
55
+ if (arg === "--clear") {
56
+ clearSessionCheckout(sessionId);
57
+ console.log("[dsh-git-badge] checkout registration cleared for this session.");
58
+ process.exit(0);
59
+ }
60
+ if (arg === void 0 || arg === "") {
61
+ usage();
62
+ process.exit(2);
63
+ }
64
+
65
+ const path = resolve(arg);
66
+ let paths;
67
+ try {
68
+ paths = worktreePaths(path);
69
+ } catch (error) {
70
+ console.error(`[dsh-git-badge] ${path} is not a git worktree: ${String(error?.message ?? error)}`);
71
+ process.exit(1);
72
+ }
73
+ if (!paths.includes(path)) {
74
+ console.error(`[dsh-git-badge] ${path} is not a worktree of its repository (git lists: ${paths.join(", ")})`);
75
+ process.exit(1);
76
+ }
77
+
78
+ if (!writeSessionCheckout(sessionId, path)) {
79
+ console.error("[dsh-git-badge] could not write the registration; the badge keeps its inferred checkout.");
80
+ process.exit(1);
81
+ }
82
+ console.log(`[dsh-git-badge] this session's checkout is now ${basename(path)} (${branchOf(path)}).`);
package/seam/store.js CHANGED
@@ -1,4 +1,13 @@
1
1
  /**
2
+ * Two out-of-band stores live here, both keyed files under dataDir():
3
+ *
4
+ * restart-pending.json install/applied a host patch → the UI offers a restart
5
+ * session-checkouts.json which checkout a SESSION is working in — the one
6
+ * fact dsh cannot supply (a session's cwd is immutable
7
+ * creation metadata and always the main checkout, so a
8
+ * session working in a linked worktree is invisible
9
+ * without an explicit registration)
10
+ *
2
11
  * The restart-pending marker — the bridge from install-time (node side) to the
3
12
  * running UI.
4
13
  *
@@ -68,4 +77,60 @@ export function clearRestartMarker() {
68
77
  } catch {
69
78
  // Absent is the goal; any error leaves the marker to be cleared later.
70
79
  }
71
- }
80
+ }
81
+ /** How long a session-checkout registration stays trustworthy. */
82
+ export const SESSION_CHECKOUT_TTL_MS = 24 * 60 * 60 * 1000;
83
+
84
+ function sessionCheckoutsPath() {
85
+ return join(dataDir(), "session-checkouts.json");
86
+ }
87
+
88
+ /** Every registration, `{ sessionId: { path, at } }`. Never throws. */
89
+ export function readSessionCheckouts() {
90
+ try {
91
+ if (!existsSync(sessionCheckoutsPath())) return {};
92
+ const raw = JSON.parse(readFileSync(sessionCheckoutsPath(), "utf8"));
93
+ return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
94
+ ? raw.sessions
95
+ : {};
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Register the checkout a session is working in. Written by the agent (the
103
+ * git-worktree and /gh checkout paths), read by the node half.
104
+ */
105
+ export function writeSessionCheckout(sessionId, path) {
106
+ try {
107
+ mkdirSync(dataDir(), { recursive: true });
108
+ const sessions = readSessionCheckouts();
109
+ sessions[sessionId] = { path, at: new Date().toISOString() };
110
+ writeFileSync(
111
+ sessionCheckoutsPath(),
112
+ JSON.stringify({ schema: "dsh-git-badge/session-checkout/v1", sessions }),
113
+ "utf8",
114
+ );
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /** Forget one session's registration (worktree removed, or the session left). */
122
+ export function clearSessionCheckout(sessionId) {
123
+ try {
124
+ const sessions = readSessionCheckouts();
125
+ if (!(sessionId in sessions)) return true;
126
+ delete sessions[sessionId];
127
+ writeFileSync(
128
+ sessionCheckoutsPath(),
129
+ JSON.stringify({ schema: "dsh-git-badge/session-checkout/v1", sessions }),
130
+ "utf8",
131
+ );
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
package/skill/gh/SKILL.md CHANGED
@@ -79,12 +79,23 @@ unstaged / untracked / unmerged), plus any in-progress operation marker
79
79
  what is dirty and confirm the switch when work would carry over. Unknown
80
80
  branch → say so and list the closest local names.
81
81
 
82
+ If the switch lands you in — or out of — a linked worktree, register where you
83
+ are so the session badge follows the right checkout:
84
+ `npx dsh-git-badge-checkout "$(git rev-parse --show-toplevel)"` (best effort;
85
+ ignore failure). Clearing back to the main checkout is the same command run
86
+ against the main worktree — the badge treats the main checkout as the default.
87
+ A session's cwd is fixed at creation, so this registration is the ONLY signal
88
+ the badge has.
89
+
82
90
  - **`/gh order`** — reconfigure which action class the badge ranks first. The
83
- order is one line of JSON at `~/.dsh/git-badge-next.json`:
84
- `{ "order": ["operation", "unmerged", "sync", "publish", "merge", "commit",
85
- "checks"] }` — any subset, any order; unlisted categories rank after in
86
- default order. Read the file (it may not exist yet), apply the requested
87
- change, show the resulting order, and never invent categories. Typical
91
+ order lives in the `git-badge` settings namespace, edited in the UI at
92
+ **Settings → Plugins → Git Badge** (an ordered list with up/down controls).
93
+ When the request comes through the agent instead, read the current value from
94
+ the settings document (`~/.dsh/settings.yaml`, the `git-badge:` section) and
95
+ change that `order` list — any subset, any order; unlisted categories rank
96
+ after in default order. Never invent categories. The older
97
+ `~/.dsh/git-badge-next.json` is only a fallback for hosts without the
98
+ settings stack; do not edit it when the namespace is present. Typical
88
99
  invocations: "/gh order commit first", "/gh order never suggest checks
89
100
  before push".
90
101