dsh-git-badge 0.16.1 → 0.17.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,113 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.17.0] - 2026-09-16
14
+
15
+ ### Added
16
+
17
+ - **The PR token has its own hover: the commits in that pull request.** `PR#47 ✓`
18
+ now shows the commits on the branch that are not on the base
19
+ (`git log <base>..HEAD`, capped at 10 with "… and k more"), in the same line
20
+ format as the branch hover's list — hash, subject, compressed age, full-width
21
+ with ellipsis. Nothing else rides it: the token already states the number and
22
+ CI state, so the one question left is what is in it. Hover-gated with the rest
23
+ of `detail=1`, and the total reuses the ahead count already computed.
24
+ - **`/gh clean [<path>...]` appears in the `/gh` menu behind a risk gate.** The
25
+ menu is built from status, not from this skill's prose, so the verb is offered
26
+ there whenever untracked files exist — always last, never as the badge's
27
+ suggested action, and carrying the host's own `RiskConfirmation`: Confirm
28
+ stays disabled until "I understand untracked files cannot be recovered" is
29
+ ticked. That gate names the risk; the agent then names the files
30
+ (`git clean -n`, quoted back) and asks again before `git clean -f`.
31
+ - **A `/gh clean [<path>...]` verb, confirm-first by construction.** Removing
32
+ untracked files is the one operation with no reflog and no stash entry, so the
33
+ verb's first step is always `git clean -n -- <paths>` with the output quoted
34
+ back verbatim (the user approves the list, not the idea), then an explicit yes,
35
+ then `git clean -f -- <paths>` on named paths only. `-x`, `-d` and bare
36
+ `git clean -f` are forbidden — ignored files are often credentials and `-d`
37
+ turns one path into a tree. It stays out of the badge's ranked suggestions: a
38
+ destructive action the agent cannot infer intent for is never suggested, only
39
+ available on request.
40
+
41
+ ### Changed
42
+
43
+ - **The session row's action token only appears when the session is idle.** A turn
44
+ in flight is exactly when a git suggestion is noise: the checkout is still
45
+ moving and the agent has not finished. The row reads its run state from the
46
+ shell's standard kit — `useSessions`, the same selector hook the workspace UI
47
+ itself uses — and renders nothing while that session is `running`. The filter is
48
+ on render, not on the fetch, so the row's hover line is unaffected. On a shell
49
+ that seeds no such hook the row behaves as before, and the console says which of
50
+ the two it got.
51
+ - **The PR/CI token stops lagging behind the forge.** The forge read is
52
+ TTL-bounded and its refresh is request-driven, while an idle chip only asks
53
+ every 60s — so the old 90s `prTtlMs` silently suppressed every other poll, and
54
+ a pull request that appeared on GitHub with no local git event to announce it
55
+ could take ~2.5 minutes to show up. At 20s the TTL no longer outlives the
56
+ client's poll, so the first refresh after the forge changes picks it up and the
57
+ worst case falls to roughly the poll interval. Cost: up to one `gh pr view` per
58
+ open chip per minute, where it was one per ~2 minutes.
59
+ - **The branch hover shows only the merge axis.** `upstream` and `sync` are gone
60
+ — they stated the same divergence against the branch's own remote, in arrows,
61
+ when `merge` already states it against the base branch in words. The `merge`
62
+ row now ALWAYS renders: `ready to merge`, `blocked: draft | review required |
63
+ checks failing`, `no pull request`, each joined with `N ahead, M behind main`
64
+ when those counts are known. A row that can be absent is a row the reader has
65
+ to reconstruct.
66
+ - The commit cells lost their trailing colon (a later change had reintroduced it
67
+ after the earlier removal), so both hovers read `abc1234 subject · 2h ago`.
68
+ - **One action list, built by the server.** Both halves used to derive the
69
+ pull/push/commit/pr conditions, and the copies drifted: the card once promoted
70
+ a client-only extra into its action row, and `/gh pr` was offered on the
71
+ default branch. The node half now publishes `actions` — the ranked rules in the
72
+ configured order with the top entry flagged `primary` (also served as `next`,
73
+ on every response), then the standing options (`pr view <n>`, `pr`, and the
74
+ flagged `clean`). `merge <n>` becomes a standing entry whenever the PR is
75
+ mergeable, so it now appears alongside a primary `push`/`pull` instead of only
76
+ when it wins the ranking. The client renders the list verbatim and keeps only
77
+ presentation — labels and the clean risk gate; `actions` rides the `pr=1`
78
+ payload only, so sidebar rows stay lean. Net: ~65 lines of duplicated client
79
+ rules deleted, and the conditions are unit-tested where the data lives.
80
+ - **The upstream sync numbers ride the branch's hover and link.** `↑a ↓b` were
81
+ rendered inside the file count's names tooltip and outside the branch's compare
82
+ link, though they are the branch's standing against its upstream — so they now
83
+ sit with the branch name: same lineage hover, same compare-view link, and the
84
+ link's accessible name says "N ahead and M behind upstream". `✎n` keeps its own
85
+ names tooltip. Visible side effect: the arrows render before a paused-operation
86
+ token rather than after it.
87
+
88
+ ### Fixed
89
+
90
+ - **The PR token's hover lines its commits up like the branch hover.** Both lists
91
+ now come from one renderer: a fixed-width monospace hash cell, so every
92
+ description starts on the same column and the list reads as a table. The PR
93
+ list had reused the card's label style, whose `minWidth` and `flex` only apply
94
+ inside a flex row — on a plain inline span they did nothing, so the hash sat
95
+ flush against its description with the columns ragged.
96
+ - **The PR token's hover is reachable by hovering the token itself.** The commits
97
+ it lists are detail-only, and only the status mark and the branch name enabled
98
+ the `detail=1` fetch — so a pointer that went straight to `PR#47 ✓` had rested
99
+ on nothing that fetches, and its hover could not open until the mark had been
100
+ hovered first. The token is now its own detail gate, in both its linked and
101
+ plain forms.
102
+ - **No more "open a pull request" on `main`, and the hover card shows only the
103
+ server's verdict.** Two layers disagreed: the node half correctly returned
104
+ nothing to do for a clean, synced default branch, while the client's extras
105
+ still offered `/gh pr` — and the card promoted that extra into its `action:`
106
+ row, so a checkout with nothing to do displayed a suggestion. The node half now
107
+ resolves the repository's default branch (`refs/remotes/origin/HEAD`,
108
+ TTL-cached and refreshed out of band like the fetch, so the hot path pays
109
+ nothing) and flags it on the response; the `pr` extra is suppressed when the
110
+ branch IS that default; and the card's action row reads the server's `next`
111
+ directly, leaving the extras to the `/gh` picker where they belong.
112
+ - **The seam suite no longer writes into your real `~/.dsh/git-badge-seam`.**
113
+ One block spawned the postinstall hook without `SEAM_DATA_DIR`, so the hook
114
+ resolved its store to the machine's actual data directory: a test run
115
+ overwrote the real `backup-client.js`/`backup-index.js` and left a
116
+ `restart-pending.json` behind. It passed on CI only because the runner's
117
+ `$HOME` is writable — under a read-only `$HOME` it failed with EROFS. The
118
+ block now takes a throwaway data dir like the rest of the file.
119
+
13
120
  ## [0.16.1] - 2026-09-16
14
121
 
15
122
  ### Fixed
package/README.md CHANGED
@@ -6,12 +6,12 @@ Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek
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
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
- - **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.
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. It appears only when the session is **idle** — while a turn is running the row stays quiet, because the checkout is still moving and a suggestion would describe a state that is about to change. 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 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.
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 ~20s. 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
@@ -232,45 +232,40 @@ window.__ModuleLoader__.load({
232
232
 
233
233
 
234
234
  /**
235
- * The /gh picker's sub-actions — the skill invocations the checkout
236
- * justifies, most urgent first. The server's `next` (the ranked top rule,
237
- * already carrying args/why/what) always leads; the client adds the other
238
- * justified actions as secondary entries, mirroring the server's category
239
- * rules but never re-deciding the winner.
235
+ * The actions BOTH surfaces render — the server's list, verbatim.
236
+ *
237
+ * This half used to re-derive pull/push/commit/pr from the raw counts,
238
+ * which meant two implementations of the same conditions drifting apart:
239
+ * the card once promoted a client-only extra into its action row, and
240
+ * `/gh pr` was offered on the default branch. The node half owns the
241
+ * rules and the ranking now; the client adds presentation only — the risk
242
+ * gate for `clean`, and the labels.
243
+ *
244
+ * Falls back to the single `next` for a payload from an older node half.
240
245
  */
241
246
  function ghSkillActions(info) {
242
247
  if (info === void 0 || info === null || info.git !== true) return [];
243
- const subs = [];
244
- const seen = new Set();
245
- const add = (args, why, what) => {
246
- if (args === void 0 || args === null || args === "" || seen.has(args)) return;
247
- seen.add(args);
248
- subs.push({ args, why, what });
248
+ if (Array.isArray(info.actions)) return info.actions;
249
+ return info.next === void 0 || info.next === null ? [] : [info.next];
250
+ }
251
+
252
+ /**
253
+ * The risk gate the menu shows for `/gh clean`, in the host's own
254
+ * confirmation shape (RiskConfirmation: title, description, an acknowledge
255
+ * checkbox, two button labels). Confirm stays disabled until the box is
256
+ * ticked — that is the "are you sure?", before the invocation is even sent.
257
+ * The agent then re-establishes the exact list with `git clean -n` and asks
258
+ * again: two gates, because this one names the risk and that one names the
259
+ * files.
260
+ */
261
+ function cleanGate() {
262
+ return {
263
+ title: "Remove untracked files?",
264
+ description: "Untracked files have no reflog and no stash entry — once removed they cannot be recovered. The agent will show the exact list (`git clean -n`) and ask again before removing anything.",
265
+ acknowledgeLabel: "I understand untracked files cannot be recovered",
266
+ cancelLabel: "Cancel",
267
+ confirmLabel: "Continue"
249
268
  };
250
- const next = info.next;
251
- if (next !== void 0 && next !== null) add(next.args, next.why, next.what);
252
- const ahead = info.ahead || 0;
253
- const behind = info.behind || 0;
254
- const staged = info.stagedFiles || 0;
255
- const unstaged = info.unstagedFiles || 0;
256
- const untracked = info.untrackedFiles || 0;
257
- if (behind > 0 && ahead > 0) {
258
- add("sync", ahead + " ahead, " + behind + " behind — diverged", "rebase your commits onto upstream and push (asks before any force)");
259
- } else {
260
- if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"), "update this branch from upstream");
261
- if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"), "push local commits to the remote");
262
- }
263
- if (staged > 0) add("commit", staged + " staged", "commit the staged changes");
264
- if (unstaged > 0) add("commit", unstaged + " unstaged", "stage and commit the working changes");
265
- if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked", "stage the new files and commit them");
266
- const pr = info.pr;
267
- if (pr !== void 0 && pr !== null && pr.number !== void 0) {
268
- if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number, "watch the CI checks on pull request " + pr.number);
269
- add("pr view " + pr.number, "open pull request #" + pr.number, "show pull request " + pr.number + " on GitHub");
270
- } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
271
- add("pr", "branch is pushed and has no pull request", "open a pull request for this branch");
272
- }
273
- return subs.slice(0, 6);
274
269
  }
275
270
 
276
271
  /**
@@ -305,16 +300,29 @@ window.__ModuleLoader__.load({
305
300
  * - clean workspaces stay quiet: arrows only when nonzero, no ✎ at 0
306
301
  * - no upstream: arrows omitted entirely
307
302
  */
308
- function formatGitSuffix(info) {
309
- const parts = [];
303
+ /**
304
+ * The upstream sync arrows (" ↑1 ↓2"), or "". Split out from the file
305
+ * count so each rides the surface that OWNS it: the arrows belong to the
306
+ * branch (they are the branch's standing against its upstream, and the
307
+ * branch name's hover and compare link cover them), while ✎n belongs to the
308
+ * count's names tooltip.
309
+ */
310
+ function formatSync(info) {
310
311
  const hasUpstream = info.ahead !== void 0 || info.behind !== void 0;
311
312
  const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
312
- if (hasUpstream && (files > 0 || info.ahead > 0 || info.behind > 0)) {
313
- parts.push("\u2191" + (info.ahead || 0));
314
- parts.push("\u2193" + (info.behind || 0));
315
- }
316
- if (files > 0) parts.push("\u270E" + files);
317
- return parts.length > 0 ? " " + parts.join(" ") : "";
313
+ if (!hasUpstream || (files === 0 && !(info.ahead > 0) && !(info.behind > 0))) return "";
314
+ return " \u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0);
315
+ }
316
+
317
+ /** The ✎n file count (" ✎3"), or "". */
318
+ function formatFileCount(info) {
319
+ const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
320
+ return files > 0 ? " \u270E" + files : "";
321
+ }
322
+
323
+ /** Both halves, in chip order — kept for callers that want the whole suffix. */
324
+ function formatGitSuffix(info) {
325
+ return formatSync(info) + formatFileCount(info);
318
326
  }
319
327
 
320
328
  /**
@@ -544,9 +552,57 @@ window.__ModuleLoader__.load({
544
552
  */
545
553
  const ACTION_STYLE = { flex: "none", marginLeft: "auto", marginRight: "8px", fontWeight: 500 };
546
554
 
547
- /** The action badge: floated, weighted, and coloured by severity. */
548
- function SessionGitBadge({ sessionId }) {
555
+ /**
556
+ * The row's own run state, from the shell's standard kit.
557
+ *
558
+ * The renderer that assembles slots hands EVERY entry the sessions store as
559
+ * a selector hook (`useSessions`) — the same kit that supplies
560
+ * `useWorkspaces` — which is how a row can tell an idle session from one
561
+ * mid-turn without the seam having to carry it. A shell that does not seed
562
+ * that kit leaves the prop undefined: then we cannot know, and the badge
563
+ * behaves exactly as it did before rather than guessing "idle".
564
+ *
565
+ * One helper and one call site, so the hook order is identical on every path
566
+ * (the fallback is a plain function, never a hook).
567
+ */
568
+ function useSessionRunning(useSessions, sessionId) {
569
+ const select = typeof useSessions === "function" ? useSessions : () => void 0;
570
+ return select((state) => state?.byId?.[sessionId]?.running) === true;
571
+ }
572
+
573
+ /**
574
+ * Report the idle gate ONCE, the way the boot diagnostics report the seam and
575
+ * the Tooltip primitive. Whether the shell actually handed a slot the
576
+ * sessions store is invisible from anywhere else, and a gate that silently
577
+ * never fires is indistinguishable from a gate that never needed to.
578
+ */
579
+ let idleGateReported = false;
580
+ function reportIdleGate(present) {
581
+ if (idleGateReported) return;
582
+ idleGateReported = true;
583
+ console.info(
584
+ "[dsh-git-badge] session-row idle gate = " +
585
+ (present
586
+ ? "on (the standard kit supplies useSessions)."
587
+ : "off (no useSessions prop) — the token cannot tell a running turn, so it shows as before.")
588
+ );
589
+ }
590
+
591
+ /**
592
+ * The action badge: floated, weighted, and coloured by severity — and shown
593
+ * only when the session is IDLE. A turn in flight is precisely when a git
594
+ * suggestion is noise: the checkout is still moving and the agent has not
595
+ * finished, so the action describes a state that is about to change. Nothing
596
+ * about the action itself changes; the gate is the whole feature.
597
+ *
598
+ * The filter is on RENDER, not on the fetch: hiding the row must not take the
599
+ * hover card's detail line down with it — both read the same cached status.
600
+ */
601
+ function SessionGitBadge({ sessionId, useSessions }) {
549
602
  const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
603
+ const running = useSessionRunning(useSessions, sessionId);
604
+ reportIdleGate(typeof useSessions === "function");
605
+ if (running) return null;
550
606
  if (sessionId === void 0 || info === void 0 || info.git !== true) return null;
551
607
  const action = actionToken(info);
552
608
  if (action === "") return null;
@@ -651,6 +707,64 @@ window.__ModuleLoader__.load({
651
707
  return name;
652
708
  }
653
709
 
710
+ /**
711
+ * ONE commit line, shared by the branch hover and the PR token's hover so
712
+ * the two cannot drift apart again: a fixed-width monospace hash cell, so
713
+ * every description starts on the same column and the list reads as a
714
+ * table, then `subject · age`. `sign` belongs to the branch hover — "+"
715
+ * (only on this branch) or "−" (only on main); the PR list passes none,
716
+ * because all of its commits are `base..HEAD`.
717
+ *
718
+ * The cell needs `display: inline-block` for `minWidth` to mean anything: an
719
+ * inline span ignores it, which is exactly how the PR list came to render a
720
+ * hash with NO gap at all — it reused the card's label style, whose
721
+ * `minWidth` and `flex` only do anything inside a flex row.
722
+ */
723
+ function CommitLine({ commit, sign = "" }) {
724
+ return react_jsx_runtime.jsxs("span", {
725
+ style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
726
+ children: [
727
+ react_jsx_runtime.jsx("span", {
728
+ style: {
729
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
730
+ fontFamily: "monospace",
731
+ display: "inline-block",
732
+ minWidth: "68px",
733
+ flex: "none"
734
+ },
735
+ children: sign + commit.hash
736
+ }),
737
+ commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
738
+ ]
739
+ });
740
+ }
741
+
742
+ /**
743
+ * The PR token's own hover body — the commits IN THIS PULL REQUEST
744
+ * (`base..HEAD`), and nothing else: the token already says the number and
745
+ * CI state, so the one question left is "what is in it?". Same line format
746
+ * as the branch hover's commit list (see CommitLine — one renderer, so the
747
+ * columns line up in both by construction), and the card's own width
748
+ * behaviour: full-width lines, nowrap, ellipsis.
749
+ */
750
+ function PrCommitsList({ info }) {
751
+ const commits = info?.prCommits;
752
+ if (!Array.isArray(commits) || commits.length === 0) {
753
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no commits beyond the base branch" });
754
+ }
755
+ return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children: [
756
+ react_jsx_runtime.jsxs("div", {
757
+ style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
758
+ children: [
759
+ ...commits.map((commit) => react_jsx_runtime.jsx(CommitLine, { commit }, commit.hash)),
760
+ (typeof info.prCommitsTotal === "number" && info.prCommitsTotal > commits.length
761
+ ? "\u2026 and " + (info.prCommitsTotal - commits.length) + " more"
762
+ : null)
763
+ ]
764
+ })
765
+ ] });
766
+ }
767
+
654
768
  const CARD_CONTAINER = {
655
769
  display: "flex",
656
770
  flexDirection: "column",
@@ -741,27 +855,11 @@ window.__ModuleLoader__.load({
741
855
  if (value === void 0 || value === null || value === "") return;
742
856
  rows.push(cardRow(label, value));
743
857
  };
744
- add("upstream", info?.upstream === void 0 ? "none configured" : info.upstream);
745
- const ahead = info?.ahead || 0;
746
- const behind = info?.behind || 0;
747
- // The sync row speaks ONLY about the upstream it names: with no
748
- // upstream configured there are no ahead/behind counts at all (no
749
- // `# branch.ab` header), and falling back to 0/0 would claim "in
750
- // sync" — a lie the upstream row directly contradicts. Omit it; the
751
- // "none configured" row above carries the fact.
752
- if (info?.upstream !== void 0) {
753
- add("sync", ahead === 0 && behind === 0
754
- ? "in sync with upstream"
755
- : "\u2191" + ahead + " \u2193" + behind);
756
- }
757
- // The merge-state row, in WORDS (deliberately not arrows — the sync
758
- // row above already uses ↑↓ for the push axis, and two arrow pairs
759
- // with different references would be read as one). "merge" is its
760
- // label because it is the merge axis — the counterpart of "sync",
761
- // the push axis — and it stays correct whatever the default branch
762
- // is named. A BLOCKED verdict becomes the reason (review required,
763
- // checks failing, draft) so a dead-end word names the next action;
764
- // CLEAN stays silent — the action row already offers /gh merge.
858
+ // ONLY the merge axis, and it ALWAYS renders. The old upstream and sync
859
+ // rows spoke about the push axis (the branch's own remote), which the
860
+ // merge row subsumes: "1 ahead, 0 behind main" is the same fact stated
861
+ // against the branch that matters, in words rather than arrows. A row
862
+ // that can be absent is a row the reader has to reconstruct.
765
863
  const pr = info?.pr;
766
864
  const mergeParts = [];
767
865
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
@@ -771,12 +869,15 @@ window.__ModuleLoader__.load({
771
869
  : pr.state === "failing" ? "blocked: checks failing"
772
870
  : "blocked");
773
871
  } else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
774
- else if (pr.mergeState !== "CLEAN" && pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
872
+ else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) mergeParts.push("ready to merge");
873
+ else if (pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
874
+ } else {
875
+ mergeParts.push("no pull request");
775
876
  }
776
877
  if (info?.mainAhead !== void 0 || info?.mainBehind !== void 0) {
777
878
  mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
778
879
  }
779
- if (mergeParts.length > 0) add("merge", mergeParts.join(" \u00B7 "));
880
+ add("merge", mergeParts.join(" \u00B7 "));
780
881
  const commits = info?.branchCommits;
781
882
  if (Array.isArray(commits) && commits.length > 0) {
782
883
  // Full-width block, not the label/value two-column row: commit lines
@@ -788,23 +889,9 @@ window.__ModuleLoader__.load({
788
889
  react_jsx_runtime.jsxs("div", {
789
890
  style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
790
891
  children:
791
- commits.map((commit) => react_jsx_runtime.jsxs("span", {
792
- style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
793
- children: [
794
- // fixed-width monospace hash cell: every description starts
795
- // on the same column, so the list reads as a table
796
- react_jsx_runtime.jsx("span", {
797
- style: {
798
- color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
799
- fontFamily: "monospace",
800
- display: "inline-block",
801
- minWidth: "68px",
802
- flex: "none"
803
- },
804
- children: (commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : "") + commit.hash + ":"
805
- }),
806
- commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
807
- ]
892
+ commits.map((commit) => react_jsx_runtime.jsx(CommitLine, {
893
+ commit,
894
+ sign: commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : ""
808
895
  }, commit.hash))
809
896
  }, "branch-commits")
810
897
  );
@@ -891,11 +978,13 @@ window.__ModuleLoader__.load({
891
978
  rows.push(cardRow(label, value));
892
979
  };
893
980
  // The "action" row — the top line, in the card's ordinary row layout:
894
- // the gh skill invocation the checkout justifies, derived from the same
895
- // state the /gh picker uses (first sub-action wins). Plain text, no
896
- // affordance: the actionable surface is the (+) menu's /gh picker, and
897
- // this row is the read-only pointer to it.
898
- const topAction = ghSkillActions(data)[0];
981
+ // the SERVER's ranked suggestion, and nothing else. The client's
982
+ // extras (secondary picker entries) must never promote themselves into
983
+ // this line: when the server says there is nothing to do — clean and
984
+ // synced — the row is omitted, and "no suggestion" stays a suggestion.
985
+ // Plain text, no affordance: the actionable surface is the (+) menu's
986
+ // /gh picker, and this row is the read-only pointer to it.
987
+ const topAction = data.next;
899
988
  if (topAction !== void 0) {
900
989
  // git-comment convention: the invocation, two spaces, then "# "
901
990
  // plus what it does
@@ -1011,12 +1100,18 @@ window.__ModuleLoader__.load({
1011
1100
  // only (the node half rebuilt the URL from parsed parts); new tab,
1012
1101
  // never navigating the conversation away.
1013
1102
  const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1103
+ // The sync arrows ride WITH the branch: they are the branch's standing
1104
+ // against its upstream, so the same hover (lineage) and the same link
1105
+ // (compare view, where the arrows are the diff) cover them. The ✎n
1106
+ // count stays its own surface — it answers a different question.
1107
+ const sync = formatSync(info);
1108
+ const label = info.branch + sync;
1014
1109
  const branch = isLink
1015
1110
  ? react_jsx_runtime.jsx("a", {
1016
1111
  href: detail.compareUrl,
1017
1112
  target: "_blank",
1018
1113
  rel: "noopener noreferrer",
1019
- "aria-label": "Compare " + info.branch + " with main on GitHub",
1114
+ "aria-label": "Compare " + info.branch + " with main on GitHub" + (sync === "" ? "" : ", " + (info.ahead || 0) + " ahead and " + (info.behind || 0) + " behind upstream"),
1020
1115
  onPointerEnter: () => { setHovered(true); setBranchLinkHover(true); },
1021
1116
  onPointerLeave: () => setBranchLinkHover(false),
1022
1117
  onFocus: () => { setHovered(true); setBranchLinkHover(true); },
@@ -1026,12 +1121,12 @@ window.__ModuleLoader__.load({
1026
1121
  textDecoration: branchLinkHover ? "underline" : "none",
1027
1122
  cursor: "pointer"
1028
1123
  },
1029
- children: info.branch
1124
+ children: label
1030
1125
  })
1031
1126
  : react_jsx_runtime.jsx("span", {
1032
1127
  onPointerEnter: () => setHovered(true),
1033
1128
  style: { cursor: "default" },
1034
- children: info.branch
1129
+ children: label
1035
1130
  });
1036
1131
  if (Tooltip === void 0 || detail === void 0 || detail === null) {
1037
1132
  // mid-fetch the branch shows plain; the lineage tooltip is
@@ -1051,10 +1146,12 @@ window.__ModuleLoader__.load({
1051
1146
  // either triggers it once — and degrades to a plain count on a shell
1052
1147
  // without the Tooltip primitive.
1053
1148
  const countHover = (() => {
1149
+ // ✎n only: the sync arrows moved to the branch's surface above
1150
+ const countText = formatFileCount(info);
1054
1151
  const count = react_jsx_runtime.jsx("span", {
1055
1152
  onPointerEnter: () => setHovered(true),
1056
1153
  style: { cursor: "default" },
1057
- children: formatGitSuffix(info)
1154
+ children: countText
1058
1155
  });
1059
1156
  const hasNames = (Array.isArray(detail?.untrackedNames) && detail.untrackedNames.length > 0)
1060
1157
  || (Array.isArray(detail?.unstagedNames) && detail.unstagedNames.length > 0);
@@ -1070,6 +1167,61 @@ window.__ModuleLoader__.load({
1070
1167
  })();
1071
1168
  const prToken = formatPrToken(info);
1072
1169
  const prUrl = prLinkUrl(info);
1170
+ // The PR token, with its own hover: the commits IN THIS PR (see
1171
+ // PrCommitsList). Built here so the link/plain cases and the tooltip
1172
+ // compose once instead of nesting a third conditional in the children.
1173
+ // The token is its OWN detail gate: resting on it (or focusing it)
1174
+ // enables the same `detail=1` fetch the mark and the branch do. Without
1175
+ // that, a pointer going straight to the token had rested on nothing that
1176
+ // fetches, so `prCommits` was never requested and this hover could not
1177
+ // open at all — the data it renders is detail-only.
1178
+ const prTokenHover = (() => {
1179
+ const token = prUrl === void 0
1180
+ ? react_jsx_runtime.jsx("span", {
1181
+ // the glyph is not the only channel: the token says what the
1182
+ // CI state IS, for anyone who cannot see it
1183
+ "aria-label": prTokenLabel(info),
1184
+ onPointerEnter: () => setHovered(true),
1185
+ style: { cursor: "default" },
1186
+ children: prToken
1187
+ })
1188
+ : react_jsx_runtime.jsx("a", {
1189
+ href: prUrl,
1190
+ // a new tab, because navigating THIS one away from the app
1191
+ // would lose the conversation; noopener/noreferrer keep the
1192
+ // opened tab from getting a handle on it
1193
+ target: "_blank",
1194
+ rel: "noopener noreferrer",
1195
+ "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
1196
+ // the chip's own colour rather than the browser's link blue /
1197
+ // visited purple, so the token still reads as one row. The
1198
+ // affordance is the pointer cursor plus an underline on hover
1199
+ // or focus, set HERE rather than left to the host stylesheet:
1200
+ // a shell that resets anchors would otherwise drop it silently.
1201
+ style: {
1202
+ color: "inherit",
1203
+ textDecoration: linkHover ? "underline" : "none",
1204
+ cursor: "pointer"
1205
+ },
1206
+ // focus joins hover — the token is keyboard-reachable now, and a
1207
+ // keyboard user needs the same "this is a link" signal
1208
+ onPointerEnter: () => { setHovered(true); setLinkHover(true); },
1209
+ onPointerLeave: () => setLinkHover(false),
1210
+ onFocus: () => { setHovered(true); setLinkHover(true); },
1211
+ onBlur: () => setLinkHover(false),
1212
+ children: prToken
1213
+ });
1214
+ if (Tooltip === void 0 || detail === void 0 || detail === null
1215
+ || !Array.isArray(detail.prCommits) || detail.prCommits.length === 0) {
1216
+ return token;
1217
+ }
1218
+ return react_jsx_runtime.jsx(Tooltip, {
1219
+ side: "top",
1220
+ maxWidth: 480,
1221
+ label: () => react_jsx_runtime.jsx(PrCommitsList, { info: detail }),
1222
+ children: token
1223
+ });
1224
+ })();
1073
1225
  const chip = react_jsx_runtime.jsxs("span", {
1074
1226
  style: {
1075
1227
  display: "inline-flex",
@@ -1103,43 +1255,7 @@ window.__ModuleLoader__.load({
1103
1255
  branchHover,
1104
1256
  react_jsx_runtime.jsx("span", { key: "text", children: text }),
1105
1257
  countHover,
1106
- prToken === ""
1107
- ? null
1108
- : prUrl === void 0
1109
- ? react_jsx_runtime.jsx("span", {
1110
- key: "pr",
1111
- // the glyph is not the only channel: the token says what the
1112
- // CI state IS, for anyone who cannot see it
1113
- "aria-label": prTokenLabel(info),
1114
- children: prToken
1115
- })
1116
- : react_jsx_runtime.jsx("a", {
1117
- key: "pr",
1118
- href: prUrl,
1119
- // a new tab, because navigating THIS one away from the app
1120
- // would lose the conversation; noopener/noreferrer keep the
1121
- // opened tab from getting a handle on it
1122
- target: "_blank",
1123
- rel: "noopener noreferrer",
1124
- "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
1125
- // the chip's own colour rather than the browser's link blue /
1126
- // visited purple, so the token still reads as one row. The
1127
- // affordance is the pointer cursor plus an underline on hover
1128
- // or focus, set HERE rather than left to the host stylesheet:
1129
- // a shell that resets anchors would otherwise drop it silently.
1130
- style: {
1131
- color: "inherit",
1132
- textDecoration: linkHover ? "underline" : "none",
1133
- cursor: "pointer"
1134
- },
1135
- // focus joins hover — the token is keyboard-reachable now, and a
1136
- // keyboard user needs the same "this is a link" signal
1137
- onPointerEnter: () => setLinkHover(true),
1138
- onPointerLeave: () => setLinkHover(false),
1139
- onFocus: () => setLinkHover(true),
1140
- onBlur: () => setLinkHover(false),
1141
- children: prToken
1142
- }),
1258
+ prTokenHover,
1143
1259
  /**
1144
1260
  * The seam-absent notice: the market-install notification for the
1145
1261
  * state a blocked postinstall leaves behind — chip working, sidebar
@@ -1424,7 +1540,12 @@ window.__ModuleLoader__.load({
1424
1540
  info = await fetch("/api/git-badge?" + query).then((r) => r.json());
1425
1541
  }
1426
1542
  const actions = ghSkillActions(info);
1427
- return actions.map((action) => ({ label: "/gh " + action.args, detail: action.why, args: action.args }));
1543
+ return actions.map((action) => ({
1544
+ label: "/gh " + action.args,
1545
+ detail: action.why,
1546
+ args: action.args,
1547
+ ...action.danger === true ? { confirmation: cleanGate() } : {}
1548
+ }));
1428
1549
  } catch (error) {
1429
1550
  console.error("[dsh-git-badge] /gh options failed:", error);
1430
1551
  return [];
@@ -1667,7 +1788,7 @@ window.__ModuleLoader__.load({
1667
1788
  // Additive; the host reads apply/inject and ignores the rest. The suite
1668
1789
  // drives these to assert the REQUEST contract — which surface asks for the
1669
1790
  // expensive extras — without a browser, a fetch or a network.
1670
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, moveCategory };
1791
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
1671
1792
  return module.exports;
1672
1793
  }
1673
1794
  });
package/lib/index.js CHANGED
@@ -95,6 +95,8 @@ const config = {
95
95
  fetchTimeoutMs: 8000,
96
96
  /** minimum interval between background fetches per toplevel */
97
97
  fetchTtlMs: 60000,
98
+ /** how long a resolved default-branch name is trusted (per toplevel) */
99
+ defaultBranchTtlMs: 60000,
98
100
  /** fs-event burst collapse window */
99
101
  debounceMs: 200,
100
102
  /**
@@ -130,8 +132,16 @@ const config = {
130
132
  * process — see the PR region.
131
133
  */
132
134
  prStatus: "auto",
133
- /** minimum interval between forge refreshes per repository */
134
- prTtlMs: 90000,
135
+ /**
136
+ * Minimum interval between forge refreshes per repository. It must stay BELOW
137
+ * the client's idle poll (FALLBACK_POLL_MS, 60s in lib/client.js): the refresh
138
+ * is request-driven, so a TTL longer than that poll silently suppresses every
139
+ * OTHER request — and a pull request that appears on the forge with no local
140
+ * git event to announce it then waits out TTL + poll before it shows. At 90s
141
+ * that worst case was ~2.5 minutes; 20s bounds it at roughly one poll, for up
142
+ * to one `gh pr view` per open chip per minute.
143
+ */
144
+ prTtlMs: 20000,
135
145
  /**
136
146
  * Forge call budget. Unlike git this is a network round trip to GitHub, so
137
147
  * it gets a more generous budget than the status sample — but it is bounded
@@ -210,6 +220,32 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
210
220
  * auto-fetch), with a TTL rather than a timer: fetch only happens when
211
221
  * someone is actually looking at the badge.
212
222
  */
223
+ /**
224
+ * toplevel -> { at, value } for the repository's default branch name ("main"),
225
+ * resolved from `refs/remotes/origin/HEAD`. Read on the HOT path and refreshed
226
+ * out of band, exactly like the fetch: a status call must never pay a process
227
+ * for it, so the first request after a cache miss simply answers without the
228
+ * flag and the next one carries it.
229
+ */
230
+ const defaultBranchState = new Map();
231
+
232
+ /** The cached default branch, kicking a refresh when stale. Never throws. */
233
+ function defaultBranchFor(toplevel, notify) {
234
+ const now = Date.now();
235
+ const cached = defaultBranchState.get(toplevel);
236
+ if (cached === void 0 || now - cached.at >= config.defaultBranchTtlMs) {
237
+ // out of band: this answer is served from what we already know
238
+ void (async () => {
239
+ const out = await runGit(toplevel, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
240
+ const name = out.stdout === null ? "" : out.stdout.trim().replace(/^origin\//u, "");
241
+ const previous = defaultBranchState.get(toplevel)?.value;
242
+ defaultBranchState.set(toplevel, { at: Date.now(), value: name === "" ? void 0 : name });
243
+ if (previous !== defaultBranchState.get(toplevel).value) notify?.();
244
+ })();
245
+ }
246
+ return cached?.value;
247
+ }
248
+
213
249
  /** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
214
250
  const fetchState = new Map();
215
251
 
@@ -730,7 +766,7 @@ function nextOrder() {
730
766
  *
731
767
  * @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
732
768
  */
733
- function nextStep(info) {
769
+ function nextStepRules(info) {
734
770
  if (info === void 0 || info === null || info.git !== true) return null;
735
771
  const unmerged = info.unmergedFiles || 0;
736
772
  const plural = (n) => (n === 1 ? "" : "s");
@@ -786,15 +822,80 @@ function nextStep(info) {
786
822
  if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
787
823
  add("checks", `checks ${pr.number}`, `checks failing on #${pr.number}`, "watch the CI checks on pull request " + pr.number, `gh pr checks ${pr.number} --watch`);
788
824
  }
789
- if (rules.length === 0) return null;
825
+
826
+ // STANDING options: never ranked, never the primary suggestion — they are
827
+ // things you may do, not things the checkout is asking for. `pr view` exists
828
+ // whenever a pull request does; `pr` (create) only for a pushed branch that
829
+ // is not the default; `clean` last and flagged, because a destructive verb
830
+ // whose intent git status cannot reveal must never be suggested.
831
+ const standing = [];
832
+ if (pr !== void 0 && pr.number !== void 0) {
833
+ standing.push({
834
+ args: `pr view ${pr.number}`,
835
+ why: `open pull request #${pr.number}`,
836
+ what: `show pull request ${pr.number} on GitHub`
837
+ });
838
+ } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && dirty === 0 && info.defaultBranch !== true) {
839
+ standing.push({ args: "pr", why: "branch is pushed and has no pull request", what: "open a pull request for this branch" });
840
+ }
841
+ if (untracked > 0) {
842
+ standing.push({
843
+ args: "clean",
844
+ why: `${untracked} untracked file${plural(untracked)}`,
845
+ what: "remove untracked files — lists them and asks first",
846
+ danger: true
847
+ });
848
+ }
790
849
 
791
850
  const order = nextOrder();
792
851
  const rank = (cat) => {
793
852
  const at = order.indexOf(cat);
794
853
  return at === -1 ? order.length : at;
795
854
  };
796
- const [top] = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat));
797
- return { args: top.args, command: top.command, why: top.why, what: top.what };
855
+ const ranked = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat)).slice(0, 5);
856
+ return { ranked, standing };
857
+ }
858
+
859
+ /**
860
+ * Every action the checkout justifies, in order — the ONE list both surfaces
861
+ * render: the card shows the primary entry, the (+) picker lists them all.
862
+ *
863
+ * The server owns the whole list because both halves previously carried their
864
+ * own copy of these conditions and drifted: the card promoted a client-only
865
+ * extra into its action row, and `/gh pr` was offered on the default branch.
866
+ * One authority, one list, no reconciliation.
867
+ *
868
+ * Exactly one entry can be `primary` (the top RANKED rule) or none at all —
869
+ * a clean, synced checkout has actions (look at the PR, clean up) but no
870
+ * suggestion, because "no suggestion" is also a suggestion.
871
+ *
872
+ * @returns {{ actions: Array<{args, why, what, command?, danger?, primary?}>, next: object|null }}
873
+ */
874
+ function nextActions(info) {
875
+ const built = nextStepRules(info);
876
+ if (built === null) return { actions: [], next: null };
877
+ const { ranked, standing } = built;
878
+ const actions = [
879
+ ...ranked.map((rule, index) => ({
880
+ args: rule.args,
881
+ why: rule.why,
882
+ what: rule.what,
883
+ ...(rule.command === void 0 ? {} : { command: rule.command }),
884
+ ...(index === 0 ? { primary: true } : {})
885
+ })),
886
+ ...standing
887
+ ];
888
+ const primary = actions.find((action) => action.primary === true) ?? null;
889
+ return { actions, next: primary };
890
+ }
891
+
892
+ /**
893
+ * The single ranked suggestion, or null — the card's action row and the legacy
894
+ * shape. It is the primary entry of {@link nextActions}, never a standing
895
+ * option.
896
+ */
897
+ function nextStep(info) {
898
+ return nextActions(info).next;
798
899
  }
799
900
 
800
901
  /**
@@ -897,6 +998,15 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
897
998
  ahead: parsed.ahead,
898
999
  behind: parsed.behind
899
1000
  };
1001
+ // Is this the repository's DEFAULT branch? main/master with no pull request
1002
+ // is that branch's normal state, and "open a pull request for this branch" is
1003
+ // nonsense there, so the surfaces suppress it. Cached and refreshed out of
1004
+ // band: the flag may be absent on the very first read, present on the next.
1005
+ const defaultBranch = defaultBranchFor(toplevel, notifyChange);
1006
+ if (defaultBranch !== void 0 && defaultBranch === parsed.branch) info.defaultBranch = true;
1007
+ // Resolved inside the detail block; the PR-token hover below reuses it, so it
1008
+ // is declared here rather than scoped to that block.
1009
+ let baseRef = null;
900
1010
  if (wantDetail) {
901
1011
  // The ✎n NAMES — the one part of the count the numbers cannot answer
902
1012
  // ("what are these?"). Hover-gated with the rest of detail=1, so no
@@ -944,7 +1054,7 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
944
1054
  if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
945
1055
  info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
946
1056
  }
947
- const baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
1057
+ baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
948
1058
  ? "origin/main"
949
1059
  : probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
950
1060
  if (baseRef !== null && info.repoUrl !== void 0) {
@@ -1009,8 +1119,28 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
1009
1119
  const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
1010
1120
  if (pr !== void 0) info.pr = pr;
1011
1121
  }
1012
- const next = nextStep(info);
1122
+ if (baseRef !== null && info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0
1123
+ && info.pr.open !== false && (info.mainAhead || 0) > 0) {
1124
+ // The PR's OWN commits (base..HEAD) — the token's hover answers "what is in
1125
+ // this pull request", so it lists exactly that: not the branch's recent
1126
+ // history, nothing else. Hover-gated with the rest of detail=1, and the
1127
+ // total is the ahead count already computed above (no second rev-list).
1128
+ const prOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", baseRef + "..HEAD"]);
1129
+ if (prOut.stdout !== null && prOut.stdout.trim() !== "") {
1130
+ info.prCommits = prOut.stdout.trim().split("\n").map((line) => {
1131
+ const [hash, subject, when] = line.split("\t");
1132
+ return { hash, subject: subject ?? "", when: when ?? "" };
1133
+ }).filter((c) => c.hash !== void 0);
1134
+ info.prCommitsTotal = info.mainAhead;
1135
+ }
1136
+ }
1137
+ // The ranked suggestion rides EVERY response (it is pure derivation, and a
1138
+ // sidebar row may one day use it instead of its own copy of these rules);
1139
+ // the full action list is sent only for `pr=1`, the surfaces that have a
1140
+ // picker, so a row's payload stays lean.
1141
+ const { actions, next } = nextActions(info);
1013
1142
  if (next !== null) info.next = next;
1143
+ if (wantPr && actions.length > 0) info.actions = actions;
1014
1144
  return info;
1015
1145
  }
1016
1146
 
@@ -1649,6 +1779,7 @@ export {
1649
1779
  OPERATION_MARKERS,
1650
1780
  operationMarker,
1651
1781
  nextStep,
1782
+ nextActions,
1652
1783
  outerGitDir,
1653
1784
  parseStatusV2,
1654
1785
  parseWorktreeList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.16.1",
3
+ "version": "0.17.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/skill/gh/SKILL.md CHANGED
@@ -87,6 +87,27 @@ unstaged / untracked / unmerged), plus any in-progress operation marker
87
87
  A session's cwd is fixed at creation, so this registration is the ONLY signal
88
88
  the badge has.
89
89
 
90
+ - **`/gh clean [<path>...]`** — remove untracked files, and only with the user's
91
+ explicit yes for the exact list. It appears in the `/gh` menu whenever
92
+ untracked files exist, always last, behind the host's risk gate: picking it
93
+ opens a confirmation whose box must be ticked before the invocation is even
94
+ sent. That gate names the RISK, not the files — so the explicit paths and the
95
+ second yes below are still required. This is the one verb whose first step is a
96
+ DRY RUN, always:
97
+
98
+ 1. `git clean -n -- <paths>` and quote its output back verbatim — the user is
99
+ approving that list, not the idea of cleaning;
100
+ 2. ask; if the answer is anything but an unambiguous yes, stop and change
101
+ nothing;
102
+ 3. `git clean -f -- <paths>` — named paths only.
103
+
104
+ Never `-x` (ignored files are often credentials), never `-d` (whole
105
+ directories), never a bare `git clean -f`, and never a path the user did not
106
+ name or approve. With no argument, list what `git clean -n` reports and ask
107
+ which paths to remove rather than picking for them. Untracked files have no
108
+ reflog and no stash entry: what this removes cannot be recovered, so a
109
+ mistaken cleanup is unrecoverable in a way a mistaken commit is not.
110
+
90
111
  - **`/gh order`** — reconfigure which action class the badge ranks first. The
91
112
  order lives in the `git-badge` settings namespace, edited in the UI at
92
113
  **Settings → Plugins → Git Badge** (an ordered list with up/down controls).
@@ -108,7 +129,8 @@ the `gh-pr` skill; hand off rather than improvise a lifecycle.
108
129
  ## Hard rules
109
130
 
110
131
  - Never `git reset --hard`, `git clean`, `git checkout -- .`, `git push
111
- --force` (plain), or any history rewrite without an explicit user yes.
132
+ --force` (plain), or any history rewrite without an explicit user yes —
133
+ `/gh clean`'s dry run and verbatim quote come before any `git clean -f`.
112
134
  - `GIT_TERMINAL_PROMPT=0` on network commands so a missing credential fails
113
135
  fast instead of hanging.
114
136
  - One operation per invocation; report the outcome, then stop.