@link-assistant/hive-mind 2.16.0 → 2.18.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +55 -4
  11. package/src/cleanup.mjs +57 -3
  12. package/src/disk-guard.lib.mjs +21 -1
  13. package/src/formal-ai-version.lib.mjs +10 -6
  14. package/src/github-url-parser.lib.mjs +80 -23
  15. package/src/github-url-recovery.lib.mjs +514 -0
  16. package/src/hive.mjs +10 -0
  17. package/src/instrument.mjs +12 -14
  18. package/src/instrument.sanitize.lib.mjs +52 -0
  19. package/src/isolation-runner.lib.mjs +56 -33
  20. package/src/isolation-runner.parsers.lib.mjs +29 -3
  21. package/src/isolation-runner.resume.lib.mjs +263 -0
  22. package/src/locales/en.lino +7 -0
  23. package/src/locales/hi.lino +7 -0
  24. package/src/locales/ru.lino +7 -0
  25. package/src/locales/zh.lino +7 -0
  26. package/src/pull-request-changes.lib.mjs +1 -1
  27. package/src/session-kill-diagnostics.lib.mjs +47 -5
  28. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  29. package/src/session-kill-resume.lib.mjs +43 -16
  30. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  31. package/src/session-store.lib.mjs +1 -1
  32. package/src/solve.clone-errors.lib.mjs +86 -0
  33. package/src/solve.repository.lib.mjs +36 -63
  34. package/src/solve.resource-diagnostics.lib.mjs +34 -1
  35. package/src/solve.validation.lib.mjs +16 -0
  36. package/src/start-command-cli.lib.mjs +60 -0
  37. package/src/telegram-bot.mjs +51 -95
  38. package/src/telegram-overrides-validation.lib.mjs +73 -0
  39. package/src/working-session-summary.lib.mjs +1 -1
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Recovery layer for GitHub URLs that "look valid" but are not.
3
+ *
4
+ * A URL pasted into a chat client rarely arrives byte-for-byte as it left the
5
+ * address bar: messengers wrap it in punctuation, IMEs substitute full-width
6
+ * punctuation, bidi and zero-width characters ride along invisibly, and users type
7
+ * `/pulls/30` where they meant `/pull/30` — which github.com answers with HTTP 200
8
+ * (the pull request *list* page), so the link preview looks perfectly healthy while
9
+ * the number the user cared about is silently dropped.
10
+ *
11
+ * Every function here is pure. Repairs are conservative enough to be no-ops on a
12
+ * well-formed URL, and each one is reported back to the caller, so the bot can say
13
+ * what it actually interpreted instead of failing with "invalid URL".
14
+ *
15
+ * Scope note: the confusable-punctuation folding below is applied to the scheme and
16
+ * host, and to path *separators* only. A `tree`/`blob` file path therefore keeps its
17
+ * original bytes apart from its slashes — those URL types address no task in this
18
+ * codebase, and corrupting a file name would be worse than failing to normalize one.
19
+ *
20
+ * @see https://github.com/link-assistant/hive-mind/issues/2194
21
+ * @module github-url-recovery
22
+ */
23
+
24
+ /** Canonical GitHub web host. */
25
+ const GITHUB_HOST = 'github.com';
26
+
27
+ /**
28
+ * Characters that must never survive into a URL:
29
+ * - `\p{Cf}` — zero-width space/joiner, BOM, soft hyphen, bidi marks and embeddings
30
+ * - `\p{Cc}` — C0/C1 control characters (including tab, CR and LF)
31
+ * - `\p{Zl}`/`\p{Zp}` — line and paragraph separators
32
+ * - U+034F COMBINING GRAPHEME JOINER and U+FE00–U+FE0F variation selectors
33
+ * They are removable rather than replaceable: they carry no addressing information.
34
+ */
35
+ const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Cc}\p{Zl}\p{Zp}\u034F\uFE00-\uFE0F]/gu;
36
+
37
+ /**
38
+ * Unicode space separators other than U+0020. A URL can never contain one, so a
39
+ * space that is not the plain ASCII space is always paste damage. The ASCII space
40
+ * is deliberately left in place: it is how the caller still detects "this is two
41
+ * words, not a URL".
42
+ */
43
+ const EXOTIC_SPACES = /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g;
44
+
45
+ /** Names for the characters we most often have to strip, for the diagnostics. */
46
+ const CHARACTER_NAMES = {
47
+ 0x0009: 'TAB',
48
+ 0x000a: 'LINE FEED',
49
+ 0x000d: 'CARRIAGE RETURN',
50
+ 0x00ad: 'SOFT HYPHEN',
51
+ 0x00a0: 'NO-BREAK SPACE',
52
+ 0x034f: 'COMBINING GRAPHEME JOINER',
53
+ 0x200b: 'ZERO WIDTH SPACE',
54
+ 0x200c: 'ZERO WIDTH NON-JOINER',
55
+ 0x200d: 'ZERO WIDTH JOINER',
56
+ 0x200e: 'LEFT-TO-RIGHT MARK',
57
+ 0x200f: 'RIGHT-TO-LEFT MARK',
58
+ 0x202a: 'LEFT-TO-RIGHT EMBEDDING',
59
+ 0x202b: 'RIGHT-TO-LEFT EMBEDDING',
60
+ 0x202c: 'POP DIRECTIONAL FORMATTING',
61
+ 0x202d: 'LEFT-TO-RIGHT OVERRIDE',
62
+ 0x202e: 'RIGHT-TO-LEFT OVERRIDE',
63
+ 0x202f: 'NARROW NO-BREAK SPACE',
64
+ 0x2060: 'WORD JOINER',
65
+ 0x2066: 'LEFT-TO-RIGHT ISOLATE',
66
+ 0x2067: 'RIGHT-TO-LEFT ISOLATE',
67
+ 0x2068: 'FIRST STRONG ISOLATE',
68
+ 0x2069: 'POP DIRECTIONAL ISOLATE',
69
+ 0x3000: 'IDEOGRAPHIC SPACE',
70
+ 0xfe0f: 'VARIATION SELECTOR-16',
71
+ 0xfeff: 'ZERO WIDTH NO-BREAK SPACE (BOM)',
72
+ 0xff0e: 'FULLWIDTH FULL STOP',
73
+ 0xff0f: 'FULLWIDTH SOLIDUS',
74
+ 0xff1a: 'FULLWIDTH COLON',
75
+ 0x2044: 'FRACTION SLASH',
76
+ 0x2215: 'DIVISION SLASH',
77
+ };
78
+
79
+ /** Look-alike separators, mapped to the ASCII character they impersonate. */
80
+ const SEPARATOR_CONFUSABLES = new Map([
81
+ ['/', '/'], // FULLWIDTH SOLIDUS
82
+ ['⁄', '/'], // FRACTION SLASH
83
+ ['∕', '/'], // DIVISION SLASH
84
+ ['⧸', '/'], // BIG SOLIDUS
85
+ [':', ':'], // FULLWIDTH COLON
86
+ ['﹕', ':'], // SMALL COLON
87
+ ['︓', ':'], // PRESENTATION FORM FOR VERTICAL COLON
88
+ ['∶', ':'], // RATIO
89
+ ]);
90
+
91
+ /** Everything above, plus host punctuation that only makes sense before the path. */
92
+ const PREFIX_CONFUSABLES = new Map([
93
+ ...SEPARATOR_CONFUSABLES,
94
+ ['.', '.'], // FULLWIDTH FULL STOP
95
+ ['@', '@'], // FULLWIDTH COMMERCIAL AT
96
+ ['-', '-'], // FULLWIDTH HYPHEN-MINUS
97
+ ]);
98
+
99
+ /** Paired delimiters a messenger or a prose sentence may wrap a URL in. */
100
+ const WRAPPERS = new Map([
101
+ ['<', '>'],
102
+ ['(', ')'],
103
+ ['[', ']'],
104
+ ['{', '}'],
105
+ ['"', '"'],
106
+ ["'", "'"],
107
+ ['`', '`'],
108
+ ['«', '»'], // « »
109
+ ['“', '”'], // “ ”
110
+ ['‘', '’'], // ‘ ’
111
+ ]);
112
+
113
+ /** Sentence punctuation that can only be prose, never the end of a GitHub URL. */
114
+ const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?', '…']);
115
+
116
+ /** Closing delimiters that are stripped when the matching opener is absent. */
117
+ const UNBALANCED_CLOSERS = new Map([
118
+ [')', '('],
119
+ [']', '['],
120
+ ['}', '{'],
121
+ ['>', '<'],
122
+ ['»', '«'],
123
+ ['”', '“'],
124
+ ['’', '‘'],
125
+ ]);
126
+
127
+ /**
128
+ * What may legally stand in front of `github.com`: an optional scheme (with the
129
+ * colon, or without it when at least two slashes follow — the `https//github.com`
130
+ * typo), optional SSH user info, and one of the few host aliases that serve the
131
+ * same site. Anything else — `gist.`, `raw.`, `evil.com/`, `notgithub.com` — must
132
+ * NOT be rewritten into a github.com address.
133
+ */
134
+ const HOST_PREFIX = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/{0,3}|[A-Za-z][A-Za-z0-9+.-]*\/{2,3}|\/{0,3})(?:[^/\s@]*@)?(www\.|m\.|api\.)?$/;
135
+
136
+ /** Repairs that mean the token was wrapped in prose punctuation rather than typed as a URL. */
137
+ const WRAPPER_REPAIR_CODES = new Set(['markdown-link-unwrapped', 'wrapper-stripped']);
138
+
139
+ /** The first path segment must be able to be a GitHub login for the shorthand form. */
140
+ const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\/|$)/;
141
+
142
+ /** Any explicit `scheme://`, which means the input names its own host. */
143
+ const EXPLICIT_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
144
+
145
+ /** Alternate spellings of the two entity kinds we can act on. */
146
+ const ENTITY_KIND_ALIASES = new Map([
147
+ ['issue', 'issues'],
148
+ ['issues', 'issues'],
149
+ ['pull', 'pull'],
150
+ ['pulls', 'pulls'],
151
+ ['pullrequest', 'pull'],
152
+ ['pullrequests', 'pulls'],
153
+ ['pull-request', 'pull'],
154
+ ['pull-requests', 'pulls'],
155
+ ['pull_request', 'pull'],
156
+ ['pull_requests', 'pulls'],
157
+ ['merge_requests', 'pulls'],
158
+ ]);
159
+
160
+ /**
161
+ * Repairs a user cannot see for themselves. These change *which* entity the URL
162
+ * addresses, or undo damage that is invisible on screen, so the bot has to say out
163
+ * loud what it did. The rest (a stripped bracket, an added `https://`) are obvious
164
+ * from the URL itself and would only be noise.
165
+ */
166
+ const NOTABLE_REPAIR_CODES = new Set(['invisible-characters-removed', 'confusables-normalized', 'entity-kind-corrected', 'entity-subpath-dropped', 'fullwidth-digits-normalized', 'duplicate-slashes-collapsed', 'api-url-converted']);
167
+
168
+ /** Rejection reasons, worded exactly as `parseGitHubUrl` has always worded them. */
169
+ export const REJECT_NOT_GITHUB = 'Not a GitHub URL';
170
+ export const REJECT_MALFORMED = 'Invalid GitHub URL format';
171
+
172
+ /** Single-character probes need their own non-global copies of the /g regexes. */
173
+ const INVISIBLE_CHARACTER = new RegExp(INVISIBLE_CHARACTERS.source, 'u');
174
+ const EXOTIC_SPACE = new RegExp(EXOTIC_SPACES.source, 'u');
175
+
176
+ /**
177
+ * Describe every character in `text` that is invisible, exotic whitespace, or a
178
+ * punctuation look-alike, so a log line or an error message can show the user *why*
179
+ * a URL that looks correct on screen was not.
180
+ *
181
+ * @param {string} text
182
+ * @returns {Array<{index: number, codePoint: number, escape: string, name: string}>}
183
+ */
184
+ export function describeHiddenCharacters(text) {
185
+ if (!text || typeof text !== 'string') return [];
186
+ const found = [];
187
+ for (let index = 0; index < text.length; index++) {
188
+ const character = text[index];
189
+ if (!INVISIBLE_CHARACTER.test(character) && !EXOTIC_SPACE.test(character) && !PREFIX_CONFUSABLES.has(character)) continue;
190
+ const codePoint = character.codePointAt(0);
191
+ const escape = `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`;
192
+ found.push({ index, codePoint, escape, name: CHARACTER_NAMES[codePoint] || escape });
193
+ }
194
+ return found;
195
+ }
196
+
197
+ /**
198
+ * Render `text` with every hidden character replaced by its `U+XXXX` escape, so the
199
+ * damage survives a copy/paste into an issue report. Visible characters are kept.
200
+ *
201
+ * @param {string} text
202
+ * @param {{maxLength?: number}} [options]
203
+ * @returns {string}
204
+ */
205
+ export function revealHiddenCharacters(text, { maxLength = 300 } = {}) {
206
+ if (!text || typeof text !== 'string') return '';
207
+ const hidden = new Map(describeHiddenCharacters(text).map(entry => [entry.index, entry.escape]));
208
+ let revealed = '';
209
+ for (let index = 0; index < text.length; index++) {
210
+ revealed += hidden.has(index) ? `[${hidden.get(index)}]` : text[index];
211
+ }
212
+ return revealed.length > maxLength ? `${revealed.slice(0, maxLength)}… (truncated)` : revealed;
213
+ }
214
+
215
+ /** Replace look-alike punctuation with ASCII, preserving string length. */
216
+ function fold(text, table) {
217
+ let folded = '';
218
+ for (const character of text) folded += table.get(character) ?? character;
219
+ return folded;
220
+ }
221
+
222
+ /** Record a repair once; a repeated code keeps its first (most specific) message. */
223
+ function addRepair(repairs, code, message) {
224
+ if (repairs.some(repair => repair.code === code)) return;
225
+ repairs.push({ code, message, notable: NOTABLE_REPAIR_CODES.has(code) });
226
+ }
227
+
228
+ /** Strip paired wrappers and prose punctuation that a messenger glued to the URL. */
229
+ function stripDecoration(text, repairs) {
230
+ let current = text;
231
+ let changed = true;
232
+ while (changed && current.length > 1) {
233
+ changed = false;
234
+ // [label](url) — a Markdown link pasted whole.
235
+ const markdownLink = current.match(/^\[[^\]]*\]\((.+)\)$/);
236
+ if (markdownLink) {
237
+ current = markdownLink[1].trim();
238
+ addRepair(repairs, 'markdown-link-unwrapped', 'unwrapped a Markdown link');
239
+ changed = true;
240
+ continue;
241
+ }
242
+ if (WRAPPERS.get(current[0]) === current[current.length - 1]) {
243
+ current = current.slice(1, -1).trim();
244
+ addRepair(repairs, 'wrapper-stripped', 'removed the surrounding brackets or quotes');
245
+ changed = true;
246
+ continue;
247
+ }
248
+ const last = current[current.length - 1];
249
+ if (TRAILING_PUNCTUATION.has(last)) {
250
+ current = current.slice(0, -1);
251
+ addRepair(repairs, 'trailing-punctuation-stripped', `removed the trailing "${last}"`);
252
+ changed = true;
253
+ continue;
254
+ }
255
+ const opener = UNBALANCED_CLOSERS.get(last);
256
+ if (opener && !current.includes(opener)) {
257
+ current = current.slice(0, -1);
258
+ addRepair(repairs, 'trailing-punctuation-stripped', `removed the unmatched "${last}"`);
259
+ changed = true;
260
+ }
261
+ }
262
+ return current;
263
+ }
264
+
265
+ /** Remove invisible characters and exotic spaces, reporting what was dropped. */
266
+ function stripHiddenCharacters(text, repairs) {
267
+ const cleaned = text.replace(INVISIBLE_CHARACTERS, '').replace(EXOTIC_SPACES, ' ');
268
+ if (cleaned === text) return text;
269
+ const names = [...new Set(describeHiddenCharacters(text).map(entry => entry.name))].slice(0, 4).join(', ');
270
+ addRepair(repairs, 'invisible-characters-removed', `removed invisible character(s)${names ? `: ${names}` : ''}`);
271
+ return cleaned;
272
+ }
273
+
274
+ /**
275
+ * Rewrite the scheme/host prefix to the canonical `https://github.com`.
276
+ *
277
+ * @returns {{text: string}|{rejection: string}|null} `null` when the input carries
278
+ * no `github.com` at all, leaving the caller to consider the shorthand form.
279
+ */
280
+ function normalizeHost(text, repairs) {
281
+ const folded = fold(text, PREFIX_CONFUSABLES);
282
+ const hostIndex = folded.toLowerCase().indexOf(GITHUB_HOST);
283
+ if (hostIndex === -1) return null;
284
+
285
+ const hostEnd = hostIndex + GITHUB_HOST.length;
286
+ const prefix = folded.slice(0, hostIndex);
287
+ const prefixMatch = prefix.match(HOST_PREFIX);
288
+ if (!prefixMatch) return { rejection: REJECT_NOT_GITHUB };
289
+
290
+ // Only the separators of the remainder are folded — see the module note.
291
+ let after = fold(text.slice(hostEnd, hostEnd + 1), SEPARATOR_CONFUSABLES) + text.slice(hostEnd + 1);
292
+ if (after !== '' && !/^[/:?#]/.test(after)) return { rejection: REJECT_NOT_GITHUB };
293
+
294
+ // `git@github.com:owner/repo.git` is an SSH remote; `support@github.com` on its
295
+ // own is an email address and must never become a GitHub URL (issue #2194).
296
+ if (prefix.includes('@') && !/^[:/]./.test(after)) return { rejection: REJECT_NOT_GITHUB };
297
+
298
+ const port = after.match(/^:(\d+)(.*)$/s);
299
+ if (port) after = port[2];
300
+ const rest = after.replace(/^:+/, '/');
301
+
302
+ const alias = (prefixMatch[1] || '').toLowerCase();
303
+ let path = rest;
304
+ if (alias === 'api.') {
305
+ // https://api.github.com/repos/{owner}/{repo}/{issues|pulls}/{number}
306
+ const apiPath = rest.match(/^\/repos\/([^/\s]+\/[^/\s]+(?:\/.*)?)$/s);
307
+ if (!apiPath) return { rejection: REJECT_NOT_GITHUB };
308
+ path = `/${apiPath[1]}`;
309
+ addRepair(repairs, 'api-url-converted', 'read the api.github.com address as its web address');
310
+ } else if (alias) {
311
+ addRepair(repairs, 'host-normalized', `normalized the "${alias}${GITHUB_HOST}" host to ${GITHUB_HOST}`);
312
+ }
313
+ if (prefix.includes('@')) addRepair(repairs, 'ssh-url-converted', 'read the SSH/git remote address as its web address');
314
+ if (folded.slice(0, hostEnd) !== text.slice(0, hostEnd)) addRepair(repairs, 'confusables-normalized', 'replaced look-alike punctuation with ASCII');
315
+ if (!/^https:\/\/github\.com(?:[/?#]|$)/.test(text)) addRepair(repairs, 'scheme-normalized', 'normalized the address to https://github.com');
316
+
317
+ return { text: `https://${GITHUB_HOST}${path === '' || /^[/?#]/.test(path) ? path : `/${path}`}` };
318
+ }
319
+
320
+ /** Convert full-width digits (`30`) to ASCII; returns null when not all digits. */
321
+ function toAsciiDigits(segment) {
322
+ if (!/^[0-9]+$/.test(segment)) return null;
323
+ return segment.replace(/[0-9]/g, digit => String.fromCharCode(digit.charCodeAt(0) - 0xff10 + 0x30));
324
+ }
325
+
326
+ /** Collapse repeated slashes in the path and drop a `.git` suffix on the repo. */
327
+ function normalizePathShape(text, repairs) {
328
+ const match = text.match(/^(https:\/\/github\.com)([\s\S]*)$/);
329
+ if (!match) return text;
330
+ const pathAndRest = match[2];
331
+ const boundary = pathAndRest.search(/[?#]/);
332
+ const path = boundary === -1 ? pathAndRest : pathAndRest.slice(0, boundary);
333
+ const suffix = boundary === -1 ? '' : pathAndRest.slice(boundary);
334
+
335
+ let repaired = path.replace(/\/{2,}/g, '/');
336
+ if (repaired !== path) addRepair(repairs, 'duplicate-slashes-collapsed', 'collapsed repeated slashes');
337
+
338
+ const segments = repaired.split('/').filter(Boolean);
339
+ let rebuild = false;
340
+ if (segments.length >= 2 && /\.git$/i.test(segments[1])) {
341
+ segments[1] = segments[1].replace(/\.git$/i, '');
342
+ rebuild = true;
343
+ addRepair(repairs, 'git-suffix-removed', 'removed the ".git" suffix from the repository name');
344
+ }
345
+ // Full-width digits have to be folded here, before `new URL()` percent-encodes
346
+ // them out of recognition. Only the entity number is touched, never a file name.
347
+ if (segments.length >= 4 && ENTITY_KIND_ALIASES.has(segments[2].toLowerCase())) {
348
+ const asciiNumber = toAsciiDigits(segments[3]);
349
+ if (asciiNumber) {
350
+ segments[3] = asciiNumber;
351
+ rebuild = true;
352
+ addRepair(repairs, 'fullwidth-digits-normalized', 'converted full-width digits to ASCII');
353
+ }
354
+ }
355
+ if (rebuild) repaired = `/${segments.join('/')}`;
356
+ return `https://${GITHUB_HOST}${repaired}${suffix}`;
357
+ }
358
+
359
+ /**
360
+ * Does `text` address github.com *as its host*?
361
+ *
362
+ * A substring test cannot answer this. `https://evil.example/github.com/o/r` and
363
+ * `https://github.com.evil.example/o/r` both contain "github.com" and neither one is
364
+ * a GitHub URL, while `HTTPS://GITHUB.COM/o/r` and `https://github.com/o/r` are
365
+ * GitHub URLs that contain no ASCII lowercase "github.com" at all. So the question
366
+ * is answered by the same host normalizer the repair path uses, and by nothing else.
367
+ *
368
+ * The shorthand form (`owner/repo`) names no host and is deliberately not a match:
369
+ * this is the check a caller makes *before* deciding the input was meant to be a URL
370
+ * at all, so a bare word must stay distinguishable from a mistyped address.
371
+ *
372
+ * @param {string} text - The URL exactly as the user supplied it.
373
+ * @returns {boolean}
374
+ */
375
+ export function namesGitHubHost(text) {
376
+ if (!text || typeof text !== 'string') return false;
377
+ const repairs = [];
378
+ const stripped = stripDecoration(stripHiddenCharacters(text.trim(), repairs).trim(), repairs);
379
+ if (stripped === '') return false;
380
+ const host = normalizeHost(stripped, repairs);
381
+ return Boolean(host) && !host.rejection;
382
+ }
383
+
384
+ /**
385
+ * Repair the textual form of a GitHub URL before it is parsed.
386
+ *
387
+ * @param {string} raw - The URL exactly as the user supplied it.
388
+ * @returns {{text: string, repairs: Array<{code: string, message: string, notable: boolean}>, rejection?: string}}
389
+ * `text` is the repaired URL (identical to the trimmed input when nothing needed
390
+ * fixing), `repairs` lists what changed, and `rejection` is set when the input
391
+ * names a host that must not be rewritten.
392
+ */
393
+ export function repairGitHubUrlText(raw) {
394
+ const repairs = [];
395
+ if (!raw || typeof raw !== 'string') return { text: '', repairs };
396
+
397
+ let text = stripHiddenCharacters(raw.trim(), repairs).trim();
398
+ text = stripDecoration(text, repairs);
399
+ if (text === '') return { text, repairs, rejection: REJECT_MALFORMED };
400
+ const wasWrapped = repairs.some(repair => WRAPPER_REPAIR_CODES.has(repair.code));
401
+
402
+ const host = normalizeHost(text, repairs);
403
+ if (host?.rejection) return { text, repairs, rejection: host.rejection };
404
+ if (host) return { text: normalizePathShape(host.text, repairs), repairs };
405
+
406
+ // No github.com anywhere. An input that names its own scheme names its own host
407
+ // too, so it is handed to the parser untouched and rejected there — this is what
408
+ // keeps `https://gitlab.com/owner/repo` from being rewritten into a GitHub URL.
409
+ if (EXPLICIT_SCHEME.test(text)) return { text, repairs };
410
+ // `[label](target)` and `"word"` in the middle of a sentence are prose. Unwrapping
411
+ // them is only worth doing when what comes out already names github.com, so a bare
412
+ // token that was wrapped must not be promoted to a GitHub profile (issue #2194).
413
+ if (wasWrapped) return { text, repairs, rejection: REJECT_MALFORMED };
414
+ if (!GITHUB_LOGIN.test(text)) return { text, repairs, rejection: REJECT_MALFORMED };
415
+ addRepair(repairs, 'scheme-normalized', `read the shorthand as a ${GITHUB_HOST} path`);
416
+ return { text: normalizePathShape(`https://${GITHUB_HOST}/${text.replace(/^\/+/, '')}`, repairs), repairs };
417
+ }
418
+
419
+ /**
420
+ * Repair the `owner/repo/kind/number` shape of an already-split path.
421
+ *
422
+ * This is where the reported failure is fixed: `/owner/repo/pulls/30` carries every
423
+ * byte needed to address pull request 30, so it is restored to `/owner/repo/pull/30`
424
+ * instead of being reported as "the pull requests list page".
425
+ *
426
+ * @param {string[]} parts - Path segments, with the empty ones already removed.
427
+ * @returns {{parts: string[], repairs: Array<{code: string, message: string, notable: boolean}>}}
428
+ */
429
+ export function repairGitHubPathParts(parts) {
430
+ const repairs = [];
431
+ if (!Array.isArray(parts) || parts.length < 3) return { parts, repairs };
432
+
433
+ const repaired = [...parts];
434
+ const rawKind = repaired[2];
435
+ const kind = ENTITY_KIND_ALIASES.get(rawKind.toLowerCase());
436
+ if (!kind) return { parts: repaired, repairs };
437
+
438
+ const asciiNumber = repaired.length > 3 ? toAsciiDigits(repaired[3]) : null;
439
+ if (asciiNumber) {
440
+ repaired[3] = asciiNumber;
441
+ addRepair(repairs, 'fullwidth-digits-normalized', 'converted full-width digits to ASCII');
442
+ }
443
+ const hasNumber = repaired.length > 3 && /^\d+$/.test(repaired[3]);
444
+
445
+ // `/pulls/30` addresses pull request 30; a bare `/pull` addresses the list. Any
446
+ // other `/pull/<something>` (`/pull/new/branch`) is left exactly as it was.
447
+ let canonicalKind = kind;
448
+ if (kind === 'pulls' && hasNumber) canonicalKind = 'pull';
449
+ if (kind === 'pull' && repaired.length === 3) canonicalKind = 'pulls';
450
+
451
+ if (canonicalKind !== rawKind) {
452
+ repaired[2] = canonicalKind;
453
+ const isReportedCase = canonicalKind === 'pull' && rawKind.toLowerCase() === 'pulls';
454
+ addRepair(repairs, 'entity-kind-corrected', isReportedCase ? `"/${rawKind}/${repaired[3]}" is the pull request list page — read it as "/pull/${repaired[3]}"` : `corrected the path segment "${rawKind}" to "${canonicalKind}"`);
455
+ }
456
+
457
+ // A tab suffix (`/files`, `/commits`, `/checks`, …) still addresses the same entity.
458
+ if (hasNumber && repaired.length > 4 && (canonicalKind === 'pull' || canonicalKind === 'issues')) {
459
+ const dropped = repaired.slice(4).join('/');
460
+ repaired.length = 4;
461
+ addRepair(repairs, 'entity-subpath-dropped', `ignored the "/${dropped}" tab and used the ${canonicalKind === 'pull' ? 'pull request' : 'issue'} itself`);
462
+ }
463
+ return { parts: repaired, repairs };
464
+ }
465
+
466
+ /**
467
+ * Render a repair list as one human-readable sentence fragment.
468
+ *
469
+ * @param {Array<{message: string}>} repairs
470
+ * @param {{notableOnly?: boolean}} [options]
471
+ * @returns {string} Empty string when there is nothing worth saying.
472
+ */
473
+ export function formatUrlRepairs(repairs, { notableOnly = false } = {}) {
474
+ if (!Array.isArray(repairs) || repairs.length === 0) return '';
475
+ const selected = notableOnly ? repairs.filter(repair => repair.notable) : repairs;
476
+ return selected.map(repair => repair.message).join('; ');
477
+ }
478
+
479
+ /** True when at least one repair changed something the user could not see. */
480
+ export function hasNotableRepair(repairs) {
481
+ return Array.isArray(repairs) && repairs.some(repair => repair.notable);
482
+ }
483
+
484
+ /**
485
+ * Verbose-only trace of one recovery step.
486
+ *
487
+ * `parseGitHubUrl` is synchronous and cannot await the project logger, but the
488
+ * stdio interceptor in `lib.mjs` mirrors console output into the session log, so a
489
+ * `--verbose` run still ends up with the codepoint-level record that issue #2194
490
+ * asked for ("add debug output and verbose mode … that will allow us to find root
491
+ * cause on next iteration").
492
+ *
493
+ * @param {string} stage
494
+ * @param {Object} details
495
+ */
496
+ export function traceUrlRecovery(stage, details) {
497
+ if (!globalThis.verboseMode) return;
498
+ try {
499
+ console.error(`[url-recovery] ${stage} ${JSON.stringify(details)}`);
500
+ } catch {
501
+ console.error(`[url-recovery] ${stage} (details could not be serialized)`);
502
+ }
503
+ }
504
+
505
+ export default {
506
+ describeHiddenCharacters,
507
+ formatUrlRepairs,
508
+ hasNotableRepair,
509
+ namesGitHubHost,
510
+ repairGitHubPathParts,
511
+ repairGitHubUrlText,
512
+ revealHiddenCharacters,
513
+ traceUrlRecovery,
514
+ };
package/src/hive.mjs CHANGED
@@ -86,6 +86,8 @@ if (isRunningDirectly) {
86
86
  const { validateYouTrackConfig, testYouTrackConnection, createYouTrackConfigFromEnv } = youTrackLib;
87
87
  const youTrackSync = await import('./youtrack/youtrack-sync.mjs');
88
88
  const { syncYouTrackToGitHub, formatIssuesForHive } = youTrackSync;
89
+ // Issue #2194: recovery diagnostics for URLs that had to be repaired before parsing.
90
+ const { formatUrlRepairs, hasNotableRepair, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs');
89
91
  const memCheck = await import('./memory-check.mjs');
90
92
  const { checkSystem } = memCheck;
91
93
  const exitHandler = await import('./exit-handler.lib.mjs');
@@ -185,6 +187,14 @@ if (isRunningDirectly) {
185
187
  console.error(' - owner/repo (will be converted to https://github.com/owner/repo)');
186
188
  await safeExit(1, 'Error occurred');
187
189
  }
190
+ // Issue #2194: report a repaired URL before monitoring starts, so the user
191
+ // can catch a wrong guess instead of watching the wrong repository.
192
+ if (hasNotableRepair(parsedUrl.repairs)) {
193
+ console.error('ℹ️ Repaired the GitHub URL before monitoring:');
194
+ console.error(` You typed: ${revealHiddenCharacters(githubUrl)}`);
195
+ console.error(` Using: ${parsedUrl.canonical || parsedUrl.normalized}`);
196
+ console.error(` Repaired: ${formatUrlRepairs(parsedUrl.repairs, { notableOnly: true })}`);
197
+ }
188
198
  // Check if it's a valid type for hive (user or repo)
189
199
  if (parsedUrl.type !== 'user' && parsedUrl.type !== 'repo') {
190
200
  console.error('Error: Invalid GitHub URL for monitoring');
@@ -1,17 +1,6 @@
1
1
  // Lazy-load config only when needed to avoid loading use-m at module initialization
2
2
  // This prevents network fetches that can hang during --help or --version
3
- import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
-
5
- const sanitizeEventValue = (value, seen = new WeakSet()) => {
6
- if (typeof value === 'string') return sanitizeCredentialText(value);
7
- if (!value || typeof value !== 'object') return value;
8
- if (seen.has(value)) return value;
9
- seen.add(value);
10
- for (const [key, item] of Object.entries(value)) {
11
- value[key] = sanitizeEventValue(item, seen);
12
- }
13
- return value;
14
- };
3
+ import { sanitizeSentryLog, sanitizeSentryValue } from './instrument.sanitize.lib.mjs';
15
4
 
16
5
  // Check if Sentry should be disabled
17
6
  const shouldDisableSentry = () => {
@@ -78,7 +67,9 @@ if (!shouldDisableSentry()) {
78
67
  environment: process.env.NODE_ENV || 'production',
79
68
  release: `hive-mind@${process.env.npm_package_version || version.default}`,
80
69
 
81
- // Send structured logs to Sentry
70
+ // Send structured logs to Sentry. Stated explicitly even though Sentry
71
+ // 10.71 made it the default, so the setting stays a decision rather than
72
+ // whatever the SDK happens to default to next.
82
73
  enableLogs: true,
83
74
 
84
75
  // Tracing
@@ -98,7 +89,7 @@ if (!shouldDisableSentry()) {
98
89
 
99
90
  // Before send hook to filter out sensitive data
100
91
  beforeSend(event) {
101
- sanitizeEventValue(event);
92
+ sanitizeSentryValue(event);
102
93
 
103
94
  // Filter out sensitive environment variables
104
95
  if (event.contexts && event.contexts.runtime && event.contexts.runtime.env) {
@@ -123,6 +114,13 @@ if (!shouldDisableSentry()) {
123
114
  return event;
124
115
  },
125
116
 
117
+ // Structured logs never pass through beforeSend, so they get the same
118
+ // masking here — otherwise a token printed by `Sentry.logger.*` would
119
+ // leave the process verbatim.
120
+ beforeSendLog(log) {
121
+ return sanitizeSentryLog(log);
122
+ },
123
+
126
124
  // Integration specific options
127
125
  ignoreErrors: [
128
126
  // Ignore specific errors that are expected or not relevant
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Credential sanitization for everything Hive Mind hands to Sentry.
3
+ *
4
+ * `src/instrument.mjs` has always masked credentials in *events* (`beforeSend`).
5
+ * Structured logs are a second, separate pipeline: `enableLogs` sends
6
+ * `Sentry.logger.*` records straight to the transport, and `beforeSend` is never
7
+ * called for them. Sentry 10.71 made `enableLogs` the default, so any consumer
8
+ * that has not opted out now ships that pipeline whether it meant to or not —
9
+ * which is why the same masking is applied through `beforeSendLog` here.
10
+ *
11
+ * Both hooks share one walker so a token can never be masked in one surface and
12
+ * printed verbatim in the other.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
15
+ */
16
+
17
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
18
+
19
+ /**
20
+ * Recursively mask credentials in a Sentry payload, in place.
21
+ *
22
+ * Mutates rather than clones on purpose: Sentry hands us the object it is about
23
+ * to serialize, and a copy would leave the original untouched. Cycles are
24
+ * tracked so a self-referencing payload cannot spin forever — the same class of
25
+ * unbounded work this issue is about.
26
+ *
27
+ * @param {*} value - Any part of a Sentry event or log record
28
+ * @param {WeakSet} [seen] - Cycle guard, supplied by the recursion
29
+ * @returns {*} The same value with every string masked
30
+ */
31
+ export const sanitizeSentryValue = (value, seen = new WeakSet()) => {
32
+ if (typeof value === 'string') return sanitizeCredentialText(value);
33
+ if (!value || typeof value !== 'object') return value;
34
+ if (seen.has(value)) return value;
35
+ seen.add(value);
36
+ for (const [key, item] of Object.entries(value)) {
37
+ value[key] = sanitizeSentryValue(item, seen);
38
+ }
39
+ return value;
40
+ };
41
+
42
+ /**
43
+ * `beforeSendLog` hook: mask credentials in a structured log record.
44
+ *
45
+ * Returns the record (never `null`) so sanitization only ever changes the
46
+ * content of a log, never whether it is delivered — dropping logs silently
47
+ * would recreate the blind spot described in Finding F5.
48
+ *
49
+ * @param {Object} log - The log record Sentry is about to send
50
+ * @returns {Object} The same record, masked
51
+ */
52
+ export const sanitizeSentryLog = log => sanitizeSentryValue(log);