@link-assistant/hive-mind 2.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3fc12ef: Recover broken GitHub URLs instead of rejecting them, and say what was repaired (issue #2194).
8
+
9
+ A user sent `/Claude https://github.com/G-Ivan-A/aether-orbis/pulls/30`. Telegram drew a healthy GitHub preview card under it, because github.com really does answer `/owner/repo/pulls/30` with **HTTP 200** and a full set of Open Graph tags — a genuinely wrong path like `/pullz/30` 404s and gets no card. The bot then refused the command and told the user to "1. Open the repository: https://github.com/G-Ivan-A/aether-orbis/**pulls/30**" — the same broken link that had just failed. Meanwhile `parseGitHubUrl` had already extracted the number: the pre-fix `case 'pulls'` stored `'30'` in `result.subpath` and nothing ever read it. The data to restore the URL from was sitting in the result object.
10
+
11
+ - **`src/github-url-recovery.lib.mjs`** repairs a URL before it is parsed: strips invisible characters (`\p{Cf}`/`\p{Cc}`/`\p{Zl}`/`\p{Zp}`, U+034F, variation selectors), unwraps `[title](url)` / `<url>` / `(url).`, folds full-width and fraction punctuation and full-width digits to ASCII, lower-cases scheme and host, reads `git@github.com:owner/repo.git` and `api.github.com/repos/...` as their web addresses, and reads `/pulls/30` as `/pull/30`, `/issue/123` as `/issues/123`, `/pull/30/files` as the pull request itself.
12
+ - **Recovery is on by default inside `parseGitHubUrl`**, so all 48 call sites across 14 files get it without changing any of them. `{ recover: false }` reproduces the old code path exactly, which is what makes the before/after evidence reproducible.
13
+ - **It never invents a GitHub URL.** `gitlab.com`, `bitbucket.org`, `gist.github.com`, `raw.githubusercontent.com`, `github.com.evil.example` and `evil.example/github.com/...` are all still rejected — and so is over-reach in the other direction: `support@github.com` is an email address rather than a repository (`git@github.com:owner/repo.git` still works), and `[the PR](aether-orbis)` stays rejected because unwrapping prose is only worth doing when what comes out already names github.com.
14
+ - **It never repairs silently.** Every result carries `original`, `repairs[]` and `recovered`, plus `hidden`/`revealed` when something invisible was removed. The Telegram bot (new `telegram.url_recovered` string in `en`/`ru`/`hi`/`zh`), `solve` and `hive` each print what they understood before acting on it.
15
+ - **Two silent-corruption bugs went with it.** A zero-width space in a repository name used to produce `valid: true` for `aether-orbis%E2%80%8B` — a repository that does not exist. `HTTPS://GITHUB.COM/...` used to parse as a _relative path_ with `HTTPS:` as the owner; the bot's own `url.includes('github.com')` gate had the same flaw and rejected the URL before the parser saw it — that gate now asks the recovery layer whether the text names github.com as its _host_, so `evil.example/github.com/...` no longer passes it either.
16
+ - **The log could not have proved an invisible character even if there had been one** — it never recorded the message text. `/solve` and its aliases now log raw text through `revealHiddenCharacters()` (a zero-width space appears as `[U+200B]`), and `traceUrlRecovery()` reports each repair stage under `--verbose`.
17
+
18
+ `normalize-url@9` and `confusables@1` were installed and measured against these exact inputs rather than assumed: `normalize-url` percent-encodes the zero-width space instead of removing it and **throws** on a full-width colon, and `confusables` turns `github.com/Ćwikła/...` into `github.com/Cwikla/...` — it would silently retarget a real owner. Neither knows that `/pulls/30` means pull request 30.
19
+
20
+ 77 assertions in `tests/test-issue-2194-broken-url-recovery.mjs`. Timeline, evidence and the full analysis are in `docs/case-studies/issue-2194/README.md`, with reproduction scripts in `experiments/issue-2194/` and `examples/github-url-recovery-demo.mjs`.
21
+
3
22
  ## 2.17.0
4
23
 
5
24
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -1,8 +1,37 @@
1
1
  import { reportError } from './sentry.lib.mjs';
2
+ import { describeHiddenCharacters, repairGitHubPathParts, repairGitHubUrlText, revealHiddenCharacters, traceUrlRecovery } from './github-url-recovery.lib.mjs';
3
+
4
+ /**
5
+ * Attach the issue #2194 recovery diagnostics to a parse result.
6
+ *
7
+ * `original` is always present so a caller can show the user what they actually
8
+ * sent; `hidden`/`revealed` only appear when there was something invisible to
9
+ * reveal, keeping the common result small.
10
+ *
11
+ * @param {Object} result - The result object to annotate (mutated and returned).
12
+ * @param {string} original - The URL exactly as it was passed in.
13
+ * @param {Array<{code: string, message: string, notable: boolean}>} repairs
14
+ * @param {Array<{escape: string, name: string}>} hidden
15
+ * @returns {Object} The same result object.
16
+ */
17
+ function withRecoveryDiagnostics(result, original, repairs, hidden) {
18
+ result.original = original;
19
+ result.repairs = repairs;
20
+ result.recovered = repairs.length > 0;
21
+ if (hidden.length > 0) {
22
+ result.hidden = hidden;
23
+ result.revealed = revealHiddenCharacters(original);
24
+ }
25
+ return result;
26
+ }
2
27
 
3
28
  /**
4
29
  * Universal GitHub URL parser that handles various formats
5
30
  * @param {string} url - The GitHub URL to parse
31
+ * @param {Object} [options] - Parsing options
32
+ * @param {boolean} [options.recover=true] - Repair recoverable damage (invisible
33
+ * Unicode, wrappers, look-alike punctuation, `/pulls/30` for `/pull/30`, …)
34
+ * before parsing. Pass `false` to see the URL exactly as it was typed.
6
35
  * @returns {Object} Parsed URL information including:
7
36
  * - valid: boolean indicating if the URL is valid
8
37
  * - normalized: the normalized URL (https://github.com/...), query/fragment kept
@@ -13,22 +42,41 @@ import { reportError } from './sentry.lib.mjs';
13
42
  * - number: issue/PR number (if applicable)
14
43
  * - path: additional path components
15
44
  * - error: error message if invalid
45
+ * - original: the URL exactly as it was passed in
46
+ * - repairs: the list of repairs recovery had to apply (issue #2194)
47
+ * - recovered: true when at least one repair was applied
48
+ * - hidden/revealed: codepoint diagnostics, present only when the input carried
49
+ * invisible or look-alike characters
16
50
  */
17
- export function parseGitHubUrl(url) {
51
+ export function parseGitHubUrl(url, options = {}) {
18
52
  if (!url || typeof url !== 'string') {
19
53
  return {
20
54
  valid: false,
21
55
  error: 'Invalid input: URL must be a non-empty string',
22
56
  };
23
57
  }
58
+ const { recover = true } = options;
59
+ // Issue #2194: a URL that renders correctly on screen can still be broken —
60
+ // `…/pulls/30` previews as a healthy page, a zero-width space is unprintable by
61
+ // definition. Repair what can be repaired first, and keep a record of it, so the
62
+ // user is told what was interpreted instead of being told "invalid URL".
63
+ const hidden = describeHiddenCharacters(url);
64
+ let repairs = [];
24
65
  // Trim whitespace and remove trailing slashes
25
66
  let normalizedUrl = url.trim().replace(/\/+$/, '');
67
+ if (recover) {
68
+ const repaired = repairGitHubUrlText(normalizedUrl);
69
+ repairs = repaired.repairs;
70
+ if (repaired.rejection) {
71
+ traceUrlRecovery('rejected', { original: url, error: repaired.rejection, repairs, hidden });
72
+ return withRecoveryDiagnostics({ valid: false, error: repaired.rejection }, url, repairs, hidden);
73
+ }
74
+ normalizedUrl = repaired.text.replace(/\/+$/, '');
75
+ if (repairs.length > 0) traceUrlRecovery('repaired-text', { original: url, repaired: normalizedUrl, repairs, hidden });
76
+ }
26
77
  // Check if this looks like a valid GitHub-related input Reject clearly invalid inputs (spaces in the URL, special chars at the start, etc.)
27
78
  if (/\s/.test(normalizedUrl) || /^[!@#$%^&*()[\]{}|\\:;"'<>,?`~]/.test(normalizedUrl)) {
28
- return {
29
- valid: false,
30
- error: 'Invalid GitHub URL format',
31
- };
79
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
32
80
  }
33
81
  // Handle protocol normalization
34
82
  if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
@@ -40,10 +88,7 @@ export function parseGitHubUrl(url) {
40
88
  normalizedUrl = 'https://github.com/' + normalizedUrl;
41
89
  } else {
42
90
  // Has github.com somewhere but not at the start - likely malformed
43
- return {
44
- valid: false,
45
- error: 'Invalid GitHub URL format',
46
- };
91
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
47
92
  }
48
93
  }
49
94
  // Convert http to https
@@ -56,11 +101,16 @@ export function parseGitHubUrl(url) {
56
101
  // Generate suggested URL by replacing backslashes with forward slashes
57
102
  const suggestedUrl = urlBeforeQueryAndHash.replace(/\\/g, '/');
58
103
  const urlAfterPath = normalizedUrl.substring(urlBeforeQueryAndHash.length);
59
- return {
60
- valid: false,
61
- error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
62
- suggestion: suggestedUrl + urlAfterPath,
63
- };
104
+ return withRecoveryDiagnostics(
105
+ {
106
+ valid: false,
107
+ error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
108
+ suggestion: suggestedUrl + urlAfterPath,
109
+ },
110
+ url,
111
+ repairs,
112
+ hidden
113
+ );
64
114
  }
65
115
  // Parse the URL
66
116
  let urlObj;
@@ -74,17 +124,11 @@ export function parseGitHubUrl(url) {
74
124
  url: normalizedUrl,
75
125
  });
76
126
  }
77
- return {
78
- valid: false,
79
- error: 'Invalid URL format',
80
- };
127
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid URL format' }, url, repairs, hidden);
81
128
  }
82
129
  // Ensure it's a GitHub URL
83
130
  if (urlObj.hostname !== 'github.com' && urlObj.hostname !== 'www.github.com') {
84
- return {
85
- valid: false,
86
- error: 'Not a GitHub URL',
87
- };
131
+ return withRecoveryDiagnostics({ valid: false, error: 'Not a GitHub URL' }, url, repairs, hidden);
88
132
  }
89
133
  // Normalize hostname
90
134
  if (urlObj.hostname === 'www.github.com') {
@@ -92,7 +136,19 @@ export function parseGitHubUrl(url) {
92
136
  urlObj = new globalThis.URL(normalizedUrl);
93
137
  }
94
138
  // Parse the pathname
95
- const pathParts = urlObj.pathname.split('/').filter(p => p);
139
+ let pathParts = urlObj.pathname.split('/').filter(p => p);
140
+ // Issue #2194: `/owner/repo/pulls/30` carries every byte needed to address pull
141
+ // request 30 — restore it rather than reporting the pull request list page.
142
+ if (recover) {
143
+ const pathRepair = repairGitHubPathParts(pathParts);
144
+ if (pathRepair.repairs.length > 0) {
145
+ repairs = repairs.concat(pathRepair.repairs);
146
+ pathParts = pathRepair.parts;
147
+ urlObj.pathname = `/${pathParts.join('/')}`;
148
+ normalizedUrl = urlObj.toString().replace(/\/+$/, '');
149
+ traceUrlRecovery('repaired-path', { original: url, repaired: normalizedUrl, repairs, hidden });
150
+ }
151
+ }
96
152
  // Handle different GitHub URL patterns
97
153
  const result = {
98
154
  valid: true,
@@ -108,6 +164,7 @@ export function parseGitHubUrl(url) {
108
164
  protocol: 'https',
109
165
  path: urlObj.pathname,
110
166
  };
167
+ withRecoveryDiagnostics(result, url, repairs, hidden);
111
168
  // No path - just github.com
112
169
  if (pathParts.length === 0) {
113
170
  result.type = 'home';
@@ -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');
@@ -508,6 +508,13 @@ en
508
508
  must
509
509
  be
510
510
  type "URL must be a GitHub {{allowedTypes}} (not {{type}})"
511
+ recovered """
512
+ ℹ️ I repaired the link before starting.
513
+
514
+ You sent: {{original}}
515
+ Using: {{used}}
516
+ Repaired: {{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ Invalid language. Supported: {{supported}}.
@@ -508,6 +508,13 @@ hi
508
508
  must
509
509
  be
510
510
  type "URL GitHub {{allowedTypes}} होना चाहिए ({{type}} नहीं)"
511
+ recovered """
512
+ ℹ️ शुरू करने से पहले लिंक ठीक की गई।
513
+
514
+ आपने भेजा: {{original}}
515
+ उपयोग किया जा रहा है: {{used}}
516
+ ठीक किया गया: {{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ अमान्य भाषा। समर्थित: {{supported}}।
@@ -508,6 +508,13 @@ ru
508
508
  must
509
509
  be
510
510
  type "URL должен быть GitHub {{allowedTypes}} (не {{type}})"
511
+ recovered """
512
+ ℹ️ Ссылка была исправлена перед запуском.
513
+
514
+ Вы отправили: {{original}}
515
+ Используется: {{used}}
516
+ Исправлено: {{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ Неверный язык. Поддерживаются: {{supported}}.
@@ -508,6 +508,13 @@ zh
508
508
  must
509
509
  be
510
510
  type "URL 必须是 GitHub {{allowedTypes}}(不是 {{type}})"
511
+ recovered """
512
+ ℹ️ 开始前已修复链接。
513
+
514
+ 您发送的:{{original}}
515
+ 实际使用:{{used}}
516
+ 修复内容:{{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ 语言无效。支持的语言:{{supported}}。
@@ -35,6 +35,9 @@ const {
35
35
  } = githubLib;
36
36
 
37
37
  // Import git-related functions for identity validation and repair
38
+ // Issue #2194: recovery diagnostics for URLs that had to be repaired before parsing.
39
+ const { formatUrlRepairs, hasNotableRepair, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs');
40
+
38
41
  const gitLib = await import('./git.lib.mjs');
39
42
  const { checkGitIdentity, repairGitIdentity } = gitLib;
40
43
 
@@ -83,6 +86,16 @@ export const validateGitHubUrl = issueUrl => {
83
86
  return { isValid: false, isIssueUrl: null, isPrUrl: null };
84
87
  }
85
88
 
89
+ // Issue #2194: the URL needed repair before it could be understood. Say so up
90
+ // front, so a wrong guess is visible before a whole session runs against the
91
+ // wrong entity.
92
+ if (hasNotableRepair(parsedUrl.repairs)) {
93
+ console.error('ℹ️ Repaired the GitHub URL before solving:');
94
+ console.error(` You typed: ${revealHiddenCharacters(issueUrl)}`);
95
+ console.error(` Using: ${parsedUrl.canonical || parsedUrl.normalized}`);
96
+ console.error(` Repaired: ${formatUrlRepairs(parsedUrl.repairs, { notableOnly: true })}`);
97
+ }
98
+
86
99
  // Check if it's an issue or pull request
87
100
  const isIssueUrl = parsedUrl.type === 'issue';
88
101
  const isPrUrl = parsedUrl.type === 'pull';
@@ -102,9 +115,12 @@ export const validateGitHubUrl = issueUrl => {
102
115
  isIssueUrl,
103
116
  isPrUrl,
104
117
  normalizedUrl: parsedUrl.normalized,
118
+ canonicalUrl: parsedUrl.canonical || parsedUrl.normalized,
105
119
  owner: parsedUrl.owner,
106
120
  repo: parsedUrl.repo,
107
121
  number: parsedUrl.number,
122
+ repairs: parsedUrl.repairs || [],
123
+ recovered: Boolean(parsedUrl.recovered),
108
124
  };
109
125
  };
110
126
 
@@ -169,6 +169,8 @@ const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = lim
169
169
  const { handleShowLimitsFlag, captureStartSnapshotAndAppend } = await import('./telegram-show-limits.lib.mjs'); // #594
170
170
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
171
171
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
172
+ const { formatUrlRepairs, hasNotableRepair, namesGitHubHost, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs'); // #2194
173
+
172
174
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
173
175
  const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
174
176
  const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
@@ -315,22 +317,28 @@ async function validateGitHubUrl(args, options = {}) {
315
317
  if (!rawUrl) return { valid: false, error: t('telegram.missing_github_url', { commandName }, { locale }) };
316
318
  // Issue #1102: Clean non-printable chars (Zero-Width Space, BOM, etc.) from URLs
317
319
  const url = cleanNonPrintableChars(rawUrl);
318
- if (!url.includes('github.com')) return { valid: false, error: t('telegram.first_arg_must_be_github_url', {}, { locale }) };
320
+ // Issue #2194: the host may be typed in any case (GITHUB.COM) or hidden behind
321
+ // look-alike punctuation, and "github.com" in the path of another host is not a
322
+ // GitHub URL at all — so the recovery layer's host check makes the call, not a
323
+ // substring test that both misses the first case and accepts the second.
324
+ if (!namesGitHubHost(url)) return { valid: false, error: t('telegram.first_arg_must_be_github_url', {}, { locale }) };
319
325
  const parsed = parseGitHubUrl(url);
320
326
  if (!parsed.valid) return { valid: false, error: parsed.error || 'Invalid GitHub URL', suggestion: parsed.suggestion };
327
+ // Issue #2194: tell the user which URL we actually understood when we had to repair theirs.
328
+ const recoveryNotice = hasNotableRepair(parsed.repairs) ? t('telegram.url_recovered', { original: escapeMarkdown(makeSpecialCharsVisible(rawUrl)), used: escapeMarkdown(parsed.canonical), repairs: escapeMarkdown(formatUrlRepairs(parsed.repairs, { notableOnly: true })) }, { locale }) : null;
321
329
  if (!allowedTypes.includes(parsed.type)) {
322
330
  const allowedTypesStr = allowedTypes.map(t => (t === 'pull' ? 'pull request' : t)).join(', ');
323
331
  const baseUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
324
332
  const escapedUrl = escapeMarkdown(url),
325
333
  escapedBaseUrl = escapeMarkdown(baseUrl); // Issue #1102: escape for Markdown
326
334
  let error;
327
- if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
328
- else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url: escapedUrl, example: `${escapedBaseUrl}/pull/1` }, { locale });
335
+ if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url: escapedBaseUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
336
+ else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url: escapedBaseUrl, example: `${escapedBaseUrl}/pull/1` }, { locale });
329
337
  else if (parsed.type === 'repo') error = t('telegram.url_repo_error', { allowedTypes: allowedTypesStr, url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
330
338
  else error = t('telegram.url_must_be_type', { allowedTypes: allowedTypesStr, type: parsed.type.replace('_', ' ') }, { locale });
331
339
  return { valid: false, error };
332
340
  }
333
- return { valid: true, parsed, normalizedUrl: url };
341
+ return { valid: true, parsed, normalizedUrl: url, recoveryNotice };
334
342
  }
335
343
 
336
344
  const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage });
@@ -539,6 +547,10 @@ async function handleSolveCommand(ctx) {
539
547
  }
540
548
 
541
549
  VERBOSE && console.log(`[VERBOSE] ${solveCommandDisplay} passed all checks, executing...`);
550
+ // Issue #2194: the incoming text is the only record of what the user actually
551
+ // typed, and the log for that issue did not contain it — so an invisible
552
+ // character in the URL was invisible in the log too. Reveal it here.
553
+ VERBOSE && console.log(`[VERBOSE] ${solveCommandDisplay} raw text: ${revealHiddenCharacters(ctx.message.text)}`);
542
554
  const solveToolAlias = getSolveToolAliasFromText(ctx.message.text);
543
555
  let userArgs = parseCommandArgs(ctx.message.text);
544
556
 
@@ -607,6 +619,8 @@ async function handleSolveCommand(ctx) {
607
619
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
608
620
  return;
609
621
  }
622
+ // Issue #2194: the link needed repair, so show what we understood before we act on it.
623
+ if (validation.recoveryNotice) await safeReply(ctx, validation.recoveryNotice, { reply_to_message_id: ctx.message.message_id });
610
624
  userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
611
625
  // Issue #2166: hand the spawned session the same canonical URL that is shown
612
626
  // in the chat, so the echo and the actual work can never disagree.
@@ -826,6 +840,8 @@ async function handleHiveCommand(ctx) {
826
840
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
827
841
  return;
828
842
  }
843
+ // Issue #2194: the link needed repair, so show what we understood before we act on it.
844
+ if (validation.recoveryNotice) await safeReply(ctx, validation.recoveryNotice, { reply_to_message_id: ctx.message.message_id });
829
845
  // Normalize issues_list/pulls_list to base repo URL, or use cleaned URL
830
846
  let normalizedArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
831
847
  const p = validation.parsed;