@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 +41 -0
- package/package.json +1 -1
- package/src/agent-plugin-cache.lib.mjs +176 -0
- package/src/args-overrides.lib.mjs +74 -0
- package/src/codex-capability-preflight.lib.mjs +158 -34
- package/src/telegram-bot.mjs +2 -44
- package/src/telegram-fix-command.lib.mjs +21 -4
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
|
@@ -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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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)
|
|
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
|
|
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 {
|
|
549
|
+
export default {
|
|
550
|
+
applyCodexCapabilityEnv,
|
|
551
|
+
buildPluginCachePath,
|
|
552
|
+
detectRequiredCodexCapabilities,
|
|
553
|
+
isCapabilityName,
|
|
554
|
+
isExplicitRequirement,
|
|
555
|
+
readMaterializedPluginSkills,
|
|
556
|
+
repairScopedPluginPayloads,
|
|
557
|
+
resolveRequiredCapabilities,
|
|
558
|
+
runCodexCapabilityPreflight,
|
|
559
|
+
};
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
155
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'fix', argsForExec, infoBlock,
|
|
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(
|