@link-assistant/hive-mind 2.9.1 → 2.10.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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a1a1f63: Allow `--model formal-ai` with every supported agentic CLI, route execution through `formal-ai with`, and provide a persistent Formal AI Docker service.
8
+
9
+ ## 2.9.2
10
+
11
+ ### Patch Changes
12
+
13
+ - a6d14db: Read the target repository's own agent instructions when deciding which Codex plugins and skills to provision. The capability preflight previously built its requirement corpus from the GitHub issue alone, so a repository that mandates a plugin in `AGENTS.md` — where the convention says to put it — provisioned nothing, and the model's own `request_plugin_install` attempt was rejected by Codex as an unrecognized `plugin_id`. Root and nested `AGENTS.md`, `CLAUDE.md` and `.codex/*.md` files under the checkout now feed the detector through a bounded walk that reports what it skipped, the zero-requirement path logs the sources it scanned instead of staying silent, a runtime plugin-install rejection is recognized and fails the run with a named diagnostic when the session produced no file changes, and `--require-codex-plugin` / `HIVE_MIND_CODEX_REQUIRED_PLUGINS` can state a requirement that no document spells out.
14
+
3
15
  ## 2.9.1
4
16
 
5
17
  ### Patch Changes
package/README.hi.md CHANGED
@@ -502,6 +502,11 @@ Free Models via Kilo Gateway (with --tool agent):
502
502
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
503
503
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
504
504
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
505
+
506
+ # किसी भी समर्थित agentic CLI को Formal AI के माध्यम से route करें
507
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
508
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
509
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
505
510
  ```
506
511
 
507
512
  > **📖 मुफ्त मॉडल गाइड**: OpenCode Zen और Kilo Gateway प्रदाताओं सहित सभी मुफ्त मॉडलों के बारे में व्यापक जानकारी के लिए [docs/FREE_MODELS.hi.md](./docs/FREE_MODELS.hi.md) देखें।
package/README.md CHANGED
@@ -506,6 +506,11 @@ Free Models via Kilo Gateway (with --tool agent):
506
506
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
507
507
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
508
508
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
509
+
510
+ # Route any supported agentic CLI through Formal AI
511
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
512
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
513
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
509
514
  ```
510
515
 
511
516
  Current tool defaults in Hive Mind:
package/README.ru.md CHANGED
@@ -503,6 +503,11 @@ Free Models via Kilo Gateway (with --tool agent):
503
503
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
504
504
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
505
505
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
506
+
507
+ # Маршрутизация любого поддерживаемого агентного CLI через Formal AI
508
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
509
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
510
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
506
511
  ```
507
512
 
508
513
  > **📖 Руководство по бесплатным моделям**: см. [docs/FREE_MODELS.ru.md](./docs/FREE_MODELS.ru.md) для получения полной информации обо всех бесплатных моделях, включая провайдеры OpenCode Zen и Kilo Gateway.
package/README.zh.md CHANGED
@@ -499,6 +499,11 @@ Free Models via Kilo Gateway (with --tool agent):
499
499
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
500
500
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
501
501
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
502
+
503
+ # 通过 Formal AI 路由任意受支持的 Agent CLI
504
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
505
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
506
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
502
507
  ```
503
508
 
504
509
  > **📖 免费模型指南**:有关所有免费模型(包括 OpenCode Zen 和 Kilo Gateway 提供商)的全面信息,请参见 [docs/FREE_MODELS.zh.md](./docs/FREE_MODELS.zh.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.9.1",
3
+ "version": "2.10.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",
package/src/agent.lib.mjs CHANGED
@@ -22,6 +22,7 @@ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
22
22
  import Decimal from 'decimal.js-light';
23
23
  import semver from 'semver';
24
24
  import { agentModels, defaultModels, freeToBaseModelMap } from './models/index.mjs';
25
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
25
26
  import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
26
27
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
27
28
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
@@ -502,6 +503,11 @@ export const executeAgentCommand = async params => {
502
503
 
503
504
  // Map model alias to full ID
504
505
  const mappedModel = mapModelToId(argv.model);
506
+ const toolInvocation = resolveFormalAiToolInvocation({
507
+ tool: 'agent',
508
+ model: argv.model,
509
+ toolPath: agentPath,
510
+ });
505
511
 
506
512
  // Build agent command arguments
507
513
  let agentArgs = `--model ${mappedModel}`;
@@ -546,30 +552,31 @@ export const executeAgentCommand = async params => {
546
552
  await fs.writeFile(promptFile, combinedPrompt);
547
553
  }
548
554
 
549
- const fullCommand = streamingInput ? `(cd "${tempDir}" && ${agentPath} ${agentArgs})` : `(cd "${tempDir}" && cat "${promptFile}" | ${agentPath} ${agentArgs})`;
555
+ const fullCommand = streamingInput ? `(cd "${tempDir}" && ${toolInvocation.displayCommand} ${agentArgs})` : `(cd "${tempDir}" && cat "${promptFile}" | ${toolInvocation.displayCommand} ${agentArgs})`;
550
556
 
551
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
552
- await log(`${fullCommand}`);
553
- await log('');
557
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
558
+ if (preparedResult) return preparedResult;
554
559
 
555
560
  if (streamingInput) {
556
- execCommand = $({
561
+ const commandRunner = $({
557
562
  cwd: tempDir,
558
563
  stdin: 'pipe',
559
564
  mirror: false,
560
565
  env: agentEnv,
561
- })`${agentPath} ${agentArgs}`;
566
+ });
567
+ execCommand = toolInvocation.formalAi ? commandRunner`${toolInvocation.command} ${toolInvocation.args} ${agentArgs}` : commandRunner`${toolInvocation.command} ${agentArgs}`;
562
568
  const attached = await attachStreamingInput(bidirectionalHandler, execCommand, combinedPrompt, log, !!argv.verbose, { toolLabel: 'Agent' });
563
569
  if (!attached) {
564
570
  throw new Error('Agent live stream-json input requested, but stdin attachment failed');
565
571
  }
566
572
  } else {
567
573
  // Pipe the prompt file to agent via stdin for the legacy one-shot path.
568
- execCommand = $({
574
+ const commandRunner = $({
569
575
  cwd: tempDir,
570
576
  mirror: false,
571
577
  env: agentEnv,
572
- })`cat ${promptFile} | ${agentPath} ${agentArgs}`;
578
+ });
579
+ execCommand = toolInvocation.formalAi ? commandRunner`cat ${promptFile} | ${toolInvocation.command} ${toolInvocation.args} ${agentArgs}` : commandRunner`cat ${promptFile} | ${toolInvocation.command} ${agentArgs}`;
573
580
  }
574
581
 
575
582
  await log(`${formatAligned('📋', 'Command details:', '')}`);
@@ -1,5 +1,5 @@
1
- const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
2
- const CLAUDE_MD_FILENAME = 'CLAUDE.md';
1
+ export const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
2
+ export const CLAUDE_MD_FILENAME = 'CLAUDE.md';
3
3
 
4
4
  const noopLog = async () => {};
5
5
  const fallbackFormatAligned = (_icon, label, value) => `${label} ${value}`;
@@ -22,6 +22,7 @@ import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Iss
22
22
  import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments.lib.mjs'; // Issue #1625
23
23
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
24
24
  import { CLAUDE_MODELS as availableModels, mapClaudeSubAgentModelToEnvValue } from './models/index.mjs'; // Issue #1221, #1978
25
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
25
26
  import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from './playwright-mcp.lib.mjs';
26
27
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
27
28
  import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
@@ -614,13 +615,13 @@ export const executeClaudeCommand = async params => {
614
615
  const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
615
616
  let execCommand;
616
617
  const mappedModel = mapModelToId(argv.model);
618
+ const toolInvocation = resolveFormalAiToolInvocation({ tool: 'claude', model: argv.model, toolPath: claudePath });
617
619
  const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
618
620
  const resolvedSubAgentModel = argv.subAgentModel ? mapClaudeSubAgentModelToEnvValue(argv.subAgentModel) : undefined; // Issue #1978
619
621
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
620
622
  const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
621
- // Issue #1949: Let Claude Code handle transient overload (529) fallback via its own
622
- // `--fallback-model` flag (it re-tries the primary each turn) instead of us swapping
623
- // `--model`. Only for plain `--model` runs (not opusplan) with a distinct fallback.
623
+ // Issue #1949: Let Claude Code's `--fallback-model` handle transient overloads and retry
624
+ // the primary each turn. Only for plain `--model` runs with a distinct fallback.
624
625
  const mappedFallbackModel = argv.fallbackModel ? mapModelToId(argv.fallbackModel) : undefined;
625
626
  const useClaudeFallbackModel = !resolvedPlanModel && mappedFallbackModel && mappedFallbackModel !== effectiveModel;
626
627
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
@@ -650,10 +651,9 @@ export const executeClaudeCommand = async params => {
650
651
  } else {
651
652
  claudeArgs += ` -p "${escapedPromptForAttempt}" --append-system-prompt "${escapedSystemPrompt}"`;
652
653
  }
653
- const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
654
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
655
- await log(`${fullCommand}`);
656
- await log('');
654
+ const fullCommand = `(cd "${tempDir}" && ${toolInvocation.displayCommand} ${claudeArgs} | jq -c .)`;
655
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
656
+ if (preparedResult) return preparedResult;
657
657
  if (argv.verbose) {
658
658
  await log(`📋 User prompt:\n---BEGIN USER PROMPT---\n${promptForAttempt}\n---END USER PROMPT---`, { verbose: true });
659
659
  await log(`📋 System prompt:\n---BEGIN SYSTEM PROMPT---\n${systemPrompt}\n---END SYSTEM PROMPT---`, { verbose: true });
@@ -684,15 +684,15 @@ export const executeClaudeCommand = async params => {
684
684
  if (useClaudeFallbackModel && argv.verbose) await log(`📊 Claude --fallback-model: ${mappedFallbackModel} (Issue #1949 — primary --model ${effectiveModel} stays stable across overload retries)`, { verbose: true });
685
685
  if (argv.resume) {
686
686
  const simpleEscapedPrompt = promptForAttempt.replace(/"/g, '\\"');
687
- execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
687
+ execCommand = toolInvocation.formalAi ? $({ cwd: tempDir, mirror: false, env: claudeEnv })`${toolInvocation.command} ${toolInvocation.args} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"` : $({ cwd: tempDir, mirror: false, env: claudeEnv })`${toolInvocation.command} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
688
688
  } else if (streamingInput) {
689
689
  // Issue #817: Drive Claude via --input-format stream-json on a pipe
690
690
  // stdin. Initial prompt + later PR comments are written as NDJSON
691
691
  // frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
692
692
  const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
693
- execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
693
+ execCommand = toolInvocation.formalAi ? $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${toolInvocation.command} ${toolInvocation.args} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"` : $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${toolInvocation.command} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
694
694
  } else {
695
- execCommand = $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
695
+ execCommand = toolInvocation.formalAi ? $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${toolInvocation.command} ${toolInvocation.args} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"` : $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${toolInvocation.command} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
696
696
  }
697
697
  if (streamingInput) {
698
698
  await attachStreamingInput(bidirectionalHandler, execCommand, promptForAttempt, log, !!argv.verbose);
@@ -14,16 +14,20 @@ import path from 'node:path';
14
14
  import { promisify } from 'node:util';
15
15
 
16
16
  import { CODEX_PLUGIN_CLI, buildPluginCachePath as buildAgentPluginCachePath, buildPluginPayloadRepairs, pluginIdParts, readMaterializedPluginSkills as readAgentMaterializedPluginSkills, repairPluginPayloads } from './agent-plugin-cache.lib.mjs';
17
+ import { AGENTS_MD_FILENAMES, CLAUDE_MD_FILENAME } from './agents-md-claude-support.lib.mjs';
17
18
 
18
19
  const execFileAsync = promisify(execFile);
19
- const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|use)\b/i;
20
+ const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|us(?:e|es|ing))\b/i;
20
21
  const NEGATED_REQUIREMENT = /\b(?:does\s+not\s+require|not\s+required|optional)\b/i;
21
22
  // `(?!\.[a-z])` keeps email addresses and hostnames (`ops@example.com`) out of
22
23
  // the plugin selector space.
23
24
  const PLUGIN_SELECTOR = /\b([a-z0-9][a-z0-9-]*@[a-z0-9][a-z0-9-]*(?:-remote)?)\b(?!\.[a-z])/gi;
24
25
  const NAMESPACED_SKILL = /\b([a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*)\b/gi;
25
26
  const EXPLICIT_BARE_SKILL = /\$([a-z0-9][a-z0-9-]*)|`([a-z0-9][a-z0-9-]*)`\s+(?:agent\s+)?skill/gi;
26
- const CAPABILITY_PREFIX = /\b(?:depend(?:s|ed)?\s+on|install|invoke|must\s+(?:install|invoke|use)|need(?:ed|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|require(?:d|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|use)\s+(?:(?:the|an?)\s+)?(?:(?:agent\s+)?skill\s+)?(?:named\s+)?[`$]?$/i;
27
+ // Issue #2102: `us(?:e|ing)` covers the imperative form (`Use \`ns:skill\``) and
28
+ // the participle a repository AGENTS.md uses for the same instruction
29
+ // (`Execute approved plans in an isolated worktree using \`ns:skill\``).
30
+ const CAPABILITY_PREFIX = /\b(?:depend(?:s|ed)?\s+on|install|invoke|must\s+(?:install|invoke|use)|need(?:ed|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|require(?:d|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|us(?:e|ing))\s+(?:(?:the|an?)\s+)?(?:(?:agent\s+)?skill\s+)?(?:named\s+)?[`$]?$/i;
27
31
  const CAPABILITY_SUFFIX = /^`?\s+(?:agent\s+)?(?:skill|capability)\b/i;
28
32
  const STRUCTURED_DATA_VALUES = new Set(['array', 'boolean', 'false', 'integer', 'null', 'number', 'object', 'string', 'true']);
29
33
 
@@ -49,6 +53,11 @@ const PROSE_TOKENS = new Set(['agent', 'caution', 'codex', 'default', 'error', '
49
53
 
50
54
  const isCapabilityToken = value => CAPABILITY_TOKEN.test(value) && !PROSE_TOKENS.has(value);
51
55
 
56
+ const skillParts = skill => {
57
+ const separator = skill.indexOf(':');
58
+ return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
59
+ };
60
+
52
61
  const hasExplicitCapabilityContext = (line, match) => {
53
62
  const before = line.slice(0, match.index);
54
63
  const after = line.slice(match.index + match[0].length);
@@ -89,7 +98,7 @@ export function normalizePluginSelector(selector) {
89
98
  .replace(/@openai-curated-remote$/u, '@openai-curated');
90
99
  }
91
100
 
92
- export function detectRequiredCodexCapabilities(text) {
101
+ export function detectRequiredCodexCapabilities(text, { source = null } = {}) {
93
102
  const plugins = new Set();
94
103
  const skills = new Set();
95
104
  // Issue #2088: a fully qualified reference — `plugin@marketplace` in plugin
@@ -106,12 +115,12 @@ export function detectRequiredCodexCapabilities(text) {
106
115
 
107
116
  const accept = (target, value, line, { qualified = false } = {}) => {
108
117
  if (!isCapabilityName(value)) {
109
- rejected.push({ capability: value, line });
118
+ rejected.push({ capability: value, line, source });
110
119
  return;
111
120
  }
112
121
  target.add(value);
113
122
  if (qualified) explicit.add(value);
114
- evidence.push({ capability: value, line, explicit: qualified });
123
+ evidence.push({ capability: value, line, explicit: qualified, source });
115
124
  };
116
125
 
117
126
  for (const rawLine of String(text || '').split(/\r?\n/u)) {
@@ -121,12 +130,22 @@ export function detectRequiredCodexCapabilities(text) {
121
130
  for (const match of line.matchAll(PLUGIN_SELECTOR)) {
122
131
  const selector = normalizePluginSelector(match[1]);
123
132
  if (hasExplicitPluginContext(line, match)) accept(plugins, selector, line, { qualified: true });
124
- else rejected.push({ capability: selector, line });
133
+ else rejected.push({ capability: selector, line, source });
125
134
  }
126
- for (const match of line.matchAll(NAMESPACED_SKILL)) {
127
- const skill = match[1].toLowerCase();
128
- if (hasExplicitCapabilityContext(line, match)) accept(skills, skill, line, { qualified: true });
129
- else rejected.push({ capability: skill, line });
135
+ // Issue #2102: a conjunction shares one requirement verb. `invoke `a:x` and
136
+ // `a:y`` only puts `a:x` in capability context, so a later reference to a
137
+ // namespace already qualified on the same line inherits the qualification
138
+ // instead of being dropped. Restricting inheritance to the same namespace,
139
+ // and still rejecting structured-data values, keeps issues #2077 and #2080
140
+ // from resurfacing: prose cannot produce a second `ns:name` token for a
141
+ // namespace that was just named in an explicit requirement.
142
+ const lineSkills = [...line.matchAll(NAMESPACED_SKILL)].map(match => ({ skill: match[1].toLowerCase(), qualified: hasExplicitCapabilityContext(line, match) }));
143
+ const qualifiedNamespaces = new Set(lineSkills.filter(entry => entry.qualified).map(entry => skillParts(entry.skill).namespace));
144
+ for (const entry of lineSkills) {
145
+ const { namespace, name } = skillParts(entry.skill);
146
+ const inherited = qualifiedNamespaces.has(namespace) && !STRUCTURED_DATA_VALUES.has(name);
147
+ if (entry.qualified || inherited) accept(skills, entry.skill, line, { qualified: true });
148
+ else rejected.push({ capability: entry.skill, line, source });
130
149
  }
131
150
  for (const match of line.matchAll(EXPLICIT_BARE_SKILL)) accept(skills, (match[1] || match[2]).toLowerCase(), line);
132
151
  }
@@ -231,11 +250,6 @@ const verifyModelVisibleSkills = async ({ command, env, runCommand, log, require
231
250
  throw skillVisibilityError({ missing: outcome.missing, visible: outcome.visible, requirements });
232
251
  };
233
252
 
234
- const skillParts = skill => {
235
- const separator = skill.indexOf(':');
236
- return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
237
- };
238
-
239
253
  const pluginProvidesSkill = async (plugin, skill) => {
240
254
  const sourcePath = plugin?.source?.path;
241
255
  if (!sourcePath) return false;
@@ -316,14 +330,182 @@ export async function resolveRequiredPlugins(options) {
316
330
  return (await resolveRequiredCapabilities(options)).plugins;
317
331
  }
318
332
 
319
- const readIssueRequirementText = async ({ owner, repo, issueNumber, runCommand, env }) => {
333
+ const readIssueRequirementSegments = async ({ owner, repo, issueNumber, runCommand, env }) => {
320
334
  const issueResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}`], env });
321
335
  const issue = parseJsonCommand(issueResult, 'Codex capability issue discovery');
322
336
  const commentsResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}/comments`, '--paginate'], env });
323
337
  const comments = parseJsonCommand(commentsResult, 'Codex capability comment discovery');
324
- return [issue?.title, issue?.body, ...(Array.isArray(comments) ? comments.map(comment => comment?.body) : [])].filter(Boolean).join('\n');
338
+ const bodies = (Array.isArray(comments) ? comments : []).map(comment => comment?.body).filter(Boolean);
339
+ return {
340
+ segments: [{ source: `issue #${issueNumber}`, text: [issue?.title, issue?.body].filter(Boolean).join('\n') }, ...bodies.map((text, index) => ({ source: `comment ${index + 1}`, text }))],
341
+ sources: [`issue #${issueNumber}`, ...(bodies.length > 0 ? [`${bodies.length} comment${bodies.length === 1 ? '' : 's'}`] : [])],
342
+ };
325
343
  };
326
344
 
345
+ // Issue #2102: the requirement corpus has to include the instructions the target
346
+ // repository gives the agent. CEHR2005/GCS-TS#5 delegated its entire mandatory
347
+ // workflow to `AGENTS.md` ("Follow the repository and nested engine `AGENTS.md`
348
+ // instructions"), so an issue-only corpus detected nothing, provisioning was
349
+ // skipped, and the model was left to call `request_plugin_install` — which
350
+ // `codex exec` can never satisfy.
351
+ //
352
+ // `CLAUDE.md` is scanned for parity with `--tool claude` (see
353
+ // `agents-md-claude-support.lib.mjs`, which presents `AGENTS.md` under that name)
354
+ // and `.codex/*.md` for repositories that keep Codex-specific rules there.
355
+ const AGENT_INSTRUCTION_FILENAMES = new Set([...AGENTS_MD_FILENAMES, CLAUDE_MD_FILENAME, 'claude.md']);
356
+ const CODEX_INSTRUCTION_DIRECTORY = '.codex';
357
+ // Vendored dependencies, build output and VCS internals carry instructions that
358
+ // belong to other projects; reading them would invent requirements the task
359
+ // never had.
360
+ const SKIPPED_INSTRUCTION_DIRECTORIES = new Set(['__pycache__', 'build', 'coverage', 'dist', 'node_modules', 'out', 'target', 'tmp', 'vendor', 'venv']);
361
+ const WALKED_HIDDEN_DIRECTORIES = new Set([CODEX_INSTRUCTION_DIRECTORY, '.github']);
362
+ const INSTRUCTION_WALK_LIMITS = Object.freeze({ maxDepth: 3, maxFiles: 24, maxBytes: 256 * 1024 });
363
+
364
+ const isInstructionFile = ({ name, directoryName }) => AGENT_INSTRUCTION_FILENAMES.has(name) || (directoryName === CODEX_INSTRUCTION_DIRECTORY && name.toLowerCase().endsWith('.md'));
365
+
366
+ /**
367
+ * Bounded walk over the checked-out repository's agent instruction files.
368
+ *
369
+ * Bounded on purpose: depth, file count and file size are all capped. A
370
+ * candidate dropped by the count or size cap is reported in `skipped`, so a
371
+ * truncated scan is visible in the log instead of looking like "nothing to
372
+ * find"; directories excluded by name or depth are not reported, because
373
+ * listing every skipped `node_modules` would bury that signal. Symlinked
374
+ * directories are not followed, which also makes the walk cycle-free.
375
+ */
376
+ export async function collectAgentInstructionFiles({ projectDir, maxDepth = INSTRUCTION_WALK_LIMITS.maxDepth, maxFiles = INSTRUCTION_WALK_LIMITS.maxFiles, maxBytes = INSTRUCTION_WALK_LIMITS.maxBytes } = {}) {
377
+ const files = [];
378
+ const skipped = [];
379
+ if (!projectDir) return { files, skipped };
380
+
381
+ const candidates = [];
382
+ const walk = async (directory, relative, depth) => {
383
+ let entries;
384
+ try {
385
+ entries = await fs.readdir(directory, { withFileTypes: true });
386
+ } catch {
387
+ return;
388
+ }
389
+ const directoryName = relative ? path.posix.basename(relative) : '';
390
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
391
+ const relativePath = relative ? `${relative}/${entry.name}` : entry.name;
392
+ if (entry.isDirectory()) {
393
+ if (depth >= maxDepth) continue;
394
+ if (SKIPPED_INSTRUCTION_DIRECTORIES.has(entry.name)) continue;
395
+ if (entry.name.startsWith('.') && !WALKED_HIDDEN_DIRECTORIES.has(entry.name)) continue;
396
+ await walk(path.join(directory, entry.name), relativePath, depth + 1);
397
+ } else if (entry.isFile() && isInstructionFile({ name: entry.name, directoryName })) {
398
+ candidates.push({ relativePath, absolutePath: path.join(directory, entry.name), depth });
399
+ }
400
+ }
401
+ };
402
+ await walk(projectDir, '', 0);
403
+
404
+ candidates.sort((left, right) => left.depth - right.depth || left.relativePath.localeCompare(right.relativePath));
405
+ for (const candidate of candidates) {
406
+ let stats;
407
+ try {
408
+ stats = await fs.stat(candidate.absolutePath);
409
+ } catch {
410
+ continue;
411
+ }
412
+ if (stats.size > maxBytes) {
413
+ skipped.push({ relativePath: candidate.relativePath, reason: 'too-large', bytes: stats.size });
414
+ continue;
415
+ }
416
+ if (files.length >= maxFiles) {
417
+ skipped.push({ relativePath: candidate.relativePath, reason: 'max-files' });
418
+ continue;
419
+ }
420
+ try {
421
+ files.push({ relativePath: candidate.relativePath, text: await fs.readFile(candidate.absolutePath, 'utf8'), bytes: stats.size });
422
+ } catch {
423
+ // An unreadable instruction file is not a requirement source.
424
+ }
425
+ }
426
+ return { files, skipped };
427
+ }
428
+
429
+ /**
430
+ * Issue #2102 escape hatch: `--require-codex-plugin` /
431
+ * `HIVE_MIND_CODEX_REQUIRED_PLUGINS` let an operator state a requirement that no
432
+ * text declares. A selector without a marketplace cannot be installed by
433
+ * `codex plugin add`, so it is reported rather than silently ignored.
434
+ */
435
+ export function parseRequiredCapabilityOverrides(value) {
436
+ const tokens = (Array.isArray(value) ? value : [value])
437
+ .flatMap(entry =>
438
+ String(entry ?? '')
439
+ .split(/[,;\s]+/u)
440
+ .map(token => token.trim())
441
+ )
442
+ .filter(Boolean);
443
+ const plugins = new Set();
444
+ const invalid = [];
445
+ for (const token of tokens) {
446
+ const selector = normalizePluginSelector(token);
447
+ if (selector.includes('@') && isCapabilityName(selector)) plugins.add(selector);
448
+ else invalid.push(token);
449
+ }
450
+ return { plugins: [...plugins].sort(), invalid };
451
+ }
452
+
453
+ /**
454
+ * Requirement corpus for the Codex capability preflight: the issue (title, body,
455
+ * comments), the checked-out repository's agent instruction files, and operator
456
+ * overrides. `sources` names everything that was scanned so a zero-requirement
457
+ * result is explainable from the log alone (issue #2102).
458
+ */
459
+ export async function collectCodexCapabilityRequirements({ owner, repo, issueNumber, projectDir, runCommand = defaultRunCommand, env = process.env, requiredPlugins, instructionLimits } = {}) {
460
+ const segments = [];
461
+ const sources = [];
462
+
463
+ if (owner && repo && issueNumber) {
464
+ const issue = await readIssueRequirementSegments({ owner, repo, issueNumber, runCommand, env });
465
+ segments.push(...issue.segments);
466
+ sources.push(...issue.sources);
467
+ }
468
+
469
+ const instructions = await collectAgentInstructionFiles({ projectDir, ...instructionLimits });
470
+ for (const file of instructions.files) {
471
+ segments.push({ source: file.relativePath, text: file.text });
472
+ sources.push(file.relativePath);
473
+ }
474
+
475
+ const plugins = new Set();
476
+ const skills = new Set();
477
+ const explicit = new Set();
478
+ const evidence = [];
479
+ const rejected = [];
480
+ for (const segment of segments) {
481
+ const detected = detectRequiredCodexCapabilities(segment.text, { source: segment.source });
482
+ for (const plugin of detected.plugins) plugins.add(plugin);
483
+ for (const skill of detected.skills) skills.add(skill);
484
+ for (const capability of detected.explicit) explicit.add(capability);
485
+ evidence.push(...detected.evidence);
486
+ rejected.push(...detected.rejected);
487
+ }
488
+
489
+ for (const { label, value } of [
490
+ { label: '--require-codex-plugin', value: requiredPlugins },
491
+ { label: 'HIVE_MIND_CODEX_REQUIRED_PLUGINS', value: env?.HIVE_MIND_CODEX_REQUIRED_PLUGINS },
492
+ ]) {
493
+ const overrides = parseRequiredCapabilityOverrides(value);
494
+ if (overrides.plugins.length === 0 && overrides.invalid.length === 0) continue;
495
+ sources.push(label);
496
+ for (const selector of overrides.plugins) {
497
+ plugins.add(selector);
498
+ // An operator override is as explicit as a requirement gets, so it fails
499
+ // closed like any other declared capability (issue #2088).
500
+ explicit.add(selector);
501
+ evidence.push({ capability: selector, line: `${label}=${selector}`, explicit: true, source: label });
502
+ }
503
+ for (const token of overrides.invalid) rejected.push({ capability: token, line: `${label}=${token} (expected plugin@marketplace)`, source: label });
504
+ }
505
+
506
+ return { plugins: [...plugins].sort(), skills: [...skills].sort(), explicit: [...explicit].sort(), evidence, rejected, sources, skippedInstructionFiles: instructions.skipped };
507
+ }
508
+
327
509
  const replaceWithRelativeSymlink = async ({ source, target }) => {
328
510
  try {
329
511
  const current = await fs.readlink(target);
@@ -586,22 +768,35 @@ export async function runCodexCapabilityPreflight(options = {}) {
586
768
  }
587
769
  }
588
770
 
589
- async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, env = process.env, baseCodexHome = env.HIVE_MIND_PARENT_CODEX_HOME || env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
590
- if (!owner || !repo || !issueNumber) return { required: false, plugins: [], codexHome: null };
771
+ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, requiredPlugins, env = process.env, baseCodexHome = env.HIVE_MIND_PARENT_CODEX_HOME || env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
772
+ // Issue #2102: requirements no longer come from the issue alone, so a missing
773
+ // issue number (a pull-request continuation, for example) must not skip the
774
+ // repository's own AGENTS.md/CLAUDE.md files or an operator override. Owner and
775
+ // repo are still required: the scoped CODEX_HOME is keyed on them.
776
+ if (!owner || !repo) return { required: false, plugins: [], codexHome: null };
591
777
 
592
778
  // `executeToolWithBun` uses a shell expression for execution. Preflight uses
593
779
  // execFile and therefore selects the installed Codex binary directly.
594
780
  const command = /\s/u.test(codexPath) ? 'codex' : codexPath;
595
- const requirementText = await readIssueRequirementText({ owner, repo, issueNumber, runCommand, env });
596
- const requirements = detectRequiredCodexCapabilities(requirementText);
597
- for (const { capability, line } of requirements.rejected || []) {
598
- await log(` ⏭️ Ignored non-capability token '${capability}' from: ${line.slice(0, 160)}`, { verbose: true });
781
+ const requirements = await collectCodexCapabilityRequirements({ owner, repo, issueNumber, projectDir, runCommand, env, requiredPlugins });
782
+ const scanned = `sources: ${requirements.sources.join(', ') || 'none'}`;
783
+ for (const { capability, line, source } of requirements.rejected || []) {
784
+ await log(` ⏭️ Ignored non-capability token '${capability}' from ${source || 'requirement text'}: ${line.slice(0, 160)}`, { verbose: true });
785
+ }
786
+ for (const { relativePath, reason, bytes } of requirements.skippedInstructionFiles || []) {
787
+ await log(` ⏭️ Skipped instruction file ${relativePath} (${reason}${reason === 'too-large' ? `: ${bytes} bytes` : ''})`, { verbose: true });
788
+ }
789
+ // Issue #2102: a silent early return made a missed requirement indistinguishable
790
+ // from a task that has none. Name what was scanned so the next report is
791
+ // diagnosable from the log alone.
792
+ if (requirements.plugins.length === 0 && requirements.skills.length === 0) {
793
+ await log(`🔌 Codex capability preflight: no plugin or skill requirements detected (${scanned})`, { verbose: true });
794
+ return { required: false, plugins: [], codexHome: null, sources: requirements.sources };
599
795
  }
600
- if (requirements.plugins.length === 0 && requirements.skills.length === 0) return { required: false, plugins: [], codexHome: null };
601
796
 
602
- await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s)`);
603
- for (const { capability, line } of requirements.evidence || []) {
604
- await log(` 🔎 '${capability}' detected from: ${line.slice(0, 160)}`, { verbose: true });
797
+ await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s) (${scanned})`);
798
+ for (const { capability, line, source } of requirements.evidence || []) {
799
+ await log(` 🔎 '${capability}' detected from ${source || 'requirement text'}: ${line.slice(0, 160)}`, { verbose: true });
605
800
  }
606
801
  // Every Codex-side preflight operation runs from the checkout that the
607
802
  // solver will enter. Besides matching `codex exec`, this keeps any future
@@ -677,9 +872,12 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
677
872
  export default {
678
873
  applyCodexCapabilityEnv,
679
874
  buildPluginCachePath,
875
+ collectAgentInstructionFiles,
876
+ collectCodexCapabilityRequirements,
680
877
  detectRequiredCodexCapabilities,
681
878
  isCapabilityName,
682
879
  isExplicitRequirement,
880
+ parseRequiredCapabilityOverrides,
683
881
  readMaterializedPluginSkills,
684
882
  repairScopedPluginPayloads,
685
883
  resolveRequiredCapabilities,