@link-assistant/hive-mind 2.0.12 → 2.0.14

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,51 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 03954a9: Show fuzzy suggestions for mistyped CLI and Telegram command options, including the closest match plus up to four additional alternatives.
8
+
9
+ ## 2.0.13
10
+
11
+ ### Patch Changes
12
+
13
+ - ca7c412: fix(branch): validate `--base-branch` existence up-front and stop misreporting a missing base branch as an empty repository (#1959)
14
+
15
+ A `/solve` run from the Telegram bot crashed with the unactionable message
16
+ `Branch operation failed`. Root cause: the user passed `--base-branch issue-375-8a4323e580780`
17
+ — a **one-character typo** of the real branch `issue-375-8a4323e58078`. The branch did not
18
+ exist, but nothing validated that before the run started. The solver cloned 72 MB, then
19
+ `git checkout -b … origin/issue-375-8a4323e580780` failed with
20
+ `fatal: 'origin/…' is not a commit`. Worse, the branch-creation error handler **misdiagnosed**
21
+ this as "the repository appears to be empty (no commits)" and suggested `--auto-init-repository`,
22
+ which is wrong for a non-empty repo. The top level then collapsed everything to the bare
23
+ `Branch operation failed` comment on GitHub.
24
+
25
+ Defense-in-depth fix applied across the codebase:
26
+ - `validateGitHubEntityExistence()` (`src/github-entity-validation.lib.mjs`) gains a new
27
+ base-branch step: when `--base-branch` is supplied, `checkBaseBranchExists()` verifies it
28
+ via `gh api repos/{owner}/{repo}/branches/{branch}` **before** cloning, in the same fail-fast
29
+ gate that already checks user/repo/issue/PR. A definitive 404 fails the run; transient
30
+ errors fail open so a network hiccup never blocks a valid run.
31
+ - New helpers `levenshteinDistance()` and `findClosestBranchName()` power a "did you mean
32
+ '<closest-existing-branch>'?" suggestion built from the repo's actual branch list, so the
33
+ exact real-world typo points the user straight at `issue-375-8a4323e58078`.
34
+ - Both entry points share the gate: `src/solve.mjs` (CLI + the GitHub comment path) and
35
+ `src/telegram-bot.mjs` (the bot pre-flight) now pass `baseBranch` in, so a missing base
36
+ branch fails immediately at every level — including in Telegram, before the solve command
37
+ is queued or spawned.
38
+ - `handleBranchCreationError()` (`src/solve.branch-errors.lib.mjs`) now receives `baseBranch`
39
+ and `branchSource`. When a custom base branch is the missing ref it reports the real root
40
+ cause instead of the bogus empty-repository advice; the genuine empty-repository path
41
+ (creating from the default branch) is preserved. `createOrCheckoutBranch()`
42
+ (`src/solve.branch.lib.mjs`) threads the base branch and its source into the handler.
43
+
44
+ Adds `tests/test-base-branch-existence.mjs` (17 offline assertions covering the helpers and
45
+ the misdiagnosis fix), `tests/test-base-branch-existence-integration.mjs`
46
+ (`@hive-mind-integration`, the real `gh` gate), and a deep case study in
47
+ `docs/case-studies/issue-1959/`.
48
+
3
49
  ## 2.0.12
4
50
 
5
51
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
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,5 +1,7 @@
1
1
  import { getenv, makeConfig, yargs as linoYargs } from 'lino-arguments';
2
2
 
3
+ import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
4
+
3
5
  export { getenv };
4
6
 
5
7
  export const hideBin = argv => argv.slice(2);
@@ -52,17 +54,24 @@ export function addCliCompatibilityAliases(parsed, { positionalAliases = [] } =
52
54
 
53
55
  export function parseCliArgumentsWithLino({ argv = process.argv, commandName = 'cli', createYargsConfig, positionalAliases = [], lenv = { enabled: true }, env = { enabled: false }, getenv: getenvOptions = { enabled: true } } = {}) {
54
56
  const fullArgv = ensureFullArgv(argv, commandName);
55
- const parsed = makeConfig({
56
- argv: fullArgv,
57
- lenv,
58
- env,
59
- getenv: getenvOptions,
60
- yargs: ({ yargs, getenv: getenvHelper }) => {
61
- const parser = yargs.exitProcess(false);
62
- const configured = createYargsConfig ? createYargsConfig(parser, getenvHelper) : parser;
63
- return configured.exitProcess(false);
64
- },
65
- });
57
+ let configuredParser = null;
58
+ let parsed;
59
+
60
+ try {
61
+ parsed = makeConfig({
62
+ argv: fullArgv,
63
+ lenv,
64
+ env,
65
+ getenv: getenvOptions,
66
+ yargs: ({ yargs, getenv: getenvHelper }) => {
67
+ const parser = yargs.exitProcess(false);
68
+ configuredParser = createYargsConfig ? createYargsConfig(parser, getenvHelper) : parser;
69
+ return configuredParser.exitProcess(false);
70
+ },
71
+ });
72
+ } catch (error) {
73
+ throw enhanceUnknownArgumentError(error, configuredParser);
74
+ }
66
75
 
67
76
  return addCliCompatibilityAliases(parsed, { positionalAliases });
68
77
  }
@@ -10,8 +10,135 @@ import { ghCmdRetry } from './lib.mjs';
10
10
  import { ghPrView, ghIssueView } from './github.lib.mjs';
11
11
 
12
12
  /**
13
- * Validate existence of GitHub entities (user/org, repository, issue/PR) before executing a command.
14
- * Checks each level in order: user/org repository issue/PR, failing fast at the first missing entity.
13
+ * Compute the Levenshtein edit distance between two strings.
14
+ * Used to suggest the closest existing branch name when a requested base
15
+ * branch is not found (issue #1959 — typos like an extra trailing character).
16
+ *
17
+ * @param {string} a - First string
18
+ * @param {string} b - Second string
19
+ * @returns {number} The minimum number of single-character edits
20
+ */
21
+ export function levenshteinDistance(a, b) {
22
+ a = String(a);
23
+ b = String(b);
24
+ if (a === b) return 0;
25
+ if (a.length === 0) return b.length;
26
+ if (b.length === 0) return a.length;
27
+
28
+ // Single-row dynamic programming to keep memory at O(min(len)).
29
+ let previousRow = Array.from({ length: b.length + 1 }, (_, i) => i);
30
+ for (let i = 0; i < a.length; i++) {
31
+ const currentRow = [i + 1];
32
+ for (let j = 0; j < b.length; j++) {
33
+ const insertCost = currentRow[j] + 1;
34
+ const deleteCost = previousRow[j + 1] + 1;
35
+ const replaceCost = previousRow[j] + (a[i] === b[j] ? 0 : 1);
36
+ currentRow.push(Math.min(insertCost, deleteCost, replaceCost));
37
+ }
38
+ previousRow = currentRow;
39
+ }
40
+ return previousRow[b.length];
41
+ }
42
+
43
+ /**
44
+ * Find the closest matching branch name to a (likely mistyped) target.
45
+ * Returns the nearest candidate only when it is close enough to be a plausible
46
+ * typo, so we never suggest an unrelated branch.
47
+ *
48
+ * @param {string} target - The requested (not found) branch name
49
+ * @param {string[]} candidates - Existing branch names to compare against
50
+ * @returns {string|null} The closest branch name, or null if none is close enough
51
+ */
52
+ export function findClosestBranchName(target, candidates) {
53
+ if (!target || !Array.isArray(candidates) || candidates.length === 0) {
54
+ return null;
55
+ }
56
+ // Allow a larger edit budget for longer names, but cap it so unrelated
57
+ // branches are never suggested (e.g. a 1-char typo on a 23-char branch).
58
+ const maxDistance = Math.max(2, Math.floor(target.length * 0.34));
59
+ let best = null;
60
+ let bestDistance = Infinity;
61
+ for (const candidate of candidates) {
62
+ if (!candidate || candidate === target) continue;
63
+ const distance = levenshteinDistance(target, candidate);
64
+ if (distance < bestDistance) {
65
+ bestDistance = distance;
66
+ best = candidate;
67
+ }
68
+ }
69
+ return bestDistance <= maxDistance ? best : null;
70
+ }
71
+
72
+ /**
73
+ * Check whether a branch exists in the given repository using the GitHub API.
74
+ * Works without cloning the repo, so it can run during early validation
75
+ * (before the heavy clone/spawn work) for both the CLI and the Telegram bot.
76
+ *
77
+ * @param {Object} options
78
+ * @param {string} options.owner - Repository owner
79
+ * @param {string} options.repo - Repository name
80
+ * @param {string} options.baseBranch - Branch name to check
81
+ * @param {boolean} [options.verbose=false] - Verbose logging
82
+ * @returns {Promise<{exists: boolean, indeterminate?: boolean}>}
83
+ * - exists: true if the branch is present (or the check was inconclusive — fail open)
84
+ * - indeterminate: true when a non-404 error prevented a definitive answer
85
+ */
86
+ export async function checkBaseBranchExists({ owner, repo, baseBranch, verbose = false }) {
87
+ try {
88
+ const result = await ghCmdRetry(() => $`gh api repos/${owner}/${repo}/branches/${baseBranch} --jq .name`, { label: `check branch ${baseBranch}` });
89
+ if (result.code === 0) {
90
+ return { exists: true };
91
+ }
92
+ const errorOutput = (result.stderr ? result.stderr.toString() : '') + (result.stdout ? result.stdout.toString() : '');
93
+ if (errorOutput.includes('404') || errorOutput.includes('Not Found') || errorOutput.includes('Branch not found')) {
94
+ return { exists: false };
95
+ }
96
+ // Non-404 errors (network, auth, rate limit): fail open so we don't block on transient issues.
97
+ verbose && console.log(`[VERBOSE] Entity check: Could not verify branch '${baseBranch}': ${errorOutput.trim()}`);
98
+ return { exists: true, indeterminate: true };
99
+ } catch (e) {
100
+ verbose && console.log(`[VERBOSE] Entity check: Branch check error for '${baseBranch}': ${e.message}`);
101
+ return { exists: true, indeterminate: true };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Build a user-facing error message for a missing base branch, optionally
107
+ * including a "did you mean" suggestion derived from the repo's branches.
108
+ *
109
+ * @param {Object} options
110
+ * @param {string} options.owner
111
+ * @param {string} options.repo
112
+ * @param {string} options.baseBranch
113
+ * @param {boolean} [options.verbose=false]
114
+ * @returns {Promise<string>} The formatted error message
115
+ */
116
+ export async function buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose = false }) {
117
+ let suggestion = '';
118
+ try {
119
+ const listResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`, { label: `list branches ${owner}/${repo}` });
120
+ if (listResult.code === 0) {
121
+ const branches = listResult.stdout
122
+ .toString()
123
+ .split('\n')
124
+ .map(s => s.trim())
125
+ .filter(Boolean);
126
+ const closest = findClosestBranchName(baseBranch, branches);
127
+ if (closest) {
128
+ suggestion = `\n\n💡 Did you mean '${closest}'? (closest existing branch)`;
129
+ }
130
+ }
131
+ } catch (e) {
132
+ verbose && console.log(`[VERBOSE] Entity check: Could not list branches for suggestion: ${e.message}`);
133
+ }
134
+ const listCmd = `gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`;
135
+ return `Base branch '${baseBranch}' does not exist in ${owner}/${repo}.${suggestion}\n\n💡 Please check:\n• The branch name is spelled correctly\n• The branch has not been deleted or renamed\n• Omit --base-branch to use the repository's default branch\n• List existing branches: ${listCmd}`;
136
+ }
137
+
138
+ /**
139
+ * Validate existence of GitHub entities (user/org, repository, base branch, issue/PR)
140
+ * before executing a command. Checks each level in order, failing fast at the first
141
+ * missing entity: user/org → repository → base branch → issue/PR.
15
142
  *
16
143
  * When autoAcceptInvite is enabled, invitations should be accepted BEFORE calling this function,
17
144
  * so that newly-accepted repos/orgs are visible to the API checks.
@@ -21,6 +148,9 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
21
148
  * @param {string} options.repo - Repository name
22
149
  * @param {number|string} [options.number] - Issue or PR number (if applicable)
23
150
  * @param {string} [options.type] - URL type: 'issue' or 'pull'
151
+ * @param {string} [options.baseBranch] - Custom base branch (from --base-branch/--target-branch).
152
+ * When provided, its existence is verified up-front so a typo fails fast with a clear
153
+ * message instead of an opaque "Branch operation failed" after cloning (issue #1959).
24
154
  * @param {boolean} [options.verbose=false] - Whether verbose logging is enabled
25
155
  * @param {boolean} [options.autoAcceptInvite=false] - Whether the caller already passed
26
156
  * `--auto-accept-invite`. When true, the repo-404 message omits the suggestion to
@@ -28,10 +158,10 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
28
158
  * @returns {Promise<{valid: boolean, error?: string, level?: string, details?: string}>}
29
159
  * - valid: true if all entities exist and are accessible
30
160
  * - error: user-facing error message (when valid=false)
31
- * - level: which entity level failed ('user', 'repo', 'issue', 'pull')
161
+ * - level: which entity level failed ('user', 'repo', 'branch', 'issue', 'pull')
32
162
  * - details: additional context for verbose logging
33
163
  */
34
- export async function validateGitHubEntityExistence({ owner, repo, number, type, verbose = false, autoAcceptInvite = false }) {
164
+ export async function validateGitHubEntityExistence({ owner, repo, number, type, baseBranch, verbose = false, autoAcceptInvite = false }) {
35
165
  // Step 1: Check user/organization existence
36
166
  try {
37
167
  const userResult = await ghCmdRetry(() => $`gh api users/${owner} --jq .login`, { label: `check user ${owner}` });
@@ -73,6 +203,24 @@ export async function validateGitHubEntityExistence({ owner, repo, number, type,
73
203
  verbose && console.log(`[VERBOSE] Entity check: Repo check error for '${owner}/${repo}': ${e.message}`);
74
204
  }
75
205
 
206
+ // Step 2.5: Check base branch existence (issue #1959)
207
+ // When the user passes --base-branch/--target-branch, verify it exists in the
208
+ // canonical repository up-front. A typo (e.g. an extra trailing character) would
209
+ // otherwise surface only after cloning as an opaque "Branch operation failed",
210
+ // and was even misdiagnosed as an "empty repository". Failing here gives the
211
+ // same fast, explicit feedback we already provide for repo/issue/PR — in the CLI
212
+ // and in the GitHub comment, and BEFORE the Telegram bot starts the solve run.
213
+ if (baseBranch) {
214
+ const branchCheck = await checkBaseBranchExists({ owner, repo, baseBranch, verbose });
215
+ if (!branchCheck.exists) {
216
+ return {
217
+ valid: false,
218
+ error: await buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose }),
219
+ level: 'branch',
220
+ };
221
+ }
222
+ }
223
+
76
224
  // Step 3: Check issue or PR existence (if number is provided)
77
225
  if (number) {
78
226
  if (type === 'pull') {
@@ -44,79 +44,97 @@ export function calculateLevenshteinDistance(a, b) {
44
44
  return matrix[b.length][a.length];
45
45
  }
46
46
 
47
- /**
48
- * Find similar option names based on Levenshtein distance
49
- *
50
- * @param {string} unknownOption - The option name that was not recognized (e.g., "branch")
51
- * @param {Object} yargsInstance - The yargs instance with defined options
52
- * @param {number} maxSuggestions - Maximum number of suggestions to return (default: 3)
53
- * @param {number} distanceThreshold - Maximum distance to consider for suggestions (default: 5)
54
- * @returns {string[]} - Array of suggested option names, sorted by similarity
55
- */
56
- export function findSimilarOptions(unknownOption, yargsInstance, maxSuggestions = 3, distanceThreshold = 5) {
57
- // Remove leading dashes from the unknown option
58
- const cleanUnknown = unknownOption.replace(/^-+/, '');
47
+ const normalizeOptionName = option =>
48
+ String(option || '')
49
+ .trim()
50
+ .replace(/^-+/, '')
51
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
52
+ .replace(/[_\s]+/g, '-')
53
+ .toLowerCase();
59
54
 
60
- // Get all available options from yargs
55
+ const compactOptionName = option => normalizeOptionName(option).replace(/-/g, '');
56
+
57
+ function getAvailableOptionNames(yargsInstance, includeShortAliases) {
61
58
  const availableOptions = yargsInstance.getOptions();
62
59
  const allOptions = new Set();
60
+ const rawHiddenOptions = availableOptions.hiddenOptions || [];
61
+ const hiddenOptionNames = Array.isArray(rawHiddenOptions) ? rawHiddenOptions : Object.keys(rawHiddenOptions);
62
+ const hiddenOptions = new Set(hiddenOptionNames.map(normalizeOptionName));
63
+
64
+ const addOption = option => {
65
+ const normalized = normalizeOptionName(option);
66
+ if (!normalized || hiddenOptions.has(normalized)) return;
67
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(normalized)) return;
68
+ if (!includeShortAliases && normalized.length === 1) return;
69
+ allOptions.add(normalized);
70
+ };
63
71
 
64
- // Collect all option names (both long form and aliases)
65
72
  if (availableOptions.key) {
66
- // Ensure it's an array before iterating
67
73
  const keys = Array.isArray(availableOptions.key) ? availableOptions.key : Object.keys(availableOptions.key || {});
68
- keys.forEach(opt => {
69
- allOptions.add(opt);
70
- });
74
+ keys.forEach(addOption);
71
75
  }
72
76
 
73
- // Collect aliases
74
77
  if (availableOptions.alias) {
75
78
  Object.entries(availableOptions.alias).forEach(([key, aliases]) => {
76
- allOptions.add(key);
79
+ addOption(key);
77
80
  if (Array.isArray(aliases)) {
78
- aliases.forEach(alias => allOptions.add(alias));
81
+ aliases.forEach(addOption);
79
82
  } else if (aliases) {
80
- // If it's not an array but exists, add it as a single alias
81
- allOptions.add(String(aliases));
83
+ addOption(aliases);
82
84
  }
83
85
  });
84
86
  }
85
87
 
86
- // Calculate distance for each option
88
+ return allOptions;
89
+ }
90
+
91
+ /**
92
+ * Find similar option names based on Levenshtein distance
93
+ *
94
+ * @param {string} unknownOption - The option name that was not recognized (e.g., "branch")
95
+ * @param {Object} yargsInstance - The yargs instance with defined options
96
+ * @param {number} maxSuggestions - Maximum number of suggestions to return (default: 5)
97
+ * @param {number} distanceThreshold - Maximum distance to consider for suggestions (default: 5)
98
+ * @returns {string[]} - Array of suggested option names, sorted by similarity
99
+ */
100
+ export function findSimilarOptions(unknownOption, yargsInstance, maxSuggestions = 5, distanceThreshold = 5) {
101
+ const cleanUnknown = normalizeOptionName(unknownOption);
102
+ const compactUnknown = compactOptionName(unknownOption);
103
+ const includeShortAliases = cleanUnknown.length === 1;
104
+ const allOptions = getAvailableOptionNames(yargsInstance, includeShortAliases);
105
+
87
106
  const distances = [];
88
107
  allOptions.forEach(option => {
89
- const distance = calculateLevenshteinDistance(cleanUnknown, option);
90
- if (distance <= distanceThreshold) {
91
- // Calculate bonus score for substring matches
92
- // If the unknown option is a substring of the valid option, it's likely what the user meant
93
- // Check both directions: is unknown a substring of option, or is option a substring of unknown
94
- const unknownInOption = option.includes(cleanUnknown);
95
- const optionInUnknown = cleanUnknown.includes(option);
96
-
97
- // Strong bonus for when user typed a word that appears in the option name
98
- // e.g., "branch" appears in "base-branch"
99
- const substringBonus = unknownInOption ? -10 : optionInUnknown ? -5 : 0;
108
+ const distance = Math.min(calculateLevenshteinDistance(cleanUnknown, option), calculateLevenshteinDistance(compactUnknown, compactOptionName(option)));
109
+ const unknownInOption = option.includes(cleanUnknown) || compactOptionName(option).includes(compactUnknown);
110
+ const optionInUnknown = cleanUnknown.includes(option) || compactUnknown.includes(compactOptionName(option));
100
111
 
101
- // Also prioritize options with similar length (user likely tried to type the full name)
112
+ if (distance <= distanceThreshold || unknownInOption || optionInUnknown) {
113
+ const substringBonus = unknownInOption ? -10 : optionInUnknown ? -5 : 0;
102
114
  const lengthDiff = Math.abs(option.length - cleanUnknown.length);
103
115
  const lengthBonus = lengthDiff < 3 ? -1 : 0;
104
116
 
105
117
  distances.push({
106
118
  option,
107
119
  distance,
120
+ lengthDiff,
108
121
  effectiveDistance: distance + substringBonus + lengthBonus,
109
122
  });
110
123
  }
111
124
  });
112
125
 
113
- // Sort by effective distance (closest first), then by actual distance
114
126
  return distances
115
127
  .sort((a, b) => {
116
128
  if (a.effectiveDistance !== b.effectiveDistance) {
117
129
  return a.effectiveDistance - b.effectiveDistance;
118
130
  }
119
- return a.distance - b.distance;
131
+ if (a.distance !== b.distance) {
132
+ return a.distance - b.distance;
133
+ }
134
+ if (a.lengthDiff !== b.lengthDiff) {
135
+ return a.lengthDiff - b.lengthDiff;
136
+ }
137
+ return a.option.localeCompare(b.option);
120
138
  })
121
139
  .slice(0, maxSuggestions)
122
140
  .map(item => item.option);
@@ -133,17 +151,17 @@ export function formatSuggestions(suggestions) {
133
151
  return '';
134
152
  }
135
153
 
136
- if (suggestions.length === 1) {
137
- return `\n\nDid you mean --${suggestions[0]}?`;
138
- }
139
-
140
- // For multiple suggestions, format them nicely
141
154
  const formattedOptions = suggestions.map(opt => {
142
155
  // If it's a single character, show as -x, otherwise --option-name
143
156
  return opt.length === 1 ? `-${opt}` : `--${opt}`;
144
157
  });
158
+ const [primarySuggestion, ...alternatives] = formattedOptions;
145
159
 
146
- return `\n\nDid you mean one of these?\n${formattedOptions.map(opt => ` • ${opt}`).join('\n')}`;
160
+ if (alternatives.length === 0) {
161
+ return `\n\nDid you mean \`${primarySuggestion}\` option?`;
162
+ }
163
+
164
+ return `\n\nDid you mean \`${primarySuggestion}\` option?\n\nOther close matches:\n${alternatives.map(opt => ` • \`${opt}\``).join('\n')}`;
147
165
  }
148
166
 
149
167
  /**
@@ -300,9 +318,13 @@ export function detectMalformedFlags(args) {
300
318
  * @returns {string} - Enhanced error message with suggestions
301
319
  */
302
320
  export function enhanceErrorMessage(originalError, yargsInstance) {
321
+ if (/Did you mean/i.test(originalError)) {
322
+ return originalError;
323
+ }
324
+
303
325
  // Extract the unknown option name from the error message
304
326
  // Typical format: "Unknown argument: branch" or "Unknown arguments: branch, test"
305
- const unknownMatch = originalError.match(/Unknown arguments?:\s*(.+?)(?:\s|$)/i);
327
+ const unknownMatch = originalError.match(/Unknown arguments?:\s*([^\n]+)/i);
306
328
 
307
329
  if (!unknownMatch) {
308
330
  return originalError;
@@ -324,3 +346,23 @@ export function enhanceErrorMessage(originalError, yargsInstance) {
324
346
 
325
347
  return enhancedMessage;
326
348
  }
349
+
350
+ export function enhanceUnknownArgumentError(error, yargsInstance) {
351
+ if (!error || error._enhanced || !yargsInstance || !/Unknown arguments?/i.test(error.message || '')) {
352
+ return error;
353
+ }
354
+
355
+ const enhancedMessage = enhanceErrorMessage(error.message, yargsInstance);
356
+ if (enhancedMessage === error.message) {
357
+ return error;
358
+ }
359
+
360
+ const enhancedError = new Error(enhancedMessage);
361
+ enhancedError.name = error.name;
362
+ enhancedError.cause = error;
363
+ for (const key of Object.keys(error)) {
364
+ enhancedError[key] = error[key];
365
+ }
366
+ enhancedError._enhanced = true;
367
+ return enhancedError;
368
+ }
@@ -219,7 +219,7 @@ export async function handleBranchCheckoutError({ branchName, prNumber, errorOut
219
219
  }
220
220
  }
221
221
 
222
- export async function handleBranchCreationError({ branchName, errorOutput, tempDir, owner, repo, formatAligned, log }) {
222
+ export async function handleBranchCreationError({ branchName, errorOutput, tempDir, owner, repo, baseBranch, branchSource, formatAligned, log }) {
223
223
  await log(`${formatAligned('❌', 'BRANCH CREATION FAILED', '')}`, { level: 'error' });
224
224
  await log('');
225
225
  await log(' 🔍 What happened:');
@@ -234,6 +234,31 @@ export async function handleBranchCreationError({ branchName, errorOutput, tempD
234
234
  }
235
235
  await log('');
236
236
 
237
+ // Issue #1959: A failure to create the branch from origin/<baseBranch> looks
238
+ // identical at the git level to an empty repository ("is not a commit"). Only
239
+ // treat it as "empty repo" when the failing ref is NOT a custom base branch.
240
+ // When a custom base branch is involved and the error references it, report the
241
+ // real root cause: the requested base branch does not exist on the remote.
242
+ const errorMentionsBaseRef = baseBranch && (errorOutput.includes(`origin/${baseBranch}`) || errorOutput.includes(`'${baseBranch}'`));
243
+ const looksLikeMissingRef = errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision') || errorOutput.includes('did not match any');
244
+ const isMissingBaseBranchError = branchSource === 'custom' && errorMentionsBaseRef && looksLikeMissingRef;
245
+
246
+ if (isMissingBaseBranchError) {
247
+ await log(' 💡 Root cause:');
248
+ await log(` The requested base branch '${baseBranch}' does not exist on the remote.`);
249
+ await log(' A branch cannot be created from a base branch that is not there.');
250
+ await log('');
251
+ await log(' 🔧 How to fix:');
252
+ await log(' • Check the branch name for typos (a single extra/missing character is enough)');
253
+ await log(' • Omit --base-branch to use the repository default branch');
254
+ if (owner && repo) {
255
+ await log(` • List existing branches: gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`);
256
+ } else {
257
+ await log(' • List existing branches: git branch -r');
258
+ }
259
+ return;
260
+ }
261
+
237
262
  // Check if this is an empty repository error (no commits to branch from)
238
263
  const isEmptyRepoError = errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision');
239
264
  if (isEmptyRepoError) {
@@ -308,6 +308,10 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
308
308
  // Create a branch for the issue or checkout existing PR branch
309
309
  let branchName;
310
310
  let checkoutResult;
311
+ // Tracked across the create path so the error handler can report the real
312
+ // root cause when branch creation fails from a missing base branch (issue #1959).
313
+ let baseBranchForError = null;
314
+ let branchSourceForError = null;
311
315
 
312
316
  if (isContinueMode && prBranch) {
313
317
  // Continue mode: checkout existing PR branch
@@ -324,6 +328,8 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
324
328
  // Use user-specified base branch if provided, otherwise use repository default
325
329
  const baseBranch = argv.baseBranch || defaultBranch;
326
330
  const branchSource = argv.baseBranch ? 'custom' : 'default';
331
+ baseBranchForError = baseBranch;
332
+ branchSourceForError = branchSource;
327
333
 
328
334
  // Defense-in-depth: validate base branch name even if already validated at CLI parsing (issue #1482)
329
335
  const baseBranchValidation = validateBranchName(baseBranch);
@@ -391,6 +397,8 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
391
397
  tempDir,
392
398
  owner,
393
399
  repo,
400
+ baseBranch: baseBranchForError,
401
+ branchSource: branchSourceForError,
394
402
  formatAligned,
395
403
  log,
396
404
  });
package/src/solve.mjs CHANGED
@@ -289,7 +289,7 @@ if (!hasWriteAccess) {
289
289
  }
290
290
 
291
291
  // Issue #1552: Validate entity existence AFTER permissions (cascade: user/org → repo → issue/PR)
292
- const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
292
+ const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, baseBranch: argv.baseBranch, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
293
293
  if (!entityCheck.valid) {
294
294
  await log(`\n❌ ${entityCheck.error}\n`, { level: 'error' });
295
295
  await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
@@ -25,6 +25,7 @@ await loadLenvConfig({ override: true, quiet: true });
25
25
  const yargs = getLinoYargsFactory();
26
26
  const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
27
27
  const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
28
+ const { enhanceUnknownArgumentError } = await import('./option-suggestions.lib.mjs');
28
29
  const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
29
30
  const { extractIsolationFromArgs, isValidPerCommandIsolation } = await import('./telegram-isolation.lib.mjs');
30
31
 
@@ -229,7 +230,8 @@ if (solveEnabled && solveOverrides.length > 0) {
229
230
  process.stderr.write = originalStderrWrite;
230
231
  }
231
232
  } catch (error) {
232
- console.error(`❌ Invalid solve-overrides: ${error.message || String(error)}`);
233
+ const enhancedError = enhanceUnknownArgumentError(error, createSolveYargsConfig(yargs()));
234
+ console.error(`❌ Invalid solve-overrides: ${enhancedError.message || String(enhancedError)}`);
233
235
  console.error(` Overrides: ${solveOverrides.join(' ')}`);
234
236
  process.exit(1);
235
237
  }
@@ -275,7 +277,8 @@ if (hiveEnabled && hiveOverrides.length > 0) {
275
277
  process.stderr.write = originalStderrWrite;
276
278
  }
277
279
  } catch (error) {
278
- console.error(`❌ Invalid hive-overrides: ${error.message || String(error)}`);
280
+ const enhancedError = enhanceUnknownArgumentError(error, createHiveYargsConfig(yargs()));
281
+ console.error(`❌ Invalid hive-overrides: ${enhancedError.message || String(enhancedError)}`);
279
282
  console.error(` Overrides: ${hiveOverrides.join(' ')}`);
280
283
  process.exit(1);
281
284
  }
@@ -873,7 +876,7 @@ async function handleSolveCommand(ctx) {
873
876
  }
874
877
  // Issue #1714: read the parsed argv (default-on per #1694) instead of the raw args list,
875
878
  // so the invite hint is suppressed on the default-on path where the literal flag is absent.
876
- const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, verbose: VERBOSE, autoAcceptInvite: !!parsedSolveArgs?.autoAcceptInvite });
879
+ const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, baseBranch: parsedSolveArgs?.baseBranch, verbose: VERBOSE, autoAcceptInvite: !!parsedSolveArgs?.autoAcceptInvite });
877
880
  if (!entityCheck.valid) {
878
881
  await safeReply(ctx, `❌ ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
879
882
  return;
@@ -8,6 +8,8 @@
8
8
  * @see https://github.com/link-assistant/hive-mind/issues/1618
9
9
  */
10
10
 
11
+ import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
12
+
11
13
  export const TOOL_SOLVE_COMMAND_ALIASES = Object.freeze({
12
14
  claude: 'claude',
13
15
  codex: 'codex',
@@ -89,8 +91,9 @@ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig)
89
91
  else if (typeof callback === 'function') callback();
90
92
  return true;
91
93
  };
94
+ let parser = null;
92
95
  try {
93
- const parser = createYargsConfig(yargsFactory());
96
+ parser = createYargsConfig(yargsFactory());
94
97
  parser
95
98
  .exitProcess(false)
96
99
  .showHelpOnFail(false)
@@ -98,6 +101,8 @@ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig)
98
101
  throw err || new Error(msg || 'Invalid arguments');
99
102
  });
100
103
  return await parser.parse(args);
104
+ } catch (error) {
105
+ throw enhanceUnknownArgumentError(error, parser);
101
106
  } finally {
102
107
  process.stderr.write = originalStderrWrite;
103
108
  }
@@ -1,10 +1,12 @@
1
1
  import { buildUserMention } from './buildUserMention.lib.mjs';
2
2
  import { validateModelName } from './models/index.mjs';
3
+ import { getLinoYargsFactory } from './cli-arguments.lib.mjs';
4
+ import { createYargsConfig as createTaskYargsConfig } from './task.config.lib.mjs';
3
5
  import { createTaskIssue, parseTaskIssueCreationInput, resolveTaskIssueCreationInput } from './task.issue-creation.lib.mjs';
4
6
  import { parseTaskIssueUrl } from './task.split.lib.mjs';
5
7
  import { escapeMarkdown } from './telegram-markdown.lib.mjs';
6
8
  import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
7
- import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
9
+ import { moveArgumentToFront, parseArgsWithYargs, parseCommandArgs } from './telegram-solve-command.lib.mjs';
8
10
  import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
9
11
 
10
12
  export const TASK_COMMAND_NAMES = Object.freeze(['task', 'split']);
@@ -187,6 +189,13 @@ export function registerTaskCommands(bot, options) {
187
189
  return;
188
190
  }
189
191
 
192
+ try {
193
+ await parseArgsWithYargs(filteredArgs, getLinoYargsFactory(), createTaskYargsConfig);
194
+ } catch (error) {
195
+ await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, { reply_to_message_id: ctx.message.message_id });
196
+ return;
197
+ }
198
+
190
199
  const modelError = validateTaskModel(filteredArgs);
191
200
  if (modelError) {
192
201
  await safeReply(ctx, `❌ ${escapeMarkdown(modelError)}`, { reply_to_message_id: ctx.message.message_id });