@link-assistant/hive-mind 2.8.6 → 2.8.8

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,46 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.8.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 2547914: fix(codex): repair the repository-scoped plugin cache before `codex exec` (#2088)
8
+
9
+ The #2084 preflight measured the right thing — it asked `codex debug
10
+ prompt-input` which skills the model would receive — but had nowhere to go with
11
+ a negative answer: the provisioning step skipped `plugin add` whenever `codex
12
+ plugin list` reported the plugin `installed, enabled`, and enablement
13
+ (`config.toml`) is independent of exposure (the materialized payload under
14
+ `plugins/cache/<marketplace>/<plugin>/<version>/skills`). A scoped `CODEX_HOME`
15
+ that lost its payload therefore logged "Continuing with the operator Codex
16
+ capabilities" and started the session without the mandated skills.
17
+
18
+ The preflight now inspects the scoped payload directly, repairs it through an
19
+ escalating ladder (`install` → `reinstall` → `copy-operator-payload`), re-probes
20
+ the rendered prompt in the exact environment `codex exec` receives, and forces
21
+ one rebuild when a required skill is still invisible. An explicitly declared
22
+ plugin or `plugin:skill` requirement that cannot be made visible now fails
23
+ closed before `codex exec` with a diagnostic naming the missing skills, the
24
+ expected cache path and every repair attempted; heuristically inferred
25
+ requirements still degrade safely (#2077). The CLI-agnostic half lives in the
26
+ new `src/agent-plugin-cache.lib.mjs` and is covered against both the Codex and
27
+ Claude Code plugin verbs, since Claude Code has the same enabled-vs-materialized
28
+ split.
29
+
30
+ ## 2.8.7
31
+
32
+ ### Patch Changes
33
+
34
+ - f8abac0: fix(telegram): apply operator `TELEGRAM_SOLVE_OVERRIDES` to the `/solve` started by `/fix` (#2085)
35
+
36
+ `/fix --ci-cd` genuinely spawns the real `solve.mjs`, but the Telegram bot's
37
+ operator solve overrides (e.g. `--attach-logs`) were only merged into `/solve`
38
+ and `/hive` — never `/fix`. As a result the solve launched by `/fix` ran
39
+ without the operator's defaults. The `mergeArgsWithOverrides` helper is now
40
+ extracted into a shared `src/args-overrides.lib.mjs` module and the `/fix`
41
+ handler applies `solveOverrides` (including an optional `--isolation` override)
42
+ exactly like `/solve` does, restoring the missing defaults.
43
+
3
44
  ## 2.8.6
4
45
 
5
46
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.8.6",
3
+ "version": "2.8.8",
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,176 @@
1
+ /**
2
+ * Agent CLI plugin payload inspection and repair (issue #2088).
3
+ *
4
+ * Codex and Claude Code both install marketplace plugins into
5
+ *
6
+ * <agent home>/plugins/cache/<marketplace>/<plugin>/<version>/
7
+ *
8
+ * and both expose a plugin's Agent Skills to the model only while that payload
9
+ * carries `skills/<skill>/SKILL.md`. Enablement is recorded separately — Codex
10
+ * in `config.toml`, Claude in its config — and neither CLI reconciles the two:
11
+ *
12
+ * $ rm -rf "$HOME/plugins/cache/<marketplace>/<plugin>/<version>"
13
+ * $ codex plugin list --json # installed: true, enabled: true
14
+ * $ claude plugin list --json # enabled: true, installPath: <deleted path>
15
+ *
16
+ * In both cases the model then receives a prompt with none of that plugin's
17
+ * skills. Any provisioning step that trusts `plugin list` therefore skips the
18
+ * one action that would fix it. Both CLIs re-materialize the payload when the
19
+ * install command is run again, and doing so is idempotent.
20
+ *
21
+ * Reproduced against the real CLIs in
22
+ * experiments/issue-2088/reproduce-cache-repair.sh (Codex) and
23
+ * experiments/issue-2088/reproduce-claude-cache-gap.sh (Claude Code).
24
+ */
25
+
26
+ import fs from 'node:fs/promises';
27
+ import path from 'node:path';
28
+
29
+ // The install/remove verbs are the only part of the repair that is CLI
30
+ // specific; the cache layout and the failure mode are identical.
31
+ export const CODEX_PLUGIN_CLI = {
32
+ id: 'codex',
33
+ label: 'Codex',
34
+ install: pluginId => ['plugin', 'add', pluginId, '--json'],
35
+ remove: pluginId => ['plugin', 'remove', pluginId, '--json'],
36
+ };
37
+
38
+ export const CLAUDE_PLUGIN_CLI = {
39
+ id: 'claude',
40
+ label: 'Claude Code',
41
+ install: pluginId => ['plugin', 'install', pluginId],
42
+ remove: pluginId => ['plugin', 'uninstall', pluginId],
43
+ };
44
+
45
+ export const pluginIdParts = pluginId => {
46
+ const value = String(pluginId || '')
47
+ .trim()
48
+ .toLowerCase();
49
+ const separator = value.indexOf('@');
50
+ return separator === -1 ? { name: value, marketplace: '' } : { name: value.slice(0, separator), marketplace: value.slice(separator + 1) };
51
+ };
52
+
53
+ export const buildPluginCachePath = ({ agentHome, pluginId }) => {
54
+ const { name, marketplace } = pluginIdParts(pluginId);
55
+ return path.join(agentHome, 'plugins', 'cache', marketplace, name);
56
+ };
57
+
58
+ const listDirectoryNames = async directory => {
59
+ try {
60
+ const entries = await fs.readdir(directory, { withFileTypes: true });
61
+ return entries.filter(entry => entry.isDirectory() || entry.isSymbolicLink()).map(entry => entry.name);
62
+ } catch {
63
+ return [];
64
+ }
65
+ };
66
+
67
+ /** The skills a plugin's materialized payload would actually expose, as `<plugin>:<skill>`. */
68
+ export const readMaterializedPluginSkills = async ({ agentHome, pluginId }) => {
69
+ const root = buildPluginCachePath({ agentHome, pluginId });
70
+ const { name } = pluginIdParts(pluginId);
71
+ const skills = new Set();
72
+ const versions = await listDirectoryNames(root);
73
+ for (const version of versions) {
74
+ for (const skill of await listDirectoryNames(path.join(root, version, 'skills'))) {
75
+ try {
76
+ await fs.access(path.join(root, version, 'skills', skill, 'SKILL.md'));
77
+ skills.add(`${name}:${skill}`.toLowerCase());
78
+ } catch {
79
+ // A directory without SKILL.md is not a skill the CLI would render.
80
+ }
81
+ }
82
+ }
83
+ return { root, versions, skills };
84
+ };
85
+
86
+ /** `expectedSkills` maps a plugin id to the `<plugin>:<skill>` names that must be materialized. */
87
+ export const inspectPluginPayloads = async ({ agentHome, plugins, expectedSkills = new Map() }) => {
88
+ const report = [];
89
+ for (const pluginId of plugins) {
90
+ const expected = expectedSkills.get(pluginId) || [];
91
+ const { root, versions, skills } = await readMaterializedPluginSkills({ agentHome, pluginId });
92
+ const missing = expected.filter(skill => !skills.has(skill));
93
+ report.push({ pluginId, root, versions, materialized: [...skills].sort(), missing, healthy: versions.length > 0 && missing.length === 0 });
94
+ }
95
+ return report;
96
+ };
97
+
98
+ /**
99
+ * Repair strategies, cheapest first: the first one that materializes the
100
+ * payload wins. `copyFrom` (the operator's own agent home) is the last resort
101
+ * for a scoped home that can no longer reach its marketplace source.
102
+ */
103
+ export const buildPluginPayloadRepairs = ({ cli, onInstalled = async () => {}, onCopied = async () => {} }) => {
104
+ const install = async ({ command, env, runCommand, pluginId }) => {
105
+ const result = await runCommand({ command, args: cli.install(pluginId), env });
106
+ if (result?.code !== 0) throw new Error(`${command} ${cli.install(pluginId).join(' ')} exited with code ${result?.code}: ${String(result?.stderr || result?.stdout || '').trim()}`);
107
+ await onInstalled({ result, pluginId });
108
+ return result;
109
+ };
110
+
111
+ return [
112
+ { name: 'install', apply: install },
113
+ {
114
+ name: 'reinstall',
115
+ apply: async ({ command, env, runCommand, pluginId, agentHome }) => {
116
+ // Removal clears the enablement record together with the cache and is a
117
+ // no-op when the plugin is not installed.
118
+ await runCommand({ command, args: cli.remove(pluginId), env });
119
+ await fs.rm(buildPluginCachePath({ agentHome, pluginId }), { recursive: true, force: true });
120
+ await install({ command, env, runCommand, pluginId });
121
+ },
122
+ },
123
+ {
124
+ name: 'copy-operator-payload',
125
+ apply: async ({ pluginId, agentHome, copyFrom }) => {
126
+ if (!copyFrom) throw new Error(`No operator ${cli.label} home is available to copy ${pluginId} from.`);
127
+ const source = buildPluginCachePath({ agentHome: copyFrom, pluginId });
128
+ const operator = await readMaterializedPluginSkills({ agentHome: copyFrom, pluginId });
129
+ if (operator.versions.length === 0) throw new Error(`The operator ${cli.label} home has no materialized payload for ${pluginId} at ${source}.`);
130
+ const target = buildPluginCachePath({ agentHome, pluginId });
131
+ await fs.rm(target, { recursive: true, force: true });
132
+ await fs.mkdir(path.dirname(target), { recursive: true });
133
+ await fs.cp(source, target, { recursive: true, dereference: true, force: true });
134
+ await onCopied({ pluginId, agentHome });
135
+ },
136
+ },
137
+ ];
138
+ };
139
+
140
+ /**
141
+ * Inspect, repair, re-inspect. `force: true` applies the first strategy even to
142
+ * a payload that looks healthy — used when a stronger signal (the rendered
143
+ * prompt) says the skills are still not reaching the model.
144
+ */
145
+ export const repairPluginPayloads = async ({ command, env, runCommand, log = async () => {}, agentHome, copyFrom, plugins, expectedSkills, strategies, label = 'agent', force = false }) => {
146
+ const applied = [];
147
+ let report = await inspectPluginPayloads({ agentHome, plugins, expectedSkills });
148
+ for (const entry of report) {
149
+ await log(` 🔎 Scoped payload for ${entry.pluginId}: ${entry.versions.length} version(s), skills: ${entry.materialized.join(', ') || 'none'}`, { verbose: true });
150
+ }
151
+
152
+ for (const strategy of strategies) {
153
+ const unhealthy = report.filter(entry => force || !entry.healthy);
154
+ if (unhealthy.length === 0) break;
155
+ for (const entry of unhealthy) {
156
+ const reason = entry.versions.length === 0 ? 'payload not materialized' : entry.missing.length > 0 ? `payload missing ${entry.missing.join(', ')}` : 'model cannot see the required skills';
157
+ await log(` 🛠️ Repairing ${entry.pluginId} in ${label} state (${strategy.name}; ${reason})`);
158
+ try {
159
+ await strategy.apply({ command, env, runCommand, pluginId: entry.pluginId, agentHome, copyFrom });
160
+ applied.push(`${strategy.name}:${entry.pluginId}`);
161
+ } catch (error) {
162
+ await log(` ⚠️ Repair step '${strategy.name}' failed for ${entry.pluginId}: ${error.message}`, { verbose: true });
163
+ applied.push(`${strategy.name}:${entry.pluginId} (failed)`);
164
+ }
165
+ }
166
+ force = false;
167
+ report = await inspectPluginPayloads({ agentHome, plugins, expectedSkills });
168
+ for (const entry of report) {
169
+ if (entry.healthy) await log(` ✅ Materialized ${entry.pluginId} payload: ${entry.materialized.join(', ') || 'no skills'}`, { verbose: true });
170
+ }
171
+ }
172
+
173
+ return { report, applied, unhealthy: report.filter(entry => !entry.healthy) };
174
+ };
175
+
176
+ export default { CLAUDE_PLUGIN_CLI, CODEX_PLUGIN_CLI, buildPluginCachePath, buildPluginPayloadRepairs, inspectPluginPayloads, readMaterializedPluginSkills, repairPluginPayloads };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared CLI-argument override merging (issue #2085).
3
+ *
4
+ * The Telegram bot lets operators configure "override" options that are always
5
+ * applied to a command regardless of what the requester typed — e.g.
6
+ * `TELEGRAM_SOLVE_OVERRIDES="(\n --attach-logs\n --auto-continue\n)"`. These
7
+ * overrides encode the operator's defaults for `/solve`.
8
+ *
9
+ * `mergeArgsWithOverrides` used to live inside `telegram-bot.mjs`, where only
10
+ * the `/solve` and `/hive` handlers could reach it. `/fix` hands its generated
11
+ * issue off to `/solve`, so it must apply the very same solve overrides —
12
+ * otherwise the solve started by `/fix` silently runs without the operator's
13
+ * defaults (issue #2085: "`--attach-logs` were not applied"). Extracting the
14
+ * helper here lets `telegram-fix-command.lib.mjs` reuse the exact same merge
15
+ * semantics without importing the bot entry point (which would be circular).
16
+ */
17
+
18
+ /**
19
+ * Merge operator override options into a user-supplied argument list.
20
+ *
21
+ * Override flags win: any user flag that also appears in `overrides` (together
22
+ * with its value, if any) is dropped, then every override is appended. Boolean
23
+ * flags and `--flag value` pairs are both handled. The relative order of the
24
+ * surviving user args (including positionals like the issue/repository URL) is
25
+ * preserved, and the overrides are appended at the end so they take precedence
26
+ * in last-wins CLI parsers.
27
+ *
28
+ * @param {string[]} userArgs - Arguments the requester supplied.
29
+ * @param {string[]} overrides - Operator override options (already tokenized).
30
+ * @returns {string[]} The merged argument list.
31
+ */
32
+ export function mergeArgsWithOverrides(userArgs, overrides) {
33
+ if (!overrides || overrides.length === 0) {
34
+ return Array.isArray(userArgs) ? userArgs : [];
35
+ }
36
+ const safeUserArgs = Array.isArray(userArgs) ? userArgs : [];
37
+
38
+ // Parse overrides to identify flags and their values
39
+ const overrideFlags = new Map(); // Map of flag -> value (or null for boolean flags)
40
+
41
+ for (let i = 0; i < overrides.length; i++) {
42
+ const arg = overrides[i];
43
+ if (arg.startsWith('--')) {
44
+ // Check if next item is a value (doesn't start with --)
45
+ if (i + 1 < overrides.length && !overrides[i + 1].startsWith('--')) {
46
+ overrideFlags.set(arg, overrides[i + 1]);
47
+ i++; // Skip the value in next iteration
48
+ } else {
49
+ overrideFlags.set(arg, null); // Boolean flag
50
+ }
51
+ }
52
+ }
53
+
54
+ // Filter user args to remove any that conflict with overrides
55
+ const filteredArgs = [];
56
+ for (let i = 0; i < safeUserArgs.length; i++) {
57
+ const arg = safeUserArgs[i];
58
+ if (arg.startsWith('--')) {
59
+ // If this flag exists in overrides, skip it and its value
60
+ if (overrideFlags.has(arg)) {
61
+ // Skip the flag
62
+ // Also skip next arg if it's a value (doesn't start with --)
63
+ if (i + 1 < safeUserArgs.length && !safeUserArgs[i + 1].startsWith('--')) {
64
+ i++; // Skip the value too
65
+ }
66
+ continue;
67
+ }
68
+ }
69
+ filteredArgs.push(arg);
70
+ }
71
+
72
+ // Merge: filtered user args + overrides
73
+ return [...filteredArgs, ...overrides];
74
+ }
@@ -13,6 +13,8 @@ import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { promisify } from 'node:util';
15
15
 
16
+ import { CODEX_PLUGIN_CLI, buildPluginCachePath as buildAgentPluginCachePath, buildPluginPayloadRepairs, pluginIdParts, readMaterializedPluginSkills as readAgentMaterializedPluginSkills, repairPluginPayloads } from './agent-plugin-cache.lib.mjs';
17
+
16
18
  const execFileAsync = promisify(execFile);
17
19
  const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|use)\b/i;
18
20
  const NEGATED_REQUIREMENT = /\b(?:does\s+not\s+require|not\s+required|optional)\b/i;
@@ -90,18 +92,26 @@ export function normalizePluginSelector(selector) {
90
92
  export function detectRequiredCodexCapabilities(text) {
91
93
  const plugins = new Set();
92
94
  const skills = new Set();
95
+ // Issue #2088: a fully qualified reference — `plugin@marketplace` in plugin
96
+ // context, or `plugin:skill` in requirement context — names a capability that
97
+ // prose cannot produce by accident, so an unrepairable failure for one of
98
+ // these is a real blocker. A bare `$name` or `` `name` skill `` token is a
99
+ // guess about free text and stays advisory, which keeps issue #2077's
100
+ // false-positive protection intact.
101
+ const explicit = new Set();
93
102
  // Every accepted capability keeps the line it came from so `--verbose` can
94
103
  // explain a detection instead of only reporting its consequence (issue #2077).
95
104
  const evidence = [];
96
105
  const rejected = [];
97
106
 
98
- const accept = (target, value, line) => {
107
+ const accept = (target, value, line, { qualified = false } = {}) => {
99
108
  if (!isCapabilityName(value)) {
100
109
  rejected.push({ capability: value, line });
101
110
  return;
102
111
  }
103
112
  target.add(value);
104
- evidence.push({ capability: value, line });
113
+ if (qualified) explicit.add(value);
114
+ evidence.push({ capability: value, line, explicit: qualified });
105
115
  };
106
116
 
107
117
  for (const rawLine of String(text || '').split(/\r?\n/u)) {
@@ -110,20 +120,22 @@ export function detectRequiredCodexCapabilities(text) {
110
120
 
111
121
  for (const match of line.matchAll(PLUGIN_SELECTOR)) {
112
122
  const selector = normalizePluginSelector(match[1]);
113
- if (hasExplicitPluginContext(line, match)) accept(plugins, selector, line);
123
+ if (hasExplicitPluginContext(line, match)) accept(plugins, selector, line, { qualified: true });
114
124
  else rejected.push({ capability: selector, line });
115
125
  }
116
126
  for (const match of line.matchAll(NAMESPACED_SKILL)) {
117
127
  const skill = match[1].toLowerCase();
118
- if (hasExplicitCapabilityContext(line, match)) accept(skills, skill, line);
128
+ if (hasExplicitCapabilityContext(line, match)) accept(skills, skill, line, { qualified: true });
119
129
  else rejected.push({ capability: skill, line });
120
130
  }
121
131
  for (const match of line.matchAll(EXPLICIT_BARE_SKILL)) accept(skills, (match[1] || match[2]).toLowerCase(), line);
122
132
  }
123
133
 
124
- return { plugins: [...plugins].sort(), skills: [...skills].sort(), evidence, rejected };
134
+ return { plugins: [...plugins].sort(), skills: [...skills].sort(), explicit: [...explicit].sort(), evidence, rejected };
125
135
  }
126
136
 
137
+ export const isExplicitRequirement = (requirements, capability) => (requirements?.explicit || []).includes(String(capability || '').toLowerCase());
138
+
127
139
  const sanitizePathSegment = value => String(value || '').replace(/[^a-zA-Z0-9._-]/gu, '_');
128
140
 
129
141
  export function buildCodexCapabilityStatePath({ baseCodexHome, owner, repo }) {
@@ -195,19 +207,28 @@ const readModelVisibleSkills = async ({ command, env, runCommand, log }) => {
195
207
  return skills;
196
208
  };
197
209
 
198
- const verifyModelVisibleSkills = async ({ command, env, runCommand, log, requiredSkills }) => {
199
- if (!requiredSkills || requiredSkills.length === 0) return;
210
+ // `status: 'unknown'` keeps the probe advisory when it cannot run at all;
211
+ // `status: 'missing'` is a fact about the prompt the model would receive.
212
+ const checkModelVisibleSkills = async ({ command, env, runCommand, log, requiredSkills }) => {
213
+ if (!requiredSkills || requiredSkills.length === 0) return { status: 'satisfied', visible: null, missing: [] };
200
214
  const visible = await readModelVisibleSkills({ command, env, runCommand, log });
201
- if (!visible) return;
215
+ if (!visible) return { status: 'unknown', visible: null, missing: [] };
202
216
 
203
217
  await log(` 🔎 Model-visible skills (${visible.size}): ${[...visible].sort().join(', ') || 'none'}`, { verbose: true });
204
- const invisible = requiredSkills.filter(skill => !visible.has(skill.toLowerCase()));
205
- if (invisible.length === 0) {
206
- await log(` ✅ Verified ${requiredSkills.length} required skill(s) are visible to the model`);
207
- return;
208
- }
218
+ const missing = requiredSkills.filter(skill => !visible.has(skill.toLowerCase()));
219
+ return { status: missing.length === 0 ? 'satisfied' : 'missing', visible, missing };
220
+ };
221
+
222
+ const skillVisibilityError = ({ missing, visible, requirements, repairs = [] }) => new CodexCapabilityPreflightError(`Codex reports the required plugins as installed, but the model cannot see: ${missing.join(', ')}. ` + `Codex exposes a plugin's skills only while its payload is materialized under ` + `CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills. ` + `Visible skills were: ${visible ? [...visible].sort().join(', ') || 'none' : 'unknown'}.` + (repairs.length > 0 ? ` Attempted repairs: ${repairs.join(', ')}.` : ''), { missing, failClosed: missing.some(skill => isExplicitRequirement(requirements, skill)) });
209
223
 
210
- throw new CodexCapabilityPreflightError(`Codex reports the required plugins as installed, but the model cannot see: ${invisible.join(', ')}. ` + `Codex exposes a plugin's skills only while its payload is materialized under ` + `CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills. ` + `Visible skills were: ${[...visible].sort().join(', ') || 'none'}.`, { missing: invisible });
224
+ const verifyModelVisibleSkills = async ({ command, env, runCommand, log, requiredSkills, requirements }) => {
225
+ const outcome = await checkModelVisibleSkills({ command, env, runCommand, log, requiredSkills });
226
+ if (outcome.status === 'unknown') return outcome;
227
+ if (outcome.status === 'satisfied') {
228
+ if (requiredSkills?.length) await log(` ✅ Verified ${requiredSkills.length} required skill(s) are visible to the model`);
229
+ return outcome;
230
+ }
231
+ throw skillVisibilityError({ missing: outcome.missing, visible: outcome.visible, requirements });
211
232
  };
212
233
 
213
234
  const skillParts = skill => {
@@ -240,10 +261,14 @@ const skillExistsInDirectories = async (skill, skillDirectories) => {
240
261
  return false;
241
262
  };
242
263
 
243
- export async function resolveRequiredPlugins({ requirements, catalog, skillDirectories = [] }) {
264
+ // Issue #2088: repairing a payload requires knowing *which* plugin is expected
265
+ // to provide each required skill, so resolution reports the mapping rather than
266
+ // only the set of plugin selectors.
267
+ export async function resolveRequiredCapabilities({ requirements, catalog, skillDirectories = [] }) {
244
268
  const entries = catalogEntries(catalog);
245
269
  const byId = new Map(entries.map(entry => [normalizePluginSelector(entry.pluginId), entry]));
246
270
  const selected = new Map();
271
+ const providers = new Map();
247
272
  const missing = [];
248
273
 
249
274
  for (const selector of requirements.plugins || []) {
@@ -266,15 +291,29 @@ export async function resolveRequiredPlugins({ requirements, catalog, skillDirec
266
291
  break;
267
292
  }
268
293
  }
269
- if (provider) selected.set(normalizePluginSelector(provider.pluginId), provider);
270
- else missing.push(skill);
294
+ if (provider) {
295
+ selected.set(normalizePluginSelector(provider.pluginId), provider);
296
+ providers.set(skill, { pluginId: normalizePluginSelector(provider.pluginId), pluginName: provider.name || skillParts(skill).namespace, skillName: skillParts(skill).name });
297
+ } else missing.push(skill);
271
298
  }
272
299
 
273
300
  if (missing.length > 0) {
274
- 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 });
301
+ // A missing capability that the issue named explicitly is a blocker; one
302
+ // inferred from prose is a guess that failed (issues #2077 and #2088).
303
+ // Only `plugin@marketplace` selectors qualify here: at this point nothing
304
+ // has corroborated the detection, and a `plugin:skill` token the catalog has
305
+ // never heard of is more likely a false positive (issue #2080) than a real
306
+ // requirement. Once the catalog *does* resolve the provider, a skill that
307
+ // repair cannot materialize does fail closed further down.
308
+ const explicitMissing = missing.filter(capability => isExplicitRequirement(requirements, capability) && capability.includes('@'));
309
+ 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, failClosed: explicitMissing.length > 0 });
275
310
  }
276
311
 
277
- return [...selected.keys()].sort();
312
+ return { plugins: [...selected.keys()].sort(), providers };
313
+ }
314
+
315
+ export async function resolveRequiredPlugins(options) {
316
+ return (await resolveRequiredCapabilities(options)).plugins;
278
317
  }
279
318
 
280
319
  const readIssueRequirementText = async ({ owner, repo, issueNumber, runCommand }) => {
@@ -349,8 +388,55 @@ const prepareScopedCodexHome = async ({ baseCodexHome, codexHome }) => {
349
388
  }
350
389
  };
351
390
 
391
+ // --- issue #2088: repair the repository-scoped plugin payload ----------------
392
+ //
393
+ // `codex plugin list` reporting `installed, enabled` proves only that
394
+ // `config.toml` declares the plugin and that Codex could resolve a source for
395
+ // it. The skill loader reads
396
+ // `CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills`, so a
397
+ // scoped home whose cache is missing or stale exposes zero skills while every
398
+ // enablement check passes. Reproduced end to end with a real Codex CLI in
399
+ // experiments/issue-2088/reproduce-cache-repair.sh.
400
+
401
+ export const buildPluginCachePath = ({ codexHome, pluginId }) => buildAgentPluginCachePath({ agentHome: codexHome, pluginId: normalizePluginSelector(pluginId) });
402
+
403
+ export const readMaterializedPluginSkills = ({ codexHome, pluginId }) => readAgentMaterializedPluginSkills({ agentHome: codexHome, pluginId: normalizePluginSelector(pluginId) });
404
+
405
+ // `codex plugin remove` also drops the `[plugins."…"]` block, so a payload
406
+ // restored by copying it back in has to be re-declared to stay enabled.
407
+ const enablePluginInScopedConfig = async ({ agentHome: codexHome, pluginId }) => {
408
+ const configPath = path.join(codexHome, 'config.toml');
409
+ const config = await readIfPresent(configPath);
410
+ if (new RegExp(`^\\[plugins\\."${pluginId.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}"\\]`, 'mu').test(config)) return;
411
+ await fs.writeFile(configPath, `${config.trimEnd()}\n\n[plugins."${pluginId}"]\nenabled = true\n`);
412
+ };
413
+
414
+ const PLUGIN_PAYLOAD_REPAIRS = buildPluginPayloadRepairs({
415
+ cli: CODEX_PLUGIN_CLI,
416
+ onInstalled: ({ result, pluginId }) => parseJsonCommand(result, `Installing required Codex plugin ${pluginId}`),
417
+ onCopied: enablePluginInScopedConfig,
418
+ });
419
+
420
+ // The provider map answers "which plugin must expose which skill", which is
421
+ // what turns a payload listing into a health verdict.
422
+ const expectedSkillsByPlugin = providers => {
423
+ const expected = new Map();
424
+ for (const provider of providers?.values() || []) {
425
+ const skill = `${pluginIdParts(provider.pluginId).name}:${provider.skillName}`.toLowerCase();
426
+ expected.set(provider.pluginId, [...(expected.get(provider.pluginId) || []), skill]);
427
+ }
428
+ return expected;
429
+ };
430
+
431
+ export const repairScopedPluginPayloads = ({ command, env, runCommand, log, codexHome, baseCodexHome, plugins, providers, strategies = PLUGIN_PAYLOAD_REPAIRS, force = false }) => repairPluginPayloads({ command, env, runCommand, log, agentHome: codexHome, copyFrom: baseCodexHome, plugins, expectedSkills: expectedSkillsByPlugin(providers), strategies, force, label: 'repository-scoped Codex' });
432
+
352
433
  export const isCodexCapabilityStrict = (env = process.env) => /^(?:1|true|yes|on)$/iu.test(String(env.HIVE_MIND_CODEX_CAPABILITY_STRICT || ''));
353
434
 
435
+ // Issue #2088: explicitly declared capabilities fail closed. This escape hatch
436
+ // restores the previous advisory-only behaviour for an operator who would
437
+ // rather run degraded than not at all.
438
+ export const isCodexCapabilityAdvisory = (env = process.env) => /^(?:1|true|yes|on)$/iu.test(String(env.HIVE_MIND_CODEX_CAPABILITY_ADVISORY || ''));
439
+
354
440
  export async function runCodexCapabilityPreflight(options = {}) {
355
441
  const { log = async () => {}, env = process.env } = options;
356
442
  try {
@@ -363,6 +449,15 @@ export async function runCodexCapabilityPreflight(options = {}) {
363
449
  // ratio (`16:9`) was read as a skill name. Degrade to a warning and let
364
450
  // Codex execute with the operator's own capabilities.
365
451
  if (isCodexCapabilityStrict(env)) throw error;
452
+ // Issue #2088: that reasoning does not extend to a capability the issue
453
+ // named explicitly and that repair could not materialize. Continuing there
454
+ // spends a full solver run that is already known to be unable to satisfy
455
+ // the task, so an explicit requirement fails closed before `codex exec`.
456
+ if (error.details?.failClosed && !isCodexCapabilityAdvisory(env)) {
457
+ await log(`❌ Codex capability preflight failed: ${error.message}`);
458
+ await log(' The issue names this capability explicitly, so Codex was not started. Set HIVE_MIND_CODEX_CAPABILITY_ADVISORY=1 to run degraded instead.');
459
+ throw error;
460
+ }
366
461
  await log(`⚠️ Codex capability preflight skipped: ${error.message}`);
367
462
  await log(' Continuing with the operator Codex capabilities. Set HIVE_MIND_CODEX_CAPABILITY_STRICT=1 to fail instead.');
368
463
  return { required: false, degraded: true, error: error.message, plugins: [], codexHome: null };
@@ -390,13 +485,13 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
390
485
  const baseCatalogResult = await runCommand({ command, args: ['plugin', 'list', '--available', '--json'], env: baseEnv });
391
486
  const baseCatalog = parseJsonCommand(baseCatalogResult, 'Codex plugin catalog discovery');
392
487
  const skillDirectories = [path.join(os.homedir(), '.agents', 'skills'), projectDir && path.join(projectDir, '.agents', 'skills')].filter(Boolean);
393
- const plugins = await resolveRequiredPlugins({ requirements, catalog: baseCatalog, skillDirectories });
488
+ const { plugins, providers } = await resolveRequiredCapabilities({ requirements, catalog: baseCatalog, skillDirectories });
394
489
  for (const plugin of plugins) {
395
490
  await log(` ✅ Verified ${plugin} in the Codex plugin catalog`, { verbose: true });
396
491
  }
397
492
  if (plugins.length === 0) {
398
493
  await log(' ✅ Required Agent Skills are already available from standard skill directories');
399
- await verifyModelVisibleSkills({ command, env: baseEnv, runCommand, log, requiredSkills: requirements.skills });
494
+ await verifyModelVisibleSkills({ command, env: baseEnv, runCommand, log, requiredSkills: requirements.skills, requirements });
400
495
  return { required: true, plugins, skills: requirements.skills, codexHome: null, baseCodexHome };
401
496
  }
402
497
 
@@ -404,32 +499,61 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
404
499
  await prepareScopedCodexHome({ baseCodexHome, codexHome });
405
500
  const scopedEnv = { ...process.env, CODEX_HOME: codexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
406
501
 
407
- const scopedCatalogResult = await runCommand({ command, args: ['plugin', 'list', '--json'], env: scopedEnv });
408
- const scopedCatalog = parseJsonCommand(scopedCatalogResult, 'Repository-scoped Codex plugin discovery');
409
- const installed = new Set((scopedCatalog.installed || []).filter(plugin => plugin.installed && plugin.enabled).map(plugin => normalizePluginSelector(plugin.pluginId)));
410
-
411
- for (const plugin of plugins) {
412
- if (installed.has(plugin)) continue;
413
- const installResult = await runCommand({ command, args: ['plugin', 'add', plugin, '--json'], env: scopedEnv });
414
- parseJsonCommand(installResult, `Installing required Codex plugin ${plugin}`);
415
- await log(` ✅ Provisioned ${plugin} in repository-scoped Codex state`);
502
+ // Issue #2088: install *and repair*. Enablement recorded in the scoped
503
+ // `config.toml` survives a container restart while the payload under
504
+ // `plugins/cache` may not, and `codex plugin list` cannot tell those states
505
+ // apart — so the payload itself is the thing that gets checked and rebuilt.
506
+ const repair = await repairScopedPluginPayloads({ command, env: scopedEnv, runCommand, log, codexHome, baseCodexHome, plugins, providers });
507
+ for (const entry of repair.report) {
508
+ if (entry.healthy) await log(` ✅ Provisioned ${entry.pluginId} in repository-scoped Codex state`);
416
509
  }
417
510
 
418
511
  const verifyResult = await runCommand({ command, args: ['plugin', 'list', '--json'], env: scopedEnv });
419
512
  const verifiedCatalog = parseJsonCommand(verifyResult, 'Codex capability verification');
420
513
  const verified = new Set((verifiedCatalog.installed || []).filter(plugin => plugin.installed && plugin.enabled).map(plugin => normalizePluginSelector(plugin.pluginId)));
421
514
  const unverified = plugins.filter(plugin => !verified.has(plugin));
422
- if (unverified.length > 0) throw new CodexCapabilityPreflightError(`Codex capability installation did not verify successfully: ${unverified.join(', ')}`, { missing: unverified });
515
+ if (unverified.length > 0) {
516
+ const blockedSkills = repair.unhealthy.flatMap(entry => entry.missing);
517
+ throw new CodexCapabilityPreflightError(`Codex capability installation did not verify successfully: ${unverified.join(', ')}. ` + (blockedSkills.length > 0 ? `The required skills ${blockedSkills.join(', ')} are therefore unavailable. ` : '') + `Expected the plugin payload under CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills in ${codexHome}. ` + `Attempted repairs: ${repair.applied.join(', ') || 'none'}.`, {
518
+ missing: unverified,
519
+ failClosed: unverified.some(plugin => isExplicitRequirement(requirements, plugin)) || blockedSkills.some(skill => isExplicitRequirement(requirements, skill)),
520
+ });
521
+ }
423
522
 
424
523
  // Issue #2084: enablement is not exposure. The failing run reached this point
425
524
  // with `superpowers@openai-curated` reported as "installed, enabled" while
426
525
  // the model saw zero `superpowers:*` skills, so the run proceeded and then
427
526
  // stalled on the repository's mandatory preflight. Confirm the requirement
428
527
  // against the catalog the model actually receives.
429
- await verifyModelVisibleSkills({ command, env: scopedEnv, runCommand, log, requiredSkills: requirements.skills });
528
+ let visibility = await checkModelVisibleSkills({ command, env: scopedEnv, runCommand, log, requiredSkills: requirements.skills });
529
+ if (visibility.status === 'missing') {
530
+ // The payload looks materialized but the prompt disagrees: rebuild it from
531
+ // scratch and re-probe before deciding (issue #2088).
532
+ await log(` 🛠️ Model cannot see ${visibility.missing.join(', ')}; forcing a repository-scoped plugin payload rebuild`);
533
+ const forced = await repairScopedPluginPayloads({ command, env: scopedEnv, runCommand, log, codexHome, baseCodexHome, plugins, providers, strategies: PLUGIN_PAYLOAD_REPAIRS.slice(1), force: true });
534
+ repair.applied.push(...forced.applied);
535
+ visibility = await checkModelVisibleSkills({ command, env: scopedEnv, runCommand, log, requiredSkills: requirements.skills });
536
+ }
537
+ if (visibility.status === 'missing') throw skillVisibilityError({ missing: visibility.missing, visible: visibility.visible, requirements, repairs: repair.applied });
538
+ if (visibility.status === 'unknown' && repair.unhealthy.length > 0) {
539
+ // Without the probe the materialized payload is the only evidence there is.
540
+ const missing = repair.unhealthy.flatMap(entry => (entry.missing.length > 0 ? entry.missing : [entry.pluginId]));
541
+ throw new CodexCapabilityPreflightError(`Repository-scoped Codex plugin payload could not be materialized for: ${missing.join(', ')}. ` + `Expected skills under CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills in ${codexHome}. ` + `Attempted repairs: ${repair.applied.join(', ') || 'none'}.`, { missing, failClosed: missing.some(capability => isExplicitRequirement(requirements, capability)) });
542
+ }
543
+ if (visibility.status === 'satisfied' && requirements.skills.length > 0) await log(` ✅ Verified ${requirements.skills.length} required skill(s) are visible to the model`);
430
544
 
431
545
  await log(` Codex capability state: ${codexHome}`, { verbose: true });
432
- return { required: true, plugins, skills: requirements.skills, codexHome, baseCodexHome };
546
+ return { required: true, plugins, skills: requirements.skills, codexHome, baseCodexHome, repairs: repair.applied };
433
547
  }
434
548
 
435
- export default { applyCodexCapabilityEnv, detectRequiredCodexCapabilities, isCapabilityName, runCodexCapabilityPreflight };
549
+ export default {
550
+ applyCodexCapabilityEnv,
551
+ buildPluginCachePath,
552
+ detectRequiredCodexCapabilities,
553
+ isCapabilityName,
554
+ isExplicitRequirement,
555
+ readMaterializedPluginSkills,
556
+ repairScopedPluginPayloads,
557
+ resolveRequiredCapabilities,
558
+ runCodexCapabilityPreflight,
559
+ };
@@ -29,6 +29,7 @@ const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config
29
29
  const { enhanceUnknownArgumentError } = await import('./option-suggestions.lib.mjs');
30
30
  const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
31
31
  const { extractIsolationFromArgs, isValidPerCommandIsolation } = await import('./telegram-isolation.lib.mjs');
32
+ const { mergeArgsWithOverrides } = await import('./args-overrides.lib.mjs'); // issue #2085
32
33
 
33
34
  const config = createTelegramYargsConfig(yargs(hideBin(process.argv))).parse();
34
35
 
@@ -367,49 +368,6 @@ function validateModelInArgs(args, tool = 'claude') {
367
368
  return null;
368
369
  }
369
370
 
370
- function mergeArgsWithOverrides(userArgs, overrides) {
371
- if (!overrides || overrides.length === 0) {
372
- return userArgs;
373
- }
374
-
375
- // Parse overrides to identify flags and their values
376
- const overrideFlags = new Map(); // Map of flag -> value (or null for boolean flags)
377
-
378
- for (let i = 0; i < overrides.length; i++) {
379
- const arg = overrides[i];
380
- if (arg.startsWith('--')) {
381
- // Check if next item is a value (doesn't start with --)
382
- if (i + 1 < overrides.length && !overrides[i + 1].startsWith('--')) {
383
- overrideFlags.set(arg, overrides[i + 1]);
384
- i++; // Skip the value in next iteration
385
- } else {
386
- overrideFlags.set(arg, null); // Boolean flag
387
- }
388
- }
389
- }
390
-
391
- // Filter user args to remove any that conflict with overrides
392
- const filteredArgs = [];
393
- for (let i = 0; i < userArgs.length; i++) {
394
- const arg = userArgs[i];
395
- if (arg.startsWith('--')) {
396
- // If this flag exists in overrides, skip it and its value
397
- if (overrideFlags.has(arg)) {
398
- // Skip the flag
399
- // Also skip next arg if it's a value (doesn't start with --)
400
- if (i + 1 < userArgs.length && !userArgs[i + 1].startsWith('--')) {
401
- i++; // Skip the value too
402
- }
403
- continue;
404
- }
405
- }
406
- filteredArgs.push(arg);
407
- }
408
-
409
- // Merge: filtered user args + overrides
410
- return [...filteredArgs, ...overrides];
411
- }
412
-
413
371
  // Inject --language LOCALE into spawn args if no language flag is already present.
414
372
  // Issue #378: telegram bot resolves the user's effective locale and propagates
415
373
  // it to spawned solve/hive sessions so the AI tool replies in the same language.
@@ -595,7 +553,7 @@ registerSubscribeCommands(bot, sharedCommandOpts);
595
553
  const { registerTaskCommands } = await import('./telegram-task-command.lib.mjs');
596
554
  const { handleTaskCommand, TASK_COMMAND_NAMES } = registerTaskCommands(bot, { ...sharedCommandOpts, taskEnabled, safeReply, executeAndUpdateMessage, resolveLocale: resolveLocaleFromTelegramCtx });
597
555
  const { registerFixCommand } = await import('./telegram-fix-command.lib.mjs');
598
- const { handleFixCommand, FIX_COMMAND_NAMES } = registerFixCommand(bot, { ...sharedCommandOpts, fixEnabled, safeReply, executeAndUpdateMessage, resolveLocale: resolveLocaleFromTelegramCtx });
556
+ const { handleFixCommand, FIX_COMMAND_NAMES } = registerFixCommand(bot, { ...sharedCommandOpts, fixEnabled, safeReply, executeAndUpdateMessage, resolveLocale: resolveLocaleFromTelegramCtx, solveOverrides });
599
557
  const { registerAuthCommand } = await import('./telegram-auth-command.lib.mjs');
600
558
  const { handleAuthCommand } = registerAuthCommand(bot, { ...sharedCommandOpts, allowedChats, authEnabled, safeReply });
601
559
 
@@ -12,6 +12,7 @@ import { validateModelName } from './models/index.mjs';
12
12
  import { parseFixRepository } from './fix.ci-cd.lib.mjs';
13
13
  import { escapeMarkdown } from './telegram-markdown.lib.mjs';
14
14
  import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
15
+ import { mergeArgsWithOverrides } from './args-overrides.lib.mjs';
15
16
  import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
16
17
  import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
17
18
 
@@ -87,7 +88,7 @@ function injectLanguageIfMissing(args, locale) {
87
88
  }
88
89
 
89
90
  export function registerFixCommand(bot, options) {
90
- const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, resolveLocale = null } = options;
91
+ const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, resolveLocale = null, solveOverrides = [] } = options;
91
92
 
92
93
  async function handleFixCommand(ctx) {
93
94
  const commandDisplay = '/fix';
@@ -137,7 +138,22 @@ export function registerFixCommand(bot, options) {
137
138
  return;
138
139
  }
139
140
 
140
- const modelError = validateFixModel(filteredArgs);
141
+ // Issue #2085: /fix hands the generated issue off to /solve, so it must
142
+ // apply the operator's solve overrides (TELEGRAM_SOLVE_OVERRIDES) exactly
143
+ // like the /solve handler does — otherwise the solve started by /fix runs
144
+ // without the operator's defaults (e.g. --attach-logs). The overrides are
145
+ // forwarded to /solve because /fix passes every option it does not consume
146
+ // through to solve.mjs. An --isolation override applies to the /fix work
147
+ // session itself (which contains the nested /solve), mirroring /solve.
148
+ const { backend: overrideIsolation, filteredArgs: solveOverridesWithoutIsolation } = extractIsolationFromArgs(solveOverrides);
149
+ if (overrideIsolation && !isValidPerCommandIsolation(overrideIsolation)) {
150
+ await safeReply(ctx, `❌ Invalid --isolation value '${escapeMarkdown(overrideIsolation)}' in solve overrides. Must be: screen, tmux, or docker`, { reply_to_message_id: ctx.message.message_id });
151
+ return;
152
+ }
153
+ const effectiveIsolation = overrideIsolation || perCommandIsolation;
154
+ const mergedArgs = mergeArgsWithOverrides(filteredArgs, solveOverridesWithoutIsolation);
155
+
156
+ const modelError = validateFixModel(mergedArgs);
141
157
  if (modelError) {
142
158
  await safeReply(ctx, `❌ ${escapeMarkdown(modelError)}`, { reply_to_message_id: ctx.message.message_id });
143
159
  return;
@@ -147,12 +163,13 @@ export function registerFixCommand(bot, options) {
147
163
  const userOptionsRaw = built.args.slice(1).join(' ');
148
164
  let infoBlock = `Requested by: ${requester}\nRepository: ${escapeMarkdown(built.repository.url)}`;
149
165
  if (userOptionsRaw) infoBlock += `\n\n🛠 Options: ${escapeMarkdown(userOptionsRaw)}`;
166
+ if (solveOverrides.length > 0) infoBlock += `\n\n🔒 Solve overrides: ${escapeMarkdown(solveOverrides.join(' '))}`;
150
167
 
151
168
  const fixUrlContext = { owner: built.repository.owner, repo: built.repository.repo, normalized: built.repository.url };
152
169
  const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
153
170
  const fixLocale = resolveLocale ? resolveLocale(ctx) : null;
154
- const argsForExec = injectLanguageIfMissing(filteredArgs, fixLocale);
155
- await executeAndUpdateMessage(ctx, startingMessage, 'fix', argsForExec, infoBlock, perCommandIsolation || null, getFixToolFromArgs(argsForExec), fixUrlContext);
171
+ const argsForExec = injectLanguageIfMissing(mergedArgs, fixLocale);
172
+ await executeAndUpdateMessage(ctx, startingMessage, 'fix', argsForExec, infoBlock, effectiveIsolation || null, getFixToolFromArgs(argsForExec), fixUrlContext);
156
173
  }
157
174
 
158
175
  bot.command(