@link-assistant/hive-mind 2.14.0 → 2.15.1

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,48 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 98fb373: Guarantee that a working session converts its pull request back to "ready for review" (issue #2182).
8
+
9
+ A task ran for 4d 12h 13m 35s and printed `✅ PR IS MERGEABLE!` 2692 times, each followed by `GraphQL: Pull Request is still a draft (mergePullRequest)`. The pull request was a draft because hive-mind had put it there and never took it back out: over the whole 102 244-line run it performed exactly one draft/ready conversion — `Converting PR: To draft mode` — and zero conversions back. The only draft → ready transition came from the AI model itself, running `gh pr ready 142` because the prompt asked it to.
10
+
11
+ **The state machine is now symmetric.** `pr-draft-state.lib.mjs` tracks every draft it hands out, so the matching ready conversion is guaranteed by code rather than requested from the AI:
12
+
13
+ - `executeToolIteration` converts the pull request back to ready in a `finally` block, so a crash, an API error or an aborted tool process still ends the iteration with a mergeable pull request. Previously it drafted the pull request (issue #2123) and had no counterpart at all.
14
+ - `endWorkSession` performs the ready conversion unconditionally. It used to be gated behind `isContinueMode`, which was `false` for the entire reported run, so the one place responsible for the transition never ran. Only the session _comments_ stay gated — they are `--watch`/`--auto-continue` reporting, not state.
15
+ - `solve.mjs` converts the pull request to ready **before** starting the auto-merge watch loop. The AI working session is over at that point; the loop that follows can run for days, and `endWorkSession()` sits behind it.
16
+ - The CTRL+C handler and the fatal-error handler drain the outstanding-draft registry, so an aborted session cannot leave a pull request permanently unmergeable. On interrupt this runs before the log upload, which can be cut off by the isolation backend's SIGKILL (issue #2052).
17
+ - `solve.results.lib.mjs` no longer shells out to `gh pr ready` inline; every transition goes through the state machine, so merged/closed pull requests are skipped and the registry stays accurate.
18
+
19
+ The prompt line asking the AI to mark the pull request ready stays, but nothing depends on it any more.
20
+
21
+ **Defence in depth** — each of these alone would also have ended the reported run, and they bound the damage of a draft pull request whatever its origin:
22
+
23
+ - **`checkPRMergeable` ignored `isDraft`.** A draft pull request with no other blockers reports `mergeable: MERGEABLE` with `mergeStateStatus: CLEAN` — GitHub does not return `DRAFT` there — so the old `mergeable === 'MERGEABLE'` test said yes. Mergeability is now decided by `evaluatePullRequestMergeability`, which treats a draft as not mergeable and reports why. `getMergeBlockers` emits a `draft` blocker on both its normal path and the early "checks have not started yet" path.
24
+ - **Merge failures were unclassified.** Every failed `gh pr merge` was logged as "Will continue monitoring...", regardless of cause. `classifyMergeError` now sorts the error into draft/conflict/blocked/closed/permission/not-mergeable/unknown, the loop self-heals a draft up to three times by marking the pull request ready, and any category stops after `MAX_CONSECUTIVE_MERGE_FAILURES` (3) instead of retrying indefinitely.
25
+ - **The watch loop had no wall-clock limit.** `--auto-restart-until-mergeable-timeout-hours` is new and defaults to 24; the loop now checks elapsed time on every pass and stops with a `watch_timeout` reason.
26
+
27
+ The single-shot merge attempt, the Telegram merge queue and its wait loop use the same classification, so a draft is skipped with the real reason instead of timing out. Every draft/ready conversion now logs the reason it was made, so the log answers "who drafted this and who was supposed to undo it" directly. Full analysis, the run log excerpts and a reproduction script are in `docs/case-studies/issue-2182/`.
28
+
29
+ ## 2.15.0
30
+
31
+ ### Minor Changes
32
+
33
+ - 29612e7: Turn off every AI tool's own cross-task memory and Claude Code's auto-mode classifier by default (issue #2178).
34
+
35
+ A hive-mind task is a disposable container that opens one pull request. The repository — commits, issues, pull requests, case studies — is meant to be the only memory it keeps, because it is the only memory a reviewer can see, correct or revert. Every agentic CLI has since grown a private cross-session store that works against that, and the largest of them (Gemini CLI's auto-memory) is a whole second agent re-reading past sessions on a second model.
36
+
37
+ `--agent-memory-disabled` is new and defaults to `true`. It applies, per tool:
38
+
39
+ - **claude** — `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`, `CLAUDE_CODE_DISABLE_ORG_MEMORY=1`, `autoMemoryEnabled: false`, and `permissions.disableAutoMode: "disable"`. The last one is what removes the classifier: auto mode is what pays for it, and the settings gate is checked before the provider and model gates, so it holds everywhere the environment-variable opt-in would not. Tasks already run `--dangerously-skip-permissions` inside a disposable container, so a classifier deciding whether an action is safe is answering a question that has already been answered.
40
+ - **codex** — `-c features.memories=false -c features.external_agent_memory_import=false`, on both the `codex exec` path and the `--use-agent-commander` path. Both default to off upstream today, but `memories` is a stable-stage flag, so pinning it per run is what makes the default actually hold.
41
+ - **gemini, qwen** — `tools.exclude: ["save_memory"]` and `experimental.autoMemory: false`, merged into the tool's settings file without disturbing anything else already there.
42
+ - **opencode, agent** — no cross-session memory feature was found in either; recorded explicitly so the claim can be re-checked rather than assumed.
43
+
44
+ `--no-agent-memory-disabled` opts out for codex, gemini and qwen, and when it does the policy adds no arguments at all rather than arguments set to `true`. It does not reach claude: those switches are `ENV` lines in the Docker image and settings written by `configure-claude`, neither of which sees a `solve` argv, so they stay off either way and the flag's description says so.
45
+
3
46
  ## 2.14.0
4
47
 
5
48
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -14,6 +14,7 @@ import { detectUsageLimit } from './usage-limit.lib.mjs';
14
14
  import { applyFormalAiPricingOverride } from './formal-ai-pricing.lib.mjs'; // Issue #2119
15
15
  import { getCacheReadTokenCount, getCumulativeContextInputTokens, getOutputTokenCount } from './context-fill.lib.mjs';
16
16
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
17
+ import { CLAUDE_MEMORY_DISABLE_ENV, buildCodexMemoryDisableConfigArgs, isAgentMemoryDisabled } from './agent-memory-policy.lib.mjs'; // Issue #2178
17
18
 
18
19
  export const AGENT_COMMANDER_TOOLS = new Set(['claude', 'codex', 'opencode', 'agent', 'qwen', 'gemini']);
19
20
 
@@ -57,6 +58,12 @@ const buildClaudeToolOptions = (argv = {}) => {
57
58
  if (argv.showThinkingContent) extraEnv.CLAUDE_CODE_SHOW_THINKING = '1';
58
59
  if (argv.planModel) extraEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = argv.planModel;
59
60
  if (argv.subAgentModel) extraEnv.CLAUDE_CODE_SUBAGENT_MODEL = mapClaudeSubAgentModelToEnvValue(argv.subAgentModel);
61
+ // Issue #2178: agent-commander spawns `claude` itself, so it needs the memory
62
+ // opt-out in its own env rather than relying on the settings file alone. Not
63
+ // gated on --agent-memory-disabled: for claude these switches are part of the
64
+ // quiet configuration baked into the image, and the flag governs the tools
65
+ // whose memory is applied per run (codex, gemini, qwen).
66
+ Object.assign(extraEnv, CLAUDE_MEMORY_DISABLE_ENV);
60
67
  appendExtraEnv(options, extraEnv);
61
68
 
62
69
  return options;
@@ -73,6 +80,7 @@ const buildCodexToolOptions = (argv = {}) => {
73
80
  appendExtraArgs(options, reasoningArgs);
74
81
 
75
82
  appendExtraArgs(options, buildCodexDisable1mContextConfigArgs(!!argv.disable1mContext));
83
+ appendExtraArgs(options, buildCodexMemoryDisableConfigArgs(isAgentMemoryDisabled(argv))); // Issue #2178
76
84
  try {
77
85
  appendExtraArgs(options, buildCodexSubSessionSizeConfigArgs(parseSubSessionSize(argv.subSessionSize)));
78
86
  } catch {
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cross-task memory is off for every agentic CLI hive-mind drives (issue #2178).
4
+ *
5
+ * A hive-mind task is a one-shot: a container is created, an issue is solved, a
6
+ * pull request is opened, the container is destroyed. Nothing an agent learns in
7
+ * one task is meant to reach the next one. The repository is the memory — commits,
8
+ * issues, pull requests, `docs/case-studies/` — and it is the only memory a
9
+ * reviewer can see, correct, or revert.
10
+ *
11
+ * Every agentic CLI now ships some form of private cross-session memory that
12
+ * works against that. It is not free:
13
+ *
14
+ * - it burns inference on top of the task (Gemini CLI runs a whole background
15
+ * extraction agent over past sessions; Claude Code loads a memory index into
16
+ * every session);
17
+ * - it carries facts between unrelated repositories with no review step;
18
+ * - it makes a run irreproducible — the same prompt on the same commit behaves
19
+ * differently depending on what the tool happened to remember.
20
+ *
21
+ * The permission classifiers are the same kind of waste for the same reason.
22
+ * Claude Code's "auto" mode pays a classifier call per tool use to decide whether
23
+ * an action is safe. Hive-mind tasks already run with unrestricted access inside a
24
+ * disposable Docker container (`--dangerously-skip-permissions`,
25
+ * `--dangerously-bypass-approvals-and-sandbox`, `--approval-mode yolo`), so the
26
+ * classifier can only ever answer a question nobody asked.
27
+ *
28
+ * This module is the single place that knows which knob turns each of these off,
29
+ * so `solve` and the Docker image baseline stay in agreement and a new tool
30
+ * version cannot quietly re-enable one of them without a test noticing.
31
+ *
32
+ * Verified against claude-code 2.1.246, codex-cli 0.148.0, gemini-cli 0.51.0,
33
+ * qwen-code 0.7.1 and opencode 1.18.5; see `docs/case-studies/issue-2178/`.
34
+ *
35
+ * @see https://github.com/link-assistant/hive-mind/issues/2178
36
+ */
37
+
38
+ import fsPromises from 'node:fs/promises';
39
+ import os from 'node:os';
40
+ import path from 'node:path';
41
+
42
+ /** Tools `solve --tool` accepts that this policy has something to say about. */
43
+ export const AGENT_MEMORY_POLICY_TOOLS = Object.freeze(['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agent']);
44
+
45
+ /**
46
+ * Claude Code environment variables that switch off every cross-session memory
47
+ * store it can reach.
48
+ *
49
+ * `CLAUDE_CODE_DISABLE_AUTO_MEMORY` disables the per-project memory directory,
50
+ * and the same branch also refuses the team stores named by
51
+ * `CLAUDE_MEMORY_STORES`. `CLAUDE_CODE_DISABLE_ORG_MEMORY` disables the
52
+ * organization-wide memory sync, which is gated separately.
53
+ */
54
+ export const CLAUDE_MEMORY_DISABLE_ENV = Object.freeze({
55
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
56
+ CLAUDE_CODE_DISABLE_ORG_MEMORY: '1',
57
+ });
58
+
59
+ /**
60
+ * Claude Code settings that switch off memory.
61
+ *
62
+ * `autoMemoryEnabled: false` is the settings-file equivalent of
63
+ * `CLAUDE_CODE_DISABLE_AUTO_MEMORY` — both are checked, and either one is
64
+ * enough, so they are set together rather than one being trusted alone.
65
+ */
66
+ export const CLAUDE_MEMORY_DISABLE_SETTINGS = Object.freeze({
67
+ autoMemoryEnabled: false,
68
+ });
69
+
70
+ /**
71
+ * Claude Code `permissions` block that removes auto mode.
72
+ *
73
+ * `"disable"` is the only value the setting accepts, and it is checked before
74
+ * the plan-level, provider-level and model-level gates, so it holds regardless
75
+ * of which provider or model a task runs on. Auto mode is what pays for the
76
+ * classifier; with it gone the classifier has no caller.
77
+ */
78
+ export const CLAUDE_AUTO_MODE_DISABLE_PERMISSIONS = Object.freeze({
79
+ disableAutoMode: 'disable',
80
+ });
81
+
82
+ /**
83
+ * Codex feature flags that hold its memory subsystem off.
84
+ *
85
+ * `memories` is the `~/.codex/memories` store Codex writes across sessions;
86
+ * `external_agent_memory_import` pulls another agent's memory in. Both default
87
+ * to off today, but `memories` is stage `stable`, so it can be switched on by a
88
+ * rollout or by an operator's `~/.codex/config.toml` — pinning them per run is
89
+ * what makes the default actually hold.
90
+ */
91
+ export const CODEX_MEMORY_DISABLE_FEATURES = Object.freeze(['memories', 'external_agent_memory_import']);
92
+
93
+ /**
94
+ * `-c key=value` overrides that turn {@link CODEX_MEMORY_DISABLE_FEATURES} off.
95
+ *
96
+ * `-c features.<name>=false` is used rather than the `--disable <name>` alias
97
+ * because `--disable` is documented as exactly equivalent and `-c` is accepted
98
+ * by every `codex exec` version hive-mind supports.
99
+ *
100
+ * @param {boolean} [disabled=true] - false returns [] so `--no-agent-memory-disabled` is a no-op.
101
+ * @returns {string[]}
102
+ */
103
+ export const buildCodexMemoryDisableConfigArgs = (disabled = true) => {
104
+ if (!disabled) return [];
105
+ return CODEX_MEMORY_DISABLE_FEATURES.flatMap(feature => ['-c', `features.${feature}=false`]);
106
+ };
107
+
108
+ /**
109
+ * Tool name (as the CLI exposes it) of the Gemini-family memory writer.
110
+ *
111
+ * Gemini CLI dropped `save_memory` in 0.51 in favour of the `experimental.autoMemory`
112
+ * background extractor; Qwen Code, forked earlier, still ships the tool. Excluding
113
+ * the name covers the versions that have it and is inert on the versions that do not.
114
+ */
115
+ export const GEMINI_FAMILY_MEMORY_TOOL = 'save_memory';
116
+
117
+ /**
118
+ * Settings merged into `~/.gemini/settings.json` and `~/.qwen/settings.json`.
119
+ *
120
+ * `tools.exclude` is the nested form both CLIs resolve to (Qwen maps its legacy
121
+ * flat `excludeTools` onto it). `experimental.autoMemory` gates Gemini's
122
+ * background "skill extraction" agent, which re-reads past sessions with a second
123
+ * model and writes a memory index — the most expensive item on this list.
124
+ */
125
+ export const GEMINI_FAMILY_MEMORY_DISABLE_SETTINGS = Object.freeze({
126
+ tools: Object.freeze({ exclude: Object.freeze([GEMINI_FAMILY_MEMORY_TOOL]) }),
127
+ experimental: Object.freeze({ autoMemory: false }),
128
+ });
129
+
130
+ /** Where each Gemini-family CLI keeps its user settings. */
131
+ export const GEMINI_FAMILY_SETTINGS_PATHS = Object.freeze({
132
+ gemini: Object.freeze(['.gemini', 'settings.json']),
133
+ qwen: Object.freeze(['.qwen', 'settings.json']),
134
+ });
135
+
136
+ /**
137
+ * Tools with no cross-session memory feature to switch off.
138
+ *
139
+ * Recorded explicitly rather than left out: "we looked and there was nothing" and
140
+ * "nobody looked" are different states, and only the first one stays true without
141
+ * someone re-checking. The test for this module asserts the list, so a future
142
+ * reader can see the claim was made deliberately.
143
+ */
144
+ export const TOOLS_WITHOUT_MEMORY_FEATURE = Object.freeze(['opencode', 'agent']);
145
+
146
+ /**
147
+ * Is the policy on for this run?
148
+ *
149
+ * Reads `--agent-memory-disabled`, which defaults to true. Only an explicit
150
+ * `--no-agent-memory-disabled` turns it off, so an argv object that predates the
151
+ * flag (or omits it) still gets the policy.
152
+ *
153
+ * @param {Object} [argv]
154
+ */
155
+ export const isAgentMemoryDisabled = (argv = {}) => argv?.agentMemoryDisabled !== false;
156
+
157
+ const isPlainObject = value => !!value && typeof value === 'object' && !Array.isArray(value);
158
+
159
+ /**
160
+ * Merge `desired` into `target` in place, returning the dotted paths that changed.
161
+ *
162
+ * Arrays are unioned rather than replaced so an operator's own `tools.exclude`
163
+ * entries survive; scalars are overwritten, because the whole point is that the
164
+ * policy wins.
165
+ *
166
+ * `__proto__`, `constructor` and `prototype` are skipped outright. Today this
167
+ * function is only ever handed {@link GEMINI_FAMILY_MEMORY_DISABLE_SETTINGS}, a
168
+ * frozen literal whose keys are all ordinary, so none of them can occur — but
169
+ * that is a fact about the caller, not about the function, and callers change
170
+ * (CodeQL `js/prototype-pollution-utility`). The guard is written as explicit
171
+ * comparisons rather than a lookup in a shared set because that is the shape the
172
+ * query recognises as a barrier, and a guard a scanner cannot see is one that
173
+ * gets reported again every time someone touches the file.
174
+ */
175
+ const mergeSettings = (target, desired, prefix = '') => {
176
+ const changed = [];
177
+ for (const [key, value] of Object.entries(desired)) {
178
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
179
+ const dotted = prefix ? `${prefix}.${key}` : key;
180
+ if (isPlainObject(value)) {
181
+ if (!isPlainObject(target[key])) target[key] = {};
182
+ changed.push(...mergeSettings(target[key], value, dotted));
183
+ continue;
184
+ }
185
+ if (Array.isArray(value)) {
186
+ const existing = Array.isArray(target[key]) ? target[key] : [];
187
+ const merged = [...existing];
188
+ let added = false;
189
+ for (const entry of value) {
190
+ if (!merged.includes(entry)) {
191
+ merged.push(entry);
192
+ added = true;
193
+ }
194
+ }
195
+ if (added || !Array.isArray(target[key])) {
196
+ target[key] = merged;
197
+ changed.push(dotted);
198
+ }
199
+ continue;
200
+ }
201
+ if (target[key] !== value) {
202
+ target[key] = value;
203
+ changed.push(dotted);
204
+ }
205
+ }
206
+ return changed;
207
+ };
208
+
209
+ /**
210
+ * Resolve the settings file a Gemini-family CLI reads.
211
+ *
212
+ * @param {'gemini'|'qwen'} tool
213
+ * @param {Object} [options]
214
+ * @param {string} [options.homeDir]
215
+ * @returns {string|null} null when the tool has no Gemini-family settings file.
216
+ */
217
+ export const resolveGeminiFamilySettingsPath = (tool, { homeDir = os.homedir() } = {}) => {
218
+ const segments = GEMINI_FAMILY_SETTINGS_PATHS[tool];
219
+ if (!segments) return null;
220
+ return path.join(homeDir, ...segments);
221
+ };
222
+
223
+ /**
224
+ * Write {@link GEMINI_FAMILY_MEMORY_DISABLE_SETTINGS} into a Gemini-family
225
+ * settings file, preserving everything already there.
226
+ *
227
+ * Never throws. A task that cannot write the settings file is still a task worth
228
+ * running: the failure costs inference, not correctness, and the caller logs it.
229
+ *
230
+ * @param {Object} [params]
231
+ * @param {'gemini'|'qwen'} params.tool
232
+ * @param {string} [params.settingsPath] - Overrides the tool's default location (tests).
233
+ * @param {string} [params.homeDir]
234
+ * @param {Function} [params.log]
235
+ * @param {Object} [params.fsImpl] - `node:fs/promises`-shaped, for tests.
236
+ * @returns {Promise<{applied: boolean, path: string|null, changed: string[], error: string|null}>}
237
+ */
238
+ export const ensureGeminiFamilyMemoryDisabled = async ({ tool, settingsPath, homeDir = os.homedir(), log, fsImpl = fsPromises } = {}) => {
239
+ const resolvedPath = settingsPath || resolveGeminiFamilySettingsPath(tool, { homeDir });
240
+ if (!resolvedPath) return { applied: false, path: null, changed: [], error: null };
241
+
242
+ let settings = {};
243
+ try {
244
+ const parsed = JSON.parse(await fsImpl.readFile(resolvedPath, 'utf-8'));
245
+ if (isPlainObject(parsed)) settings = parsed;
246
+ } catch (error) {
247
+ if (error?.code !== 'ENOENT' && log) {
248
+ await log(`⚠️ Could not read ${resolvedPath}: ${error.message}`, { verbose: true });
249
+ }
250
+ }
251
+
252
+ const changed = mergeSettings(settings, GEMINI_FAMILY_MEMORY_DISABLE_SETTINGS);
253
+ try {
254
+ if (changed.length > 0) {
255
+ await fsImpl.mkdir(path.dirname(resolvedPath), { recursive: true });
256
+ await fsImpl.writeFile(resolvedPath, JSON.stringify(settings, null, 2));
257
+ }
258
+ if (log) {
259
+ await log(`🧠 Cross-task memory ${changed.length > 0 ? 'disabled' : 'already disabled'} for ${tool} in ${resolvedPath} (issue #2178)`, { verbose: true });
260
+ }
261
+ return { applied: true, path: resolvedPath, changed, error: null };
262
+ } catch (error) {
263
+ const message = error?.message || String(error);
264
+ if (log) await log(`⚠️ Could not write ${resolvedPath}: ${message}`, { verbose: true });
265
+ return { applied: false, path: resolvedPath, changed: [], error: message };
266
+ }
267
+ };
268
+
269
+ /**
270
+ * One line describing what the policy does for a tool, for `--verbose` logs and
271
+ * for the docs to quote without drifting from the code.
272
+ *
273
+ * @param {string} tool
274
+ * @returns {string}
275
+ */
276
+ export const describeAgentMemoryPolicy = tool => {
277
+ switch (tool) {
278
+ case 'claude':
279
+ return `settings ${JSON.stringify({ ...CLAUDE_MEMORY_DISABLE_SETTINGS, permissions: CLAUDE_AUTO_MODE_DISABLE_PERMISSIONS })}, env ${Object.keys(CLAUDE_MEMORY_DISABLE_ENV).join(', ')}`;
280
+ case 'codex':
281
+ return buildCodexMemoryDisableConfigArgs(true).join(' ');
282
+ case 'gemini':
283
+ case 'qwen':
284
+ return `settings ${JSON.stringify(GEMINI_FAMILY_MEMORY_DISABLE_SETTINGS)}`;
285
+ default:
286
+ return TOOLS_WITHOUT_MEMORY_FEATURE.includes(tool) ? 'no cross-session memory feature to disable' : 'no policy recorded for this tool';
287
+ }
288
+ };
289
+
290
+ export default {
291
+ AGENT_MEMORY_POLICY_TOOLS,
292
+ CLAUDE_AUTO_MODE_DISABLE_PERMISSIONS,
293
+ CLAUDE_MEMORY_DISABLE_ENV,
294
+ CLAUDE_MEMORY_DISABLE_SETTINGS,
295
+ CODEX_MEMORY_DISABLE_FEATURES,
296
+ GEMINI_FAMILY_MEMORY_DISABLE_SETTINGS,
297
+ GEMINI_FAMILY_MEMORY_TOOL,
298
+ GEMINI_FAMILY_SETTINGS_PATHS,
299
+ TOOLS_WITHOUT_MEMORY_FEATURE,
300
+ buildCodexMemoryDisableConfigArgs,
301
+ describeAgentMemoryPolicy,
302
+ ensureGeminiFamilyMemoryDisabled,
303
+ isAgentMemoryDisabled,
304
+ resolveGeminiFamilySettingsPath,
305
+ };
@@ -4,8 +4,12 @@ import fs from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
+ // Issue #2178: memory and auto mode are governed by one cross-tool policy module
8
+ // so `solve` cannot disagree with the Docker image baseline about what "off" means.
9
+ import { CLAUDE_AUTO_MODE_DISABLE_PERMISSIONS, CLAUDE_MEMORY_DISABLE_ENV, CLAUDE_MEMORY_DISABLE_SETTINGS } from './agent-memory-policy.lib.mjs';
10
+
7
11
  export const REQUIRED_CLAUDE_QUIET_ENV = Object.freeze({
8
- CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
12
+ ...CLAUDE_MEMORY_DISABLE_ENV,
9
13
  CLAUDE_CODE_DISABLE_CRON: '1',
10
14
  CLAUDE_CODE_DISABLE_TERMINAL_TITLE: '1',
11
15
  CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1',
@@ -20,7 +24,7 @@ export const REQUIRED_CLAUDE_QUIET_ENV = Object.freeze({
20
24
  });
21
25
 
22
26
  export const REQUIRED_CLAUDE_QUIET_SETTINGS = Object.freeze({
23
- autoMemoryEnabled: false,
27
+ ...CLAUDE_MEMORY_DISABLE_SETTINGS,
24
28
  spinnerTipsEnabled: false,
25
29
  awaySummaryEnabled: false,
26
30
  feedbackSurveyRate: 0,
@@ -38,6 +42,7 @@ export const REQUIRED_CLAUDE_QUIET_ATTRIBUTION = Object.freeze({
38
42
  });
39
43
 
40
44
  export const REQUIRED_CLAUDE_QUIET_PERMISSIONS = Object.freeze({
45
+ ...CLAUDE_AUTO_MODE_DISABLE_PERMISSIONS,
41
46
  defaultMode: 'bypassPermissions',
42
47
  });
43
48
 
package/src/codex.lib.mjs CHANGED
@@ -40,6 +40,7 @@ import { buildAuthRemedyLines, buildFormalAiEnvExports, isPrepareOnly, logPrepar
40
40
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
41
41
  import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
42
42
  import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
43
+ import { buildCodexMemoryDisableConfigArgs, isAgentMemoryDisabled } from './agent-memory-policy.lib.mjs'; // Issue #2178
43
44
  import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
44
45
  import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
45
46
  import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-capability-preflight.lib.mjs'; // Issue #2074
@@ -708,9 +709,15 @@ export const executeCodexCommand = async params => {
708
709
  for (const arg of subSessionSizeArgs) {
709
710
  codexArgs += ` ${shellQuote(arg)}`;
710
711
  }
712
+ // Issue #2178: a hive-mind task must not remember anything a reviewer cannot see.
713
+ const memoryDisableArgs = buildCodexMemoryDisableConfigArgs(isAgentMemoryDisabled(argv));
714
+ for (const arg of memoryDisableArgs) {
715
+ codexArgs += ` ${shellQuote(arg)}`;
716
+ }
711
717
  if (argv.verbose) {
712
718
  if (disable1mArgs.length) await log(`📊 Codex --disable-1m-context: ${disable1mArgs.join(' ')}`, { verbose: true });
713
719
  if (subSessionSizeArgs.length) await log(`📊 Codex --sub-session-size: ${subSessionSizeArgs.join(' ')}`, { verbose: true });
720
+ if (memoryDisableArgs.length) await log(`🧠 Codex cross-task memory disabled: ${memoryDisableArgs.join(' ')} (issue #2178)`, { verbose: true });
714
721
  }
715
722
  // Issue #2130: re-export the Formal AI environment inside the `sh -lc` script so a
716
723
  // stale `formal-ai with --global` block in the operator profile cannot override it.
@@ -31,6 +31,7 @@ import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.li
31
31
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
32
32
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
33
33
  import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
34
+ import { ensureGeminiFamilyMemoryDisabled, isAgentMemoryDisabled } from './agent-memory-policy.lib.mjs'; // Issue #2178
34
35
 
35
36
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
36
37
 
@@ -421,6 +422,10 @@ export const executeGeminiCommand = async params => {
421
422
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
422
423
 
423
424
  const mappedModel = mapModelToId(argv.model || defaultModels.gemini);
425
+ // Issue #2178: the repository is the only memory a hive-mind task keeps, so
426
+ // `save_memory` and the background auto-memory extractor are switched off
427
+ // before the CLI reads its settings.
428
+ if (isAgentMemoryDisabled(argv)) await ensureGeminiFamilyMemoryDisabled({ tool: 'gemini', log });
424
429
  // Issue #2130: Formal AI runs the native CLI against a local Formal AI server (no argv wrapper).
425
430
  const toolInvocation = await resolveFormalAiToolExecution({ tool: 'gemini', model: argv.model || defaultModels.gemini, toolPath: geminiPath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv) });
426
431
  const geminiEnv = { ...process.env, ...toolInvocation.env };
@@ -21,6 +21,9 @@ import { githubLimits } from './config.lib.mjs';
21
21
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
22
22
  import { getTerminalGitHubEntityErrorMessage, isTerminalGitHubEntityError } from './github-terminal-state.lib.mjs';
23
23
  import { cancellableSleep } from './interruptible-sleep.lib.mjs';
24
+ // Issue #2182: draft detection and merge-failure classification live in one
25
+ // pure module shared by every merge call site.
26
+ import { classifyMergeError, evaluatePullRequestMergeability } from './merge-error-classification.lib.mjs';
24
27
 
25
28
  // Issue #1722: gh api `--paginate --slurp` responses for repos with many
26
29
  // historical workflow runs can easily exceed Node's default 1 MB exec buffer
@@ -466,7 +469,12 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, o
466
469
  for (let attempt = 0; attempt < MAX_UNKNOWN_RETRIES; attempt++) {
467
470
  if (isCancelled?.()) return { mergeable: false, reason: 'Operation was cancelled', cancelled: true };
468
471
  try {
469
- const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
472
+ // Issue #2182: `isDraft` MUST be part of this query. GitHub answers
473
+ // mergeable=MERGEABLE / mergeStateStatus=CLEAN for a draft pull request
474
+ // with no other blockers, so without this field a draft PR was declared
475
+ // mergeable and `gh pr merge` failed forever with
476
+ // "Pull Request is still a draft".
477
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json isDraft,mergeable,mergeStateStatus`);
470
478
  const pr = JSON.parse(stdout.trim());
471
479
 
472
480
  // Issue #1339: If mergeStateStatus is 'UNKNOWN', GitHub is still computing.
@@ -486,36 +494,16 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, o
486
494
  return { mergeable: false, mergeableState: pr.mergeable, mergeStateStatus: pr.mergeStateStatus, reason: `Merge state: UNKNOWN (GitHub could not compute mergeability after ${MAX_UNKNOWN_RETRIES} attempts)` };
487
495
  }
488
496
 
489
- const mergeable = pr.mergeable === 'MERGEABLE';
490
- let reason = null;
491
-
492
- if (!mergeable) {
493
- switch (pr.mergeStateStatus) {
494
- case 'BLOCKED':
495
- reason = 'PR is blocked (possibly by branch protection rules)';
496
- break;
497
- case 'BEHIND':
498
- reason = 'PR branch is behind the base branch';
499
- break;
500
- case 'DIRTY':
501
- reason = 'PR has merge conflicts';
502
- break;
503
- case 'UNSTABLE':
504
- reason = 'PR has failing required status checks';
505
- break;
506
- case 'DRAFT':
507
- reason = 'PR is a draft';
508
- break;
509
- default:
510
- reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
511
- }
512
- }
497
+ const evaluation = evaluatePullRequestMergeability(pr);
513
498
 
514
499
  if (verbose) {
515
- console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${mergeable}, state: ${pr.mergeStateStatus}`);
500
+ // Issue #2182: isDraft is logged explicitly. In the reported 4.5-day run
501
+ // the log only ever showed "mergeable: true, state: CLEAN", which hid the
502
+ // actual blocker.
503
+ console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${evaluation.mergeable}, state: ${pr.mergeStateStatus}, isDraft: ${pr.isDraft === true}`);
516
504
  }
517
505
 
518
- return { mergeable, mergeableState: pr.mergeable, mergeStateStatus: pr.mergeStateStatus, reason };
506
+ return { mergeable: evaluation.mergeable, isDraft: evaluation.isDraft, mergeableState: evaluation.mergeableState, mergeStateStatus: evaluation.mergeStateStatus, reason: evaluation.reason };
519
507
  } catch (error) {
520
508
  if (isTerminalGitHubEntityError(error)) {
521
509
  const terminalError = getTerminalGitHubEntityErrorMessage(error);
@@ -607,13 +595,21 @@ export async function mergePullRequest(owner, repo, prNumber, options = {}, verb
607
595
 
608
596
  return { success: true, error: null };
609
597
  } catch (error) {
598
+ // Issue #2182: classify the failure so watch loops can stop (or self-heal)
599
+ // instead of retrying an impossible merge every 120 seconds forever.
600
+ const classification = classifyMergeError(error.message);
610
601
  if (verbose) {
611
602
  console.log(`[VERBOSE] /merge: Failed to merge PR #${prNumber}: ${error.message}`);
603
+ console.log(`[VERBOSE] /merge: Failure category: ${classification.category} (terminal=${classification.terminal}, recoverable=${classification.recoverable})`);
612
604
  }
613
- return { success: false, error: error.message };
605
+ return { success: false, error: error.message, category: classification.category, terminal: classification.terminal, recoverable: classification.recoverable, resolution: classification.resolution };
614
606
  }
615
607
  }
616
608
 
609
+ // Issue #2182: re-exported so merge call sites can import classification from
610
+ // the same module they already use for merging.
611
+ export { classifyMergeError, evaluatePullRequestMergeability, MERGE_ERROR_CATEGORIES, MAX_CONSECUTIVE_MERGE_FAILURES } from './merge-error-classification.lib.mjs';
612
+
617
613
  /**
618
614
  * Parse and validate a repository URL for the merge command
619
615
  * @param {string} url - Repository URL