@imdeadpool/guardex 5.0.0
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/CONTRIBUTING.md +25 -0
- package/LICENSE +21 -0
- package/README.md +428 -0
- package/SECURITY.md +28 -0
- package/bin/multiagent-safety.js +2478 -0
- package/package.json +68 -0
- package/templates/AGENTS.multiagent-safety.md +60 -0
- package/templates/claude/commands/guardex.md +18 -0
- package/templates/codex/skills/guardex/SKILL.md +36 -0
- package/templates/githooks/pre-commit +178 -0
- package/templates/githooks/pre-push +57 -0
- package/templates/scripts/agent-branch-finish.sh +389 -0
- package/templates/scripts/agent-branch-start.sh +289 -0
- package/templates/scripts/agent-file-locks.py +406 -0
- package/templates/scripts/agent-worktree-prune.sh +155 -0
- package/templates/scripts/codex-agent.sh +94 -0
- package/templates/scripts/install-agent-git-hooks.sh +21 -0
- package/templates/scripts/openspec/init-plan-workspace.sh +118 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
BASE_BRANCH=""
|
|
5
|
+
BASE_BRANCH_EXPLICIT=0
|
|
6
|
+
SOURCE_BRANCH=""
|
|
7
|
+
PUSH_ENABLED=1
|
|
8
|
+
DELETE_REMOTE_BRANCH=1
|
|
9
|
+
MERGE_MODE="auto"
|
|
10
|
+
GH_BIN="${MUSAFETY_GH_BIN:-gh}"
|
|
11
|
+
|
|
12
|
+
while [[ $# -gt 0 ]]; do
|
|
13
|
+
case "$1" in
|
|
14
|
+
--base)
|
|
15
|
+
BASE_BRANCH="${2:-}"
|
|
16
|
+
BASE_BRANCH_EXPLICIT=1
|
|
17
|
+
shift 2
|
|
18
|
+
;;
|
|
19
|
+
--branch)
|
|
20
|
+
SOURCE_BRANCH="${2:-}"
|
|
21
|
+
shift 2
|
|
22
|
+
;;
|
|
23
|
+
--no-push)
|
|
24
|
+
PUSH_ENABLED=0
|
|
25
|
+
shift
|
|
26
|
+
;;
|
|
27
|
+
--keep-remote-branch)
|
|
28
|
+
DELETE_REMOTE_BRANCH=0
|
|
29
|
+
shift
|
|
30
|
+
;;
|
|
31
|
+
--mode)
|
|
32
|
+
MERGE_MODE="${2:-auto}"
|
|
33
|
+
shift 2
|
|
34
|
+
;;
|
|
35
|
+
--via-pr)
|
|
36
|
+
MERGE_MODE="pr"
|
|
37
|
+
shift
|
|
38
|
+
;;
|
|
39
|
+
--direct-only)
|
|
40
|
+
MERGE_MODE="direct"
|
|
41
|
+
shift
|
|
42
|
+
;;
|
|
43
|
+
*)
|
|
44
|
+
echo "[agent-branch-finish] Unknown argument: $1" >&2
|
|
45
|
+
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--keep-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
|
|
46
|
+
exit 1
|
|
47
|
+
;;
|
|
48
|
+
esac
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
case "$MERGE_MODE" in
|
|
52
|
+
auto|direct|pr) ;;
|
|
53
|
+
*)
|
|
54
|
+
echo "[agent-branch-finish] Invalid --mode value: ${MERGE_MODE} (expected auto|direct|pr)" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
|
|
59
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
60
|
+
echo "[agent-branch-finish] Not inside a git repository." >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
repo_root="$(git rev-parse --show-toplevel)"
|
|
65
|
+
current_worktree="$(pwd -P)"
|
|
66
|
+
|
|
67
|
+
if [[ -z "$SOURCE_BRANCH" ]]; then
|
|
68
|
+
SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
|
|
72
|
+
echo "[agent-branch-finish] --base requires a non-empty branch name." >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
|
|
77
|
+
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
|
|
78
|
+
if [[ -n "$configured_base" ]]; then
|
|
79
|
+
BASE_BRANCH="$configured_base"
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
if [[ -z "$BASE_BRANCH" ]]; then
|
|
84
|
+
branch_stored_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.musafetyBase" || true)"
|
|
85
|
+
if [[ -n "$branch_stored_base" ]]; then
|
|
86
|
+
BASE_BRANCH="$branch_stored_base"
|
|
87
|
+
fi
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
if [[ -z "$BASE_BRANCH" ]]; then
|
|
91
|
+
source_upstream="$(git -C "$repo_root" for-each-ref --format='%(upstream:short)' "refs/heads/${SOURCE_BRANCH}" | head -n 1)"
|
|
92
|
+
source_upstream="${source_upstream:-}"
|
|
93
|
+
if [[ "$source_upstream" == */* ]]; then
|
|
94
|
+
BASE_BRANCH="${source_upstream#*/}"
|
|
95
|
+
fi
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
if [[ -z "$BASE_BRANCH" ]]; then
|
|
99
|
+
current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
100
|
+
if [[ -n "$current_branch" && "$current_branch" != "HEAD" && "$current_branch" != "$SOURCE_BRANCH" ]]; then
|
|
101
|
+
BASE_BRANCH="$current_branch"
|
|
102
|
+
fi
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
if [[ -z "$BASE_BRANCH" ]]; then
|
|
106
|
+
BASE_BRANCH="dev"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then
|
|
110
|
+
echo "[agent-branch-finish] Source branch and base branch are both '$BASE_BRANCH'." >&2
|
|
111
|
+
echo "[agent-branch-finish] Switch to your agent branch or pass --branch <agent-branch>." >&2
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then
|
|
116
|
+
echo "[agent-branch-finish] Local source branch does not exist: ${SOURCE_BRANCH}" >&2
|
|
117
|
+
exit 1
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
get_worktree_for_branch() {
|
|
121
|
+
local branch="$1"
|
|
122
|
+
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
|
|
123
|
+
$1 == "worktree" { wt = $2 }
|
|
124
|
+
$1 == "branch" && $2 == target { print wt; exit }
|
|
125
|
+
'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
is_clean_worktree() {
|
|
129
|
+
local wt="$1"
|
|
130
|
+
git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
|
|
131
|
+
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
|
|
135
|
+
created_source_probe=0
|
|
136
|
+
source_probe_path=""
|
|
137
|
+
|
|
138
|
+
if [[ -z "$source_worktree" ]]; then
|
|
139
|
+
source_probe_path="${repo_root}/.omx/agent-worktrees/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
|
|
140
|
+
mkdir -p "$(dirname "$source_probe_path")"
|
|
141
|
+
git -C "$repo_root" worktree add "$source_probe_path" "$SOURCE_BRANCH" >/dev/null
|
|
142
|
+
source_worktree="$source_probe_path"
|
|
143
|
+
created_source_probe=1
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
if ! is_clean_worktree "$source_worktree"; then
|
|
147
|
+
echo "[agent-branch-finish] Source worktree is not clean for '${SOURCE_BRANCH}': ${source_worktree}" >&2
|
|
148
|
+
echo "[agent-branch-finish] Commit/stash changes on the source branch before finishing." >&2
|
|
149
|
+
exit 1
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
start_ref="$BASE_BRANCH"
|
|
153
|
+
if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
154
|
+
git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet
|
|
155
|
+
start_ref="origin/${BASE_BRANCH}"
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
require_before_finish_raw="$(git -C "$repo_root" config --get multiagent.sync.requireBeforeFinish || true)"
|
|
159
|
+
if [[ -z "$require_before_finish_raw" ]]; then
|
|
160
|
+
require_before_finish_raw="true"
|
|
161
|
+
fi
|
|
162
|
+
require_before_finish="$(printf '%s' "$require_before_finish_raw" | tr '[:upper:]' '[:lower:]')"
|
|
163
|
+
should_require_sync=0
|
|
164
|
+
case "$require_before_finish" in
|
|
165
|
+
1|true|yes|on) should_require_sync=1 ;;
|
|
166
|
+
0|false|no|off) should_require_sync=0 ;;
|
|
167
|
+
*) should_require_sync=1 ;;
|
|
168
|
+
esac
|
|
169
|
+
|
|
170
|
+
if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
171
|
+
behind_count="$(git -C "$repo_root" rev-list --left-right --count "${SOURCE_BRANCH}...origin/${BASE_BRANCH}" 2>/dev/null | awk '{print $2}')"
|
|
172
|
+
behind_count="${behind_count:-0}"
|
|
173
|
+
if [[ "$behind_count" -gt 0 ]]; then
|
|
174
|
+
echo "[agent-sync-guard] Branch '${SOURCE_BRANCH}' is behind origin/${BASE_BRANCH} by ${behind_count} commit(s)." >&2
|
|
175
|
+
echo "[agent-sync-guard] Auto-syncing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH} before finish..." >&2
|
|
176
|
+
if ! git -C "$source_worktree" rebase "origin/${BASE_BRANCH}"; then
|
|
177
|
+
git_dir="$(git -C "$source_worktree" rev-parse --git-dir)"
|
|
178
|
+
rebase_active=0
|
|
179
|
+
if [[ -e "${git_dir}/rebase-merge" || -e "${git_dir}/rebase-apply" ]]; then
|
|
180
|
+
rebase_active=1
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
echo "[agent-sync-guard] Auto-sync failed while rebasing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH}." >&2
|
|
184
|
+
if [[ "$rebase_active" -eq 1 ]]; then
|
|
185
|
+
echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
|
|
186
|
+
echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
|
|
187
|
+
fi
|
|
188
|
+
exit 1
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
behind_after="$(git -C "$repo_root" rev-list --left-right --count "${SOURCE_BRANCH}...origin/${BASE_BRANCH}" 2>/dev/null | awk '{print $2}')"
|
|
192
|
+
behind_after="${behind_after:-0}"
|
|
193
|
+
echo "[agent-sync-guard] Auto-sync complete (behind now: ${behind_after})." >&2
|
|
194
|
+
fi
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
integration_worktree="${repo_root}/.omx/agent-worktrees/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
|
|
198
|
+
integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
|
|
199
|
+
mkdir -p "$(dirname "$integration_worktree")"
|
|
200
|
+
|
|
201
|
+
git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
|
|
202
|
+
git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null
|
|
203
|
+
|
|
204
|
+
cleanup() {
|
|
205
|
+
if [[ -d "$integration_worktree" ]]; then
|
|
206
|
+
git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true
|
|
207
|
+
fi
|
|
208
|
+
if [[ "$created_source_probe" -eq 1 && -n "$source_probe_path" && -d "$source_probe_path" ]]; then
|
|
209
|
+
git -C "$repo_root" worktree remove "$source_probe_path" --force >/dev/null 2>&1 || true
|
|
210
|
+
fi
|
|
211
|
+
}
|
|
212
|
+
trap cleanup EXIT
|
|
213
|
+
|
|
214
|
+
if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
215
|
+
git -C "$source_worktree" fetch origin "$BASE_BRANCH" --quiet
|
|
216
|
+
|
|
217
|
+
if ! git -C "$source_worktree" merge --no-commit --no-ff "origin/${BASE_BRANCH}" >/dev/null 2>&1; then
|
|
218
|
+
conflict_files="$(git -C "$source_worktree" diff --name-only --diff-filter=U || true)"
|
|
219
|
+
git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true
|
|
220
|
+
|
|
221
|
+
echo "[agent-branch-finish] Preflight conflict detected between '${SOURCE_BRANCH}' and latest origin/${BASE_BRANCH}." >&2
|
|
222
|
+
if [[ -n "$conflict_files" ]]; then
|
|
223
|
+
echo "[agent-branch-finish] Conflicting files:" >&2
|
|
224
|
+
while IFS= read -r file; do
|
|
225
|
+
[[ -n "$file" ]] && echo " - ${file}" >&2
|
|
226
|
+
done <<< "$conflict_files"
|
|
227
|
+
fi
|
|
228
|
+
echo "[agent-branch-finish] Rebase/merge '${BASE_BRANCH}' into '${SOURCE_BRANCH}' and resolve conflicts before finishing." >&2
|
|
229
|
+
exit 1
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
|
|
236
|
+
echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
|
|
237
|
+
git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true
|
|
238
|
+
exit 1
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
merge_completed=1
|
|
242
|
+
merge_status="direct"
|
|
243
|
+
direct_push_error=""
|
|
244
|
+
pr_url=""
|
|
245
|
+
|
|
246
|
+
is_local_branch_delete_error() {
|
|
247
|
+
local output="$1"
|
|
248
|
+
if [[ "$output" != *"failed to delete local branch"* ]]; then
|
|
249
|
+
return 1
|
|
250
|
+
fi
|
|
251
|
+
if [[ "$output" == *"cannot delete branch"* ]] || [[ "$output" == *"used by worktree"* ]]; then
|
|
252
|
+
return 0
|
|
253
|
+
fi
|
|
254
|
+
return 1
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
run_pr_flow() {
|
|
258
|
+
if ! command -v "$GH_BIN" >/dev/null 2>&1; then
|
|
259
|
+
echo "[agent-branch-finish] PR fallback requested but GitHub CLI not found: ${GH_BIN}" >&2
|
|
260
|
+
return 1
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
git -C "$source_worktree" push -u origin "$SOURCE_BRANCH"
|
|
264
|
+
|
|
265
|
+
pr_title="$(git -C "$repo_root" log -1 --pretty=%s "$SOURCE_BRANCH" 2>/dev/null || true)"
|
|
266
|
+
if [[ -z "$pr_title" ]]; then
|
|
267
|
+
pr_title="Merge ${SOURCE_BRANCH} into ${BASE_BRANCH}"
|
|
268
|
+
fi
|
|
269
|
+
pr_body="Automated by scripts/agent-branch-finish.sh (PR flow)."
|
|
270
|
+
|
|
271
|
+
"$GH_BIN" pr create \
|
|
272
|
+
--base "$BASE_BRANCH" \
|
|
273
|
+
--head "$SOURCE_BRANCH" \
|
|
274
|
+
--title "$pr_title" \
|
|
275
|
+
--body "$pr_body" >/dev/null 2>&1 || true
|
|
276
|
+
|
|
277
|
+
pr_url="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json url --jq '.url' 2>/dev/null || true)"
|
|
278
|
+
|
|
279
|
+
merge_output=""
|
|
280
|
+
if merge_output="$("$GH_BIN" pr merge "$SOURCE_BRANCH" --squash --delete-branch 2>&1)"; then
|
|
281
|
+
return 0
|
|
282
|
+
fi
|
|
283
|
+
if is_local_branch_delete_error "$merge_output"; then
|
|
284
|
+
echo "[agent-branch-finish] PR merged but gh could not delete the local branch (active worktree); continuing local cleanup." >&2
|
|
285
|
+
return 0
|
|
286
|
+
fi
|
|
287
|
+
|
|
288
|
+
auto_output=""
|
|
289
|
+
if auto_output="$("$GH_BIN" pr merge "$SOURCE_BRANCH" --squash --delete-branch --auto 2>&1)"; then
|
|
290
|
+
echo "[agent-branch-finish] PR auto-merge enabled; waiting for required checks/reviews." >&2
|
|
291
|
+
return 2
|
|
292
|
+
fi
|
|
293
|
+
|
|
294
|
+
if [[ -n "$merge_output" ]]; then
|
|
295
|
+
echo "[agent-branch-finish] PR merge not completed yet; leaving PR open." >&2
|
|
296
|
+
echo "${merge_output}" >&2
|
|
297
|
+
fi
|
|
298
|
+
if [[ -n "$auto_output" ]]; then
|
|
299
|
+
echo "${auto_output}" >&2
|
|
300
|
+
fi
|
|
301
|
+
return 2
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if [[ "$PUSH_ENABLED" -eq 1 ]]; then
|
|
305
|
+
if [[ "$MERGE_MODE" != "pr" ]]; then
|
|
306
|
+
if ! direct_push_output="$(git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" 2>&1)"; then
|
|
307
|
+
direct_push_error="$direct_push_output"
|
|
308
|
+
merge_completed=0
|
|
309
|
+
fi
|
|
310
|
+
else
|
|
311
|
+
merge_completed=0
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
if [[ "$merge_completed" -eq 0 ]]; then
|
|
315
|
+
if [[ "$MERGE_MODE" == "direct" ]]; then
|
|
316
|
+
echo "[agent-branch-finish] Direct push/merge failed in --direct-only mode." >&2
|
|
317
|
+
if [[ -n "$direct_push_error" ]]; then
|
|
318
|
+
echo "$direct_push_error" >&2
|
|
319
|
+
fi
|
|
320
|
+
exit 1
|
|
321
|
+
fi
|
|
322
|
+
|
|
323
|
+
if run_pr_flow; then
|
|
324
|
+
merge_completed=1
|
|
325
|
+
merge_status="pr"
|
|
326
|
+
else
|
|
327
|
+
pr_exit=$?
|
|
328
|
+
if [[ "$pr_exit" -eq 2 ]]; then
|
|
329
|
+
echo "[agent-branch-finish] PR flow created/updated branch '${SOURCE_BRANCH}' against '${BASE_BRANCH}'." >&2
|
|
330
|
+
if [[ -n "$pr_url" ]]; then
|
|
331
|
+
echo "[agent-branch-finish] PR: ${pr_url}" >&2
|
|
332
|
+
fi
|
|
333
|
+
echo "[agent-branch-finish] Merge pending review/check policy. Branch cleanup skipped for now." >&2
|
|
334
|
+
exit 0
|
|
335
|
+
fi
|
|
336
|
+
echo "[agent-branch-finish] PR flow failed." >&2
|
|
337
|
+
if [[ -n "$direct_push_error" ]]; then
|
|
338
|
+
echo "[agent-branch-finish] Direct push failure details:" >&2
|
|
339
|
+
echo "$direct_push_error" >&2
|
|
340
|
+
fi
|
|
341
|
+
exit 1
|
|
342
|
+
fi
|
|
343
|
+
fi
|
|
344
|
+
fi
|
|
345
|
+
|
|
346
|
+
if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then
|
|
347
|
+
python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true
|
|
348
|
+
fi
|
|
349
|
+
|
|
350
|
+
if [[ "$source_worktree" == "$repo_root" ]]; then
|
|
351
|
+
if is_clean_worktree "$source_worktree"; then
|
|
352
|
+
git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true
|
|
353
|
+
if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
354
|
+
git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
|
|
355
|
+
fi
|
|
356
|
+
fi
|
|
357
|
+
elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
|
|
358
|
+
git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
|
|
362
|
+
git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true
|
|
363
|
+
fi
|
|
364
|
+
|
|
365
|
+
git -C "$repo_root" branch -d "$SOURCE_BRANCH"
|
|
366
|
+
|
|
367
|
+
if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
|
|
368
|
+
if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
|
|
369
|
+
git -C "$repo_root" push origin --delete "$SOURCE_BRANCH"
|
|
370
|
+
fi
|
|
371
|
+
fi
|
|
372
|
+
|
|
373
|
+
base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
|
|
374
|
+
if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
|
|
375
|
+
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
|
|
376
|
+
fi
|
|
377
|
+
|
|
378
|
+
if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
|
|
379
|
+
if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then
|
|
380
|
+
echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
|
|
381
|
+
echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2
|
|
382
|
+
fi
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and removed branch."
|
|
386
|
+
if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
|
|
387
|
+
echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2
|
|
388
|
+
echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2
|
|
389
|
+
fi
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
TASK_NAME="task"
|
|
5
|
+
AGENT_NAME="agent"
|
|
6
|
+
BASE_BRANCH=""
|
|
7
|
+
BASE_BRANCH_EXPLICIT=0
|
|
8
|
+
WORKTREE_MODE=1
|
|
9
|
+
ALLOW_IN_PLACE=0
|
|
10
|
+
WORKTREE_ROOT_REL=".omx/agent-worktrees"
|
|
11
|
+
POSITIONAL_ARGS=()
|
|
12
|
+
|
|
13
|
+
while [[ $# -gt 0 ]]; do
|
|
14
|
+
case "$1" in
|
|
15
|
+
--task)
|
|
16
|
+
TASK_NAME="${2:-task}"
|
|
17
|
+
shift 2
|
|
18
|
+
;;
|
|
19
|
+
--agent)
|
|
20
|
+
AGENT_NAME="${2:-agent}"
|
|
21
|
+
shift 2
|
|
22
|
+
;;
|
|
23
|
+
--base)
|
|
24
|
+
BASE_BRANCH="${2:-}"
|
|
25
|
+
BASE_BRANCH_EXPLICIT=1
|
|
26
|
+
shift 2
|
|
27
|
+
;;
|
|
28
|
+
--in-place)
|
|
29
|
+
WORKTREE_MODE=0
|
|
30
|
+
shift
|
|
31
|
+
;;
|
|
32
|
+
--allow-in-place)
|
|
33
|
+
ALLOW_IN_PLACE=1
|
|
34
|
+
shift
|
|
35
|
+
;;
|
|
36
|
+
--worktree-root)
|
|
37
|
+
WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}"
|
|
38
|
+
shift 2
|
|
39
|
+
;;
|
|
40
|
+
--)
|
|
41
|
+
shift
|
|
42
|
+
while [[ $# -gt 0 ]]; do
|
|
43
|
+
POSITIONAL_ARGS+=("$1")
|
|
44
|
+
shift
|
|
45
|
+
done
|
|
46
|
+
break
|
|
47
|
+
;;
|
|
48
|
+
-*)
|
|
49
|
+
echo "[agent-branch-start] Unknown option: $1" >&2
|
|
50
|
+
echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root <path>]" >&2
|
|
51
|
+
exit 1
|
|
52
|
+
;;
|
|
53
|
+
*)
|
|
54
|
+
POSITIONAL_ARGS+=("$1")
|
|
55
|
+
shift
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then
|
|
61
|
+
echo "[agent-branch-start] Too many positional arguments." >&2
|
|
62
|
+
echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root <path>]" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
if [[ "${#POSITIONAL_ARGS[@]}" -ge 1 ]]; then
|
|
67
|
+
TASK_NAME="${POSITIONAL_ARGS[0]}"
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if [[ "${#POSITIONAL_ARGS[@]}" -ge 2 ]]; then
|
|
71
|
+
AGENT_NAME="${POSITIONAL_ARGS[1]}"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
if [[ "${#POSITIONAL_ARGS[@]}" -ge 3 ]]; then
|
|
75
|
+
BASE_BRANCH="${POSITIONAL_ARGS[2]}"
|
|
76
|
+
BASE_BRANCH_EXPLICIT=1
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
sanitize_slug() {
|
|
80
|
+
local raw="$1"
|
|
81
|
+
local fallback="${2:-task}"
|
|
82
|
+
local slug
|
|
83
|
+
slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
|
|
84
|
+
if [[ -z "$slug" ]]; then
|
|
85
|
+
slug="$fallback"
|
|
86
|
+
fi
|
|
87
|
+
printf '%s' "$slug"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolve_active_codex_snapshot_name() {
|
|
91
|
+
local override="${MUSAFETY_CODEX_AUTH_SNAPSHOT:-}"
|
|
92
|
+
if [[ -n "$override" ]]; then
|
|
93
|
+
printf '%s' "$override"
|
|
94
|
+
return 0
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
local codex_auth_bin="${MUSAFETY_CODEX_AUTH_BIN:-codex-auth}"
|
|
98
|
+
if ! command -v "$codex_auth_bin" >/dev/null 2>&1; then
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
"$codex_auth_bin" list 2>/dev/null \
|
|
103
|
+
| sed -n 's/^[[:space:]]*\*[[:space:]]\+//p' \
|
|
104
|
+
| head -n 1 \
|
|
105
|
+
| tr -d '\r' || true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
has_local_changes() {
|
|
109
|
+
local root="$1"
|
|
110
|
+
if ! git -C "$root" diff --quiet; then
|
|
111
|
+
return 0
|
|
112
|
+
fi
|
|
113
|
+
if ! git -C "$root" diff --cached --quiet; then
|
|
114
|
+
return 0
|
|
115
|
+
fi
|
|
116
|
+
if [[ -n "$(git -C "$root" ls-files --others --exclude-standard)" ]]; then
|
|
117
|
+
return 0
|
|
118
|
+
fi
|
|
119
|
+
return 1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
has_tracked_changes() {
|
|
123
|
+
local root="$1"
|
|
124
|
+
if ! git -C "$root" diff --quiet; then
|
|
125
|
+
return 0
|
|
126
|
+
fi
|
|
127
|
+
if ! git -C "$root" diff --cached --quiet; then
|
|
128
|
+
return 0
|
|
129
|
+
fi
|
|
130
|
+
return 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
resolve_protected_branches() {
|
|
134
|
+
local root="$1"
|
|
135
|
+
local raw
|
|
136
|
+
raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git -C "$root" config --get multiagent.protectedBranches || true)}"
|
|
137
|
+
if [[ -z "$raw" ]]; then
|
|
138
|
+
raw="dev main master"
|
|
139
|
+
fi
|
|
140
|
+
raw="${raw//,/ }"
|
|
141
|
+
printf '%s' "$raw"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
is_protected_branch_name() {
|
|
145
|
+
local branch="$1"
|
|
146
|
+
local protected_raw="$2"
|
|
147
|
+
for protected_branch in $protected_raw; do
|
|
148
|
+
if [[ "$branch" == "$protected_branch" ]]; then
|
|
149
|
+
return 0
|
|
150
|
+
fi
|
|
151
|
+
done
|
|
152
|
+
return 1
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
156
|
+
echo "[agent-branch-start] Not inside a git repository." >&2
|
|
157
|
+
exit 1
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
repo_root="$(git rev-parse --show-toplevel)"
|
|
161
|
+
|
|
162
|
+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
|
|
163
|
+
echo "[agent-branch-start] --base requires a non-empty branch name." >&2
|
|
164
|
+
exit 1
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
|
|
168
|
+
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
|
|
169
|
+
if [[ -n "$configured_base" ]]; then
|
|
170
|
+
BASE_BRANCH="$configured_base"
|
|
171
|
+
else
|
|
172
|
+
current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
173
|
+
if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then
|
|
174
|
+
BASE_BRANCH="$current_branch"
|
|
175
|
+
else
|
|
176
|
+
BASE_BRANCH="dev"
|
|
177
|
+
fi
|
|
178
|
+
fi
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
182
|
+
git fetch origin "${BASE_BRANCH}" --quiet
|
|
183
|
+
start_ref="origin/${BASE_BRANCH}"
|
|
184
|
+
else
|
|
185
|
+
if ! git show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
|
|
186
|
+
echo "[agent-branch-start] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2
|
|
187
|
+
exit 1
|
|
188
|
+
fi
|
|
189
|
+
start_ref="${BASE_BRANCH}"
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
task_slug="$(sanitize_slug "$TASK_NAME" "task")"
|
|
193
|
+
agent_slug="$(sanitize_slug "$AGENT_NAME" "agent")"
|
|
194
|
+
snapshot_name="$(resolve_active_codex_snapshot_name)"
|
|
195
|
+
snapshot_slug="$(sanitize_slug "$snapshot_name" "")"
|
|
196
|
+
timestamp="$(date +%Y%m%d-%H%M%S)"
|
|
197
|
+
if [[ -n "$snapshot_slug" ]]; then
|
|
198
|
+
branch_name="agent/${agent_slug}/${timestamp}-${snapshot_slug}-${task_slug}"
|
|
199
|
+
else
|
|
200
|
+
branch_name="agent/${agent_slug}/${timestamp}-${task_slug}"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
if git show-ref --verify --quiet "refs/heads/${branch_name}"; then
|
|
204
|
+
echo "[agent-branch-start] Branch already exists: ${branch_name}" >&2
|
|
205
|
+
exit 1
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
if [[ "$WORKTREE_MODE" -eq 0 ]]; then
|
|
209
|
+
if [[ "$ALLOW_IN_PLACE" -ne 1 ]]; then
|
|
210
|
+
echo "[agent-branch-start] --in-place is blocked by default to prevent accidental edits on protected branches." >&2
|
|
211
|
+
echo "[agent-branch-start] If you really need it, pass both: --in-place --allow-in-place" >&2
|
|
212
|
+
exit 1
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
if ! git diff --quiet || ! git diff --cached --quiet; then
|
|
216
|
+
echo "[agent-branch-start] Working tree is not clean. Commit/stash changes before starting an in-place branch." >&2
|
|
217
|
+
exit 1
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
current_branch="$(git rev-parse --abbrev-ref HEAD)"
|
|
221
|
+
if [[ "$current_branch" != "$BASE_BRANCH" ]]; then
|
|
222
|
+
git checkout "$BASE_BRANCH"
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
226
|
+
git pull --ff-only origin "$BASE_BRANCH"
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
git checkout -b "$branch_name"
|
|
230
|
+
git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true
|
|
231
|
+
echo "[agent-branch-start] Created in-place branch: ${branch_name}"
|
|
232
|
+
echo "$branch_name"
|
|
233
|
+
exit 0
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
worktree_root="${repo_root}/${WORKTREE_ROOT_REL}"
|
|
237
|
+
mkdir -p "$worktree_root"
|
|
238
|
+
worktree_path="${worktree_root}/${branch_name//\//__}"
|
|
239
|
+
|
|
240
|
+
if [[ -e "$worktree_path" ]]; then
|
|
241
|
+
echo "[agent-branch-start] Worktree path already exists: ${worktree_path}" >&2
|
|
242
|
+
exit 1
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
auto_transfer_stash_ref=""
|
|
246
|
+
auto_transfer_message=""
|
|
247
|
+
current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
248
|
+
protected_branches_raw="$(resolve_protected_branches "$repo_root")"
|
|
249
|
+
if [[ "$current_branch" == "$BASE_BRANCH" ]] && is_protected_branch_name "$BASE_BRANCH" "$protected_branches_raw"; then
|
|
250
|
+
if has_tracked_changes "$repo_root"; then
|
|
251
|
+
auto_transfer_message="musafety-auto-transfer-${timestamp}-${agent_slug}-${task_slug}"
|
|
252
|
+
if git -C "$repo_root" stash push --include-untracked --message "$auto_transfer_message" >/dev/null 2>&1; then
|
|
253
|
+
auto_transfer_stash_ref="$(
|
|
254
|
+
git -C "$repo_root" stash list \
|
|
255
|
+
| awk -v msg="$auto_transfer_message" '$0 ~ msg { ref=$1; sub(/:$/, "", ref); print ref; exit }'
|
|
256
|
+
)"
|
|
257
|
+
if [[ -n "$auto_transfer_stash_ref" ]]; then
|
|
258
|
+
echo "[agent-branch-start] Detected local changes on protected base '${BASE_BRANCH}'. Moving them to '${branch_name}'..."
|
|
259
|
+
fi
|
|
260
|
+
fi
|
|
261
|
+
fi
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref"
|
|
265
|
+
git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true
|
|
266
|
+
|
|
267
|
+
if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
|
|
268
|
+
git -C "$worktree_path" branch --set-upstream-to="origin/${BASE_BRANCH}" "$branch_name" >/dev/null 2>&1 || true
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
if [[ -n "$auto_transfer_stash_ref" ]]; then
|
|
272
|
+
if git -C "$worktree_path" stash apply "$auto_transfer_stash_ref" >/dev/null 2>&1; then
|
|
273
|
+
git -C "$repo_root" stash drop "$auto_transfer_stash_ref" >/dev/null 2>&1 || true
|
|
274
|
+
echo "[agent-branch-start] Moved local changes from '${BASE_BRANCH}' into '${branch_name}'."
|
|
275
|
+
else
|
|
276
|
+
echo "[agent-branch-start] Failed to auto-apply moved changes in new worktree." >&2
|
|
277
|
+
echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${BASE_BRANCH}." >&2
|
|
278
|
+
echo "[agent-branch-start] Apply manually with: git -C \"$worktree_path\" stash apply \"${auto_transfer_stash_ref}\"" >&2
|
|
279
|
+
exit 1
|
|
280
|
+
fi
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
echo "[agent-branch-start] Created branch: ${branch_name}"
|
|
284
|
+
echo "[agent-branch-start] Worktree: ${worktree_path}"
|
|
285
|
+
echo "[agent-branch-start] Next steps:"
|
|
286
|
+
echo " cd \"${worktree_path}\""
|
|
287
|
+
echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" <file...>"
|
|
288
|
+
echo " # implement + commit"
|
|
289
|
+
echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\""
|