@link-assistant/hive-mind 1.75.0 → 1.76.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 +67 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +5 -0
- package/src/claude.prompts.lib.mjs +2 -1
- package/src/codex.lib.mjs +5 -0
- package/src/codex.prompts.lib.mjs +2 -1
- package/src/handoff-skill.lib.mjs +256 -0
- package/src/handoff.prompts.lib.mjs +158 -0
- package/src/isolation-runner.lib.mjs +60 -6
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/solve.branch-divergence.lib.mjs +55 -0
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.fork-sync.lib.mjs +302 -0
- package/src/solve.repository.lib.mjs +4 -214
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.76.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 13e7e6a: Docker isolation: reuse the host image instead of re-downloading a copy inside the (nested) Docker daemon (#1879).
|
|
8
|
+
- src/isolation-runner.lib.mjs: add `HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG` to pin the
|
|
9
|
+
isolation image tag, and `HIVE_MIND_DOCKER_ISOLATION_PULL` (always|missing|never) to emit a
|
|
10
|
+
`docker run --pull` policy. Verbose mode now logs the resolved image and pull policy.
|
|
11
|
+
- scripts/preload-dind-isolation-image.mjs: seed a DinD container's nested daemon from the
|
|
12
|
+
host (`docker save | docker exec -i … docker load`) so isolated tasks reuse the host image.
|
|
13
|
+
- .env.example: document the Docker isolation image/pull controls.
|
|
14
|
+
- Dockerfile / Dockerfile.dind / coolify/Dockerfile: bump the box base images to
|
|
15
|
+
`konard/box:2.2.0` / `konard/box-dind:2.2.0` (and the `docs/UBUNTU-SERVER*.md` examples).
|
|
16
|
+
v2.2.0 ships box's native host-image passthrough (box#94/#95), so the DinD deployment can seed
|
|
17
|
+
the nested daemon from the host automatically with
|
|
18
|
+
`-v /var/run/docker.sock:/var/run/host-docker.sock:ro -e DIND_HOST_PASSTHROUGH=public`.
|
|
19
|
+
- tests/test-issue-1879-docker-image-reuse.mjs: regression coverage.
|
|
20
|
+
- docs/case-studies/issue-1879: deep case study with logs, timeline, root causes, and runbook;
|
|
21
|
+
records that box#94 shipped in v2.2.0 and reports two upstream follow-ups — box#96
|
|
22
|
+
(public-mode passthrough test false positive) and box#97 (per-repository passthrough allowlist).
|
|
23
|
+
|
|
24
|
+
- 7335a73: Continue fork PRs with "Allow edits by maintainers" instead of halting on a misclassified fork divergence (#1893).
|
|
25
|
+
|
|
26
|
+
When the solver continues a cross-repository PR opened from another contributor's fork, it
|
|
27
|
+
synced the upstream default branch and then tried to push it back to `origin` — the
|
|
28
|
+
contributor's fork, which the operating maintainer does not own. GitHub rejected the push
|
|
29
|
+
with `! [remote rejected] main -> main (permission denied)`, and the solver misclassified
|
|
30
|
+
that permission error as a fork divergence (the heuristic matched the substring `rejected`),
|
|
31
|
+
halting with `Repository setup halted - fork divergence requires user decision` and advising
|
|
32
|
+
`--allow-fork-divergence-resolution-using-force-push-with-lease` — a flag that cannot help,
|
|
33
|
+
since force-push also requires fork write access.
|
|
34
|
+
- src/solve.branch-divergence.lib.mjs: add two pure helpers —
|
|
35
|
+
`shouldPushDefaultBranchToFork({currentUser, forkedRepo})` (skip the push when the user does
|
|
36
|
+
not own the fork; fail-open when owner/user is unknown) and `isPermissionDeniedPushError()`
|
|
37
|
+
(recognize a permission-denied rejection so it is never treated as divergence).
|
|
38
|
+
- src/solve.fork-sync.lib.mjs: new module holding `setupUpstreamAndSync` (extracted from
|
|
39
|
+
solve.repository.lib.mjs to stay under the 1500-line limit, re-exported unchanged). It now
|
|
40
|
+
resolves the current user, skips the fork's default-branch push when the user is not the fork
|
|
41
|
+
owner, and on a permission-denied push warns and continues on the PR branch instead of
|
|
42
|
+
halting. Genuine non-fast-forward divergence still triggers the original guidance. Adds
|
|
43
|
+
verbose diagnostics explaining each skip/continue decision.
|
|
44
|
+
- tests/test-issue-1893-fork-pr-permission-denied.mjs: regression coverage (9 cases) using the
|
|
45
|
+
exact failure output from the run log.
|
|
46
|
+
- docs/case-studies/issue-1893: deep case study with downloaded logs/data, timeline, root
|
|
47
|
+
causes, fix, codebase-wide audit, and existing-components review.
|
|
48
|
+
|
|
49
|
+
## 1.76.0
|
|
50
|
+
|
|
51
|
+
### Minor Changes
|
|
52
|
+
|
|
53
|
+
- 80c56fa: Add experimental `--use-handoff` HANDOFF.md continuity **Agent Skill** (issue
|
|
54
|
+
#1877). When enabled, Hive Mind deploys a real `SKILL.md` (the Agent Skills open
|
|
55
|
+
standard created by Anthropic) into the session working directory for both tools
|
|
56
|
+
natively — `.claude/skills/handoff/SKILL.md` for `--tool claude` and
|
|
57
|
+
`.agents/skills/handoff/SKILL.md` for `--tool codex` — so the very same skill
|
|
58
|
+
teaches each tool to read `HANDOFF.md` (repository root) first when present and
|
|
59
|
+
keep it updated with task, current state, decisions, next steps, gotchas, and
|
|
60
|
+
critical files. A minimal activation nudge in the system prompt ensures the
|
|
61
|
+
read-at-session-start behavior fires reliably. Because each Hive Mind working
|
|
62
|
+
session runs in an ephemeral working directory cloned from the PR branch, the
|
|
63
|
+
handoff file is committed to the branch — making it the shared cross-session,
|
|
64
|
+
cross-tool memory so Claude and Codex can continue each other's work in a single
|
|
65
|
+
pull request. The deployed `SKILL.md` is tooling (re-deployed every session) and
|
|
66
|
+
is kept out of the target repository via `.git/info/exclude`, so it never appears
|
|
67
|
+
in the PR. Disabled by default; auto-forwarded by `hive`. Includes a case study
|
|
68
|
+
in `docs/case-studies/issue-1877/` and tests in `tests/handoff-prompt.test.mjs`.
|
|
69
|
+
|
|
3
70
|
## 1.75.0
|
|
4
71
|
|
|
5
72
|
### Minor Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { fetchModelInfo } from './model-info.lib.mjs';
|
|
|
28
28
|
import { classifyRetryableError, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
29
29
|
import { resolveSubSessionSize } from './sub-session-size.lib.mjs'; // Issue #1706
|
|
30
30
|
import { withAgentsMdAsClaudeMd } from './agents-md-claude-support.lib.mjs';
|
|
31
|
+
import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
31
32
|
import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.lib.mjs'; // Issue #1834 (PR #1835 feedback)
|
|
32
33
|
export { availableModels, fetchModelInfo }; // Re-export for backward compatibility
|
|
33
34
|
const showResumeCommand = async (sessionId, tempDir, claudePath, model, log, argv = null) => {
|
|
@@ -353,6 +354,10 @@ export const executeClaude = async params => {
|
|
|
353
354
|
const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
|
|
354
355
|
const escapedSystemPrompt = systemPrompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
|
|
355
356
|
|
|
357
|
+
// Issue #1877: deploy the experimental HANDOFF.md Agent Skill so Claude loads
|
|
358
|
+
// it natively from .claude/skills/handoff/SKILL.md (no-op unless --use-handoff).
|
|
359
|
+
await deployHandoffSkill({ tempDir, argv, log, $ });
|
|
360
|
+
|
|
356
361
|
return await withAgentsMdAsClaudeMd({ tempDir, branchName, argv, prompt, fs, path, $, log, formatAligned }, () =>
|
|
357
362
|
executeClaudeCommand({
|
|
358
363
|
tempDir,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getArchitectureCareSubPrompt } from './architecture-care.prompts.lib.mjs';
|
|
7
|
+
import { getHandoffSubPrompt } from './handoff.prompts.lib.mjs';
|
|
7
8
|
import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
|
|
8
9
|
import { primaryModelNames } from './models/index.mjs';
|
|
9
10
|
import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
|
|
@@ -338,7 +339,7 @@ Visual UI work and screenshots.
|
|
|
338
339
|
- When the fix is visual, include side-by-side or sequential comparison of before/after states in the PR description.
|
|
339
340
|
- When possible, create automated visual regression tests to prevent the UI bug from recurring.`
|
|
340
341
|
: ''
|
|
341
|
-
}${ciExamples}${getArchitectureCareSubPrompt(argv)}${buildWorkLanguageDirective()}`;
|
|
342
|
+
}${ciExamples}${getArchitectureCareSubPrompt(argv)}${getHandoffSubPrompt(argv)}${buildWorkLanguageDirective()}`;
|
|
342
343
|
};
|
|
343
344
|
|
|
344
345
|
// Export all functions as default object too
|
package/src/codex.lib.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { defaultModels } from './models/index.mjs';
|
|
|
29
29
|
import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
30
30
|
import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
|
|
31
31
|
import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
|
|
32
|
+
import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
32
33
|
import Decimal from 'decimal.js-light';
|
|
33
34
|
|
|
34
35
|
const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
@@ -661,6 +662,10 @@ export const executeCodex = async params => {
|
|
|
661
662
|
}
|
|
662
663
|
}
|
|
663
664
|
|
|
665
|
+
// Issue #1877: deploy the experimental HANDOFF.md Agent Skill so Codex loads
|
|
666
|
+
// it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
|
|
667
|
+
await deployHandoffSkill({ tempDir, argv, log, $ });
|
|
668
|
+
|
|
664
669
|
// Execute the Codex command
|
|
665
670
|
return await executeCodexCommand({
|
|
666
671
|
tempDir,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getArchitectureCareSubPrompt } from './architecture-care.prompts.lib.mjs';
|
|
7
|
+
import { getHandoffSubPrompt } from './handoff.prompts.lib.mjs';
|
|
7
8
|
import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
|
|
8
9
|
import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
|
|
9
10
|
import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
|
|
@@ -306,7 +307,7 @@ Visual UI work and screenshots.
|
|
|
306
307
|
- When the fix is visual, include side-by-side or sequential comparison of before/after states in the PR description.
|
|
307
308
|
- When possible, create automated visual regression tests to prevent the UI bug from recurring.`
|
|
308
309
|
: ''
|
|
309
|
-
}${ciExamples}${getArchitectureCareSubPrompt(argv)}${buildWorkLanguageDirective()}`;
|
|
310
|
+
}${ciExamples}${getArchitectureCareSubPrompt(argv)}${getHandoffSubPrompt(argv)}${buildWorkLanguageDirective()}`;
|
|
310
311
|
};
|
|
311
312
|
|
|
312
313
|
// Export all functions as default object too
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HANDOFF.md Agent Skill deployment (issue #1877)
|
|
3
|
+
*
|
|
4
|
+
* Writes the canonical handoff `SKILL.md` (built by handoff.prompts.lib.mjs)
|
|
5
|
+
* into the session working directory so the AI tool loads it natively as an
|
|
6
|
+
* Agent Skill, instead of relying on an injected prompt.
|
|
7
|
+
*
|
|
8
|
+
* Both supported tools read the Agent Skills standard, but from different
|
|
9
|
+
* hardcoded project directories (neither tool exposes a setting or env var to
|
|
10
|
+
* point at a custom/shared folder):
|
|
11
|
+
* - Claude Code: .claude/skills/<name>/SKILL.md
|
|
12
|
+
* - Codex: .agents/skills/<name>/SKILL.md
|
|
13
|
+
*
|
|
14
|
+
* To answer "can both CLIs use the SAME folder?": there is no native shared
|
|
15
|
+
* location, so we make one ourselves. The SKILL.md is written exactly ONCE into
|
|
16
|
+
* a single real directory (the Claude path, `.claude/skills/handoff/`), and the
|
|
17
|
+
* Codex path (`.agents/skills/handoff`) is a relative **symlink** pointing at
|
|
18
|
+
* that one real directory. Both tools therefore read byte-for-byte the same
|
|
19
|
+
* file from a single source of truth on disk — not two copies that could drift.
|
|
20
|
+
* If the filesystem cannot create a symlink (e.g. Windows without privilege),
|
|
21
|
+
* we fall back to writing a real second copy so the feature still works.
|
|
22
|
+
*
|
|
23
|
+
* The deployed skill is tool configuration, not project state, so it is:
|
|
24
|
+
* - re-deployed every session by hive-mind (each session clones fresh), and
|
|
25
|
+
* - excluded from git via `.git/info/exclude` (a local, never-committed
|
|
26
|
+
* ignore) so it never pollutes the pull request or the "uncommitted
|
|
27
|
+
* changes" checks. Only the HANDOFF.md the tool produces is committed.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Fetch use-m if not available (matches the rest of src/*.lib.mjs).
|
|
31
|
+
if (typeof globalThis.use === 'undefined') {
|
|
32
|
+
globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
33
|
+
}
|
|
34
|
+
const fs = (await use('fs')).promises;
|
|
35
|
+
const path = (await use('path')).default;
|
|
36
|
+
|
|
37
|
+
import { buildHandoffSkillFile, HANDOFF_SKILL_NAME } from './handoff.prompts.lib.mjs';
|
|
38
|
+
|
|
39
|
+
const noopLog = async () => {};
|
|
40
|
+
|
|
41
|
+
const SKILL_FILE = 'SKILL.md';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The single real skill directory the SKILL.md is written into. Claude Code
|
|
45
|
+
* reads it directly; Codex reaches the same files through a symlink (below).
|
|
46
|
+
* @type {string}
|
|
47
|
+
*/
|
|
48
|
+
export const HANDOFF_PRIMARY_SKILL_DIR = path.join('.claude', 'skills', HANDOFF_SKILL_NAME);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Additional skill directories that should resolve to the same SKILL.md. Each
|
|
52
|
+
* is created as a symlink to HANDOFF_PRIMARY_SKILL_DIR (one source of truth),
|
|
53
|
+
* falling back to a real copy only if symlinking is unsupported.
|
|
54
|
+
* @type {string[]}
|
|
55
|
+
*/
|
|
56
|
+
export const HANDOFF_LINKED_SKILL_DIRS = Object.freeze([
|
|
57
|
+
path.join('.agents', 'skills', HANDOFF_SKILL_NAME), // Codex
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* All skill directories the deployment touches (primary + links). Kept for the
|
|
62
|
+
* git-exclude bookkeeping and for callers/tests that enumerate every location.
|
|
63
|
+
* @type {string[]}
|
|
64
|
+
*/
|
|
65
|
+
export const HANDOFF_SKILL_DIRS = Object.freeze([HANDOFF_PRIMARY_SKILL_DIR, ...HANDOFF_LINKED_SKILL_DIRS]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Determine whether a path is already tracked by git in the working dir. We
|
|
69
|
+
* never clobber a file/dir the target repository tracks itself.
|
|
70
|
+
*/
|
|
71
|
+
const isTracked = async ({ $, tempDir, relPath }) => {
|
|
72
|
+
if (!$) return false;
|
|
73
|
+
try {
|
|
74
|
+
const result = await $({ cwd: tempDir })`git ls-files --error-unmatch ${relPath} 2>/dev/null`;
|
|
75
|
+
return result.code === 0;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the local git exclude file (`.git/info/exclude`), honoring worktrees
|
|
83
|
+
* via `git rev-parse --git-path`. Falls back to the conventional location.
|
|
84
|
+
*/
|
|
85
|
+
const resolveExcludePath = async ({ $, tempDir }) => {
|
|
86
|
+
if ($) {
|
|
87
|
+
try {
|
|
88
|
+
const result = await $({ cwd: tempDir })`git rev-parse --git-path info/exclude 2>/dev/null`;
|
|
89
|
+
const rel = (result.stdout || '').toString().trim();
|
|
90
|
+
if (result.code === 0 && rel) {
|
|
91
|
+
return path.isAbsolute(rel) ? rel : path.join(tempDir, rel);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// fall through to default
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return path.join(tempDir, '.git', 'info', 'exclude');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Append the skill directories to `.git/info/exclude` (idempotent) so the
|
|
102
|
+
* deployed SKILL.md files (real dir and symlink alike) stay invisible to git.
|
|
103
|
+
* Entries are written WITHOUT a trailing slash so they match both a real
|
|
104
|
+
* directory and a directory symlink (git would not match a symlink against a
|
|
105
|
+
* `dir/` pattern).
|
|
106
|
+
*/
|
|
107
|
+
const updateGitExclude = async ({ $, tempDir, log }) => {
|
|
108
|
+
const excludePath = await resolveExcludePath({ $, tempDir });
|
|
109
|
+
// Only touch the exclude file if its parent (.git/info) exists — i.e. this is
|
|
110
|
+
// a real git working dir. Avoid creating a stray `.git/` in non-git dirs.
|
|
111
|
+
try {
|
|
112
|
+
await fs.access(path.dirname(excludePath));
|
|
113
|
+
} catch {
|
|
114
|
+
await log(' Handoff skill: no .git/info directory; skipping git-exclude update', { verbose: true });
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let existing = '';
|
|
119
|
+
try {
|
|
120
|
+
existing = await fs.readFile(excludePath, 'utf8');
|
|
121
|
+
} catch {
|
|
122
|
+
existing = '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entries = HANDOFF_SKILL_DIRS.map(dir => `/${dir.split(path.sep).join('/')}`);
|
|
126
|
+
const existingLines = existing.split(/\r?\n/);
|
|
127
|
+
const missing = entries.filter(entry => !existingLines.includes(entry));
|
|
128
|
+
if (missing.length === 0) return true;
|
|
129
|
+
|
|
130
|
+
const header = '# hive-mind --use-handoff: experimental HANDOFF.md Agent Skill (issue #1877)';
|
|
131
|
+
const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
132
|
+
const block = `${prefix}${existing.includes(header) ? '' : header + '\n'}${missing.join('\n')}\n`;
|
|
133
|
+
await fs.writeFile(excludePath, existing + block, 'utf8');
|
|
134
|
+
return true;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Write the real SKILL.md into the primary skill directory.
|
|
139
|
+
*/
|
|
140
|
+
const writeRealSkill = async ({ tempDir, content }) => {
|
|
141
|
+
const absDir = path.join(tempDir, HANDOFF_PRIMARY_SKILL_DIR);
|
|
142
|
+
await fs.mkdir(absDir, { recursive: true });
|
|
143
|
+
await fs.writeFile(path.join(absDir, SKILL_FILE), content, 'utf8');
|
|
144
|
+
return absDir;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Make `relLinkDir` resolve to the same files as the primary skill directory.
|
|
149
|
+
* Prefers a relative symlink (single source of truth); if symlinking is not
|
|
150
|
+
* supported, falls back to writing a real copy of the SKILL.md.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Promise<'symlink'|'copy'>}
|
|
153
|
+
*/
|
|
154
|
+
const linkOrCopySkill = async ({ tempDir, relLinkDir, primaryAbsDir, content }) => {
|
|
155
|
+
const absLinkDir = path.join(tempDir, relLinkDir);
|
|
156
|
+
const parent = path.dirname(absLinkDir);
|
|
157
|
+
await fs.mkdir(parent, { recursive: true });
|
|
158
|
+
const relTarget = path.relative(parent, primaryAbsDir);
|
|
159
|
+
|
|
160
|
+
// Reconcile any pre-existing entry (e.g. from a prior session re-deploy).
|
|
161
|
+
try {
|
|
162
|
+
const st = await fs.lstat(absLinkDir);
|
|
163
|
+
if (st.isSymbolicLink()) {
|
|
164
|
+
const current = await fs.readlink(absLinkDir);
|
|
165
|
+
if (current === relTarget) return 'symlink'; // already correct
|
|
166
|
+
await fs.rm(absLinkDir, { recursive: true, force: true });
|
|
167
|
+
} else if (st.isDirectory()) {
|
|
168
|
+
// A real directory is already there (prior copy fallback). Refresh the
|
|
169
|
+
// copy in place rather than replacing the directory.
|
|
170
|
+
await fs.writeFile(path.join(absLinkDir, SKILL_FILE), content, 'utf8');
|
|
171
|
+
return 'copy';
|
|
172
|
+
} else {
|
|
173
|
+
await fs.rm(absLinkDir, { force: true });
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Nothing there yet — fall through and create it.
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await fs.symlink(relTarget, absLinkDir, 'dir');
|
|
181
|
+
return 'symlink';
|
|
182
|
+
} catch {
|
|
183
|
+
await fs.mkdir(absLinkDir, { recursive: true });
|
|
184
|
+
await fs.writeFile(path.join(absLinkDir, SKILL_FILE), content, 'utf8');
|
|
185
|
+
return 'copy';
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Deploy the handoff SKILL.md into the session working directory.
|
|
191
|
+
*
|
|
192
|
+
* @param {Object} params
|
|
193
|
+
* @param {string} params.tempDir - The repo working directory.
|
|
194
|
+
* @param {Object} params.argv - Parsed CLI args (uses argv.useHandoff).
|
|
195
|
+
* @param {Function} [params.log] - Logger.
|
|
196
|
+
* @param {Function} [params.$] - Command runner (for git checks); optional.
|
|
197
|
+
* @returns {Promise<{deployed: boolean, reason?: string, paths: string[], shared: boolean}>}
|
|
198
|
+
*/
|
|
199
|
+
export const deployHandoffSkill = async ({ tempDir, argv, log = noopLog, $ = null } = {}) => {
|
|
200
|
+
if (!argv || !argv.useHandoff) {
|
|
201
|
+
return { deployed: false, reason: 'disabled', paths: [], shared: false };
|
|
202
|
+
}
|
|
203
|
+
if (!tempDir) {
|
|
204
|
+
return { deployed: false, reason: 'no-temp-dir', paths: [], shared: false };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const content = buildHandoffSkillFile();
|
|
208
|
+
const written = [];
|
|
209
|
+
let allShared = true;
|
|
210
|
+
|
|
211
|
+
// 1. Write the single real SKILL.md (unless the repo tracks it itself).
|
|
212
|
+
const primaryRelFile = path.join(HANDOFF_PRIMARY_SKILL_DIR, SKILL_FILE);
|
|
213
|
+
let primaryAbsDir = path.join(tempDir, HANDOFF_PRIMARY_SKILL_DIR);
|
|
214
|
+
if (await isTracked({ $, tempDir, relPath: primaryRelFile })) {
|
|
215
|
+
await log(` Handoff skill: ${primaryRelFile} is tracked by the repo; leaving it untouched`, { verbose: true });
|
|
216
|
+
} else {
|
|
217
|
+
try {
|
|
218
|
+
primaryAbsDir = await writeRealSkill({ tempDir, content });
|
|
219
|
+
written.push(primaryRelFile);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
await log(` Handoff skill: failed to deploy ${primaryRelFile}: ${error.message}`, { verbose: true });
|
|
222
|
+
return { deployed: false, reason: 'write-failed', paths: [], shared: false };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 2. Point every other tool's skill dir at that same real directory.
|
|
227
|
+
for (const relLinkDir of HANDOFF_LINKED_SKILL_DIRS) {
|
|
228
|
+
const relFile = path.join(relLinkDir, SKILL_FILE);
|
|
229
|
+
if (await isTracked({ $, tempDir, relPath: relFile })) {
|
|
230
|
+
await log(` Handoff skill: ${relFile} is tracked by the repo; leaving it untouched`, { verbose: true });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const mode = await linkOrCopySkill({ tempDir, relLinkDir, primaryAbsDir, content });
|
|
235
|
+
if (mode !== 'symlink') allShared = false;
|
|
236
|
+
written.push(relFile);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await log(` Handoff skill: failed to link ${relFile}: ${error.message}`, { verbose: true });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (written.length > 0) {
|
|
243
|
+
await updateGitExclude({ $, tempDir, log });
|
|
244
|
+
const how = allShared ? 'one shared folder via symlink' : 'copied (symlink unsupported)';
|
|
245
|
+
await log(` Handoff skill deployed (--use-handoff, ${how}): ${written.join(', ')}`, { verbose: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { deployed: written.length > 0, paths: written, shared: allShared };
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export default {
|
|
252
|
+
HANDOFF_PRIMARY_SKILL_DIR,
|
|
253
|
+
HANDOFF_LINKED_SKILL_DIRS,
|
|
254
|
+
HANDOFF_SKILL_DIRS,
|
|
255
|
+
deployHandoffSkill,
|
|
256
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HANDOFF.md support — Agent Skill (issue #1877)
|
|
3
|
+
*
|
|
4
|
+
* Instead of injecting a bespoke sub-prompt, this module ships a real
|
|
5
|
+
* **Agent Skill** (https://agentskills.io) — a `SKILL.md` document with YAML
|
|
6
|
+
* frontmatter — that teaches the AI tool to read and maintain a HANDOFF.md file
|
|
7
|
+
* in the repository root. The Agent Skills format is an open standard (created
|
|
8
|
+
* by Anthropic) that BOTH supported tools load natively:
|
|
9
|
+
* - Claude Code discovers project skills from `.claude/skills/<name>/SKILL.md`.
|
|
10
|
+
* - Codex discovers project skills from `.agents/skills/<name>/SKILL.md`.
|
|
11
|
+
* The exact same `SKILL.md` works for both, so "same skill, same way" is
|
|
12
|
+
* satisfied by a single canonical file rather than a tool-specific prompt.
|
|
13
|
+
* Because neither tool lets you redirect its skills folder, the deployment
|
|
14
|
+
* writes that file ONCE and symlinks the second tool's path to it, so both
|
|
15
|
+
* tools literally read the same folder (see handoff-skill.lib.mjs).
|
|
16
|
+
*
|
|
17
|
+
* The skill is deployed into the session working directory by
|
|
18
|
+
* `handoff-skill.lib.mjs` (gated behind the experimental --use-handoff flag).
|
|
19
|
+
* This module only builds the canonical text; the deployment module writes it.
|
|
20
|
+
*
|
|
21
|
+
* Goal: cross-session AND cross-tool continuity — a session driven by one tool
|
|
22
|
+
* (e.g. Claude) can be continued by another tool (e.g. Codex) inside the same
|
|
23
|
+
* pull request, because the HANDOFF.md state travels with the branch.
|
|
24
|
+
*
|
|
25
|
+
* Design rationale specific to hive-mind:
|
|
26
|
+
* - Each working session runs in an ephemeral temp working directory that is
|
|
27
|
+
* cloned fresh from the pull request branch. The ONLY state that persists
|
|
28
|
+
* between sessions (and between different tools) is what is committed to the
|
|
29
|
+
* branch. Therefore, unlike the general "disposable temp-dir handoff"
|
|
30
|
+
* convention, the handoff file here MUST be committed to the PR branch so
|
|
31
|
+
* the next session/tool can read it. We keep a single active HANDOFF.md per
|
|
32
|
+
* branch to avoid ambiguity.
|
|
33
|
+
* - The skill file itself (SKILL.md) is tool configuration, not project state,
|
|
34
|
+
* so it is re-deployed each session by hive-mind and is NOT committed to the
|
|
35
|
+
* target repository (see handoff-skill.lib.mjs).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The default handoff file name (repository root, relative path).
|
|
40
|
+
* @type {string}
|
|
41
|
+
*/
|
|
42
|
+
export const HANDOFF_FILE_NAME = 'HANDOFF.md';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The skill directory / invocation name (Agent Skills standard).
|
|
46
|
+
* @type {string}
|
|
47
|
+
*/
|
|
48
|
+
export const HANDOFF_SKILL_NAME = 'handoff';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The skill description used in the SKILL.md frontmatter. Front-loads the key
|
|
52
|
+
* use case and trigger words so the tool can match the skill implicitly.
|
|
53
|
+
* @type {string}
|
|
54
|
+
*/
|
|
55
|
+
export const HANDOFF_SKILL_DESCRIPTION = "Maintain a HANDOFF.md continuity document in the repository root so any session can continue a previous session's work — even across different AI tools (Claude and Codex) in the same pull request. Use when starting, resuming, or finishing work on a long-running task, issue, or pull request.";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the canonical handoff skill instructions (the markdown body that follows
|
|
59
|
+
* the YAML frontmatter in SKILL.md). This is tool-agnostic and identical for
|
|
60
|
+
* Claude and Codex.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} [options]
|
|
63
|
+
* @param {string} [options.fileName=HANDOFF_FILE_NAME] - Handoff file name.
|
|
64
|
+
* @returns {string} The markdown instructions body.
|
|
65
|
+
*/
|
|
66
|
+
export const buildHandoffSkillBody = ({ fileName = HANDOFF_FILE_NAME } = {}) => {
|
|
67
|
+
return `# HANDOFF.md continuity skill
|
|
68
|
+
|
|
69
|
+
${fileName} is a single shared handoff document in the repository root that lets any session continue the work of any previous session, even when a different AI tool (for example Claude and Codex) is used. It travels with the pull request branch, so it is the cross-tool, cross-session memory for this PR.
|
|
70
|
+
|
|
71
|
+
## When to use this skill
|
|
72
|
+
|
|
73
|
+
- When you start a working session, read ${fileName} first if it exists. Treat its "Next steps" section as your immediate starting point and honor the decisions and constraints it records before exploring anything else.
|
|
74
|
+
- When ${fileName} does not exist yet and the task is non-trivial, create it early so an interrupted session can always be resumed.
|
|
75
|
+
- When you make meaningful progress, update ${fileName} so it always reflects the current truth. Keep exactly one active ${fileName} per pull request branch (do not create per-session copies).
|
|
76
|
+
- When all requirements are fully met and the work is complete, record that completion at the top of ${fileName} (or delete the file) so the next session knows there is nothing left to continue.
|
|
77
|
+
|
|
78
|
+
## How to write ${fileName}
|
|
79
|
+
|
|
80
|
+
- Keep it concise and tool-agnostic: describe state by referencing file paths, function names, branch, and commit SHAs rather than tool-specific commands, so the next tool (Claude or Codex) can act on it directly. Prefer pointers to existing artifacts over duplicating their content.
|
|
81
|
+
- Include these sections:
|
|
82
|
+
1. **Task** — the issue/PR being solved and the goal.
|
|
83
|
+
2. **Current state** — what is done and verified.
|
|
84
|
+
3. **Decisions** — key choices made and why (so they are not re-litigated).
|
|
85
|
+
4. **Next steps** — the concrete, ordered actions the next session should take.
|
|
86
|
+
5. **Gotchas** — known pitfalls, failing checks, or constraints.
|
|
87
|
+
6. **Critical files** — the important paths and what each is for.
|
|
88
|
+
- When you record next steps, make them specific and actionable (a path, a function, a command to run) instead of vague goals, and remove items as they are completed.
|
|
89
|
+
|
|
90
|
+
## Committing and safety
|
|
91
|
+
|
|
92
|
+
- When you finish a step that changes the state, commit ${fileName} together with the related code changes so the handoff stays in sync with the branch and is never lost if the session is interrupted.
|
|
93
|
+
- Never include secrets, tokens, API keys, passwords, or personal data in ${fileName} — it is committed to the repository.`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build a complete SKILL.md document (Agent Skills standard): YAML frontmatter
|
|
98
|
+
* with `name` and `description`, followed by the instructions body. This exact
|
|
99
|
+
* file is deployed verbatim for both Claude (.claude/skills/handoff/SKILL.md)
|
|
100
|
+
* and Codex (.agents/skills/handoff/SKILL.md).
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} [options]
|
|
103
|
+
* @param {string} [options.fileName=HANDOFF_FILE_NAME] - Handoff file name.
|
|
104
|
+
* @param {string} [options.name=HANDOFF_SKILL_NAME] - Skill name (frontmatter).
|
|
105
|
+
* @param {string} [options.description=HANDOFF_SKILL_DESCRIPTION] - Skill description.
|
|
106
|
+
* @returns {string} The full SKILL.md content.
|
|
107
|
+
*/
|
|
108
|
+
export const buildHandoffSkillFile = ({ fileName = HANDOFF_FILE_NAME, name = HANDOFF_SKILL_NAME, description = HANDOFF_SKILL_DESCRIPTION } = {}) => {
|
|
109
|
+
return `---
|
|
110
|
+
name: ${name}
|
|
111
|
+
description: ${description}
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
${buildHandoffSkillBody({ fileName })}
|
|
115
|
+
`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a minimal activation nudge for the system prompt. The full procedure
|
|
120
|
+
* lives in the deployed SKILL.md (loaded natively by the tool); this short
|
|
121
|
+
* pointer only ensures the read-at-session-start behavior reliably fires, since
|
|
122
|
+
* that is triggered by session lifecycle rather than by a task description.
|
|
123
|
+
*
|
|
124
|
+
* @param {Object} [options]
|
|
125
|
+
* @param {string} [options.fileName=HANDOFF_FILE_NAME] - Handoff file name.
|
|
126
|
+
* @param {string} [options.name=HANDOFF_SKILL_NAME] - Skill name.
|
|
127
|
+
* @returns {string} The activation nudge.
|
|
128
|
+
*/
|
|
129
|
+
export const buildHandoffSubPrompt = ({ fileName = HANDOFF_FILE_NAME, name = HANDOFF_SKILL_NAME } = {}) => {
|
|
130
|
+
return `
|
|
131
|
+
HANDOFF.md continuity skill (experimental, --use-handoff).
|
|
132
|
+
- A reusable "${name}" Agent Skill is installed in this workspace (.claude/skills/${name}/ for Claude, .agents/skills/${name}/ for Codex). It defines how to read and maintain ${fileName} so any session can continue the work of a previous one — even across tools (Claude and Codex) in the same pull request.
|
|
133
|
+
- At the start of this session, use the ${name} skill: if ${fileName} exists in the repository root, read it first and continue from its "Next steps". Create or update ${fileName} as you make progress and commit it to the pull request branch.`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get the handoff skill activation nudge if enabled.
|
|
138
|
+
*
|
|
139
|
+
* @param {Object} argv - Parsed command line arguments.
|
|
140
|
+
* @returns {string} The sub-prompt content, or an empty string when disabled.
|
|
141
|
+
*/
|
|
142
|
+
export const getHandoffSubPrompt = argv => {
|
|
143
|
+
if (argv && argv.useHandoff) {
|
|
144
|
+
return buildHandoffSubPrompt();
|
|
145
|
+
}
|
|
146
|
+
return '';
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Export all functions as default object too (mirrors architecture-care module)
|
|
150
|
+
export default {
|
|
151
|
+
HANDOFF_FILE_NAME,
|
|
152
|
+
HANDOFF_SKILL_NAME,
|
|
153
|
+
HANDOFF_SKILL_DESCRIPTION,
|
|
154
|
+
buildHandoffSkillBody,
|
|
155
|
+
buildHandoffSkillFile,
|
|
156
|
+
buildHandoffSubPrompt,
|
|
157
|
+
getHandoffSubPrompt,
|
|
158
|
+
};
|
|
@@ -28,8 +28,13 @@ const { $ } = await use('command-stream');
|
|
|
28
28
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
29
29
|
const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
|
|
30
30
|
const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
|
|
31
|
-
const
|
|
32
|
-
const
|
|
31
|
+
const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
32
|
+
const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
33
|
+
const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
34
|
+
// Docker's `--pull` accepts these policies. We only emit the flag when an
|
|
35
|
+
// operator explicitly opts in; otherwise Docker's own default ("missing")
|
|
36
|
+
// applies and `docker run` reuses any locally present image. See issue #1879.
|
|
37
|
+
const VALID_DOCKER_PULL_POLICIES = new Set(['always', 'missing', 'never']);
|
|
33
38
|
const DOCKER_ISOLATION_TRACKING_BACKEND = 'screen';
|
|
34
39
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
35
40
|
const DOCKER_CONTAINER_PREFIX = 'hive-mind-isolation';
|
|
@@ -79,15 +84,52 @@ function maybeAddMount(mounts, source, target, existsSync) {
|
|
|
79
84
|
mounts.push({ source, target });
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the tag used for the Docker isolation image.
|
|
89
|
+
*
|
|
90
|
+
* Defaults to `latest`, but operators can pin it (e.g. to the exact version
|
|
91
|
+
* already present on the host) via `HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG`.
|
|
92
|
+
* Pinning matters for Docker-in-Docker deployments: the nested daemon starts
|
|
93
|
+
* with an empty image store, so an unpinned `:latest` whose registry digest has
|
|
94
|
+
* drifted from the host copy forces a fresh multi-gigabyte pull on every task.
|
|
95
|
+
* A pinned tag lets a pre-seeded image be reused instead. See issue #1879.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveDockerIsolationImageTag({ env = process.env } = {}) {
|
|
98
|
+
const explicit = String(env.HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG || '').trim();
|
|
99
|
+
return explicit || DEFAULT_HIVE_MIND_IMAGE_TAG;
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
/**
|
|
83
103
|
* Pick the Docker image used for `--isolation docker`.
|
|
84
104
|
*
|
|
85
105
|
* start-command defaults its Docker backend to a base OS image. Hive Mind needs
|
|
86
106
|
* an image with the same CLI/tooling baseline as the parent process instead.
|
|
107
|
+
*
|
|
108
|
+
* `HIVE_MIND_DOCKER_ISOLATION_IMAGE` is a full override (repo:tag). Otherwise
|
|
109
|
+
* the repo is chosen by image variant and the tag by
|
|
110
|
+
* `resolveDockerIsolationImageTag()`.
|
|
87
111
|
*/
|
|
88
112
|
export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
89
113
|
if (env.HIVE_MIND_DOCKER_ISOLATION_IMAGE) return env.HIVE_MIND_DOCKER_ISOLATION_IMAGE;
|
|
90
|
-
|
|
114
|
+
const repo = String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? HIVE_MIND_DIND_IMAGE_REPO : HIVE_MIND_IMAGE_REPO;
|
|
115
|
+
return `${repo}:${resolveDockerIsolationImageTag({ env })}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the Docker `--pull` policy for isolated tasks.
|
|
120
|
+
*
|
|
121
|
+
* Returns one of `always` | `missing` | `never`, or `null` when unset (in which
|
|
122
|
+
* case the `--pull` flag is omitted and Docker's default applies). Operators set
|
|
123
|
+
* `HIVE_MIND_DOCKER_ISOLATION_PULL=never` to force reuse of an image already
|
|
124
|
+
* present in the (possibly nested) daemon and fail fast instead of silently
|
|
125
|
+
* re-downloading it. Invalid values are ignored. See issue #1879.
|
|
126
|
+
*/
|
|
127
|
+
export function getDockerIsolationPullPolicy({ env = process.env } = {}) {
|
|
128
|
+
const raw = String(env.HIVE_MIND_DOCKER_ISOLATION_PULL || '')
|
|
129
|
+
.trim()
|
|
130
|
+
.toLowerCase();
|
|
131
|
+
if (!raw) return null;
|
|
132
|
+
return VALID_DOCKER_PULL_POLICIES.has(raw) ? raw : null;
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
/**
|
|
@@ -123,7 +165,16 @@ export function buildDockerIsolationCommand(command, args = [], options = {}) {
|
|
|
123
165
|
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
|
|
124
166
|
const image = getDockerIsolationImage({ env });
|
|
125
167
|
const innerCommand = buildShellCommand(command, args);
|
|
126
|
-
const dockerArgs = ['docker', 'run', '--rm'
|
|
168
|
+
const dockerArgs = ['docker', 'run', '--rm'];
|
|
169
|
+
|
|
170
|
+
// Reuse a locally present image instead of re-downloading it when the
|
|
171
|
+
// operator opts in. Omitted by default so Docker's "missing" policy applies.
|
|
172
|
+
const pullPolicy = getDockerIsolationPullPolicy({ env });
|
|
173
|
+
if (pullPolicy) {
|
|
174
|
+
dockerArgs.push('--pull', pullPolicy);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
dockerArgs.push('--name', makeDockerContainerName(sessionId), '--workdir', DOCKER_CONTAINER_HOME, '-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`);
|
|
127
178
|
|
|
128
179
|
if (shouldRunPrivilegedDockerIsolation(image, env)) {
|
|
129
180
|
dockerArgs.push('--privileged');
|
|
@@ -359,9 +410,12 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
359
410
|
if (verbose) {
|
|
360
411
|
console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
|
|
361
412
|
if (backend === 'docker') {
|
|
362
|
-
const
|
|
363
|
-
const
|
|
413
|
+
const env = options.env || process.env;
|
|
414
|
+
const image = getDockerIsolationImage({ env });
|
|
415
|
+
const pullPolicy = getDockerIsolationPullPolicy({ env });
|
|
416
|
+
const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
|
|
364
417
|
console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
|
|
418
|
+
console.log(`[VERBOSE] isolation-runner: Docker isolation pull policy: ${pullPolicy || '(docker default: missing — reuse local image if present)'}`);
|
|
365
419
|
console.log(`[VERBOSE] isolation-runner: Docker isolation mounts: ${mounts.map(m => m.target).join(', ') || '(none)'}`);
|
|
366
420
|
}
|
|
367
421
|
}
|
|
@@ -38,6 +38,61 @@ export function classifyPushRejection(errorOutput = '') {
|
|
|
38
38
|
return 'unknown';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Detect whether a push failure was caused by missing permissions rather than
|
|
43
|
+
* by branch divergence. Git surfaces this as `! [remote rejected] ...
|
|
44
|
+
* (permission denied)` (HTTP 403). This is fundamentally different from a
|
|
45
|
+
* non-fast-forward / divergence rejection: force-pushing or force-with-lease
|
|
46
|
+
* will NOT help because the user simply cannot write to the remote.
|
|
47
|
+
*
|
|
48
|
+
* Issue #1893: when continuing another contributor's fork PR, the maintainer
|
|
49
|
+
* does not own the fork, so pushing the fork's default branch is rejected with
|
|
50
|
+
* "permission denied". The old heuristic matched the substring "rejected" and
|
|
51
|
+
* misclassified this as fork divergence, halting the run and recommending a
|
|
52
|
+
* useless `--allow-fork-divergence-resolution-using-force-push-with-lease`.
|
|
53
|
+
*/
|
|
54
|
+
export function isPermissionDeniedPushError(errorOutput = '') {
|
|
55
|
+
const normalized = String(errorOutput || '').toLowerCase();
|
|
56
|
+
return normalized.includes('permission denied') || normalized.includes('permission to') || normalized.includes('error: 403') || normalized.includes('the requested url returned error: 403') || (normalized.includes('denied') && normalized.includes('to https://'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether the solver should push the freshly-synced default branch to
|
|
61
|
+
* the fork's `origin` remote.
|
|
62
|
+
*
|
|
63
|
+
* We only push the default branch to keep a fork we OWN in sync with upstream.
|
|
64
|
+
* When continuing someone else's fork PR (the fork belongs to the contributor,
|
|
65
|
+
* not the current user), the maintainer has push rights only to the PR branch
|
|
66
|
+
* (via "Allow edits by maintainers"), never to the fork's default branch.
|
|
67
|
+
* Attempting the push is both impossible (permission denied) and unnecessary,
|
|
68
|
+
* so we skip it. Issue #1893.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} params
|
|
71
|
+
* @param {string|null} params.currentUser - authenticated GitHub login
|
|
72
|
+
* @param {string|null} params.forkedRepo - "owner/name" of the fork (origin)
|
|
73
|
+
* @returns {{ shouldPush: boolean, reason: string, forkOwner: string|null }}
|
|
74
|
+
*/
|
|
75
|
+
export function shouldPushDefaultBranchToFork({ currentUser, forkedRepo } = {}) {
|
|
76
|
+
const forkOwner = forkedRepo && forkedRepo.includes('/') ? forkedRepo.split('/')[0] : null;
|
|
77
|
+
|
|
78
|
+
if (!forkOwner) {
|
|
79
|
+
// Without a parseable fork owner we cannot prove ownership; fall back to the
|
|
80
|
+
// historical behaviour of attempting the push so nothing regresses.
|
|
81
|
+
return { shouldPush: true, reason: 'fork-owner-unknown', forkOwner: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!currentUser) {
|
|
85
|
+
// Could not resolve the current user; attempt the push and let git report.
|
|
86
|
+
return { shouldPush: true, reason: 'current-user-unknown', forkOwner };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (currentUser.toLowerCase() === forkOwner.toLowerCase()) {
|
|
90
|
+
return { shouldPush: true, reason: 'owns-fork', forkOwner };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { shouldPush: false, reason: 'not-fork-owner', forkOwner };
|
|
94
|
+
}
|
|
95
|
+
|
|
41
96
|
export function shouldTreatPushRejectionAsRemoteSynchronized(divergence = null) {
|
|
42
97
|
if (!divergence?.remoteExists || divergence.ahead !== 0 || divergence.behind !== 0) {
|
|
43
98
|
return false;
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -480,6 +480,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
480
480
|
description: 'Create comprehensive case study documentation for the issue including logs, analysis, timeline, root cause investigation, and proposed solutions. Organizes findings into ./docs/case-studies/issue-{id}/ directory. Supported for --tool claude and --tool codex.',
|
|
481
481
|
default: false,
|
|
482
482
|
},
|
|
483
|
+
'use-handoff': {
|
|
484
|
+
type: 'boolean',
|
|
485
|
+
description: '[EXPERIMENTAL] Enable the HANDOFF.md continuity Agent Skill so a session can continue the work of a previous session — even when a different AI tool is used (e.g. Claude and Codex continuing each other in the same pull request). A real SKILL.md (the open Agent Skills standard) is deployed into the working directory so each tool loads it natively (.claude/skills/handoff/ for Claude, .agents/skills/handoff/ for Codex). The AI reads HANDOFF.md (repository root) first when present and keeps it updated with task, current state, decisions, next steps, gotchas, and critical files. HANDOFF.md is committed to the PR branch so it persists across the ephemeral per-session working directories; the SKILL.md itself is re-deployed each session and git-excluded so it never pollutes the PR. The same skill file is used identically for --tool claude and --tool codex. Disabled by default (issue #1877).',
|
|
486
|
+
default: false,
|
|
487
|
+
},
|
|
483
488
|
'prompt-playwright-mcp': {
|
|
484
489
|
type: 'boolean',
|
|
485
490
|
description: 'Enable Playwright MCP browser automation hints in system prompt (enabled by default, only takes effect if Playwright MCP is installed). Use --no-prompt-playwright-mcp to disable. Supported for --tool claude, --tool codex, --tool opencode, --tool agent, --tool qwen, and --tool gemini.',
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Fork upstream-sync module for the solve command.
|
|
4
|
+
// Extracted from solve.repository.lib.mjs to keep files under 1500 lines (#1893).
|
|
5
|
+
|
|
6
|
+
// Use use-m to dynamically import modules for cross-runtime compatibility
|
|
7
|
+
// Check if use is already defined globally (when imported from solve.mjs)
|
|
8
|
+
// If not, fetch it (when running standalone)
|
|
9
|
+
if (typeof globalThis.use === 'undefined') {
|
|
10
|
+
globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
11
|
+
}
|
|
12
|
+
const use = globalThis.use;
|
|
13
|
+
|
|
14
|
+
// Use command-stream for consistent $ behavior; wrap with rate-limit retry (#1726)
|
|
15
|
+
const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
|
|
16
|
+
const $ = wrapDollarWithGhRetry((await use('command-stream')).$);
|
|
17
|
+
|
|
18
|
+
// Import shared library functions
|
|
19
|
+
const lib = await import('./lib.mjs');
|
|
20
|
+
const { log, formatAligned } = lib;
|
|
21
|
+
|
|
22
|
+
// Import exit handler
|
|
23
|
+
import { safeExit } from './exit-handler.lib.mjs';
|
|
24
|
+
|
|
25
|
+
// Issue #1893: helpers that decide whether the fork's default branch may be
|
|
26
|
+
// pushed and that distinguish a permission-denied rejection from a genuine
|
|
27
|
+
// fork divergence.
|
|
28
|
+
const { isPermissionDeniedPushError, shouldPushDefaultBranchToFork } = await import('./solve.branch-divergence.lib.mjs');
|
|
29
|
+
|
|
30
|
+
// Set up upstream remote and sync fork
|
|
31
|
+
export const setupUpstreamAndSync = async (tempDir, forkedRepo, upstreamRemote, owner, repo, argv) => {
|
|
32
|
+
if (!forkedRepo || !upstreamRemote) return;
|
|
33
|
+
|
|
34
|
+
await log(`${formatAligned('🔗', 'Setting upstream:', upstreamRemote)}`);
|
|
35
|
+
|
|
36
|
+
// Check if upstream remote already exists
|
|
37
|
+
const checkUpstreamResult = await $({ cwd: tempDir })`git remote get-url upstream 2>/dev/null`;
|
|
38
|
+
let upstreamExists = checkUpstreamResult.code === 0;
|
|
39
|
+
|
|
40
|
+
if (upstreamExists) {
|
|
41
|
+
await log(`${formatAligned('ℹ️', 'Upstream exists:', 'Using existing upstream remote')}`);
|
|
42
|
+
} else {
|
|
43
|
+
// Add upstream remote since it doesn't exist
|
|
44
|
+
const upstreamResult = await $({ cwd: tempDir })`git remote add upstream https://github.com/${upstreamRemote}.git`;
|
|
45
|
+
|
|
46
|
+
if (upstreamResult.code === 0) {
|
|
47
|
+
await log(`${formatAligned('✅', 'Upstream set:', upstreamRemote)}`);
|
|
48
|
+
upstreamExists = true;
|
|
49
|
+
} else {
|
|
50
|
+
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to add upstream remote')}`);
|
|
51
|
+
if (upstreamResult.stderr) {
|
|
52
|
+
await log(`${formatAligned('', 'Error details:', upstreamResult.stderr.toString().trim())}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Proceed with fork sync if upstream remote is available
|
|
58
|
+
if (upstreamExists) {
|
|
59
|
+
// Fetch upstream
|
|
60
|
+
await log(`${formatAligned('🔄', 'Fetching upstream...', '')}`);
|
|
61
|
+
const fetchResult = await $({ cwd: tempDir })`git fetch upstream`;
|
|
62
|
+
if (fetchResult.code === 0) {
|
|
63
|
+
await log(`${formatAligned('✅', 'Upstream fetched:', 'Successfully')}`);
|
|
64
|
+
|
|
65
|
+
// Sync the default branch with upstream to avoid merge conflicts
|
|
66
|
+
await log(`${formatAligned('🔄', 'Syncing default branch...', '')}`);
|
|
67
|
+
|
|
68
|
+
// Get current branch so we can return to it after sync
|
|
69
|
+
const currentBranchResult = await $({ cwd: tempDir })`git branch --show-current`;
|
|
70
|
+
if (currentBranchResult.code === 0) {
|
|
71
|
+
const currentBranch = currentBranchResult.stdout.toString().trim();
|
|
72
|
+
|
|
73
|
+
// Get the default branch name from the original repository using GitHub API
|
|
74
|
+
const repoInfoResult = await $`gh api repos/${owner}/${repo} --jq .default_branch`;
|
|
75
|
+
if (repoInfoResult.code === 0) {
|
|
76
|
+
const upstreamDefaultBranch = repoInfoResult.stdout.toString().trim();
|
|
77
|
+
await log(`${formatAligned('ℹ️', 'Default branch:', upstreamDefaultBranch)}`);
|
|
78
|
+
|
|
79
|
+
// Always sync the default branch, regardless of current branch
|
|
80
|
+
// This ensures fork is up-to-date even if we're working on a different branch
|
|
81
|
+
|
|
82
|
+
// Step 1: Switch to default branch if not already on it
|
|
83
|
+
let syncSuccessful = true;
|
|
84
|
+
if (currentBranch !== upstreamDefaultBranch) {
|
|
85
|
+
await log(`${formatAligned('🔄', 'Switching to:', `${upstreamDefaultBranch} branch`)}`);
|
|
86
|
+
const checkoutResult = await $({ cwd: tempDir })`git checkout ${upstreamDefaultBranch}`;
|
|
87
|
+
if (checkoutResult.code !== 0) {
|
|
88
|
+
await log(`${formatAligned('⚠️', 'Warning:', `Failed to checkout ${upstreamDefaultBranch}`)}`);
|
|
89
|
+
syncSuccessful = false; // Cannot proceed with sync
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 2: Sync default branch with upstream (only if checkout was successful)
|
|
94
|
+
if (syncSuccessful) {
|
|
95
|
+
const syncResult = await $({ cwd: tempDir })`git reset --hard upstream/${upstreamDefaultBranch}`;
|
|
96
|
+
if (syncResult.code === 0) {
|
|
97
|
+
await log(`${formatAligned('✅', 'Default branch synced:', `with upstream/${upstreamDefaultBranch}`)}`);
|
|
98
|
+
|
|
99
|
+
// Step 3: Push the updated default branch to fork to keep it in sync.
|
|
100
|
+
//
|
|
101
|
+
// Issue #1893: only push the default branch when the current user
|
|
102
|
+
// OWNS the fork. When continuing another contributor's fork PR the
|
|
103
|
+
// fork belongs to them, and "Allow edits by maintainers" grants
|
|
104
|
+
// push access only to the PR branch — never to the fork's default
|
|
105
|
+
// branch. Attempting the push there is guaranteed to be rejected
|
|
106
|
+
// with "permission denied" and is unnecessary, so we skip it and
|
|
107
|
+
// keep working on the PR branch.
|
|
108
|
+
const currentUserResult = await $`gh api user --jq .login`;
|
|
109
|
+
const currentUser = currentUserResult.code === 0 ? currentUserResult.stdout.toString().trim() : null;
|
|
110
|
+
const pushDecision = shouldPushDefaultBranchToFork({ currentUser, forkedRepo });
|
|
111
|
+
|
|
112
|
+
if (!pushDecision.shouldPush) {
|
|
113
|
+
await log(`${formatAligned('ℹ️', 'Skipping fork push:', `${upstreamDefaultBranch} synced locally only`)}`);
|
|
114
|
+
await log(`${formatAligned('', 'Reason:', `Fork ${forkedRepo} is owned by ${pushDecision.forkOwner}, not ${currentUser || 'the current user'}`)}`, {
|
|
115
|
+
verbose: true,
|
|
116
|
+
});
|
|
117
|
+
await log(`${formatAligned('', 'Next:', 'Continuing on the PR branch (maintainer edits allowed on the PR head only)')}`, {
|
|
118
|
+
verbose: true,
|
|
119
|
+
});
|
|
120
|
+
// Fall through to Step 4 (return to original branch) without pushing.
|
|
121
|
+
if (currentBranch !== upstreamDefaultBranch) {
|
|
122
|
+
await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
|
|
123
|
+
const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
|
|
124
|
+
if (returnResult.code === 0) {
|
|
125
|
+
await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
|
|
126
|
+
} else {
|
|
127
|
+
await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await log(`${formatAligned('🔄', 'Pushing to fork:', `${upstreamDefaultBranch} branch`)}`);
|
|
134
|
+
const pushResult = await $({ cwd: tempDir })`git push origin ${upstreamDefaultBranch} 2>&1`;
|
|
135
|
+
if (pushResult.code === 0) {
|
|
136
|
+
await log(`${formatAligned('✅', 'Fork updated:', 'Default branch pushed to fork')}`);
|
|
137
|
+
} else {
|
|
138
|
+
// Check if it's a non-fast-forward error (fork has diverged from upstream)
|
|
139
|
+
const errorMsg = (pushResult.stderr ? pushResult.stderr.toString().trim() : '') || (pushResult.stdout ? pushResult.stdout.toString().trim() : '');
|
|
140
|
+
|
|
141
|
+
// Issue #1893: a "permission denied" rejection is NOT a divergence.
|
|
142
|
+
// It means the current user cannot write to this fork (e.g. it
|
|
143
|
+
// belongs to another contributor). Force-push / force-with-lease
|
|
144
|
+
// cannot fix that, so never recommend the divergence flag here.
|
|
145
|
+
// Syncing the default branch is best-effort, so we warn and
|
|
146
|
+
// continue working on the PR branch instead of halting.
|
|
147
|
+
if (isPermissionDeniedPushError(errorMsg)) {
|
|
148
|
+
await log('');
|
|
149
|
+
await log(`${formatAligned('ℹ️', 'Skipping fork sync:', `No push access to ${forkedRepo}`)}`);
|
|
150
|
+
await log(`${formatAligned('', 'Reason:', "Fork's default branch is owned by another user; this is expected when")}`, { verbose: true });
|
|
151
|
+
await log(`${formatAligned('', '', "continuing a contributor's fork PR (maintainer edits cover the PR branch only)")}`, { verbose: true });
|
|
152
|
+
await log(`${formatAligned('', 'Push output:', errorMsg.split('\n')[0] || errorMsg)}`, { verbose: true });
|
|
153
|
+
// Return to the original branch and continue without halting.
|
|
154
|
+
if (currentBranch !== upstreamDefaultBranch) {
|
|
155
|
+
await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
|
|
156
|
+
const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
|
|
157
|
+
if (returnResult.code === 0) {
|
|
158
|
+
await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
|
|
159
|
+
} else {
|
|
160
|
+
await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const isNonFastForward = errorMsg.includes('non-fast-forward') || errorMsg.includes('rejected') || errorMsg.includes('tip of your current branch is behind');
|
|
167
|
+
|
|
168
|
+
if (isNonFastForward) {
|
|
169
|
+
// Fork has diverged from upstream
|
|
170
|
+
await log('');
|
|
171
|
+
await log(`${formatAligned('⚠️', 'FORK DIVERGENCE DETECTED', '')}`, { level: 'warn' });
|
|
172
|
+
await log('');
|
|
173
|
+
await log(' 🔍 What happened:');
|
|
174
|
+
await log(` Your fork's ${upstreamDefaultBranch} branch has different commits than upstream`);
|
|
175
|
+
await log(' This typically occurs when upstream had a force push (e.g., git reset --hard)');
|
|
176
|
+
await log('');
|
|
177
|
+
await log(' 📦 Current state:');
|
|
178
|
+
await log(` • Fork: ${forkedRepo}`);
|
|
179
|
+
await log(` • Upstream: ${owner}/${repo}`);
|
|
180
|
+
await log(` • Branch: ${upstreamDefaultBranch}`);
|
|
181
|
+
await log('');
|
|
182
|
+
|
|
183
|
+
// Check if user has enabled automatic force push
|
|
184
|
+
if (argv.allowForkDivergenceResolutionUsingForcePushWithLease) {
|
|
185
|
+
await log(' 🔄 Auto-resolution ENABLED (--allow-fork-divergence-resolution-using-force-push-with-lease):');
|
|
186
|
+
await log(' Attempting to force-push with --force-with-lease...');
|
|
187
|
+
await log('');
|
|
188
|
+
|
|
189
|
+
// Use --force-with-lease for safer force push
|
|
190
|
+
// This will only force push if the remote hasn't changed since our last fetch
|
|
191
|
+
await log(`${formatAligned('🔄', 'Force pushing:', 'Syncing fork with upstream (--force-with-lease)')}`);
|
|
192
|
+
const forcePushResult = await $({
|
|
193
|
+
cwd: tempDir,
|
|
194
|
+
})`git push --force-with-lease origin ${upstreamDefaultBranch} 2>&1`;
|
|
195
|
+
|
|
196
|
+
if (forcePushResult.code === 0) {
|
|
197
|
+
await log(`${formatAligned('✅', 'Fork synced:', 'Successfully force-pushed to align with upstream')}`);
|
|
198
|
+
await log('');
|
|
199
|
+
} else {
|
|
200
|
+
// Force push also failed - this is a more serious issue
|
|
201
|
+
await log('');
|
|
202
|
+
await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to sync fork with upstream')}`, {
|
|
203
|
+
level: 'error',
|
|
204
|
+
});
|
|
205
|
+
await log('');
|
|
206
|
+
await log(' 🔍 What happened:');
|
|
207
|
+
await log(` Fork branch ${upstreamDefaultBranch} has diverged from upstream`);
|
|
208
|
+
await log(' Both normal push and force-with-lease push failed');
|
|
209
|
+
await log('');
|
|
210
|
+
await log(' 📦 Error details:');
|
|
211
|
+
const forceErrorMsg = forcePushResult.stderr ? forcePushResult.stderr.toString().trim() : '';
|
|
212
|
+
for (const line of forceErrorMsg.split('\n')) {
|
|
213
|
+
if (line.trim()) await log(` ${line}`);
|
|
214
|
+
}
|
|
215
|
+
await log('');
|
|
216
|
+
await log(' 💡 Possible causes:');
|
|
217
|
+
await log(' • Fork branch is protected (branch protection rules prevent force push)');
|
|
218
|
+
await log(' • Someone else pushed to fork after our fetch');
|
|
219
|
+
await log(' • Insufficient permissions to force push');
|
|
220
|
+
await log('');
|
|
221
|
+
await log(' 🔧 Manual resolution:');
|
|
222
|
+
await log(` 1. Visit your fork: https://github.com/${forkedRepo}`);
|
|
223
|
+
await log(' 2. Check branch protection settings');
|
|
224
|
+
await log(' 3. Manually sync fork with upstream:');
|
|
225
|
+
await log(' git fetch upstream');
|
|
226
|
+
await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
|
|
227
|
+
await log(` git push --force origin ${upstreamDefaultBranch}`);
|
|
228
|
+
await log('');
|
|
229
|
+
await safeExit(1, 'Repository setup failed - fork sync failed');
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Flag is not enabled - provide guidance
|
|
233
|
+
await log(' 💡 Your options:');
|
|
234
|
+
await log('');
|
|
235
|
+
await log(' Option 1: Delete your fork and recreate it (SIMPLEST)');
|
|
236
|
+
await log(` gh repo delete ${forkedRepo}`);
|
|
237
|
+
await log(' Then run the solve command again - the fork will be recreated automatically');
|
|
238
|
+
await log(' ⚠️ Only use this if your fork has no unique commits you need to preserve');
|
|
239
|
+
await log('');
|
|
240
|
+
await log(' Option 2: Enable automatic force-push (DANGEROUS)');
|
|
241
|
+
await log(' Add --allow-fork-divergence-resolution-using-force-push-with-lease flag to your command');
|
|
242
|
+
await log(' This will automatically sync your fork with upstream using force-with-lease');
|
|
243
|
+
await log(' ⚠️ Overwrites fork history - any unique commits will be LOST');
|
|
244
|
+
await log('');
|
|
245
|
+
await log(' Option 3: Manually resolve the divergence');
|
|
246
|
+
await log(' 1. Decide if you need any commits unique to your fork');
|
|
247
|
+
await log(' 2. If yes, cherry-pick them after syncing');
|
|
248
|
+
await log(' 3. If no, manually force-push:');
|
|
249
|
+
await log(' git fetch upstream');
|
|
250
|
+
await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
|
|
251
|
+
await log(` git push --force origin ${upstreamDefaultBranch}`);
|
|
252
|
+
await log('');
|
|
253
|
+
await log(' 🔧 To proceed with auto-resolution, restart with:');
|
|
254
|
+
await log(` solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-fork-divergence-resolution-using-force-push-with-lease`);
|
|
255
|
+
await log('');
|
|
256
|
+
await safeExit(1, 'Repository setup halted - fork divergence requires user decision');
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
// Some other push error (not divergence-related)
|
|
260
|
+
await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to push updated default branch to fork')}`);
|
|
261
|
+
await log(`${formatAligned('', 'Push error:', errorMsg)}`);
|
|
262
|
+
await log(`${formatAligned('', 'Reason:', 'Fork must be updated or process must stop')}`);
|
|
263
|
+
await log(`${formatAligned('', 'Solution draft:', 'Fork sync is required for proper workflow')}`);
|
|
264
|
+
await log(`${formatAligned('', 'Next steps:', '1. Check GitHub permissions for the fork')}`);
|
|
265
|
+
await log(`${formatAligned('', '', '2. Ensure fork is not protected')}`);
|
|
266
|
+
await log(`${formatAligned('', '', '3. Try again after resolving fork issues')}`);
|
|
267
|
+
await safeExit(1, 'Repository setup failed');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Step 4: Return to the original branch if it was different
|
|
272
|
+
if (currentBranch !== upstreamDefaultBranch) {
|
|
273
|
+
await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
|
|
274
|
+
const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
|
|
275
|
+
if (returnResult.code === 0) {
|
|
276
|
+
await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
|
|
277
|
+
} else {
|
|
278
|
+
await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
|
|
279
|
+
// This is not fatal, continue with sync on default branch
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
await log(`${formatAligned('⚠️', 'Warning:', `Failed to sync ${upstreamDefaultBranch} with upstream`)}`);
|
|
284
|
+
if (syncResult.stderr) {
|
|
285
|
+
await log(`${formatAligned('', 'Sync error:', syncResult.stderr.toString().trim())}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get default branch name')}`);
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get current branch')}`);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to fetch upstream')}`);
|
|
297
|
+
if (fetchResult.stderr) {
|
|
298
|
+
await log(`${formatAligned('', 'Fetch error:', fetchResult.stderr.toString().trim())}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
@@ -1045,220 +1045,10 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
|
|
|
1045
1045
|
await safeExit(1, 'Repository setup failed');
|
|
1046
1046
|
};
|
|
1047
1047
|
|
|
1048
|
-
// Set up upstream remote and sync fork
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
await log(`${formatAligned('🔗', 'Setting upstream:', upstreamRemote)}`);
|
|
1053
|
-
|
|
1054
|
-
// Check if upstream remote already exists
|
|
1055
|
-
const checkUpstreamResult = await $({ cwd: tempDir })`git remote get-url upstream 2>/dev/null`;
|
|
1056
|
-
let upstreamExists = checkUpstreamResult.code === 0;
|
|
1057
|
-
|
|
1058
|
-
if (upstreamExists) {
|
|
1059
|
-
await log(`${formatAligned('ℹ️', 'Upstream exists:', 'Using existing upstream remote')}`);
|
|
1060
|
-
} else {
|
|
1061
|
-
// Add upstream remote since it doesn't exist
|
|
1062
|
-
const upstreamResult = await $({ cwd: tempDir })`git remote add upstream https://github.com/${upstreamRemote}.git`;
|
|
1063
|
-
|
|
1064
|
-
if (upstreamResult.code === 0) {
|
|
1065
|
-
await log(`${formatAligned('✅', 'Upstream set:', upstreamRemote)}`);
|
|
1066
|
-
upstreamExists = true;
|
|
1067
|
-
} else {
|
|
1068
|
-
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to add upstream remote')}`);
|
|
1069
|
-
if (upstreamResult.stderr) {
|
|
1070
|
-
await log(`${formatAligned('', 'Error details:', upstreamResult.stderr.toString().trim())}`);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Proceed with fork sync if upstream remote is available
|
|
1076
|
-
if (upstreamExists) {
|
|
1077
|
-
// Fetch upstream
|
|
1078
|
-
await log(`${formatAligned('🔄', 'Fetching upstream...', '')}`);
|
|
1079
|
-
const fetchResult = await $({ cwd: tempDir })`git fetch upstream`;
|
|
1080
|
-
if (fetchResult.code === 0) {
|
|
1081
|
-
await log(`${formatAligned('✅', 'Upstream fetched:', 'Successfully')}`);
|
|
1082
|
-
|
|
1083
|
-
// Sync the default branch with upstream to avoid merge conflicts
|
|
1084
|
-
await log(`${formatAligned('🔄', 'Syncing default branch...', '')}`);
|
|
1085
|
-
|
|
1086
|
-
// Get current branch so we can return to it after sync
|
|
1087
|
-
const currentBranchResult = await $({ cwd: tempDir })`git branch --show-current`;
|
|
1088
|
-
if (currentBranchResult.code === 0) {
|
|
1089
|
-
const currentBranch = currentBranchResult.stdout.toString().trim();
|
|
1090
|
-
|
|
1091
|
-
// Get the default branch name from the original repository using GitHub API
|
|
1092
|
-
const repoInfoResult = await $`gh api repos/${owner}/${repo} --jq .default_branch`;
|
|
1093
|
-
if (repoInfoResult.code === 0) {
|
|
1094
|
-
const upstreamDefaultBranch = repoInfoResult.stdout.toString().trim();
|
|
1095
|
-
await log(`${formatAligned('ℹ️', 'Default branch:', upstreamDefaultBranch)}`);
|
|
1096
|
-
|
|
1097
|
-
// Always sync the default branch, regardless of current branch
|
|
1098
|
-
// This ensures fork is up-to-date even if we're working on a different branch
|
|
1099
|
-
|
|
1100
|
-
// Step 1: Switch to default branch if not already on it
|
|
1101
|
-
let syncSuccessful = true;
|
|
1102
|
-
if (currentBranch !== upstreamDefaultBranch) {
|
|
1103
|
-
await log(`${formatAligned('🔄', 'Switching to:', `${upstreamDefaultBranch} branch`)}`);
|
|
1104
|
-
const checkoutResult = await $({ cwd: tempDir })`git checkout ${upstreamDefaultBranch}`;
|
|
1105
|
-
if (checkoutResult.code !== 0) {
|
|
1106
|
-
await log(`${formatAligned('⚠️', 'Warning:', `Failed to checkout ${upstreamDefaultBranch}`)}`);
|
|
1107
|
-
syncSuccessful = false; // Cannot proceed with sync
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// Step 2: Sync default branch with upstream (only if checkout was successful)
|
|
1112
|
-
if (syncSuccessful) {
|
|
1113
|
-
const syncResult = await $({ cwd: tempDir })`git reset --hard upstream/${upstreamDefaultBranch}`;
|
|
1114
|
-
if (syncResult.code === 0) {
|
|
1115
|
-
await log(`${formatAligned('✅', 'Default branch synced:', `with upstream/${upstreamDefaultBranch}`)}`);
|
|
1116
|
-
|
|
1117
|
-
// Step 3: Push the updated default branch to fork to keep it in sync
|
|
1118
|
-
await log(`${formatAligned('🔄', 'Pushing to fork:', `${upstreamDefaultBranch} branch`)}`);
|
|
1119
|
-
const pushResult = await $({ cwd: tempDir })`git push origin ${upstreamDefaultBranch} 2>&1`;
|
|
1120
|
-
if (pushResult.code === 0) {
|
|
1121
|
-
await log(`${formatAligned('✅', 'Fork updated:', 'Default branch pushed to fork')}`);
|
|
1122
|
-
} else {
|
|
1123
|
-
// Check if it's a non-fast-forward error (fork has diverged from upstream)
|
|
1124
|
-
const errorMsg = (pushResult.stderr ? pushResult.stderr.toString().trim() : '') || (pushResult.stdout ? pushResult.stdout.toString().trim() : '');
|
|
1125
|
-
const isNonFastForward = errorMsg.includes('non-fast-forward') || errorMsg.includes('rejected') || errorMsg.includes('tip of your current branch is behind');
|
|
1126
|
-
|
|
1127
|
-
if (isNonFastForward) {
|
|
1128
|
-
// Fork has diverged from upstream
|
|
1129
|
-
await log('');
|
|
1130
|
-
await log(`${formatAligned('⚠️', 'FORK DIVERGENCE DETECTED', '')}`, { level: 'warn' });
|
|
1131
|
-
await log('');
|
|
1132
|
-
await log(' 🔍 What happened:');
|
|
1133
|
-
await log(` Your fork's ${upstreamDefaultBranch} branch has different commits than upstream`);
|
|
1134
|
-
await log(' This typically occurs when upstream had a force push (e.g., git reset --hard)');
|
|
1135
|
-
await log('');
|
|
1136
|
-
await log(' 📦 Current state:');
|
|
1137
|
-
await log(` • Fork: ${forkedRepo}`);
|
|
1138
|
-
await log(` • Upstream: ${owner}/${repo}`);
|
|
1139
|
-
await log(` • Branch: ${upstreamDefaultBranch}`);
|
|
1140
|
-
await log('');
|
|
1141
|
-
|
|
1142
|
-
// Check if user has enabled automatic force push
|
|
1143
|
-
if (argv.allowForkDivergenceResolutionUsingForcePushWithLease) {
|
|
1144
|
-
await log(' 🔄 Auto-resolution ENABLED (--allow-fork-divergence-resolution-using-force-push-with-lease):');
|
|
1145
|
-
await log(' Attempting to force-push with --force-with-lease...');
|
|
1146
|
-
await log('');
|
|
1147
|
-
|
|
1148
|
-
// Use --force-with-lease for safer force push
|
|
1149
|
-
// This will only force push if the remote hasn't changed since our last fetch
|
|
1150
|
-
await log(`${formatAligned('🔄', 'Force pushing:', 'Syncing fork with upstream (--force-with-lease)')}`);
|
|
1151
|
-
const forcePushResult = await $({
|
|
1152
|
-
cwd: tempDir,
|
|
1153
|
-
})`git push --force-with-lease origin ${upstreamDefaultBranch} 2>&1`;
|
|
1154
|
-
|
|
1155
|
-
if (forcePushResult.code === 0) {
|
|
1156
|
-
await log(`${formatAligned('✅', 'Fork synced:', 'Successfully force-pushed to align with upstream')}`);
|
|
1157
|
-
await log('');
|
|
1158
|
-
} else {
|
|
1159
|
-
// Force push also failed - this is a more serious issue
|
|
1160
|
-
await log('');
|
|
1161
|
-
await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to sync fork with upstream')}`, {
|
|
1162
|
-
level: 'error',
|
|
1163
|
-
});
|
|
1164
|
-
await log('');
|
|
1165
|
-
await log(' 🔍 What happened:');
|
|
1166
|
-
await log(` Fork branch ${upstreamDefaultBranch} has diverged from upstream`);
|
|
1167
|
-
await log(' Both normal push and force-with-lease push failed');
|
|
1168
|
-
await log('');
|
|
1169
|
-
await log(' 📦 Error details:');
|
|
1170
|
-
const forceErrorMsg = forcePushResult.stderr ? forcePushResult.stderr.toString().trim() : '';
|
|
1171
|
-
for (const line of forceErrorMsg.split('\n')) {
|
|
1172
|
-
if (line.trim()) await log(` ${line}`);
|
|
1173
|
-
}
|
|
1174
|
-
await log('');
|
|
1175
|
-
await log(' 💡 Possible causes:');
|
|
1176
|
-
await log(' • Fork branch is protected (branch protection rules prevent force push)');
|
|
1177
|
-
await log(' • Someone else pushed to fork after our fetch');
|
|
1178
|
-
await log(' • Insufficient permissions to force push');
|
|
1179
|
-
await log('');
|
|
1180
|
-
await log(' 🔧 Manual resolution:');
|
|
1181
|
-
await log(` 1. Visit your fork: https://github.com/${forkedRepo}`);
|
|
1182
|
-
await log(' 2. Check branch protection settings');
|
|
1183
|
-
await log(' 3. Manually sync fork with upstream:');
|
|
1184
|
-
await log(' git fetch upstream');
|
|
1185
|
-
await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
|
|
1186
|
-
await log(` git push --force origin ${upstreamDefaultBranch}`);
|
|
1187
|
-
await log('');
|
|
1188
|
-
await safeExit(1, 'Repository setup failed - fork sync failed');
|
|
1189
|
-
}
|
|
1190
|
-
} else {
|
|
1191
|
-
// Flag is not enabled - provide guidance
|
|
1192
|
-
await log(' 💡 Your options:');
|
|
1193
|
-
await log('');
|
|
1194
|
-
await log(' Option 1: Delete your fork and recreate it (SIMPLEST)');
|
|
1195
|
-
await log(` gh repo delete ${forkedRepo}`);
|
|
1196
|
-
await log(' Then run the solve command again - the fork will be recreated automatically');
|
|
1197
|
-
await log(' ⚠️ Only use this if your fork has no unique commits you need to preserve');
|
|
1198
|
-
await log('');
|
|
1199
|
-
await log(' Option 2: Enable automatic force-push (DANGEROUS)');
|
|
1200
|
-
await log(' Add --allow-fork-divergence-resolution-using-force-push-with-lease flag to your command');
|
|
1201
|
-
await log(' This will automatically sync your fork with upstream using force-with-lease');
|
|
1202
|
-
await log(' ⚠️ Overwrites fork history - any unique commits will be LOST');
|
|
1203
|
-
await log('');
|
|
1204
|
-
await log(' Option 3: Manually resolve the divergence');
|
|
1205
|
-
await log(' 1. Decide if you need any commits unique to your fork');
|
|
1206
|
-
await log(' 2. If yes, cherry-pick them after syncing');
|
|
1207
|
-
await log(' 3. If no, manually force-push:');
|
|
1208
|
-
await log(' git fetch upstream');
|
|
1209
|
-
await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
|
|
1210
|
-
await log(` git push --force origin ${upstreamDefaultBranch}`);
|
|
1211
|
-
await log('');
|
|
1212
|
-
await log(' 🔧 To proceed with auto-resolution, restart with:');
|
|
1213
|
-
await log(` solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-fork-divergence-resolution-using-force-push-with-lease`);
|
|
1214
|
-
await log('');
|
|
1215
|
-
await safeExit(1, 'Repository setup halted - fork divergence requires user decision');
|
|
1216
|
-
}
|
|
1217
|
-
} else {
|
|
1218
|
-
// Some other push error (not divergence-related)
|
|
1219
|
-
await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to push updated default branch to fork')}`);
|
|
1220
|
-
await log(`${formatAligned('', 'Push error:', errorMsg)}`);
|
|
1221
|
-
await log(`${formatAligned('', 'Reason:', 'Fork must be updated or process must stop')}`);
|
|
1222
|
-
await log(`${formatAligned('', 'Solution draft:', 'Fork sync is required for proper workflow')}`);
|
|
1223
|
-
await log(`${formatAligned('', 'Next steps:', '1. Check GitHub permissions for the fork')}`);
|
|
1224
|
-
await log(`${formatAligned('', '', '2. Ensure fork is not protected')}`);
|
|
1225
|
-
await log(`${formatAligned('', '', '3. Try again after resolving fork issues')}`);
|
|
1226
|
-
await safeExit(1, 'Repository setup failed');
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
// Step 4: Return to the original branch if it was different
|
|
1231
|
-
if (currentBranch !== upstreamDefaultBranch) {
|
|
1232
|
-
await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
|
|
1233
|
-
const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
|
|
1234
|
-
if (returnResult.code === 0) {
|
|
1235
|
-
await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
|
|
1236
|
-
} else {
|
|
1237
|
-
await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
|
|
1238
|
-
// This is not fatal, continue with sync on default branch
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
} else {
|
|
1242
|
-
await log(`${formatAligned('⚠️', 'Warning:', `Failed to sync ${upstreamDefaultBranch} with upstream`)}`);
|
|
1243
|
-
if (syncResult.stderr) {
|
|
1244
|
-
await log(`${formatAligned('', 'Sync error:', syncResult.stderr.toString().trim())}`);
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
} else {
|
|
1249
|
-
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get default branch name')}`);
|
|
1250
|
-
}
|
|
1251
|
-
} else {
|
|
1252
|
-
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get current branch')}`);
|
|
1253
|
-
}
|
|
1254
|
-
} else {
|
|
1255
|
-
await log(`${formatAligned('⚠️', 'Warning:', 'Failed to fetch upstream')}`);
|
|
1256
|
-
if (fetchResult.stderr) {
|
|
1257
|
-
await log(`${formatAligned('', 'Fetch error:', fetchResult.stderr.toString().trim())}`);
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
};
|
|
1048
|
+
// Set up upstream remote and sync fork.
|
|
1049
|
+
// Extracted into solve.fork-sync.lib.mjs (#1893) to keep this file under the
|
|
1050
|
+
// 1500-line limit; re-exported here so existing importers keep working.
|
|
1051
|
+
export { setupUpstreamAndSync } from './solve.fork-sync.lib.mjs';
|
|
1262
1052
|
|
|
1263
1053
|
// Set up pr-fork remote for continuing someone else's fork PR with --fork flag
|
|
1264
1054
|
export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isContinueMode, owner = null) => {
|