@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,60 @@
1
+ /**
2
+ * Shared access to the `$` CLI (start-command, link-foundation/start).
3
+ *
4
+ * Extracted from src/isolation-runner.lib.mjs (issue #2189) so the resume/attach
5
+ * wrappers added for `start-command@0.33.0` can reach the same lazily-loaded
6
+ * `command-stream` `$` and the same PATH lookup without importing the runner —
7
+ * which would create a cycle — and without duplicating either.
8
+ *
9
+ * @see https://github.com/link-foundation/start
10
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
11
+ */
12
+
13
+ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
14
+
15
+ /** The message every wrapper reports when `$` is not installed. */
16
+ export const START_COMMAND_MISSING_ERROR = '`$` (start-command) binary not found on PATH. Install link-foundation/start.';
17
+
18
+ let commandStreamDollarPromise = null;
19
+
20
+ /**
21
+ * Lazily load `command-stream`'s `$` template tag.
22
+ *
23
+ * Cached across calls; a failed load clears the cache so a transient failure
24
+ * (a cold `use-m` fetch, say) does not poison every later call.
25
+ *
26
+ * @returns {Promise<Function>} The `$` template tag
27
+ */
28
+ export async function getCommandStreamDollar() {
29
+ if (!commandStreamDollarPromise) {
30
+ commandStreamDollarPromise = (async () => {
31
+ if (typeof globalThis.use === 'undefined') {
32
+ await ensureUseM();
33
+ }
34
+ const { $ } = await globalThis.use('command-stream');
35
+ return $;
36
+ })();
37
+ }
38
+ try {
39
+ return await commandStreamDollarPromise;
40
+ } catch (error) {
41
+ commandStreamDollarPromise = null;
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Find the `$` CLI binary path.
48
+ *
49
+ * @returns {Promise<string|null>} Path to the `$` binary, or null when absent
50
+ */
51
+ export async function findStartCommandBinary() {
52
+ try {
53
+ const $ = await getCommandStreamDollar();
54
+ const result = await $({ mirror: false })`which $`;
55
+ const resolved = result.stdout?.toString().trim() || '';
56
+ return resolved || null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
@@ -27,10 +27,10 @@ const yargs = getLinoYargsFactory();
27
27
  const { createYargsConfig: createTelegramYargsConfig } = await import('./telegram.config.lib.mjs');
28
28
  const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
29
29
  const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
30
- const { enhanceUnknownArgumentError } = await import('./option-suggestions.lib.mjs');
31
30
  const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
32
31
  const { extractIsolationFromArgs, isValidPerCommandIsolation } = await import('./telegram-isolation.lib.mjs');
33
32
  const { mergeArgsWithOverrides } = await import('./args-overrides.lib.mjs'); // issue #2085
33
+ const { validateCommandOverrides } = await import('./telegram-overrides-validation.lib.mjs'); // issue #2198
34
34
  const config = createTelegramYargsConfig(yargs(hideBin(process.argv))).parse();
35
35
 
36
36
  // Configuration priority: CLI option > --configuration LINO > .lenv > .env
@@ -109,96 +109,26 @@ if (ISOLATION_BACKEND) {
109
109
  }
110
110
  }
111
111
 
112
- // Validate solve overrides early using solve's yargs config
113
- // Only validate if solve command is enabled
114
- if (solveEnabled && solveOverrides.length > 0) {
115
- console.log('Validating solve overrides...');
116
- try {
117
- const { backend: solveOverrideIsolation, filteredArgs: solveOverridesForValidation } = extractIsolationFromArgs(solveOverrides);
118
- if (solveOverrideIsolation && !isValidPerCommandIsolation(solveOverrideIsolation)) {
119
- throw new Error(`Invalid --isolation value '${solveOverrideIsolation}'. Must be: screen, tmux, or docker`);
120
- }
121
- // Add a dummy URL as the first argument (required positional for solve)
122
- const testArgs = ['https://github.com/test/test/issues/1', ...solveOverridesForValidation];
123
- // Temporarily suppress stderr to avoid yargs error output during validation
124
- const originalStderrWrite = process.stderr.write;
125
- const stderrBuffer = [];
126
- process.stderr.write = chunk => {
127
- stderrBuffer.push(chunk);
128
- return true;
129
- };
130
-
131
- try {
132
- // Use .parse() instead of yargs(args).parseSync() to ensure .strict() mode works
133
- const testYargs = createSolveYargsConfig(yargs());
134
- // Suppress yargs error output - we'll handle errors ourselves
135
- testYargs
136
- .exitProcess(false)
137
- .showHelpOnFail(false)
138
- .fail((msg, err) => {
139
- if (err) throw err;
140
- throw new Error(msg);
141
- });
142
- await testYargs.parse(testArgs);
143
- // Issue #1482: Validate --base-branch in overrides early
144
- const overrideBranchError = validateBranchInArgs(solveOverridesForValidation);
145
- if (overrideBranchError) throw new Error(overrideBranchError);
146
- console.log('✅ Solve overrides validated successfully');
147
- } finally {
148
- // Restore stderr
149
- process.stderr.write = originalStderrWrite;
150
- }
151
- } catch (error) {
152
- const enhancedError = enhanceUnknownArgumentError(error, createSolveYargsConfig(yargs()));
153
- console.error(`❌ Invalid solve-overrides: ${enhancedError.message || String(enhancedError)}`);
154
- console.error(` Overrides: ${solveOverrides.join(' ')}`);
155
- process.exit(1);
156
- }
157
- }
158
- // Validate hive overrides early using hive's yargs config
159
- // Only validate if hive command is enabled
160
- if (hiveEnabled && hiveOverrides.length > 0) {
161
- console.log('Validating hive overrides...');
162
- try {
163
- const { backend: hiveOverrideIsolation, filteredArgs: hiveOverridesForValidation } = extractIsolationFromArgs(hiveOverrides);
164
- if (hiveOverrideIsolation && !isValidPerCommandIsolation(hiveOverrideIsolation)) {
165
- throw new Error(`Invalid --isolation value '${hiveOverrideIsolation}'. Must be: screen, tmux, or docker`);
166
- }
167
- // Add a dummy URL as the first argument (required positional for hive)
168
- const testArgs = ['https://github.com/test/test', ...hiveOverridesForValidation];
169
-
170
- // Temporarily suppress stderr to avoid yargs error output during validation
171
- const originalStderrWrite = process.stderr.write;
172
- const stderrBuffer = [];
173
- process.stderr.write = chunk => {
174
- stderrBuffer.push(chunk);
175
- return true;
176
- };
177
- try {
178
- // Use .parse() instead of yargs(args).parseSync() to ensure .strict() mode works
179
- const testYargs = createHiveYargsConfig(yargs());
180
- // Suppress yargs error output - we'll handle errors ourselves
181
- testYargs
182
- .exitProcess(false)
183
- .showHelpOnFail(false)
184
- .fail((msg, err) => {
185
- if (err) throw err;
186
- throw new Error(msg);
187
- });
188
- await testYargs.parse(testArgs);
189
- const overrideBranchError = validateBranchInArgs(hiveOverridesForValidation); // Issue #1482
190
- if (overrideBranchError) throw new Error(overrideBranchError);
191
- console.log('✅ Hive overrides validated successfully');
192
- } finally {
193
- // Restore stderr
194
- process.stderr.write = originalStderrWrite;
195
- }
196
- } catch (error) {
197
- const enhancedError = enhanceUnknownArgumentError(error, createHiveYargsConfig(yargs()));
198
- console.error(`❌ Invalid hive-overrides: ${enhancedError.message || String(enhancedError)}`);
199
- console.error(` Overrides: ${hiveOverrides.join(' ')}`);
200
- process.exit(1);
112
+ // Validate solve/hive overrides early, using each command's own yargs config, so
113
+ // a bad flag is rejected at startup instead of when the first session spawns
114
+ // (issue #1209). The shared implementation lives in
115
+ // telegram-overrides-validation.lib.mjs (issue #2198).
116
+ for (const { enabled, label, flag, overrides, createYargsConfig, dummyUrl } of [
117
+ { enabled: solveEnabled, label: 'Solve', flag: 'solve-overrides', overrides: solveOverrides, createYargsConfig: createSolveYargsConfig, dummyUrl: 'https://github.com/test/test/issues/1' },
118
+ { enabled: hiveEnabled, label: 'Hive', flag: 'hive-overrides', overrides: hiveOverrides, createYargsConfig: createHiveYargsConfig, dummyUrl: 'https://github.com/test/test' },
119
+ ]) {
120
+ if (!enabled || overrides.length === 0) continue;
121
+
122
+ console.log(`Validating ${label.toLowerCase()} overrides...`);
123
+ const result = await validateCommandOverrides({ overrides, createYargsConfig, yargs, dummyUrl });
124
+ if (result.ok) {
125
+ console.log(`✅ ${label} overrides validated successfully`);
126
+ continue;
201
127
  }
128
+
129
+ console.error(`❌ Invalid ${flag}: ${result.message}`);
130
+ console.error(` Overrides: ${overrides.join(' ')}`);
131
+ process.exit(1);
202
132
  }
203
133
 
204
134
  // Handle dry-run mode - exit after validation WITHOUT loading heavy dependencies
@@ -239,6 +169,8 @@ const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = lim
239
169
  const { handleShowLimitsFlag, captureStartSnapshotAndAppend } = await import('./telegram-show-limits.lib.mjs'); // #594
240
170
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
241
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
+
242
174
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
243
175
  const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
244
176
  const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
@@ -385,22 +317,28 @@ async function validateGitHubUrl(args, options = {}) {
385
317
  if (!rawUrl) return { valid: false, error: t('telegram.missing_github_url', { commandName }, { locale }) };
386
318
  // Issue #1102: Clean non-printable chars (Zero-Width Space, BOM, etc.) from URLs
387
319
  const url = cleanNonPrintableChars(rawUrl);
388
- 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 }) };
389
325
  const parsed = parseGitHubUrl(url);
390
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;
391
329
  if (!allowedTypes.includes(parsed.type)) {
392
330
  const allowedTypesStr = allowedTypes.map(t => (t === 'pull' ? 'pull request' : t)).join(', ');
393
331
  const baseUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
394
332
  const escapedUrl = escapeMarkdown(url),
395
333
  escapedBaseUrl = escapeMarkdown(baseUrl); // Issue #1102: escape for Markdown
396
334
  let error;
397
- if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
398
- 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 });
399
337
  else if (parsed.type === 'repo') error = t('telegram.url_repo_error', { allowedTypes: allowedTypesStr, url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
400
338
  else error = t('telegram.url_must_be_type', { allowedTypes: allowedTypesStr, type: parsed.type.replace('_', ' ') }, { locale });
401
339
  return { valid: false, error };
402
340
  }
403
- return { valid: true, parsed, normalizedUrl: url };
341
+ return { valid: true, parsed, normalizedUrl: url, recoveryNotice };
404
342
  }
405
343
 
406
344
  const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage });
@@ -609,6 +547,10 @@ async function handleSolveCommand(ctx) {
609
547
  }
610
548
 
611
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)}`);
612
554
  const solveToolAlias = getSolveToolAliasFromText(ctx.message.text);
613
555
  let userArgs = parseCommandArgs(ctx.message.text);
614
556
 
@@ -677,6 +619,8 @@ async function handleSolveCommand(ctx) {
677
619
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
678
620
  return;
679
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 });
680
624
  userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
681
625
  // Issue #2166: hand the spawned session the same canonical URL that is shown
682
626
  // in the chat, so the echo and the actual work can never disagree.
@@ -896,6 +840,8 @@ async function handleHiveCommand(ctx) {
896
840
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
897
841
  return;
898
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 });
899
845
  // Normalize issues_list/pulls_list to base repo URL, or use cleaned URL
900
846
  let normalizedArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
901
847
  const p = validation.parsed;
@@ -1252,7 +1198,17 @@ async function onBotLaunched() {
1252
1198
  // the monitor resumes watching — and finally reports any that were killed while
1253
1199
  // the bot was down. Done before starting the monitor so the first tick already
1254
1200
  // sees the resumed sessions.
1255
- await resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTime: BOT_START_TIME, verbose: VERBOSE, logger: botLogger });
1201
+ // Issue #2189: before replaying the durable store, let `$ --resume-all`
1202
+ // settle the isolation backend's own record — a restart orphans the detached
1203
+ // completion watchers, so an execution that ended while the bot was down has
1204
+ // nobody left to write its exit.
1205
+ await resumeSessionsOnLaunch({
1206
+ resumeTrackedSessions,
1207
+ reconcileIsolationSessions: typeof isolationRunner?.resumeAllIsolationSessions === 'function' ? ({ verbose }) => isolationRunner.resumeAllIsolationSessions({ verbose }) : null,
1208
+ botStartTime: BOT_START_TIME,
1209
+ verbose: VERBOSE,
1210
+ logger: botLogger,
1211
+ });
1256
1212
 
1257
1213
  startSessionMonitoringOnce();
1258
1214
  startFormalAiMaintenanceOnce();
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Early validation of --solve-overrides / --hive-overrides for the Telegram bot.
3
+ *
4
+ * Extracted from src/telegram-bot.mjs, which had grown past the 1350-line
5
+ * warning threshold that scripts/check-file-line-limits.sh enforces (issue
6
+ * #1593, surfaced again by issue #2198). The solve and hive blocks were
7
+ * near-identical copies of each other, so folding them into one parameterised
8
+ * function removes the duplication as well as the lines.
9
+ *
10
+ * Behaviour is unchanged and pinned by tests/test-telegram-bot-dry-run.mjs and
11
+ * tests/test-telegram-bot-configuration-isolation-links-notation.mjs, which
12
+ * assert on the exact console output the caller prints.
13
+ *
14
+ * The function reports rather than exits: the caller owns the messages and the
15
+ * exit code, which keeps this module testable in-process.
16
+ */
17
+
18
+ import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
19
+ import { validateBranchInArgs } from './solve.branch.lib.mjs';
20
+ import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
21
+
22
+ /**
23
+ * Parses `overrides` through the real yargs config of the target command, so an
24
+ * unknown or malformed flag is rejected at bot startup rather than when the
25
+ * first command spawns a session (issue #1209).
26
+ *
27
+ * @param {object} options
28
+ * @param {string[]} options.overrides Override arguments to validate.
29
+ * @param {Function} options.createYargsConfig The command's yargs config factory.
30
+ * @param {Function} options.yargs A yargs factory (bound to Links Notation).
31
+ * @param {string} options.dummyUrl Stand-in for the command's required positional.
32
+ * @returns {Promise<{ok: true} | {ok: false, message: string}>}
33
+ */
34
+ export const validateCommandOverrides = async ({ overrides, createYargsConfig, yargs, dummyUrl }) => {
35
+ try {
36
+ const { backend: overrideIsolation, filteredArgs } = extractIsolationFromArgs(overrides);
37
+ if (overrideIsolation && !isValidPerCommandIsolation(overrideIsolation)) {
38
+ throw new Error(`Invalid --isolation value '${overrideIsolation}'. Must be: screen, tmux, or docker`);
39
+ }
40
+
41
+ const testArgs = [dummyUrl, ...filteredArgs];
42
+
43
+ // yargs writes its own diagnostics to stderr before the .fail() handler
44
+ // runs; suppress them so the caller's single message is what the operator
45
+ // sees.
46
+ const originalStderrWrite = process.stderr.write;
47
+ process.stderr.write = () => true;
48
+
49
+ try {
50
+ // .parse() rather than parseSync() so .strict() mode is honoured.
51
+ const testYargs = createYargsConfig(yargs());
52
+ testYargs
53
+ .exitProcess(false)
54
+ .showHelpOnFail(false)
55
+ .fail((msg, err) => {
56
+ if (err) throw err;
57
+ throw new Error(msg);
58
+ });
59
+ await testYargs.parse(testArgs);
60
+
61
+ // Issue #1482: --base-branch inside the overrides is validated too.
62
+ const overrideBranchError = validateBranchInArgs(filteredArgs);
63
+ if (overrideBranchError) throw new Error(overrideBranchError);
64
+ } finally {
65
+ process.stderr.write = originalStderrWrite;
66
+ }
67
+
68
+ return { ok: true };
69
+ } catch (error) {
70
+ const enhancedError = enhanceUnknownArgumentError(error, createYargsConfig(yargs()));
71
+ return { ok: false, message: enhancedError.message || String(enhancedError) };
72
+ }
73
+ };
@@ -64,7 +64,7 @@ export const formatWorkingSessionSummaryMarkdown = text => {
64
64
  let inFence = false;
65
65
  let previousNonEmpty = '';
66
66
 
67
- for (let index = 0; index < lines.length; ) {
67
+ for (let index = 0; index < lines.length;) {
68
68
  const line = lines[index];
69
69
  if (/^\s*(```|~~~)/.test(line)) {
70
70
  inFence = !inFence;