@link-assistant/hive-mind 2.8.0 → 2.8.2

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.8.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 44202ff: Explain invalid pull request base/head conflicts before attempting to retarget the pull request.
8
+
9
+ ## 2.8.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 23a2187: Provision required Codex plugins and Agent Skills in persistent repository-scoped state before solver execution, including Docker propagation and actionable missing-capability diagnostics.
14
+
3
15
  ## 2.8.0
4
16
 
5
17
  ### Minor Changes
package/README.hi.md CHANGED
@@ -210,14 +210,15 @@ docker attach hive-mind
210
210
  # --- Persisting auth data across restarts ---
211
211
 
212
212
  # Extract auth data from a running (or stopped) container to the host:
213
- mkdir -p ~/.hive-mind
213
+ mkdir -p ~/.hive-mind ~/.hive-mind/agents/skills
214
214
  docker cp hive-mind:/home/box/.claude ~/.hive-mind/claude
215
+ docker cp hive-mind:/home/box/.codex ~/.hive-mind/codex
215
216
  docker cp hive-mind:/home/box/.claude.json ~/.hive-mind/claude.json
216
217
  docker cp hive-mind:/home/box/.config/gh ~/.hive-mind/gh
217
218
 
218
219
  # Fix ownership to match the box user inside the container:
219
220
  BOX_UID=$(docker exec hive-mind id -u box)
220
- chown -R $BOX_UID:$BOX_UID ~/.hive-mind/claude ~/.hive-mind/gh
221
+ chown -R $BOX_UID:$BOX_UID ~/.hive-mind/claude ~/.hive-mind/codex ~/.hive-mind/agents ~/.hive-mind/gh
221
222
  chown $BOX_UID:$BOX_UID ~/.hive-mind/claude.json
222
223
 
223
224
  # On subsequent runs, mount the auth data to keep it between restarts:
@@ -225,11 +226,15 @@ docker run -dit \
225
226
  --name hive-mind \
226
227
  --restart unless-stopped \
227
228
  -v /root/.hive-mind/claude:/home/box/.claude \
229
+ -v /root/.hive-mind/codex:/home/box/.codex \
230
+ -v /root/.hive-mind/agents:/home/box/.agents \
228
231
  -v /root/.hive-mind/claude.json:/home/box/.claude.json \
229
232
  -v /root/.hive-mind/gh:/home/box/.config/gh \
230
233
  konard/hive-mind:latest
231
234
  ```
232
235
 
236
+ `codex exec` शुरू होने से पहले Hive Mind issue और उसकी comments में स्पष्ट plugin तथा Agent Skill requirements खोजता है। आवश्यक provider `/home/box/.codex/hive-mind/repositories/<owner>/<repo>` में install होता है, इसलिए configuration restart के बाद भी रहती है और दूसरे repositories को प्रभावित नहीं करती। `/home/box/.agents/skills` के user skills भी Docker-isolated tasks में उपलब्ध होते हैं; capability न मिलने पर preflight AI session से पहले exact identifier और remediation command बताता है।
237
+
233
238
  **Docker के लाभ:**
234
239
 
235
240
  - ✅ पूर्व-कॉन्फ़िगर Ubuntu 24.04 वातावरण
package/README.md CHANGED
@@ -213,7 +213,7 @@ docker attach hive-mind
213
213
  # --- Persisting auth data across restarts ---
214
214
 
215
215
  # On the host, create the directories used by the current Docker workflow:
216
- mkdir -p /root/.hive-mind/claude /root/.hive-mind/codex /root/.hive-mind/gh
216
+ mkdir -p /root/.hive-mind/claude /root/.hive-mind/codex /root/.hive-mind/agents/skills /root/.hive-mind/gh
217
217
  touch -a /root/.hive-mind/claude.json
218
218
 
219
219
  # In our Docker images HOME=/home/box, so Codex stores its data in /home/box/.codex.
@@ -221,13 +221,14 @@ touch -a /root/.hive-mind/claude.json
221
221
  docker run -dit --user box --name hive-mind --restart unless-stopped \
222
222
  -v /root/.hive-mind/claude:/home/box/.claude \
223
223
  -v /root/.hive-mind/codex:/home/box/.codex \
224
+ -v /root/.hive-mind/agents:/home/box/.agents \
224
225
  -v /root/.hive-mind/claude.json:/home/box/.claude.json \
225
226
  -v /root/.hive-mind/gh:/home/box/.config/gh \
226
227
  konard/hive-mind:latest bash -l -c 'bash /home/box/start-bot.sh'
227
228
 
228
229
  # After the first start, fix ownership to match the box user inside the container:
229
230
  BOX_UID=$(docker exec hive-mind id -u box)
230
- chown -R $BOX_UID:$BOX_UID /root/.hive-mind/claude /root/.hive-mind/codex /root/.hive-mind/gh
231
+ chown -R $BOX_UID:$BOX_UID /root/.hive-mind/claude /root/.hive-mind/codex /root/.hive-mind/agents /root/.hive-mind/gh
231
232
  chown $BOX_UID:$BOX_UID /root/.hive-mind/claude.json
232
233
 
233
234
  # Important: mounted ~/.codex data overrides the image-baked Codex config.
@@ -246,6 +247,8 @@ docker exec -it hive-mind bash -lc 'codex mcp list && codex mcp add playwright -
246
247
 
247
248
  The Docker image itself now registers Playwright MCP for both Claude and Codex during build, and CI verifies those registrations in the built container. If `codex mcp list` is still empty in a running container, the usual cause is not the published image itself but a mounted `/home/box/.codex` directory from the host that replaces the image's default Codex configuration.
248
249
 
250
+ Before `codex exec`, Hive Mind scans the issue and its comments for explicit required plugin selectors and Agent Skill names. It resolves skill providers from Codex's marketplace catalog and installs them under `/home/box/.codex/hive-mind/repositories/<owner>/<repo>`, so enablement persists across restarts without affecting other repositories. Required user skills placed in `/home/box/.agents/skills` are also propagated to Docker-isolated tasks. An unavailable requirement stops before the AI session with the exact capability and a catalog remediation command.
251
+
249
252
  See [docs/DOCKER.md](./docs/DOCKER.md) for advanced Docker usage.
250
253
 
251
254
  #### Stoping and removing docker container
package/README.ru.md CHANGED
@@ -210,14 +210,15 @@ docker attach hive-mind
210
210
  # --- Persisting auth data across restarts ---
211
211
 
212
212
  # Extract auth data from a running (or stopped) container to the host:
213
- mkdir -p ~/.hive-mind
213
+ mkdir -p ~/.hive-mind ~/.hive-mind/agents/skills
214
214
  docker cp hive-mind:/home/box/.claude ~/.hive-mind/claude
215
+ docker cp hive-mind:/home/box/.codex ~/.hive-mind/codex
215
216
  docker cp hive-mind:/home/box/.claude.json ~/.hive-mind/claude.json
216
217
  docker cp hive-mind:/home/box/.config/gh ~/.hive-mind/gh
217
218
 
218
219
  # Fix ownership to match the box user inside the container:
219
220
  BOX_UID=$(docker exec hive-mind id -u box)
220
- chown -R $BOX_UID:$BOX_UID ~/.hive-mind/claude ~/.hive-mind/gh
221
+ chown -R $BOX_UID:$BOX_UID ~/.hive-mind/claude ~/.hive-mind/codex ~/.hive-mind/agents ~/.hive-mind/gh
221
222
  chown $BOX_UID:$BOX_UID ~/.hive-mind/claude.json
222
223
 
223
224
  # On subsequent runs, mount the auth data to keep it between restarts:
@@ -225,11 +226,15 @@ docker run -dit \
225
226
  --name hive-mind \
226
227
  --restart unless-stopped \
227
228
  -v /root/.hive-mind/claude:/home/box/.claude \
229
+ -v /root/.hive-mind/codex:/home/box/.codex \
230
+ -v /root/.hive-mind/agents:/home/box/.agents \
228
231
  -v /root/.hive-mind/claude.json:/home/box/.claude.json \
229
232
  -v /root/.hive-mind/gh:/home/box/.config/gh \
230
233
  konard/hive-mind:latest
231
234
  ```
232
235
 
236
+ Перед запуском `codex exec` Hive Mind ищет явные требования к плагинам и Agent Skills в issue и его комментариях. Нужный провайдер устанавливается в `/home/box/.codex/hive-mind/repositories/<owner>/<repo>`, поэтому конфигурация переживает перезапуски, но не влияет на другие репозитории. Пользовательские навыки из `/home/box/.agents/skills` также передаются в Docker-изолированные задачи; если capability недоступна, preflight до запуска ИИ-сессии сообщает точный идентификатор и команду исправления.
237
+
233
238
  **Преимущества Docker:**
234
239
 
235
240
  - ✅ Предварительно настроенная среда Ubuntu 24.04
package/README.zh.md CHANGED
@@ -210,14 +210,15 @@ docker attach hive-mind
210
210
  # --- Persisting auth data across restarts ---
211
211
 
212
212
  # Extract auth data from a running (or stopped) container to the host:
213
- mkdir -p ~/.hive-mind
213
+ mkdir -p ~/.hive-mind ~/.hive-mind/agents/skills
214
214
  docker cp hive-mind:/home/box/.claude ~/.hive-mind/claude
215
+ docker cp hive-mind:/home/box/.codex ~/.hive-mind/codex
215
216
  docker cp hive-mind:/home/box/.claude.json ~/.hive-mind/claude.json
216
217
  docker cp hive-mind:/home/box/.config/gh ~/.hive-mind/gh
217
218
 
218
219
  # Fix ownership to match the box user inside the container:
219
220
  BOX_UID=$(docker exec hive-mind id -u box)
220
- chown -R $BOX_UID:$BOX_UID ~/.hive-mind/claude ~/.hive-mind/gh
221
+ chown -R $BOX_UID:$BOX_UID ~/.hive-mind/claude ~/.hive-mind/codex ~/.hive-mind/agents ~/.hive-mind/gh
221
222
  chown $BOX_UID:$BOX_UID ~/.hive-mind/claude.json
222
223
 
223
224
  # On subsequent runs, mount the auth data to keep it between restarts:
@@ -225,11 +226,15 @@ docker run -dit \
225
226
  --name hive-mind \
226
227
  --restart unless-stopped \
227
228
  -v /root/.hive-mind/claude:/home/box/.claude \
229
+ -v /root/.hive-mind/codex:/home/box/.codex \
230
+ -v /root/.hive-mind/agents:/home/box/.agents \
228
231
  -v /root/.hive-mind/claude.json:/home/box/.claude.json \
229
232
  -v /root/.hive-mind/gh:/home/box/.config/gh \
230
233
  konard/hive-mind:latest
231
234
  ```
232
235
 
236
+ 在启动 `codex exec` 之前,Hive Mind 会扫描 issue 及其评论中的显式插件和 Agent Skill 要求。它将所需提供程序安装到 `/home/box/.codex/hive-mind/repositories/<owner>/<repo>`,因此配置可跨重启持久化,但不会影响其他仓库。`/home/box/.agents/skills` 中的用户技能也会传入 Docker 隔离任务;若能力不可用,预检会在 AI 会话启动前报告确切标识符和修复命令。
237
+
233
238
  **Docker 的优势:**
234
239
 
235
240
  - ✅ 预配置的 Ubuntu 24.04 环境
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Repository-scoped Codex capability preflight (issue #2074).
3
+ *
4
+ * Explicit plugin and Agent Skill requirements are discovered from the issue
5
+ * before `codex exec`. Required marketplace plugins are installed into a
6
+ * persistent CODEX_HOME scoped by repository, leaving the operator's global
7
+ * plugin enablement and the target repository untouched.
8
+ */
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import fs from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { promisify } from 'node:util';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|use)\b/i;
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;
20
+ const NAMESPACED_SKILL = /\b([a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*)\b/gi;
21
+ const EXPLICIT_BARE_SKILL = /\$([a-z0-9][a-z0-9-]*)|`([a-z0-9][a-z0-9-]*)`\s+(?:agent\s+)?skill/gi;
22
+
23
+ export class CodexCapabilityPreflightError extends Error {
24
+ constructor(message, details = {}) {
25
+ super(message);
26
+ this.name = 'CodexCapabilityPreflightError';
27
+ this.details = details;
28
+ }
29
+ }
30
+
31
+ export function normalizePluginSelector(selector) {
32
+ return String(selector || '')
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/@openai-curated-remote$/u, '@openai-curated');
36
+ }
37
+
38
+ export function detectRequiredCodexCapabilities(text) {
39
+ const plugins = new Set();
40
+ const skills = new Set();
41
+
42
+ for (const rawLine of String(text || '').split(/\r?\n/u)) {
43
+ const line = rawLine.trim();
44
+ if (!line || !REQUIREMENT_WORDS.test(line) || NEGATED_REQUIREMENT.test(line)) continue;
45
+
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
+ }
53
+ }
54
+
55
+ return { plugins: [...plugins].sort(), skills: [...skills].sort() };
56
+ }
57
+
58
+ const sanitizePathSegment = value => String(value || '').replace(/[^a-zA-Z0-9._-]/gu, '_');
59
+
60
+ export function buildCodexCapabilityStatePath({ baseCodexHome, owner, repo }) {
61
+ return path.join(baseCodexHome, 'hive-mind', 'repositories', sanitizePathSegment(owner), sanitizePathSegment(repo));
62
+ }
63
+
64
+ export function applyCodexCapabilityEnv(env, { codexHome, baseCodexHome } = {}) {
65
+ if (!codexHome) return env;
66
+ return { ...env, CODEX_HOME: codexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
67
+ }
68
+
69
+ const defaultRunCommand = async ({ command, args, env = process.env, cwd }) => {
70
+ try {
71
+ const result = await execFileAsync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 });
72
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: 0 };
73
+ } catch (error) {
74
+ return { stdout: error.stdout || '', stderr: error.stderr || error.message, code: Number.isInteger(error.code) ? error.code : 1 };
75
+ }
76
+ };
77
+
78
+ const parseJsonCommand = (result, label) => {
79
+ if (result.code !== 0) throw new CodexCapabilityPreflightError(`${label} failed: ${String(result.stderr || result.stdout).trim()}`);
80
+ try {
81
+ return JSON.parse(result.stdout || 'null');
82
+ } catch (error) {
83
+ throw new CodexCapabilityPreflightError(`${label} returned invalid JSON: ${error.message}`);
84
+ }
85
+ };
86
+
87
+ const catalogEntries = catalog => [...(catalog?.installed || []), ...(catalog?.available || [])];
88
+
89
+ const skillParts = skill => {
90
+ const separator = skill.indexOf(':');
91
+ return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
92
+ };
93
+
94
+ const pluginProvidesSkill = async (plugin, skill) => {
95
+ const sourcePath = plugin?.source?.path;
96
+ if (!sourcePath) return false;
97
+ const { name } = skillParts(skill);
98
+ try {
99
+ await fs.access(path.join(sourcePath, 'skills', name, 'SKILL.md'));
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ };
105
+
106
+ const skillExistsInDirectories = async (skill, skillDirectories) => {
107
+ const { name } = skillParts(skill);
108
+ for (const directory of skillDirectories || []) {
109
+ try {
110
+ await fs.access(path.join(directory, name, 'SKILL.md'));
111
+ return true;
112
+ } catch {
113
+ // Keep searching the remaining standard skill locations.
114
+ }
115
+ }
116
+ return false;
117
+ };
118
+
119
+ export async function resolveRequiredPlugins({ requirements, catalog, skillDirectories = [] }) {
120
+ const entries = catalogEntries(catalog);
121
+ const byId = new Map(entries.map(entry => [normalizePluginSelector(entry.pluginId), entry]));
122
+ const selected = new Map();
123
+ const missing = [];
124
+
125
+ for (const selector of requirements.plugins || []) {
126
+ const normalized = normalizePluginSelector(selector);
127
+ const entry = byId.get(normalized);
128
+ if (!entry) missing.push(normalized);
129
+ else selected.set(normalized, entry);
130
+ }
131
+
132
+ for (const skill of requirements.skills || []) {
133
+ const { namespace } = skillParts(skill);
134
+ if (!namespace && (await skillExistsInDirectories(skill, skillDirectories))) continue;
135
+ let candidates = entries;
136
+ if (namespace) candidates = entries.filter(entry => entry.name === namespace || String(entry.pluginId || '').startsWith(`${namespace}@`));
137
+
138
+ let provider = null;
139
+ for (const entry of candidates) {
140
+ if (await pluginProvidesSkill(entry, skill)) {
141
+ provider = entry;
142
+ break;
143
+ }
144
+ }
145
+ if (provider) selected.set(normalizePluginSelector(provider.pluginId), provider);
146
+ else missing.push(skill);
147
+ }
148
+
149
+ if (missing.length > 0) {
150
+ throw new CodexCapabilityPreflightError(`Required Codex capability unavailable: ${missing.join(', ')}. ` + `Run 'codex plugin list --available --json' in the operator container and configure a marketplace that provides it. ` + `Hive Mind installs discovered capabilities into repository-scoped CODEX_HOME state; it does not enable plugins globally.`, { missing });
151
+ }
152
+
153
+ return [...selected.keys()].sort();
154
+ }
155
+
156
+ const readIssueRequirementText = async ({ owner, repo, issueNumber, runCommand }) => {
157
+ const issueResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}`], env: process.env });
158
+ const issue = parseJsonCommand(issueResult, 'Codex capability issue discovery');
159
+ const commentsResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}/comments`, '--paginate'], env: process.env });
160
+ const comments = parseJsonCommand(commentsResult, 'Codex capability comment discovery');
161
+ return [issue?.title, issue?.body, ...(Array.isArray(comments) ? comments.map(comment => comment?.body) : [])].filter(Boolean).join('\n');
162
+ };
163
+
164
+ const replaceWithRelativeSymlink = async ({ source, target }) => {
165
+ try {
166
+ const current = await fs.readlink(target);
167
+ if (path.resolve(path.dirname(target), current) === path.resolve(source)) return;
168
+ } catch {
169
+ // Missing path or a non-symlink is reconciled below.
170
+ }
171
+ await fs.rm(target, { recursive: true, force: true });
172
+ await fs.mkdir(path.dirname(target), { recursive: true });
173
+ await fs.symlink(path.relative(path.dirname(target), source), target, 'dir');
174
+ };
175
+
176
+ const syncFileIfPresent = async (source, target) => {
177
+ try {
178
+ await fs.copyFile(source, target);
179
+ return true;
180
+ } catch (error) {
181
+ if (error.code === 'ENOENT') {
182
+ await fs.rm(target, { force: true });
183
+ return false;
184
+ }
185
+ throw error;
186
+ }
187
+ };
188
+
189
+ const readIfPresent = async filePath => {
190
+ try {
191
+ return await fs.readFile(filePath, 'utf8');
192
+ } catch (error) {
193
+ if (error.code === 'ENOENT') return '';
194
+ throw error;
195
+ }
196
+ };
197
+
198
+ // Runtime settings follow the operator config while plugin enablement remains
199
+ // persistent and isolated to this repository.
200
+ const syncScopedConfig = async ({ baseConfigPath, scopedConfigPath }) => {
201
+ const baseConfig = await readIfPresent(baseConfigPath);
202
+ const scopedConfig = await readIfPresent(scopedConfigPath);
203
+ const pluginPattern = /^\[plugins\."[^"]+"\][^\n]*(?:\n(?!\[)[^\n]*)*/gmu;
204
+ const pluginBlocks = scopedConfig.match(pluginPattern) || [];
205
+ const baseWithoutPlugins = baseConfig.replace(pluginPattern, '').trimEnd();
206
+ const nextConfig = [baseWithoutPlugins, ...pluginBlocks.map(block => block.trim())].filter(Boolean).join('\n\n');
207
+ if (nextConfig) await fs.writeFile(scopedConfigPath, `${nextConfig}\n`);
208
+ };
209
+
210
+ const prepareScopedCodexHome = async ({ baseCodexHome, codexHome }) => {
211
+ await fs.mkdir(codexHome, { recursive: true });
212
+ await syncScopedConfig({ baseConfigPath: path.join(baseCodexHome, 'config.toml'), scopedConfigPath: path.join(codexHome, 'config.toml') });
213
+ await syncFileIfPresent(path.join(baseCodexHome, 'auth.json'), path.join(codexHome, 'auth.json'));
214
+ await syncFileIfPresent(path.join(baseCodexHome, 'installation_id'), path.join(codexHome, 'installation_id'));
215
+
216
+ const marketplaceSource = path.join(baseCodexHome, '.tmp', 'plugins');
217
+ const marketplaceSha = path.join(baseCodexHome, '.tmp', 'plugins.sha');
218
+ try {
219
+ await fs.access(marketplaceSource);
220
+ await fs.access(marketplaceSha);
221
+ await replaceWithRelativeSymlink({ source: marketplaceSource, target: path.join(codexHome, '.tmp', 'plugins') });
222
+ await fs.copyFile(marketplaceSha, path.join(codexHome, '.tmp', 'plugins.sha'));
223
+ } catch {
224
+ throw new CodexCapabilityPreflightError(`The operator Codex marketplace snapshot is unavailable at ${marketplaceSource}. ` + `Run 'codex plugin list --available --json' once in the operator container, then retry.`);
225
+ }
226
+ };
227
+
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 () => {} } = {}) {
229
+ if (!owner || !repo || !issueNumber) return { required: false, plugins: [], codexHome: null };
230
+
231
+ // `executeToolWithBun` uses a shell expression for execution. Preflight uses
232
+ // execFile and therefore selects the installed Codex binary directly.
233
+ const command = /\s/u.test(codexPath) ? 'codex' : codexPath;
234
+ const requirementText = await readIssueRequirementText({ owner, repo, issueNumber, runCommand });
235
+ const requirements = detectRequiredCodexCapabilities(requirementText);
236
+ if (requirements.plugins.length === 0 && requirements.skills.length === 0) return { required: false, plugins: [], codexHome: null };
237
+
238
+ await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s)`);
239
+ const baseEnv = { ...process.env, CODEX_HOME: baseCodexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
240
+ const baseCatalogResult = await runCommand({ command, args: ['plugin', 'list', '--available', '--json'], env: baseEnv });
241
+ const baseCatalog = parseJsonCommand(baseCatalogResult, 'Codex plugin catalog discovery');
242
+ const skillDirectories = [path.join(os.homedir(), '.agents', 'skills'), projectDir && path.join(projectDir, '.agents', 'skills')].filter(Boolean);
243
+ const plugins = await resolveRequiredPlugins({ requirements, catalog: baseCatalog, skillDirectories });
244
+ if (plugins.length === 0) {
245
+ await log(' ✅ Required Agent Skills are already available from standard skill directories');
246
+ return { required: true, plugins, skills: requirements.skills, codexHome: null, baseCodexHome };
247
+ }
248
+
249
+ const codexHome = buildCodexCapabilityStatePath({ baseCodexHome, owner, repo });
250
+ await prepareScopedCodexHome({ baseCodexHome, codexHome });
251
+ const scopedEnv = { ...process.env, CODEX_HOME: codexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
252
+
253
+ const scopedCatalogResult = await runCommand({ command, args: ['plugin', 'list', '--json'], env: scopedEnv });
254
+ const scopedCatalog = parseJsonCommand(scopedCatalogResult, 'Repository-scoped Codex plugin discovery');
255
+ const installed = new Set((scopedCatalog.installed || []).filter(plugin => plugin.installed && plugin.enabled).map(plugin => normalizePluginSelector(plugin.pluginId)));
256
+
257
+ for (const plugin of plugins) {
258
+ if (installed.has(plugin)) continue;
259
+ const installResult = await runCommand({ command, args: ['plugin', 'add', plugin, '--json'], env: scopedEnv });
260
+ parseJsonCommand(installResult, `Installing required Codex plugin ${plugin}`);
261
+ await log(` ✅ Provisioned ${plugin} in repository-scoped Codex state`);
262
+ }
263
+
264
+ const verifyResult = await runCommand({ command, args: ['plugin', 'list', '--json'], env: scopedEnv });
265
+ const verifiedCatalog = parseJsonCommand(verifyResult, 'Codex capability verification');
266
+ const verified = new Set((verifiedCatalog.installed || []).filter(plugin => plugin.installed && plugin.enabled).map(plugin => normalizePluginSelector(plugin.pluginId)));
267
+ const unverified = plugins.filter(plugin => !verified.has(plugin));
268
+ if (unverified.length > 0) throw new CodexCapabilityPreflightError(`Codex capability installation did not verify successfully: ${unverified.join(', ')}`, { missing: unverified });
269
+
270
+ await log(` Codex capability state: ${codexHome}`, { verbose: true });
271
+ return { required: true, plugins, skills: requirements.skills, codexHome, baseCodexHome };
272
+ }
273
+
274
+ export default { applyCodexCapabilityEnv, detectRequiredCodexCapabilities, runCodexCapabilityPreflight };
package/src/codex.lib.mjs CHANGED
@@ -35,6 +35,7 @@ import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } fro
35
35
  import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
36
36
  import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
37
37
  import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
38
+ import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-capability-preflight.lib.mjs'; // Issue #2074
38
39
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
39
40
  import Decimal from 'decimal.js-light';
40
41
 
@@ -744,6 +745,7 @@ export const executeCodex = async params => {
744
745
  // it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
745
746
  await deployHandoffSkill({ tempDir, argv, log, $ });
746
747
 
748
+ const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log });
747
749
  // Execute the Codex command
748
750
  return await executeCodexCommand({
749
751
  tempDir,
@@ -761,11 +763,12 @@ export const executeCodex = async params => {
761
763
  owner,
762
764
  repo,
763
765
  prNumber,
766
+ capabilityPreflight,
764
767
  });
765
768
  };
766
769
 
767
770
  export const executeCodexCommand = async params => {
768
- const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, codexPath, $, owner, repo, prNumber, calculatePricing = calculateCodexPricing, waitForRetryDelay = waitWithCountdown } = params;
771
+ const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, codexPath, $, owner, repo, prNumber, capabilityPreflight, calculatePricing = calculateCodexPricing, waitForRetryDelay = waitWithCountdown } = params;
769
772
 
770
773
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
771
774
  const expectedBaseBranch = String(argv?.baseBranch || '').trim();
@@ -806,7 +809,10 @@ export const executeCodexCommand = async params => {
806
809
  const mappedModel = mapModelToId(argv.model);
807
810
  const { reasoningEffort, source: reasoningEffortSource, rolloutTokenBudget } = resolveCodexReasoningEffort(argv);
808
811
  const isResumeMode = !!argv.resume;
809
- const codexEnv = getCodexExecEnv(argv.verbose);
812
+ const codexEnv = applyCodexCapabilityEnv(getCodexExecEnv(argv.verbose), {
813
+ codexHome: capabilityPreflight?.codexHome,
814
+ baseCodexHome: capabilityPreflight?.baseCodexHome,
815
+ });
810
816
 
811
817
  // For Codex, we combine system and user prompts into a single message
812
818
  // Codex doesn't have separate system prompt support in CLI mode
@@ -206,6 +206,10 @@ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.en
206
206
 
207
207
  if (normalizedTool === 'codex') {
208
208
  maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
209
+ // Issue #2074: Codex also discovers persistent user Agent Skills from
210
+ // ~/.agents/skills. Propagate that standard location alongside .codex so
211
+ // direct and Docker-isolated solver sessions expose the same capabilities.
212
+ maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
209
213
  } else if (normalizedTool === 'claude') {
210
214
  maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
211
215
  maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
@@ -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
  }