dsh-git-badge 0.13.0 → 0.14.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,22 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.14.0] - 2026-09-15
14
+
15
+ ### Added
16
+
17
+ - **A "next" row at the top of the chip's hover card.** The node half derives
18
+ the single highest-priority next step from fields the status response already
19
+ carries — a paused operation and its resume command, `git pull --ff-only`
20
+ when behind, `git push -u origin <branch>` when no upstream exists on a dirty
21
+ branch, `git push` when ahead, a conservative commit command when dirty
22
+ (`add -p` for unstaged work, `-A` only for untracked-only changes, which
23
+ `add -p` cannot see), and `gh pr checks <n> --watch` when CI fails. Each
24
+ suggestion carries its reason, and the command is a click-to-copy chip — it
25
+ runs in the user's own terminal, never through dsh. Clean, synced, nothing
26
+ failing → no row. Pure derivation: no extra git invocations, and the ranking
27
+ is unit-tested in the node suite.
28
+
13
29
  ## [0.13.0] - 2026-09-15
14
30
 
15
31
  ### Added
package/lib/client.js CHANGED
@@ -50,6 +50,11 @@ window.__ModuleLoader__.load({
50
50
  primitives = null;
51
51
  }
52
52
  const Tooltip = primitives === null ? void 0 : primitives.Tooltip;
53
+ // The shell's pull-down primitive (what the input-area selectors use) and
54
+ // its candidate ranker. Same guarded require as Tooltip: a shell without
55
+ // them loses the pull-down affordances, never the badges.
56
+ const Menu = primitives === null ? void 0 : primitives.Menu;
57
+ const rankByName = primitives === null ? void 0 : primitives.rankByName;
53
58
 
54
59
  /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
55
60
  const GIT_CACHE = new Map();
@@ -225,6 +230,94 @@ window.__ModuleLoader__.load({
225
230
  return "ok";
226
231
  }
227
232
 
233
+ /**
234
+ * The git action list both actionable surfaces render — the `!` trigger
235
+ * menu in the input and the branch pull-down on the chip. State-filtered:
236
+ * the server's `next` suggestion leads, then only actions the current
237
+ * status justifies. Deduped by command (the next step already covers one
238
+ * of these when it agrees) and capped, because a menu of twelve is a
239
+ * reference manual, not an action menu.
240
+ */
241
+ function buildActions(info) {
242
+ if (info === void 0 || info === null || info.git !== true) return [];
243
+ const actions = [];
244
+ const seen = new Set();
245
+ const add = (command, why) => {
246
+ if (typeof command !== "string" || command === "" || seen.has(command)) return;
247
+ seen.add(command);
248
+ actions.push({ name: command, label: command, description: why, command, why });
249
+ };
250
+ const next = info.next;
251
+ if (next !== void 0 && next !== null && typeof next.command === "string") {
252
+ add(next.command, next.why);
253
+ }
254
+ const ahead = info.ahead || 0;
255
+ const behind = info.behind || 0;
256
+ const staged = info.stagedFiles || 0;
257
+ const unstaged = info.unstagedFiles || 0;
258
+ const untracked = info.untrackedFiles || 0;
259
+ if (behind > 0) add("git pull --ff-only", behind + " behind " + (info.upstream ?? "upstream"));
260
+ if (ahead > 0) add("git push", ahead + " ahead of " + (info.upstream ?? "upstream"));
261
+ if (staged > 0) add("git commit", staged + " staged");
262
+ if (unstaged > 0) add("git add -p && git commit", unstaged + " unstaged");
263
+ if (unstaged === 0 && untracked > 0) add("git add -A && git commit", untracked + " untracked");
264
+ if ((info.stashCount || 0) > 0) add("git stash list", info.stashCount + " stashed");
265
+ const pr = info.pr;
266
+ if (pr !== void 0 && pr !== null && pr.number !== void 0) {
267
+ if (pr.state === "failing") add("gh pr checks " + pr.number + " --watch", "checks failing on #" + pr.number);
268
+ add("gh pr view " + pr.number, "open pull request #" + pr.number);
269
+ } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
270
+ add("gh pr create", "branch is pushed and has no pull request");
271
+ }
272
+ return actions.slice(0, 6);
273
+ }
274
+
275
+ /** The input text a picked action becomes: the command, then why as a comment. */
276
+ function actionText(action) {
277
+ return "!" + action.command + " # " + action.why;
278
+ }
279
+
280
+ /**
281
+ * The /gh picker's sub-actions — the skill invocations the checkout
282
+ * justifies, most urgent first. Same ranking the server's nextStep uses,
283
+ * but spoken in skill arguments; the generic "next" entry is skipped when
284
+ * a specific rule already names the same situation (its why would be
285
+ * identical, and two rows for one fact is noise).
286
+ */
287
+ function ghSkillActions(info) {
288
+ if (info === void 0 || info === null || info.git !== true) return [];
289
+ const subs = [];
290
+ const seen = new Set();
291
+ const add = (args, why) => {
292
+ if (seen.has(args)) return;
293
+ seen.add(args);
294
+ subs.push({ args, why });
295
+ };
296
+ const ahead = info.ahead || 0;
297
+ const behind = info.behind || 0;
298
+ const staged = info.stagedFiles || 0;
299
+ const unstaged = info.unstagedFiles || 0;
300
+ const untracked = info.untrackedFiles || 0;
301
+ if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"));
302
+ if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"));
303
+ if (staged > 0) add("commit", staged + " staged");
304
+ if (unstaged > 0) add("commit", unstaged + " unstaged");
305
+ if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked");
306
+ const pr = info.pr;
307
+ if (pr !== void 0 && pr !== null && pr.number !== void 0) {
308
+ if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number);
309
+ add("pr view " + pr.number, "open pull request #" + pr.number);
310
+ } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
311
+ add("pr", "branch is pushed and has no pull request");
312
+ }
313
+ const next = info.next;
314
+ if (next !== void 0 && next !== null && typeof next.command === "string"
315
+ && !subs.some((entry) => entry.why === next.why)) {
316
+ add("next", next.why);
317
+ }
318
+ return subs.slice(0, 6);
319
+ }
320
+
228
321
  /**
229
322
  * Mark fill per status, from the app's own semantic tokens so BOTH surfaces
230
323
  * follow light/dark and custom themes — which a hardcoded emoji cannot do.
@@ -617,6 +710,66 @@ window.__ModuleLoader__.load({
617
710
  const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
618
711
  const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
619
712
 
713
+ /**
714
+ * The "next" row's body: why, then the command as a click-to-copy chip.
715
+ * Clipboard-only by design — no server call, no path exposure; the command
716
+ * lands in the user's clipboard to run in their own terminal. `navigator.
717
+ * clipboard` needs a user gesture (a click is one) and a secure context
718
+ * (localhost is one); a denied write degrades to a no-op and the command
719
+ * is still readable as text.
720
+ */
721
+ function NextStepRow({ next }) {
722
+ const [copied, setCopied] = react.useState(false);
723
+ const copy = () => {
724
+ if (typeof next.command !== "string") return;
725
+ try {
726
+ void navigator.clipboard.writeText(next.command).then(
727
+ () => {
728
+ setCopied(true);
729
+ setTimeout(() => setCopied(false), 1500);
730
+ },
731
+ () => void 0,
732
+ );
733
+ } catch {
734
+ void 0;
735
+ }
736
+ };
737
+ return react_jsx_runtime.jsxs(
738
+ "span",
739
+ {
740
+ style: { ...CARD_VALUE, display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
741
+ children: [
742
+ react_jsx_runtime.jsx("span", { children: next.why }),
743
+ typeof next.command === "string"
744
+ ? react_jsx_runtime.jsx(
745
+ "button",
746
+ {
747
+ "aria-label": "Copy command: " + next.command,
748
+ title: "Copy command: " + next.command,
749
+ onClick: copy,
750
+ style: {
751
+ cursor: "pointer",
752
+ color: "inherit",
753
+ background: "none",
754
+ border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
755
+ borderRadius: "4px",
756
+ fontSize: "11px",
757
+ lineHeight: "16px",
758
+ padding: "0 5px",
759
+ flex: "none",
760
+ fontFamily: "monospace"
761
+ },
762
+ children: copied ? "copied" : next.command
763
+ },
764
+ "cmd"
765
+ )
766
+ : null
767
+ ]
768
+ },
769
+ "next-body"
770
+ );
771
+ }
772
+
620
773
  /**
621
774
  * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
622
775
  * over fields the status response already carries, plus the `detail=1`
@@ -640,6 +793,18 @@ window.__ModuleLoader__.load({
640
793
  }, label)
641
794
  );
642
795
  };
796
+ // The "next" row — the node half's single highest-priority suggestion,
797
+ // { command, why } or absent (clean + synced + nothing failing). Clicking
798
+ // copies the command; it is the one actionable affordance the card
799
+ // carries, and it costs no server surface: the command runs in the
800
+ // user's own terminal, never here. Per-open state: the copied ack
801
+ // resets the next time the card opens, which is the honest lifetime for it.
802
+ const next = data.next;
803
+ if (next !== void 0 && next !== null && typeof next.why === "string") {
804
+ rows.push(
805
+ react_jsx_runtime.jsxs(NextStepRow, { next }, "next")
806
+ );
807
+ }
643
808
  add("branch", info.branch);
644
809
  add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
645
810
  if (info.ahead !== void 0 || info.behind !== void 0) {
@@ -729,8 +894,38 @@ window.__ModuleLoader__.load({
729
894
  // re-render that no fetch will schedule, so the × bumps a counter here.
730
895
  const [, bumpNotice] = react.useState(0);
731
896
  const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
897
+ // The branch pull-down (model-selection pattern): a chevron opens the
898
+ // shell Menu of state-filtered git actions; picking COPIES the command —
899
+ // the composer has no public insert API (see the trigger source in
900
+ // apply() for the insertion-capable surface), so this surface's contract
901
+ // is paste-and-send, the same one the hover card's chip has always had.
902
+ const [menuOpen, setMenuOpen] = react.useState(false);
903
+ const [copied, setCopied] = react.useState(false);
732
904
  const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
733
905
  if (info === void 0 || info.git !== true) return null;
906
+ const actions = buildActions({ ...info, ...(detail ?? {}) });
907
+
908
+ // The status mark wrapped in the chip's hover card — the card's ONLY
909
+ // anchor. Pointer rest here (and only here) enables the detail=1 fetch,
910
+ // so the extras are paid for exactly when the card that can render them
911
+ // is about to open. Defined inside the component: it closes over the
912
+ // hovered state that gates the detail fetch.
913
+ const hoverableMark = (() => {
914
+ const mark = react_jsx_runtime.jsx("span", {
915
+ onPointerEnter: () => setHovered(true),
916
+ style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
917
+ children: react_jsx_runtime.jsx(StatusMark, { info })
918
+ });
919
+ if (Tooltip === void 0) return mark;
920
+ return react_jsx_runtime.jsx(Tooltip, {
921
+ side: "top",
922
+ maxWidth: 360,
923
+ // a function label keeps the card's element tree out of every render
924
+ // until the tooltip actually opens
925
+ label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
926
+ children: mark
927
+ });
928
+ })();
734
929
  // the mark is an element now rather than a leading glyph in the string, so
735
930
  // the SAME StatusMark the sidebar row draws carries the status here too;
736
931
  // the container's 4px gap supplies the space the emoji's own did
@@ -757,11 +952,78 @@ window.__ModuleLoader__.load({
757
952
  // null until the delayed check in apply() proves the seam never
758
953
  // appeared.
759
954
  title: seamHint === null ? undefined : seamHint,
760
- // the card is fetched for a pointer that RESTS here, not one that
761
- // merely crosses the chip
762
- onPointerEnter: () => setHovered(true),
763
955
  children: [
764
- react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
956
+ // The hover card belongs to the STATUS SYMBOL, not the badge: the
957
+ // circle is the "what does this colour mean" element, and a card
958
+ // opening sideways from a hover on the branch text got in the way of
959
+ // the very pull-down this row now carries. The detail fetch
960
+ // (enabled: hovered) follows the same pointer, so nothing pays for
961
+ // extras the card no longer shows. The seam-absent native title
962
+ // stays on the whole chip — it is about the badge, not the mark.
963
+ hoverableMark,
964
+
965
+ Menu === void 0 || actions.length === 0
966
+ ? null
967
+ : react_jsx_runtime.jsx(Menu, {
968
+ key: "actions",
969
+ open: menuOpen,
970
+ onClose: () => setMenuOpen(false),
971
+ items: actions.map((action) => ({ id: action.command, label: action.label, description: action.description })),
972
+ onSelect: (picked) => {
973
+ setMenuOpen(false);
974
+ // the shell may hand back the id or the whole item — take either
975
+ const id = typeof picked === "string" ? picked : picked?.id;
976
+ const action = actions.find((entry) => entry.command === id);
977
+ console.info("[dsh-git-badge] pull-down pick:", id, "->", action === void 0 ? "NOT FOUND" : actionText(action));
978
+ if (action === void 0) return;
979
+ try {
980
+ void navigator.clipboard.writeText(actionText(action)).then(() => {
981
+ setCopied(true);
982
+ setTimeout(() => setCopied(false), 1200);
983
+ }, () => void 0);
984
+ } catch {
985
+ void 0;
986
+ }
987
+ },
988
+ align: "start",
989
+ portal: true,
990
+ anchor: react_jsx_runtime.jsxs(
991
+ "button",
992
+ {
993
+ type: "button",
994
+ "aria-haspopup": "menu",
995
+ "aria-expanded": menuOpen,
996
+ "aria-label": "Git actions for " + info.branch,
997
+ title: copied ? "copied — paste in your terminal" : "git actions",
998
+ onClick: () => setMenuOpen((value) => !value),
999
+ // the metrics of the row's own selectors (the access/model
1000
+ // pull-downs): inline-flex, centered on the 24px line, no
1001
+ // border or padding, icon flex-none so the chevron reads as an
1002
+ // affordance, not a second label
1003
+ style: {
1004
+ cursor: "pointer",
1005
+ color: "inherit",
1006
+ background: "none",
1007
+ border: "none",
1008
+ display: "inline-flex",
1009
+ alignItems: "center",
1010
+ justifyContent: "center",
1011
+ fontSize: "12px",
1012
+ lineHeight: "24px",
1013
+ padding: "0",
1014
+ flex: "none"
1015
+ },
1016
+ children: [
1017
+ copied
1018
+ ? "\u2713"
1019
+ : (primitives !== null && primitives.IconChevronDownOutline14 !== void 0
1020
+ ? react_jsx_runtime.jsx(primitives.IconChevronDownOutline14, {})
1021
+ : "\u25BE")
1022
+ ]
1023
+ },
1024
+ "actions-anchor"
1025
+ )
1026
+ }),
765
1027
  react_jsx_runtime.jsx("span", { key: "text", children: text }),
766
1028
  prToken === ""
767
1029
  ? null
@@ -864,25 +1126,18 @@ window.__ModuleLoader__.load({
864
1126
  ),
865
1127
  ]
866
1128
  });
867
- // No Tooltip primitive (a shell that does not seed it): render the chip
868
- // itself rather than losing the badge. The card is an enhancement, the
1129
+ // No Tooltip primitive (a shell that does not seed it): the mark renders
1130
+ // bare and the badge still works — the card is an enhancement, the
869
1131
  // badge is the feature.
870
- if (Tooltip === void 0) return chip;
871
- return react_jsx_runtime.jsx(Tooltip, {
872
- side: "top",
873
- maxWidth: 360,
874
- // a function label keeps the card's element tree out of every render
875
- // until the tooltip actually opens
876
- label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
877
- children: chip
878
- });
1132
+ return chip;
879
1133
  }
1134
+
880
1135
  //#endregion
881
1136
 
882
1137
  // `workspaces` is no longer required: both surfaces target an id and the
883
1138
  // node half resolves the workspace, so the client never needs a service
884
1139
  // lookup. Fewer declared services also means fewer ways to fail to load.
885
- const inject = ["slots"];
1140
+ const inject = ["slots", "inputTriggers"];
886
1141
 
887
1142
  /**
888
1143
  * Register the badge into the seams. The seam owner hands each entry the row
@@ -890,6 +1145,93 @@ window.__ModuleLoader__.load({
890
1145
  * deliberately ignores the cwd it may also be given.
891
1146
  */
892
1147
  function apply(ctx) {
1148
+ // The /gh command contribution (the (+) commands menu): the state-aware
1149
+ // entry point to the gh skill. available() gates on the cached git
1150
+ // status; options() lists only the sub-actions the checkout justifies;
1151
+ // onSelect SENDS the skill invocation ("/gh push") as a conversation
1152
+ // message — the agent executes the skill under its own approval flow,
1153
+ // and the shell consumes the /gh token afterwards. This is the one
1154
+ // blessed route from a menu pick to a git action; it is a SEND, which
1155
+ // is why the destructive-step confirmation lives in the skill text.
1156
+ // Guarded: a host without commandUi loses the menu entry, never the
1157
+ // badges.
1158
+ if (typeof ctx.inject === "function") {
1159
+ ctx.inject(["commandUi", "sessions"], (scope) => {
1160
+ try {
1161
+ scope.effect(() => scope.commandUi.register({
1162
+ name: "gh",
1163
+ // the contract calls description() as a FUNCTION at menu-build
1164
+ // time (candidates: contribution.description()) — a string throws
1165
+ // TypeError on every candidates pass and kills the whole menu,
1166
+ // (+) button included
1167
+ description: () => "git/gh actions for this checkout — push, pull, pr, commit, checks",
1168
+ available: (session) => {
1169
+ // called during the host's menu build — a throw here kills the
1170
+ // whole menu, so it degrades to "hidden" instead
1171
+ try {
1172
+ const query = targetQuery({ kind: "session", id: session?.sessionId }, { pr: true });
1173
+ return query !== void 0 && GIT_CACHE.get(query)?.data?.git === true;
1174
+ } catch (error) {
1175
+ console.error("[dsh-git-badge] /gh available failed:", error);
1176
+ return false;
1177
+ }
1178
+ },
1179
+ ui: {
1180
+ options: async (session, signal) => {
1181
+ try {
1182
+ const sessionId = session?.sessionId;
1183
+ if (sessionId === void 0) return [];
1184
+ const query = targetQuery({ kind: "session", id: sessionId }, { pr: true });
1185
+ if (query === void 0) return [];
1186
+ // cache-first: the badge's own knowledge opens the picker
1187
+ // instantly; a cold session pays one status fetch.
1188
+ let info = GIT_CACHE.get(query)?.data;
1189
+ if (info === void 0 || info === null) {
1190
+ info = await fetch("/api/git-badge?" + query).then((r) => r.json());
1191
+ }
1192
+ const actions = ghSkillActions(info);
1193
+ return actions.map((action) => ({ label: "/gh " + action.args, detail: action.why, args: action.args }));
1194
+ } catch (error) {
1195
+ console.error("[dsh-git-badge] /gh options failed:", error);
1196
+ return [];
1197
+ }
1198
+ },
1199
+ onSelect: async (option, session) => {
1200
+ // Send the invocation: the message lands in the transcript
1201
+ // and the agent executes the gh skill — the approval flow
1202
+ // for anything destructive is the agent's, per the skill
1203
+ // text. The prompt path is the conversation's own: the
1204
+ // sessions service binds a sessionId to { session } and
1205
+ // binding.session.prompt(content, "queue") is exactly what
1206
+ // the composer's send() calls.
1207
+ const sessionId = session?.sessionId;
1208
+ const sessions = typeof scope.sessions === "function" ? scope.sessions() : scope.sessions;
1209
+ const binding = sessions?.binding?.(sessionId);
1210
+ const target = binding?.session;
1211
+ if (target?.prompt === void 0) {
1212
+ console.error("[dsh-git-badge] /gh: no prompt path (binding missing or session-less)", {
1213
+ sessionId,
1214
+ hasSessions: sessions !== void 0,
1215
+ hasBinding: binding !== void 0
1216
+ });
1217
+ return;
1218
+ }
1219
+ const result = await target.prompt([{ type: "text", text: "/gh " + option.args }], "queue");
1220
+ if (result !== void 0 && result !== null && result.ok === false) {
1221
+ console.error("[dsh-git-badge] /gh submit refused:", result.error);
1222
+ }
1223
+ }
1224
+ }
1225
+ }), "dsh-git-badge: /gh contribution");
1226
+ } catch (error) {
1227
+ // a failed registration must degrade to "no /gh entry", never to
1228
+ // a broken commands menu — the (+) button and the / menu are the
1229
+ // host's, and this callback runs inside their boot
1230
+ console.error("[dsh-git-badge] /gh contribution failed:", error);
1231
+ }
1232
+ });
1233
+ }
1234
+
893
1235
  // inject() re-evaluates when a seam's declaration appears, so boot
894
1236
  // order relative to the workspace browser does not matter. On an
895
1237
  // unpatched install the seam is never declared, so this callback never
@@ -971,7 +1313,7 @@ window.__ModuleLoader__.load({
971
1313
  // Additive; the host reads apply/inject and ignores the rest. The suite
972
1314
  // drives these to assert the REQUEST contract — which surface asks for the
973
1315
  // expensive extras — without a browser, a fetch or a network.
974
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken };
1316
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, buildActions, actionText, ghSkillActions };
975
1317
  return module.exports;
976
1318
  }
977
1319
  });
package/lib/index.js CHANGED
@@ -662,6 +662,93 @@ function operationMarker(gitDir) {
662
662
  return null;
663
663
  }
664
664
 
665
+ /**
666
+ * The one command a paused operation resumes with. A squash merge records no
667
+ * MERGE_HEAD (its conclusion is an ordinary commit), and bisect's next step is
668
+ * the user's judgement call (good/bad) — it gets a WHY but no command.
669
+ */
670
+ const OPERATION_NEXT = {
671
+ merge: "git merge --continue",
672
+ squash: "git commit",
673
+ "cherry-pick": "git cherry-pick --continue",
674
+ revert: "git revert --continue",
675
+ rebase: "git rebase --continue",
676
+ sequencer: "git cherry-pick --continue",
677
+ bisect: void 0
678
+ };
679
+
680
+ /**
681
+ * The hover card's "next" row: the single highest-priority thing to do next,
682
+ * as a conservative command plus the reason it applies. Pure derivation over
683
+ * fields the status response already carries — no extra git invocations, so it
684
+ * rides the base response and a future surface gets it for free. Ranking (the
685
+ * first match wins):
686
+ *
687
+ * 1. a paused operation (conflicts included) — resuming it subsumes everything
688
+ * else; unmerged files without a marker fall back to `git status`
689
+ * 2. behind — reconciling comes before publishing
690
+ * 3. no upstream on a dirty branch — publish it for the first time
691
+ * 4. ahead — publish
692
+ * 5. dirty — stage and commit (add -p for unstaged work; -A only when the
693
+ * change is untracked-only, which add -p cannot see)
694
+ * 6. PR checks failing — watch them land
695
+ *
696
+ * Clean, synced, nothing failing → null: the row is omitted entirely, because
697
+ * "no suggestion" is also a suggestion. Commands are conservative on purpose —
698
+ * the card COPIES them into the user's terminal, it never runs them, but the
699
+ * register still avoids anything destructive.
700
+ *
701
+ * @returns {{ command: string|undefined, why: string } | null}
702
+ */
703
+ function nextStep(info) {
704
+ if (info === void 0 || info === null || info.git !== true) return null;
705
+ const unmerged = info.unmergedFiles || 0;
706
+ const plural = (n) => (n === 1 ? "" : "s");
707
+ if (info.operation !== void 0 && info.operation !== null) {
708
+ const op = String(info.operation);
709
+ return {
710
+ command: OPERATION_NEXT[op],
711
+ why: unmerged > 0
712
+ ? `${unmerged} unmerged file${plural(unmerged)} blocking the paused ${op}`
713
+ : `a ${op} is paused mid-operation`
714
+ };
715
+ }
716
+ if (unmerged > 0) {
717
+ return { command: "git status", why: `${unmerged} unmerged file${plural(unmerged)} to resolve` };
718
+ }
719
+ const ahead = info.ahead || 0;
720
+ const behind = info.behind || 0;
721
+ if (behind > 0) {
722
+ return { command: "git pull --ff-only", why: `${behind} behind ${info.upstream ?? "upstream"}` };
723
+ }
724
+ const staged = info.stagedFiles || 0;
725
+ const unstaged = info.unstagedFiles || 0;
726
+ const untracked = info.untrackedFiles || 0;
727
+ if (info.upstream === void 0 && staged + unstaged + untracked > 0) {
728
+ return { command: `git push -u origin ${info.branch}`, why: "no upstream configured" };
729
+ }
730
+ if (ahead > 0) {
731
+ return { command: "git push", why: `${ahead} ahead of ${info.upstream ?? "upstream"}` };
732
+ }
733
+ if (staged + unstaged + untracked > 0) {
734
+ const why = [
735
+ staged > 0 ? `${staged} staged` : null,
736
+ unstaged > 0 ? `${unstaged} unstaged` : null,
737
+ untracked > 0 ? `${untracked} untracked` : null
738
+ ].filter(Boolean).join(", ");
739
+ const command = staged > 0
740
+ ? "git commit"
741
+ : untracked > 0 && unstaged === 0
742
+ ? "git add -A && git commit"
743
+ : "git add -p && git commit";
744
+ return { command, why: `work to commit: ${why}` };
745
+ }
746
+ if (info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0 && info.pr.state === "failing") {
747
+ return { command: `gh pr checks ${info.pr.number} --watch`, why: `checks failing on #${info.pr.number}` };
748
+ }
749
+ return null;
750
+ }
751
+
665
752
  /**
666
753
  * Git status for dir; { git: false } when dir is not a repository,
667
754
  * GIT_DEGRADED on transient git failure.
@@ -787,6 +874,8 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
787
874
  const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
788
875
  if (pr !== void 0) info.pr = pr;
789
876
  }
877
+ const next = nextStep(info);
878
+ if (next !== null) info.next = next;
790
879
  return info;
791
880
  }
792
881
 
@@ -1355,6 +1444,7 @@ export {
1355
1444
  config,
1356
1445
  OPERATION_MARKERS,
1357
1446
  operationMarker,
1447
+ nextStep,
1358
1448
  outerGitDir,
1359
1449
  parseStatusV2,
1360
1450
  parseWorktreeList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -47,17 +47,19 @@
47
47
  "node": ">=20"
48
48
  },
49
49
  "files": [
50
- "lib/index.js",
50
+ "CHANGELOG.md",
51
+ "LICENSE",
52
+ "README.md",
53
+ "cordis.patch.yml",
51
54
  "lib/client.js",
52
- "seam/apply.js",
55
+ "lib/index.js",
56
+ "package.json",
53
57
  "seam/anchors.js",
58
+ "seam/apply.js",
54
59
  "seam/postinstall.js",
60
+ "seam/skill.js",
55
61
  "seam/store.js",
56
62
  "seam/stub-index.js",
57
- "cordis.patch.yml",
58
- "package.json",
59
- "README.md",
60
- "CHANGELOG.md",
61
- "LICENSE"
63
+ "skill/gh/SKILL.md"
62
64
  ]
63
65
  }
package/seam/apply.js CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  seamPresent,
56
56
  } from "./anchors.js";
57
57
 
58
+ import { installSkill } from "./skill.js";
58
59
  import { dataDir, writeRestartMarker } from "./store.js";
59
60
 
60
61
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -165,6 +166,11 @@ function writeText(path, text) {
165
166
 
166
167
  const VERDICT_EXIT_1 = new Set(["drift", "unreadable", "missing", "ours-corrupt"]);
167
168
 
169
+ // keep the /gh skill in step with the package on every patcher run
170
+ if (["apply", "revert", "status"].includes(process.argv[2])) {
171
+ console.log(`[dsh-git-badge] gh skill: ${installSkill()}`);
172
+ }
173
+
168
174
  const verb = process.argv[2];
169
175
  if (!verb || !["apply", "revert", "status"].includes(verb)) {
170
176
  console.error("usage: apply.js apply|revert|status");
@@ -26,6 +26,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
26
26
  import { dirname, join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { applyPatch, firstFailure, isOurs, seamPresent } from "./anchors.js";
29
+ import { installSkill } from "./skill.js";
29
30
  import { dataDir, writeRestartMarker } from "./store.js";
30
31
 
31
32
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,9 @@ function dshClientPath() {
41
42
  }
42
43
 
43
44
  function main() {
45
+ // The skill does not depend on the DSH install — install it before any
46
+ // client-shaped early return, so a market install always gets /gh.
47
+ console.log(`[dsh-git-badge] gh skill: ${installSkill()} — /gh in the input's commands menu.`);
44
48
  const client = dshClientPath();
45
49
  if (client === null || !existsSync(client)) {
46
50
  console.log(`[dsh-git-badge] DSH install not found; skipping seam setup. ${HINT}`);
package/seam/skill.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The gh skill installer — copies the packaged skill into the user's skills
3
+ * catalog so `/gh` exists in the input's commands menu.
4
+ *
5
+ * WHY A SKILL: the commands menu executes git through the AGENT, not through
6
+ * this plugin — the agent owns the approval/sandbox semantics for mutating
7
+ * commands. The skill is the knowledge; the `/gh` command contribution in the
8
+ * client half is only the state-aware menu entry that invokes it.
9
+ *
10
+ * GUARDRAILS — a skill must never break an install: every failure path returns
11
+ * a reason string instead of throwing, and a current target is a no-op (the
12
+ * byte comparison makes reinstalls and updates idempotent).
13
+ */
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+
20
+ export const SKILL_NAME = "gh";
21
+
22
+ /** The user-level skills catalog — DSH_HOME honours the harness override. */
23
+ export function skillsDir() {
24
+ return join(process.env.DSH_HOME || join(process.env.HOME || "", ".dsh"), "skills");
25
+ }
26
+
27
+ export function skillSource() {
28
+ return join(HERE, "..", "skill", "gh", "SKILL.md");
29
+ }
30
+
31
+ export function skillTarget() {
32
+ return join(skillsDir(), SKILL_NAME, "SKILL.md");
33
+ }
34
+
35
+ /**
36
+ * Install (or update) the gh skill. Never throws.
37
+ * @returns {"installed"|"current"|"no packaged skill"|string} what happened.
38
+ */
39
+ export function installSkill() {
40
+ try {
41
+ const source = skillSource();
42
+ if (!existsSync(source)) return "no packaged skill";
43
+ const target = skillTarget();
44
+ mkdirSync(dirname(target), { recursive: true });
45
+ if (existsSync(target) && readFileSync(target, "utf8") === readFileSync(source, "utf8")) {
46
+ return "current";
47
+ }
48
+ copyFileSync(source, target);
49
+ return "installed";
50
+ } catch (error) {
51
+ return "skipped (" + String(error?.message ?? error) + ")";
52
+ }
53
+ }
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: gh
3
+ description: Git and GitHub operations for the current checkout, driven by an invocation argument — /gh push, /gh pr, /gh pull, /gh commit, /gh checks <n>, or bare /gh to do the single highest-priority next action.
4
+ whenToUse: When the user picks a git action from the commands menu (/gh push, /gh pr, …) or asks to sync, publish, or open a pull request for the checkout they are working in.
5
+ ---
6
+
7
+ # gh — git/GitHub actions for this checkout
8
+
9
+ You are invoked as `/gh <sub>` (or bare `/gh`). The sub-command chooses the
10
+ operation. Anything destructive requires explicit user confirmation before you
11
+ run it, every command is reported with its output, and a failure is reported
12
+ verbatim rather than retried blind.
13
+
14
+ ## First: establish state
15
+
16
+ Run one status call and read it before acting (except for `checks`, which acts
17
+ on its argument):
18
+
19
+ ```
20
+ git --no-optional-locks status --porcelain=v2 --branch
21
+ ```
22
+
23
+ Note branch, upstream, ahead/behind, and the dirty breakdown (staged /
24
+ unstaged / untracked / unmerged), plus any in-progress operation marker
25
+ (MERGE_HEAD, rebase-merge, …).
26
+
27
+ ## Sub-commands
28
+
29
+ - **`/gh next`** (or bare `/gh`) — do the single highest-priority action, first
30
+ match wins: resume a paused operation (`git merge --continue`,
31
+ `git rebase --continue`, …; a squash merge concludes with `git commit`);
32
+ resolve unmerged files only WITH the user; `git pull --ff-only` when behind;
33
+ `git push -u origin <branch>` when there is no upstream; `git push` when
34
+ ahead; stage and commit when dirty (`git commit` if work is already staged,
35
+ `git add -p && git commit` for unstaged edits, `git add -A && git commit`
36
+ only for untracked-only changes, which `add -p` cannot see); `gh pr checks
37
+ <n> --watch` when CI fails. Clean and synced → say so and stop.
38
+
39
+ - **`/gh push`** — `git push`. If there is no upstream, `git push -u origin
40
+ <branch>`. If the push is rejected as non-fast-forward, STOP and ask: offer
41
+ `--force-with-lease` as the option, never plain `--force`, and never without
42
+ an explicit yes.
43
+
44
+ - **`/gh pr`** — create a pull request for the current branch: push first if
45
+ needed, then `gh pr create` with a title from the branch's commits and a body
46
+ summarising them. If a PR already exists, report its state (`gh pr view`)
47
+ instead of creating another.
48
+
49
+ - **`/gh pull`** — `git pull --ff-only`. On failure, report the divergence and
50
+ ask; do not merge or rebase without the user choosing.
51
+
52
+ - **`/gh commit`** — commit the current work: `git commit` when work is already
53
+ staged; otherwise `git add -p` interactively is not available to you, so
54
+ stage deliberately (`git add <paths>` named from the status output) and say
55
+ exactly what you staged before committing. Untracked-only changes: `git add
56
+ -A && git commit` after listing what -A will pick up. Write the message from
57
+ the diff, not a template.
58
+
59
+ - **`/gh checks <n>`** — `gh pr checks <n>` and summarise: what fails, what is
60
+ pending, and the single most useful next step.
61
+
62
+ ## Hard rules
63
+
64
+ - Never `git reset --hard`, `git clean`, `git checkout -- .`, `git push
65
+ --force` (plain), or any history rewrite without an explicit user yes.
66
+ - `GIT_TERMINAL_PROMPT=0` on network commands so a missing credential fails
67
+ fast instead of hanging.
68
+ - One operation per invocation; report the outcome, then stop.