dsh-desktop-kit 2.2.1 → 2.7.2

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +4 -2
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +5 -5
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/index.js +18 -1
  23. package/package.json +26 -6
  24. package/plugins/dsh-agent-extensions/README.md +99 -0
  25. package/plugins/dsh-agent-extensions/index.js +167 -0
  26. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  27. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  28. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  29. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  30. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  31. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  32. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  33. package/plugins/dsh-agent-extensions/package.json +9 -0
  34. package/plugins/dsh-browser-qa/README.md +221 -0
  35. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  36. package/plugins/dsh-browser-qa/index.js +165 -0
  37. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  38. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  39. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  40. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  41. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  42. package/plugins/dsh-browser-qa/package.json +31 -0
  43. package/plugins/dsh-checkpoints/README.md +22 -0
  44. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  45. package/plugins/dsh-checkpoints/index.js +80 -0
  46. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  47. package/plugins/dsh-checkpoints/package.json +25 -0
  48. package/plugins/dsh-github-review/README.md +131 -0
  49. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  50. package/plugins/dsh-github-review/index.js +536 -0
  51. package/plugins/dsh-github-review/lib/gh.js +494 -0
  52. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  53. package/plugins/dsh-github-review/package.json +28 -0
  54. package/plugins/dsh-guardrails/README.md +264 -0
  55. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  56. package/plugins/dsh-guardrails/index.js +389 -0
  57. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  58. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  59. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  60. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  61. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  62. package/plugins/dsh-guardrails/package.json +26 -0
  63. package/plugins/dsh-mcp-manager/README.md +78 -0
  64. package/plugins/dsh-mcp-manager/client.js +373 -0
  65. package/plugins/dsh-mcp-manager/index.js +159 -0
  66. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  67. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  68. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  69. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  70. package/plugins/dsh-mcp-manager/package.json +18 -0
  71. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  72. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  73. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  74. package/plugins/dsh-memory-bridge/README.md +161 -0
  75. package/plugins/dsh-memory-bridge/client.js +2264 -0
  76. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  77. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  78. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  79. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  83. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  84. package/plugins/dsh-memory-bridge/index.js +378 -0
  85. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  86. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  87. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  88. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  89. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  90. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  91. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  92. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  93. package/plugins/dsh-memory-bridge/package.json +67 -0
  94. package/plugins/dsh-peak-guard/README.md +16 -3
  95. package/plugins/dsh-peak-guard/client.js +223 -8
  96. package/plugins/dsh-peak-guard/package.json +1 -1
  97. package/plugins/dsh-plan-mode/README.md +24 -0
  98. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  99. package/plugins/dsh-plan-mode/index.js +103 -0
  100. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  101. package/plugins/dsh-plan-mode/package.json +18 -0
  102. package/plugins/dsh-profile-transfer/client.js +277 -17
  103. package/plugins/dsh-profile-transfer/index.js +10 -0
  104. package/plugins/dsh-profile-transfer/package.json +3 -1
  105. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  106. package/plugins/dsh-project-memory/index.js +92 -2
  107. package/plugins/dsh-project-memory/package.json +1 -1
  108. package/plugins/dsh-repo-map/README.md +26 -0
  109. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  110. package/plugins/dsh-repo-map/index.js +99 -0
  111. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  112. package/plugins/dsh-repo-map/package.json +25 -0
  113. package/plugins/dsh-skill-autopilot/index.js +135 -11
  114. package/skills/delivery-gate/SKILL.md +31 -0
  115. package/skills/git-and-ci/SKILL.md +5 -1
  116. package/skills/manifest.json +31 -6
  117. package/skills/requirements-and-planning/SKILL.md +3 -0
  118. package/skills/research-first/SKILL.md +35 -0
  119. package/skills/research-first/references/query-patterns.md +20 -0
  120. package/skills/subagent-playbook/SKILL.md +36 -0
  121. package/skills/subagent-playbook/references/prompts.md +39 -0
  122. package/skills/ui-craft/SKILL.md +43 -0
  123. package/skills/visual-qa/SKILL.md +29 -0
  124. package/skills/visual-qa/references/commands.md +32 -0
  125. package/skills-baseline.json +219 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * dsh-github-review — pure parsing, normalization and redaction helpers.
3
+ *
4
+ * Nothing here spawns processes, touches the filesystem or uses the network:
5
+ * JSON/text produced by the `gh` CLI goes in, compact review objects come out.
6
+ * That keeps every parsing rule unit-testable without a GitHub account, and
7
+ * keeps secrets out of anything this plugin prints.
8
+ */
9
+
10
+ const CHECK_BUCKETS = new Set(["pass", "fail", "pending", "skipping", "cancel"]);
11
+
12
+ /** Hard limits so one giant PR cannot flood the model context. */
13
+ export const LIMITS = Object.freeze({
14
+ bodyChars: 16_000,
15
+ commentChars: 300,
16
+ files: 60,
17
+ commits: 40,
18
+ checks: 200,
19
+ inlineComments: 100,
20
+ threads: 40,
21
+ conversation: 40,
22
+ diffBytes: 200 * 1024,
23
+ minDiffBytes: 4 * 1024,
24
+ maxDiffBytes: 2 * 1024 * 1024,
25
+ maxCommentChars: 60_000,
26
+ });
27
+
28
+ /** Environment variables whose values must never be echoed back. */
29
+ const SECRET_ENV_NAMES = ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"];
30
+
31
+ const BUCKET_BY_STATE = new Map([
32
+ ["SUCCESS", "pass"],
33
+ ["PASS", "pass"],
34
+ ["PASSED", "pass"],
35
+ ["FAILURE", "fail"],
36
+ ["FAIL", "fail"],
37
+ ["FAILED", "fail"],
38
+ ["ERROR", "fail"],
39
+ ["TIMED_OUT", "fail"],
40
+ ["ACTION_REQUIRED", "fail"],
41
+ ["STARTUP_FAILURE", "fail"],
42
+ ["NEUTRAL", "skipping"],
43
+ ["SKIPPED", "skipping"],
44
+ ["SKIP", "skipping"],
45
+ ["CANCELLED", "cancel"],
46
+ ["CANCELED", "cancel"],
47
+ ["PENDING", "pending"],
48
+ ["QUEUED", "pending"],
49
+ ["IN_PROGRESS", "pending"],
50
+ ["WAITING", "pending"],
51
+ ["REQUESTED", "pending"],
52
+ ["EXPECTED", "pending"],
53
+ ]);
54
+
55
+ const REDACTION_RULES = [
56
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED:PRIVATE_KEY]"],
57
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, "[REDACTED:JWT]"],
58
+ [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED:GITHUB_TOKEN]"],
59
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/gu, "[REDACTED:GITHUB_TOKEN]"],
60
+ [/\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED:SERVICE_KEY]"],
61
+ [/\bglpat-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED:GITLAB_TOKEN]"],
62
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gu, "[REDACTED:SLACK_TOKEN]"],
63
+ [/\bAKIA[0-9A-Z]{16}\b/gu, "[REDACTED:AWS_KEY]"],
64
+ [/\bAIza[0-9A-Za-z_-]{30,}\b/gu, "[REDACTED:GOOGLE_KEY]"],
65
+ [/\bnpm_[A-Za-z0-9]{36}\b/gu, "[REDACTED:NPM_TOKEN]"],
66
+ [/\b(?:Authorization|Proxy-Authorization)\s*:\s*(?:Bearer|Basic|Token)\s+[^\s]+/giu, "Authorization: [REDACTED]"],
67
+ [/\b(?:Set-)?Cookie\s*:\s*[^\r\n]+/giu, "[REDACTED:COOKIE]"],
68
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^:@/\s]+:[^@/\s]+@/giu, "$1[REDACTED]@"],
69
+ [
70
+ /(^|\n)(\s*[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API[_-]?KEY|PRIVATE[_-]?KEY|ACCESS[_-]?KEY)[A-Z0-9_]*\s*[:=]\s*)[^\s"',;\r\n]+/gu,
71
+ "$1$2[REDACTED]",
72
+ ],
73
+ ];
74
+
75
+ /* ------------------------------------------------------------------ */
76
+ /* Small scalar helpers */
77
+ /* ------------------------------------------------------------------ */
78
+
79
+ function asString(value) {
80
+ if (typeof value === "string") return value;
81
+ if (value === null || value === undefined) return "";
82
+ return String(value);
83
+ }
84
+
85
+ function asCount(value) {
86
+ const number = Number(value);
87
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
88
+ }
89
+
90
+ function asPositiveInt(value) {
91
+ const number = Number(value);
92
+ return Number.isSafeInteger(number) && number > 0 ? number : undefined;
93
+ }
94
+
95
+ /** Strip ANSI colour/OSC sequences from captured terminal output. */
96
+ export function stripAnsi(value) {
97
+ return asString(value)
98
+ .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/gu, "")
99
+ .replace(/\u001B\[[0-9;?]*[A-Za-z]/gu, "");
100
+ }
101
+
102
+ /** One-line excerpt: collapse whitespace, cut on a character boundary. */
103
+ export function snippet(value, max = LIMITS.commentChars) {
104
+ const text = asString(value).replace(/\s+/gu, " ").trim();
105
+ if (text.length <= max) return text;
106
+ return `${text.slice(0, Math.max(0, max - 1))}…`;
107
+ }
108
+
109
+ export function formatBytes(bytes) {
110
+ const value = Number(bytes);
111
+ if (!Number.isFinite(value) || value < 0) return "0 B";
112
+ if (value < 1024) return `${Math.floor(value)} B`;
113
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
114
+ return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
115
+ }
116
+
117
+ export function byteLength(text) {
118
+ return Buffer.byteLength(asString(text), "utf8");
119
+ }
120
+
121
+ /** Clamp a user-supplied diff byte budget. */
122
+ export function clampDiffBytes(value) {
123
+ const number = Number(value);
124
+ const fallback = LIMITS.diffBytes;
125
+ if (!Number.isFinite(number) || number <= 0) return fallback;
126
+ return Math.max(LIMITS.minDiffBytes, Math.min(LIMITS.maxDiffBytes, Math.floor(number)));
127
+ }
128
+
129
+ export function truncateChars(value, max) {
130
+ const text = asString(value);
131
+ const limit = Number.isFinite(Number(max)) && Number(max) > 0 ? Math.floor(Number(max)) : LIMITS.bodyChars;
132
+ if (text.length <= limit) return { text, truncated: false, chars: text.length };
133
+ return { text: `${text.slice(0, limit)}…`, truncated: true, chars: text.length };
134
+ }
135
+
136
+ function charIndexForBytes(text, maxBytes) {
137
+ let low = 0;
138
+ let high = text.length;
139
+ while (low < high) {
140
+ const mid = Math.ceil((low + high) / 2);
141
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) low = mid;
142
+ else high = mid - 1;
143
+ }
144
+ return low;
145
+ }
146
+
147
+ /**
148
+ * Byte-capped text for a diff. Cuts on a line boundary when possible so the
149
+ * model never receives half a diff line; always reports the original size.
150
+ */
151
+ export function truncateBytes(value, maxBytes) {
152
+ const text = asString(value);
153
+ const total = Buffer.byteLength(text, "utf8");
154
+ const limit = Number.isFinite(Number(maxBytes)) && Number(maxBytes) > 0 ? Math.floor(Number(maxBytes)) : LIMITS.diffBytes;
155
+ if (total <= limit) return { text, truncated: false, bytes: total, shownBytes: total };
156
+ const index = charIndexForBytes(text, limit);
157
+ const boundary = text.lastIndexOf("\n", index);
158
+ const end = boundary > 0 ? boundary : index;
159
+ const shown = text.slice(0, end);
160
+ return { text: shown, truncated: true, bytes: total, shownBytes: Buffer.byteLength(shown, "utf8") };
161
+ }
162
+
163
+ /** Convenience wrapper used by the diff action. */
164
+ export function truncateDiff(patch, maxBytes) {
165
+ return truncateBytes(patch, clampDiffBytes(maxBytes));
166
+ }
167
+
168
+ /* ------------------------------------------------------------------ */
169
+ /* Redaction */
170
+ /* ------------------------------------------------------------------ */
171
+
172
+ /** Remove token-shaped secrets from text this plugin prints. */
173
+ export function redactSecrets(value) {
174
+ let text = asString(value);
175
+ for (const [pattern, replacement] of REDACTION_RULES) text = text.replace(pattern, replacement);
176
+ return text;
177
+ }
178
+
179
+ /** Remove values of token environment variables, whatever their shape. */
180
+ export function redactEnvironmentValues(value, env = {}) {
181
+ let text = asString(value);
182
+ for (const name of SECRET_ENV_NAMES) {
183
+ const secret = env?.[name];
184
+ if (typeof secret !== "string" || secret.length < 8) continue;
185
+ text = text.split(secret).join("[REDACTED:ENV]");
186
+ }
187
+ return text;
188
+ }
189
+
190
+ /** Full scrub for captured command output before it becomes a tool result. */
191
+ export function scrubOutput(value, env = {}) {
192
+ return redactEnvironmentValues(redactSecrets(stripAnsi(value)), env);
193
+ }
194
+
195
+ /* ------------------------------------------------------------------ */
196
+ /* Identifiers */
197
+ /* ------------------------------------------------------------------ */
198
+
199
+ /** Positive PR number, or `undefined` for anything ambiguous. */
200
+ export function parsePrNumber(value) {
201
+ if (typeof value === "number") {
202
+ return Number.isSafeInteger(value) && value > 0 && value <= 10_000_000 ? value : undefined;
203
+ }
204
+ if (typeof value === "string" && /^\s*\d{1,7}\s*$/u.test(value)) {
205
+ const number = Number(value.trim());
206
+ return number > 0 ? number : undefined;
207
+ }
208
+ return undefined;
209
+ }
210
+
211
+ const REPO_PART = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/u;
212
+
213
+ function validRepoPart(part) {
214
+ return typeof part === "string" && part.length > 0 && part !== "." && part !== ".." && REPO_PART.test(part);
215
+ }
216
+
217
+ /**
218
+ * Parse `owner/name` or `host/owner/name` (GHES). Returns `undefined` for
219
+ * anything that is not a strict repository spec, so it can never reach `gh`
220
+ * as an option or a path fragment.
221
+ */
222
+ export function parseRepoSpec(value) {
223
+ const text = asString(value).trim();
224
+ if (text.length === 0 || text.length > 300 || /\s/u.test(text)) return undefined;
225
+ const parts = text.split("/");
226
+ if (parts.length !== 2 && parts.length !== 3) return undefined;
227
+ const owner = parts.at(-2);
228
+ const name = parts.at(-1);
229
+ if (!validRepoPart(owner) || !validRepoPart(name)) return undefined;
230
+ const host = parts.length === 3 ? parts[0] : "github.com";
231
+ if (parts.length === 3 && (!validRepoPart(host.replace(/:\d+$/u, "")) || !/^[A-Za-z0-9.-]+(?::\d+)?$/u.test(host))) return undefined;
232
+ return {
233
+ host,
234
+ owner,
235
+ name,
236
+ slug: `${owner}/${name}`,
237
+ webUrl: `https://${host}/${owner}/${name}`,
238
+ };
239
+ }
240
+
241
+ /** Argument for `gh --repo`: `owner/name` on github.com, `host/owner/name` elsewhere. */
242
+ export function repoArgument(spec) {
243
+ if (!spec) return undefined;
244
+ return spec.host === "github.com" ? spec.slug : `${spec.host}/${spec.slug}`;
245
+ }
246
+
247
+ /**
248
+ * Parse a git remote URL into a repository spec. Handles https, ssh, git://,
249
+ * scp-like `git@host:owner/name.git`, and returns `undefined` for local paths
250
+ * or anything unparseable — never guesses.
251
+ */
252
+ export function parseGitRemoteUrl(value) {
253
+ const raw = asString(value).trim();
254
+ if (raw.length === 0) return undefined;
255
+ if (/^[A-Za-z]:[\\/]/u.test(raw) || raw.startsWith("/") || raw.startsWith("./") || raw.startsWith("../") || /^file:/iu.test(raw)) {
256
+ return undefined;
257
+ }
258
+ const scpLike = /^(?:[^@\s/]+@)?([A-Za-z0-9.-]+\.[A-Za-z]{2,}):([^/\s]+)\/([^/\s]+?)(?:\.git)?$/u.exec(raw);
259
+ if (scpLike) {
260
+ const [, host, owner, name] = scpLike;
261
+ return parseRepoSpec(`${host}/${owner}/${name}`);
262
+ }
263
+ let url;
264
+ try {
265
+ url = new URL(raw);
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ if (!["http:", "https:", "ssh:", "git:"].includes(url.protocol) || url.hostname.length === 0) return undefined;
270
+ const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
271
+ if (segments.length < 2) return undefined;
272
+ const name = segments.at(-1).replace(/\.git$/u, "");
273
+ return parseRepoSpec(`${url.hostname}/${segments.at(-2)}/${name}`);
274
+ }
275
+
276
+ /** Extract host/owner/name/number from a pull-request URL. */
277
+ export function parseRepoFromPrUrl(value) {
278
+ const match = /^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#]|$)/iu.exec(asString(value).trim());
279
+ if (!match) return undefined;
280
+ const spec = parseRepoSpec(`${match[1]}/${match[2]}/${match[3]}`);
281
+ if (!spec) return undefined;
282
+ const number = parsePrNumber(match[4]);
283
+ if (!number) return undefined;
284
+ return { ...spec, number };
285
+ }
286
+
287
+ /* ------------------------------------------------------------------ */
288
+ /* gh auth status */
289
+ /* ------------------------------------------------------------------ */
290
+
291
+ /**
292
+ * Parse `gh auth status` (both the 2.x multi-account layout and the older
293
+ * `as <login>` layout). Token values are deliberately not extracted at all.
294
+ */
295
+ export function parseGhAuthStatus(value) {
296
+ const lines = stripAnsi(value).split(/\r?\n/u);
297
+ const accounts = [];
298
+ const failedHosts = [];
299
+ let host = "";
300
+ let notLoggedIn = false;
301
+ let current;
302
+
303
+ for (const rawLine of lines) {
304
+ const line = rawLine.trim();
305
+ if (line.length === 0) continue;
306
+ if (/not logged in(?: to| into)? any github hosts/iu.test(line) || /You are not logged into any GitHub hosts/iu.test(line)) {
307
+ notLoggedIn = true;
308
+ continue;
309
+ }
310
+ const hostOnly = /^([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?)$/u.exec(line);
311
+ if (hostOnly && !current) {
312
+ host = hostOnly[1].toLowerCase();
313
+ continue;
314
+ }
315
+ const loginMatch = /✓\s+Logged in to\s+([^\s]+)\s+(?:account\s+([^\s(]+)|as\s+([^\s(]+))\s*(?:\(([^)]*)\))?/u.exec(line);
316
+ if (loginMatch) {
317
+ current = {
318
+ host: (loginMatch[1] || host).toLowerCase(),
319
+ login: loginMatch[2] ?? loginMatch[3] ?? "",
320
+ method: loginMatch[4] ?? "",
321
+ active: false,
322
+ scopes: [],
323
+ protocol: "",
324
+ };
325
+ host = current.host;
326
+ accounts.push(current);
327
+ continue;
328
+ }
329
+ if (!current) {
330
+ const failed = /^X\s+([^\s:]+)(?::\s*(.*))?$/u.exec(line);
331
+ if (failed) failedHosts.push((failed[1] || "").toLowerCase());
332
+ continue;
333
+ }
334
+ const activeMatch = /^[-•*]\s*Active account:\s*(true|false)/iu.exec(line);
335
+ if (activeMatch) {
336
+ current.active = activeMatch[1].toLowerCase() === "true";
337
+ continue;
338
+ }
339
+ const scopesMatch = /^[-•*]\s*Token scopes:\s*(.+)$/iu.exec(line);
340
+ if (scopesMatch) {
341
+ current.scopes = scopesMatch[1]
342
+ .split(",")
343
+ .map((scope) => scope.trim().replace(/^['"]|['"]$/gu, ""))
344
+ .filter(Boolean);
345
+ continue;
346
+ }
347
+ const protocolMatch = /^[-•*]\s*Git operations protocol:\s*(\S+)/iu.exec(line);
348
+ if (protocolMatch) current.protocol = protocolMatch[1];
349
+ }
350
+
351
+ const active = accounts.find((account) => account.active) ?? accounts[0];
352
+ return {
353
+ authenticated: accounts.some((account) => account.login.length > 0),
354
+ notLoggedIn,
355
+ host: active?.host ?? host,
356
+ accounts,
357
+ active: active ?? null,
358
+ scopes: active?.scopes ?? [],
359
+ failedHosts,
360
+ };
361
+ }
362
+
363
+ /* ------------------------------------------------------------------ */
364
+ /* Pull request */
365
+ /* ------------------------------------------------------------------ */
366
+
367
+ function loginOf(value) {
368
+ if (value === null || value === undefined) return "";
369
+ if (typeof value === "string") return value.trim();
370
+ return asString(value.login ?? value.name ?? value.email ?? "").trim();
371
+ }
372
+
373
+ function normalizeFiles(files) {
374
+ const list = Array.isArray(files) ? files.filter(Boolean) : [];
375
+ const items = list.slice(0, LIMITS.files).map((file) => ({
376
+ path: asString(file.path),
377
+ additions: asCount(file.additions),
378
+ deletions: asCount(file.deletions),
379
+ changeType: asString(file.changeType).toUpperCase(),
380
+ }));
381
+ return { total: list.length, items, truncated: list.length > items.length };
382
+ }
383
+
384
+ function normalizeCommits(commits) {
385
+ const list = Array.isArray(commits) ? commits.filter(Boolean) : [];
386
+ const items = list.slice(0, LIMITS.commits).map((commit) => {
387
+ const oid = asString(commit.oid);
388
+ const authors = Array.isArray(commit.authors) ? commit.authors : [];
389
+ return {
390
+ oid,
391
+ shortOid: oid.slice(0, 7),
392
+ messageHeadline: asString(commit.messageHeadline).trim(),
393
+ author: loginOf(authors[0] ?? commit.author),
394
+ committedDate: asString(commit.committedDate ?? commit.authoredDate),
395
+ };
396
+ });
397
+ return { total: list.length, items, truncated: list.length > items.length };
398
+ }
399
+
400
+ function normalizeLabels(labels) {
401
+ const list = Array.isArray(labels) ? labels.filter(Boolean) : [];
402
+ return list
403
+ .slice(0, 20)
404
+ .map((label) => (typeof label === "string" ? { name: label, color: "" } : { name: asString(label.name), color: asString(label.color) }))
405
+ .filter((label) => label.name.length > 0);
406
+ }
407
+
408
+ /** Normalise `gh pr view --json ...` output into the tool's PR shape. */
409
+ export function normalizePullRequest(value) {
410
+ const json = value !== null && typeof value === "object" ? value : {};
411
+ const body = truncateChars(json.body, LIMITS.bodyChars);
412
+ const files = normalizeFiles(json.files);
413
+ const commits = normalizeCommits(json.commits);
414
+ return {
415
+ number: parsePrNumber(json.number),
416
+ title: asString(json.title).trim(),
417
+ url: asString(json.url),
418
+ state: asString(json.state).toUpperCase(),
419
+ isDraft: json.isDraft === true,
420
+ author: loginOf(json.author),
421
+ base: { branch: asString(json.baseRefName), oid: asString(json.baseRefOid) },
422
+ head: { branch: asString(json.headRefName), oid: asString(json.headRefOid) },
423
+ body: { ...body, empty: body.text.trim().length === 0 },
424
+ additions: asCount(json.additions),
425
+ deletions: asCount(json.deletions),
426
+ changedFiles: asCount(json.changedFiles) || files.total,
427
+ files,
428
+ commits,
429
+ reviewDecision: asString(json.reviewDecision).toUpperCase(),
430
+ mergeable: asString(json.mergeable).toUpperCase(),
431
+ labels: normalizeLabels(json.labels),
432
+ createdAt: asString(json.createdAt),
433
+ updatedAt: asString(json.updatedAt),
434
+ };
435
+ }
436
+
437
+ /* ------------------------------------------------------------------ */
438
+ /* CI checks */
439
+ /* ------------------------------------------------------------------ */
440
+
441
+ function bucketFromState(value) {
442
+ return BUCKET_BY_STATE.get(asString(value).toUpperCase());
443
+ }
444
+
445
+ function normalizeCheckRow(row) {
446
+ const item = row !== null && typeof row === "object" ? row : {};
447
+ const workflow = asString(item.workflow);
448
+ const name = asString(item.name ?? item.context).trim() || (workflow.length > 0 ? workflow : "check");
449
+ const state = asString(item.state ?? item.conclusion ?? item.status).toUpperCase();
450
+ const given = asString(item.bucket).toLowerCase();
451
+ const bucket = CHECK_BUCKETS.has(given) ? given : bucketFromState(item.conclusion) ?? bucketFromState(item.state) ?? bucketFromState(item.status) ?? "unknown";
452
+ return {
453
+ name,
454
+ workflow,
455
+ bucket,
456
+ state,
457
+ description: snippet(item.description, 240),
458
+ link: asString(item.link ?? item.detailsUrl ?? item.targetUrl),
459
+ startedAt: asString(item.startedAt),
460
+ completedAt: asString(item.completedAt),
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Normalise check rows from `gh pr checks --json` or `statusCheckRollup`
466
+ * (CheckRun / StatusContext nodes). Summaries drive the "failed" headline.
467
+ */
468
+ export function normalizeChecks(rows) {
469
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
470
+ const items = list.slice(0, LIMITS.checks).map(normalizeCheckRow);
471
+ const summary = { pass: 0, fail: 0, pending: 0, skipping: 0, cancel: 0, unknown: 0 };
472
+ for (const item of items) summary[item.bucket] += 1;
473
+ const failed = items.filter((item) => item.bucket === "fail");
474
+ const pending = items.filter((item) => item.bucket === "pending");
475
+ return {
476
+ total: list.length,
477
+ items,
478
+ truncated: list.length > items.length,
479
+ summary,
480
+ failed,
481
+ pending,
482
+ };
483
+ }
484
+
485
+ function cellsOf(line) {
486
+ if (line.includes("\t")) return line.split("\t").map((cell) => cell.trim());
487
+ return line.split(/\s{2,}/u).map((cell) => cell.trim());
488
+ }
489
+
490
+ /**
491
+ * Tolerant parser for the plain `gh pr checks` table, used when the installed
492
+ * gh build has no `--json` for this command. Tab-separated output is the
493
+ * documented non-TTY form; space-padded tables are handled as a fallback.
494
+ */
495
+ export function parseChecksText(value) {
496
+ const rows = [];
497
+ for (const rawLine of asString(value).split(/\r?\n/u)) {
498
+ const line = rawLine.replace(/\u001B\[[0-9;?]*[A-Za-z]/gu, "").trim();
499
+ if (line.length === 0) continue;
500
+ if (/^(All checks were successful|No checks reported|no checks)/iu.test(line)) continue;
501
+ const cells = cellsOf(line).filter((cell, index, all) => !(index === 0 && cell.length === 0));
502
+ if (cells.length < 2) continue;
503
+ const bucketIndex = cells.findIndex((cell) => CHECK_BUCKETS.has(cell.toLowerCase()));
504
+ if (bucketIndex === -1) continue;
505
+ const name = cells.slice(0, bucketIndex).join(" ").trim() || cells[0];
506
+ if (name.length === 0) continue;
507
+ const rest = cells.slice(bucketIndex + 1);
508
+ const link = rest.find((cell) => /^https?:\/\//iu.test(cell)) ?? "";
509
+ const duration = rest.find((cell) => /^(?:\d+(?:\.\d+)?(?:ms|s|m|h|d))+$/iu.test(cell)) ?? "";
510
+ const description = rest
511
+ .filter((cell) => cell.length > 0 && cell !== link && cell !== duration && !/^-+$/u.test(cell))
512
+ .join(" ")
513
+ .trim();
514
+ rows.push({
515
+ name,
516
+ bucket: cells[bucketIndex].toLowerCase(),
517
+ state: cells[bucketIndex].toUpperCase(),
518
+ description,
519
+ link,
520
+ workflow: "",
521
+ duration,
522
+ });
523
+ if (rows.length >= LIMITS.checks) break;
524
+ }
525
+ return rows;
526
+ }
527
+
528
+ /* ------------------------------------------------------------------ */
529
+ /* Reviews, conversation and inline threads */
530
+ /* ------------------------------------------------------------------ */
531
+
532
+ /** Compact review summary: counts by state and the reviews blocking merge. */
533
+ export function summarizeReviews(reviews) {
534
+ const list = Array.isArray(reviews) ? reviews.filter(Boolean) : [];
535
+ const byState = { APPROVED: 0, CHANGES_REQUESTED: 0, COMMENTED: 0, DISMISSED: 0, PENDING: 0, OTHER: 0 };
536
+ const items = list.map((review) => {
537
+ const state = asString(review.state).toUpperCase();
538
+ const key = Object.prototype.hasOwnProperty.call(byState, state) ? state : "OTHER";
539
+ byState[key] += 1;
540
+ return {
541
+ author: loginOf(review.author ?? review.user),
542
+ state,
543
+ body: snippet(review.body, LIMITS.commentChars),
544
+ submittedAt: asString(review.submittedAt ?? review.submitted_at),
545
+ url: asString(review.url ?? review.html_url),
546
+ };
547
+ });
548
+ const latestByAuthor = new Map();
549
+ for (const item of items) {
550
+ if (item.author.length === 0) continue;
551
+ const previous = latestByAuthor.get(item.author);
552
+ if (!previous || item.submittedAt >= previous.submittedAt) latestByAuthor.set(item.author, item);
553
+ }
554
+ const blocking = [...latestByAuthor.values()].filter((item) => item.state === "CHANGES_REQUESTED");
555
+ return {
556
+ total: list.length,
557
+ byState,
558
+ items: items.slice(0, LIMITS.conversation),
559
+ truncated: items.length > LIMITS.conversation,
560
+ latestByAuthor: [...latestByAuthor.values()],
561
+ blocking,
562
+ };
563
+ }
564
+
565
+ /** Conversation (issue) comments from `gh pr view --json comments`. */
566
+ export function normalizeConversationComments(comments) {
567
+ const list = Array.isArray(comments) ? comments.filter(Boolean) : [];
568
+ const items = list.slice(0, LIMITS.conversation).map((comment) => ({
569
+ author: loginOf(comment.author ?? comment.user),
570
+ body: snippet(comment.body, LIMITS.commentChars),
571
+ createdAt: asString(comment.createdAt ?? comment.created_at),
572
+ url: asString(comment.url ?? comment.html_url),
573
+ }));
574
+ return { total: list.length, items, truncated: list.length > items.length };
575
+ }
576
+
577
+ function commentLogin(row) {
578
+ return loginOf(row?.user ?? row?.author);
579
+ }
580
+
581
+ function lineOf(row) {
582
+ const line = row?.line ?? row?.original_line;
583
+ const number = Number(line);
584
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
585
+ }
586
+
587
+ /** Root comment of a reply chain; tolerates cycles and unknown parents. */
588
+ function threadRootOf(row, byId) {
589
+ let current = row;
590
+ const seen = new Set();
591
+ while (current !== null && typeof current === "object") {
592
+ const parentId = Number(current.in_reply_to_id);
593
+ if (!Number.isSafeInteger(parentId) || parentId <= 0 || seen.has(parentId)) break;
594
+ const parent = byId.get(parentId);
595
+ if (!parent) break;
596
+ seen.add(parentId);
597
+ current = parent;
598
+ }
599
+ return current ?? row;
600
+ }
601
+
602
+ /**
603
+ * Group REST pull-request review comments into threads. The REST API does not
604
+ * expose resolved/outdated state; `resolvedKnown: false` records that honestly.
605
+ */
606
+ export function normalizeReviewComments(rows) {
607
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
608
+ const byId = new Map();
609
+ for (const row of list) {
610
+ const id = asPositiveInt(row.id);
611
+ if (id !== undefined) byId.set(id, row);
612
+ }
613
+ const threads = [];
614
+ const open = new Map();
615
+ for (let index = 0; index < list.length; index += 1) {
616
+ const row = list[index];
617
+ const root = threadRootOf(row, byId);
618
+ const rootId = asPositiveInt(root.id);
619
+ const key = rootId === undefined ? `row-${index}` : `id-${rootId}`;
620
+ let thread = open.get(key);
621
+ if (!thread) {
622
+ thread = {
623
+ id: rootId ?? null,
624
+ path: asString(root.path),
625
+ line: lineOf(root),
626
+ side: asString(root.side),
627
+ author: commentLogin(root),
628
+ body: snippet(root.body, LIMITS.commentChars),
629
+ createdAt: asString(root.created_at ?? root.createdAt),
630
+ url: asString(root.html_url ?? root.url),
631
+ replies: [],
632
+ };
633
+ threads.push(thread);
634
+ open.set(key, thread);
635
+ continue;
636
+ }
637
+ if (row === root) continue;
638
+ thread.replies.push({
639
+ author: commentLogin(row),
640
+ body: snippet(row.body, LIMITS.commentChars),
641
+ createdAt: asString(row.created_at ?? row.createdAt),
642
+ url: asString(row.html_url ?? row.url),
643
+ });
644
+ }
645
+ const byFile = new Map();
646
+ for (const thread of threads) {
647
+ const path = thread.path.length > 0 ? thread.path : "(без файла)";
648
+ byFile.set(path, (byFile.get(path) ?? 0) + 1);
649
+ }
650
+ return {
651
+ total: list.length,
652
+ threadCount: threads.length,
653
+ threads: threads.slice(0, LIMITS.threads),
654
+ replies: list.length - threads.length,
655
+ truncated: threads.length > LIMITS.threads,
656
+ byFile: [...byFile.entries()]
657
+ .map(([path, count]) => ({ path, count }))
658
+ .sort((left, right) => right.count - left.count || left.path.localeCompare(right.path))
659
+ .slice(0, 10),
660
+ resolvedKnown: false,
661
+ };
662
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "dsh-github-review",
3
+ "version": "1.0.0",
4
+ "description": "GitHub pull-request review tool for DSH: gh status, PR overview, CI checks, review comment threads and a byte-capped diff via the gh CLI. Read-only by default; one opt-in, explicitly confirmed comment action. No merge/close/force.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./lib": "./lib/normalize.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "lib/**",
16
+ "cordis.patch.yml",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "dsh": {
23
+ "manifestVersion": 1,
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ }
27
+ }
28
+ }