@link-assistant/hive-mind 2.8.1 → 2.8.3

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,33 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.8.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 31bd05b: Stop the Codex capability preflight from inventing requirements out of issue prose (#2077).
8
+
9
+ An image-generation issue that asked for `16:9` images aborted the whole run with
10
+ `Required Codex capability unavailable: 16:9`, because the namespaced-skill regex
11
+ accepted any `digits:digits` token and the requirement gate matched the ordinary
12
+ English word "depends".
13
+
14
+ - Capability names must now contain at least one letter, which rejects aspect
15
+ ratios, clock times, host ports, version selectors, currency amounts and email
16
+ addresses while staying compliant with the Agent Skills specification (a
17
+ leading digit remains legal, so `3d-rendering` is still valid).
18
+ - An unresolvable preflight now degrades to a warning and lets Codex run with the
19
+ operator's own capabilities instead of aborting. Set
20
+ `HIVE_MIND_CODEX_CAPABILITY_STRICT=1` to restore the previous fail-fast
21
+ behaviour.
22
+ - `--verbose` now prints the source line behind every accepted and rejected
23
+ capability detection.
24
+
25
+ ## 2.8.2
26
+
27
+ ### Patch Changes
28
+
29
+ - 44202ff: Explain invalid pull request base/head conflicts before attempting to retarget the pull request.
30
+
3
31
  ## 2.8.1
4
32
 
5
33
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.8.1",
3
+ "version": "2.8.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -16,10 +16,44 @@ import { promisify } from 'node:util';
16
16
  const execFileAsync = promisify(execFile);
17
17
  const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|use)\b/i;
18
18
  const NEGATED_REQUIREMENT = /\b(?:does\s+not\s+require|not\s+required|optional)\b/i;
19
- const PLUGIN_SELECTOR = /\b([a-z0-9][a-z0-9-]*@[a-z0-9][a-z0-9-]*(?:-remote)?)\b/gi;
19
+ // `(?!\.[a-z])` keeps email addresses and hostnames (`ops@example.com`) out of
20
+ // the plugin selector space.
21
+ const PLUGIN_SELECTOR = /\b([a-z0-9][a-z0-9-]*@[a-z0-9][a-z0-9-]*(?:-remote)?)\b(?!\.[a-z])/gi;
20
22
  const NAMESPACED_SKILL = /\b([a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*)\b/gi;
21
23
  const EXPLICIT_BARE_SKILL = /\$([a-z0-9][a-z0-9-]*)|`([a-z0-9][a-z0-9-]*)`\s+(?:agent\s+)?skill/gi;
22
24
 
25
+ // Issue #2077: a capability name must contain at least one letter.
26
+ //
27
+ // The Agent Skills specification (agentskills.io/specification) allows a
28
+ // leading digit — `3d-rendering` is a legal skill name — so requiring a leading
29
+ // letter would be stricter than the spec. Requiring only that *some* letter is
30
+ // present stays spec-compliant while rejecting the purely numeric prose tokens
31
+ // the requirement regexes otherwise capture: aspect ratios (`16:9`), clock
32
+ // times (`9:30`), host ports (`localhost:3000`), version selectors (`node@20`)
33
+ // and currency amounts (`$100`).
34
+ //
35
+ // Charset follows the spec for skills (`a-z`, `0-9`, `-`) plus the underscore
36
+ // that codex-rs `validate_plugin_segment` accepts for plugin and marketplace
37
+ // segments.
38
+ const CAPABILITY_TOKEN = /^(?=[a-z0-9_-]*[a-z])[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/u;
39
+
40
+ // Prose and markdown routinely produce `word:word` and `$word` tokens that are
41
+ // never capability references. Excluding them keeps a heuristic scan of free
42
+ // text from inventing requirements.
43
+ const PROSE_TOKENS = new Set(['agent', 'caution', 'codex', 'default', 'error', 'example', 'file', 'fixme', 'format', 'home', 'http', 'https', 'id', 'important', 'input', 'key', 'line', 'name', 'nb', 'note', 'output', 'path', 'ref', 'required', 'see', 'skill', 'the', 'tip', 'todo', 'type', 'url', 'usage', 'value', 'warning']);
44
+
45
+ const isCapabilityToken = value => CAPABILITY_TOKEN.test(value) && !PROSE_TOKENS.has(value);
46
+
47
+ export function isCapabilityName(value) {
48
+ const token = String(value || '').toLowerCase();
49
+ const separator = /[:@]/u.exec(token);
50
+ if (!separator) return isCapabilityToken(token);
51
+ const [left, right] = [token.slice(0, separator.index), token.slice(separator.index + 1)];
52
+ // A qualified reference only needs its own halves to be well formed; a prose
53
+ // word such as `note` is meaningless alone but valid as `note:taking`.
54
+ return CAPABILITY_TOKEN.test(left) && CAPABILITY_TOKEN.test(right) && !PROSE_TOKENS.has(left);
55
+ }
56
+
23
57
  export class CodexCapabilityPreflightError extends Error {
24
58
  constructor(message, details = {}) {
25
59
  super(message);
@@ -38,21 +72,30 @@ export function normalizePluginSelector(selector) {
38
72
  export function detectRequiredCodexCapabilities(text) {
39
73
  const plugins = new Set();
40
74
  const skills = new Set();
75
+ // Every accepted capability keeps the line it came from so `--verbose` can
76
+ // explain a detection instead of only reporting its consequence (issue #2077).
77
+ const evidence = [];
78
+ const rejected = [];
79
+
80
+ const accept = (target, value, line) => {
81
+ if (!isCapabilityName(value)) {
82
+ rejected.push({ capability: value, line });
83
+ return;
84
+ }
85
+ target.add(value);
86
+ evidence.push({ capability: value, line });
87
+ };
41
88
 
42
89
  for (const rawLine of String(text || '').split(/\r?\n/u)) {
43
90
  const line = rawLine.trim();
44
91
  if (!line || !REQUIREMENT_WORDS.test(line) || NEGATED_REQUIREMENT.test(line)) continue;
45
92
 
46
- for (const match of line.matchAll(PLUGIN_SELECTOR)) plugins.add(normalizePluginSelector(match[1]));
47
- for (const match of line.matchAll(NAMESPACED_SKILL)) skills.add(match[1].toLowerCase());
48
-
49
- for (const match of line.matchAll(EXPLICIT_BARE_SKILL)) {
50
- const name = (match[1] || match[2]).toLowerCase();
51
- if (!['agent', 'codex', 'required', 'the'].includes(name)) skills.add(name);
52
- }
93
+ for (const match of line.matchAll(PLUGIN_SELECTOR)) accept(plugins, normalizePluginSelector(match[1]), line);
94
+ for (const match of line.matchAll(NAMESPACED_SKILL)) accept(skills, match[1].toLowerCase(), line);
95
+ for (const match of line.matchAll(EXPLICIT_BARE_SKILL)) accept(skills, (match[1] || match[2]).toLowerCase(), line);
53
96
  }
54
97
 
55
- return { plugins: [...plugins].sort(), skills: [...skills].sort() };
98
+ return { plugins: [...plugins].sort(), skills: [...skills].sort(), evidence, rejected };
56
99
  }
57
100
 
58
101
  const sanitizePathSegment = value => String(value || '').replace(/[^a-zA-Z0-9._-]/gu, '_');
@@ -225,7 +268,27 @@ const prepareScopedCodexHome = async ({ baseCodexHome, codexHome }) => {
225
268
  }
226
269
  };
227
270
 
228
- export async function runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir, baseCodexHome = process.env.HIVE_MIND_PARENT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
271
+ export const isCodexCapabilityStrict = (env = process.env) => /^(?:1|true|yes|on)$/iu.test(String(env.HIVE_MIND_CODEX_CAPABILITY_STRICT || ''));
272
+
273
+ export async function runCodexCapabilityPreflight(options = {}) {
274
+ const { log = async () => {}, env = process.env } = options;
275
+ try {
276
+ return await provisionCodexCapabilities(options);
277
+ } catch (error) {
278
+ if (!(error instanceof CodexCapabilityPreflightError)) throw error;
279
+ // Issue #2077: requirements are inferred from free-form issue prose, so a
280
+ // preflight miss is a guess that failed rather than proof the task cannot
281
+ // run. Aborting here discarded an otherwise healthy run because an aspect
282
+ // ratio (`16:9`) was read as a skill name. Degrade to a warning and let
283
+ // Codex execute with the operator's own capabilities.
284
+ if (isCodexCapabilityStrict(env)) throw error;
285
+ await log(`⚠️ Codex capability preflight skipped: ${error.message}`);
286
+ await log(' Continuing with the operator Codex capabilities. Set HIVE_MIND_CODEX_CAPABILITY_STRICT=1 to fail instead.');
287
+ return { required: false, degraded: true, error: error.message, plugins: [], codexHome: null };
288
+ }
289
+ }
290
+
291
+ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, baseCodexHome = process.env.HIVE_MIND_PARENT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
229
292
  if (!owner || !repo || !issueNumber) return { required: false, plugins: [], codexHome: null };
230
293
 
231
294
  // `executeToolWithBun` uses a shell expression for execution. Preflight uses
@@ -233,9 +296,15 @@ export async function runCodexCapabilityPreflight({ owner, repo, issueNumber, pr
233
296
  const command = /\s/u.test(codexPath) ? 'codex' : codexPath;
234
297
  const requirementText = await readIssueRequirementText({ owner, repo, issueNumber, runCommand });
235
298
  const requirements = detectRequiredCodexCapabilities(requirementText);
299
+ for (const { capability, line } of requirements.rejected || []) {
300
+ await log(` ⏭️ Ignored non-capability token '${capability}' from: ${line.slice(0, 160)}`, { verbose: true });
301
+ }
236
302
  if (requirements.plugins.length === 0 && requirements.skills.length === 0) return { required: false, plugins: [], codexHome: null };
237
303
 
238
304
  await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s)`);
305
+ for (const { capability, line } of requirements.evidence || []) {
306
+ await log(` 🔎 '${capability}' detected from: ${line.slice(0, 160)}`, { verbose: true });
307
+ }
239
308
  const baseEnv = { ...process.env, CODEX_HOME: baseCodexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
240
309
  const baseCatalogResult = await runCommand({ command, args: ['plugin', 'list', '--available', '--json'], env: baseEnv });
241
310
  const baseCatalog = parseJsonCommand(baseCatalogResult, 'Codex plugin catalog discovery');
@@ -271,4 +340,4 @@ export async function runCodexCapabilityPreflight({ owner, repo, issueNumber, pr
271
340
  return { required: true, plugins, skills: requirements.skills, codexHome, baseCodexHome };
272
341
  }
273
342
 
274
- export default { applyCodexCapabilityEnv, detectRequiredCodexCapabilities, runCodexCapabilityPreflight };
343
+ export default { applyCodexCapabilityEnv, detectRequiredCodexCapabilities, isCapabilityName, runCodexCapabilityPreflight };
@@ -204,6 +204,40 @@ export async function getPullRequestBaseBranch({ owner, repo, prNumber, $, log }
204
204
  return baseBranch;
205
205
  }
206
206
 
207
+ async function getPullRequestBranchRange({ owner, repo, prNumber, $, log }) {
208
+ if (typeof $ !== 'function') {
209
+ throw new Error('Cannot verify pull request branches without a command runner');
210
+ }
211
+
212
+ const result = await ghWithRateLimitRetry(() => $`gh pr view ${prNumber} --repo ${owner}/${repo} --json baseRefName,headRefName`, {
213
+ label: 'gh pr view baseRefName,headRefName',
214
+ log,
215
+ });
216
+ if (result.code !== 0) {
217
+ const details = commandOutput(result) || 'unknown error';
218
+ throw new Error(`Could not verify pull request branches for #${prNumber}: ${details}`);
219
+ }
220
+
221
+ let branchRange;
222
+ try {
223
+ branchRange = JSON.parse(String(result.stdout || '').trim());
224
+ } catch {
225
+ throw new Error(`Could not verify pull request branches for #${prNumber}: gh returned invalid JSON`);
226
+ }
227
+
228
+ const baseBranch = normalizeBranchName(branchRange?.baseRefName);
229
+ const headBranch = normalizeBranchName(branchRange?.headRefName);
230
+ if (!baseBranch || !headBranch) {
231
+ throw new Error(`Could not verify pull request branches for #${prNumber}: gh returned an empty baseRefName or headRefName`);
232
+ }
233
+
234
+ return { baseBranch, headBranch };
235
+ }
236
+
237
+ function buildBaseEqualsHeadBranchMessage({ prNumber, expectedBaseBranch, currentBaseBranch }) {
238
+ return `Invalid --base-branch '${expectedBaseBranch}' for PR #${prNumber}: it is the pull request's head branch (the source/work branch), so it cannot also be the base branch (the target branch). The pull request currently targets '${currentBaseBranch}'. Rerun with --base-branch ${currentBaseBranch} to preserve that target, choose another target branch, or omit --base-branch to keep the existing pull request target. Manual intervention is required; no pull request changes were made.`;
239
+ }
240
+
207
241
  export async function ensurePullRequestBaseBranch({ owner, repo, prNumber, argv = {}, log = async () => {}, formatAligned = fallbackFormatAligned, $, onMismatch = 'restore', operation = 'verify' }) {
208
242
  const expectedBaseBranch = getExpectedPullRequestBaseBranch({ argv });
209
243
  if (!expectedBaseBranch) {
@@ -214,7 +248,7 @@ export async function ensurePullRequestBaseBranch({ owner, repo, prNumber, argv
214
248
  return { checked: false, restored: false, reason: 'missing_pull_request_context' };
215
249
  }
216
250
 
217
- const currentBaseBranch = await getPullRequestBaseBranch({ owner, repo, prNumber, $, log });
251
+ const { baseBranch: currentBaseBranch, headBranch } = await getPullRequestBranchRange({ owner, repo, prNumber, $, log });
218
252
  if (currentBaseBranch === expectedBaseBranch) {
219
253
  await log(formatAligned('🎯', 'Base branch locked:', `${expectedBaseBranch} (verified)`, 2), { verbose: true });
220
254
  return {
@@ -227,6 +261,16 @@ export async function ensurePullRequestBaseBranch({ owner, repo, prNumber, argv
227
261
 
228
262
  await log(formatAligned('⚠️', 'Base branch changed:', `PR #${prNumber} targets ${currentBaseBranch}, expected ${expectedBaseBranch}`, 2), { level: 'warning' });
229
263
 
264
+ if (headBranch === expectedBaseBranch) {
265
+ throw new Error(
266
+ buildBaseEqualsHeadBranchMessage({
267
+ prNumber,
268
+ expectedBaseBranch,
269
+ currentBaseBranch,
270
+ })
271
+ );
272
+ }
273
+
230
274
  if (onMismatch === 'throw' || onMismatch === 'fail') {
231
275
  throw new Error(
232
276
  buildPullRequestBaseBranchMismatchMessage({
@@ -249,7 +293,7 @@ export async function ensurePullRequestBaseBranch({ owner, repo, prNumber, argv
249
293
  throw new Error(`Could not restore pull request #${prNumber} base branch to ${expectedBaseBranch}: ${details}`);
250
294
  }
251
295
 
252
- const restoredBaseBranch = await getPullRequestBaseBranch({ owner, repo, prNumber, $, log });
296
+ const { baseBranch: restoredBaseBranch } = await getPullRequestBranchRange({ owner, repo, prNumber, $, log });
253
297
  if (restoredBaseBranch !== expectedBaseBranch) {
254
298
  throw new Error(`Pull request #${prNumber} still targets ${restoredBaseBranch} after attempting to restore ${expectedBaseBranch}`);
255
299
  }