@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,406 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Per-file lock registry for concurrent agent branches.
|
|
3
|
+
|
|
4
|
+
Usage examples:
|
|
5
|
+
python3 scripts/agent-file-locks.py claim --branch agent/a path/to/file1 path/to/file2
|
|
6
|
+
python3 scripts/agent-file-locks.py claim --branch agent/a --allow-delete path/to/obsolete-file
|
|
7
|
+
python3 scripts/agent-file-locks.py allow-delete --branch agent/a path/to/obsolete-file
|
|
8
|
+
python3 scripts/agent-file-locks.py validate --branch agent/a --staged
|
|
9
|
+
python3 scripts/agent-file-locks.py release --branch agent/a
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOCK_FILE_RELATIVE = Path('.omx/state/agent-file-locks.json')
|
|
26
|
+
CRITICAL_GUARDRAIL_PATHS = {
|
|
27
|
+
'AGENTS.md',
|
|
28
|
+
'.githooks/pre-commit',
|
|
29
|
+
'scripts/agent-branch-start.sh',
|
|
30
|
+
'scripts/agent-branch-finish.sh',
|
|
31
|
+
'scripts/agent-file-locks.py',
|
|
32
|
+
}
|
|
33
|
+
ALLOW_GUARDRAIL_DELETE_ENV = 'AGENT_ALLOW_GUARDRAIL_DELETE'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class LockEntry:
|
|
38
|
+
branch: str
|
|
39
|
+
claimed_at: str
|
|
40
|
+
allow_delete: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LockError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_git(args: list[str], cwd: Path) -> str:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
['git', *args],
|
|
50
|
+
cwd=str(cwd),
|
|
51
|
+
check=False,
|
|
52
|
+
stdout=subprocess.PIPE,
|
|
53
|
+
stderr=subprocess.PIPE,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
raise LockError(result.stderr.strip() or f"git {' '.join(args)} failed")
|
|
58
|
+
return result.stdout.strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_repo_root() -> Path:
|
|
62
|
+
output = run_git(['rev-parse', '--show-toplevel'], cwd=Path.cwd())
|
|
63
|
+
return Path(output).resolve()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_repo_path(repo_root: Path, raw_path: str) -> str:
|
|
67
|
+
joined = Path(raw_path)
|
|
68
|
+
abs_path = joined if joined.is_absolute() else (repo_root / joined)
|
|
69
|
+
normalized_abs = Path(os.path.normpath(str(abs_path)))
|
|
70
|
+
try:
|
|
71
|
+
relative = normalized_abs.relative_to(repo_root)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise LockError(f"Path is outside repository: {raw_path}") from exc
|
|
74
|
+
return relative.as_posix()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def lock_file_path(repo_root: Path) -> Path:
|
|
78
|
+
return repo_root / LOCK_FILE_RELATIVE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_state(repo_root: Path) -> dict[str, Any]:
|
|
82
|
+
path = lock_file_path(repo_root)
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return {'locks': {}}
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(path.read_text())
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
raise LockError(f'Lock file is invalid JSON: {path}') from exc
|
|
89
|
+
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
return {'locks': {}}
|
|
92
|
+
locks = data.get('locks', {})
|
|
93
|
+
if not isinstance(locks, dict):
|
|
94
|
+
return {'locks': {}}
|
|
95
|
+
|
|
96
|
+
# Backward-compat normalization for older lock schema.
|
|
97
|
+
normalized_locks: dict[str, dict[str, Any]] = {}
|
|
98
|
+
for file_path, entry in locks.items():
|
|
99
|
+
if not isinstance(entry, dict):
|
|
100
|
+
continue
|
|
101
|
+
branch = str(entry.get('branch', ''))
|
|
102
|
+
claimed_at = str(entry.get('claimed_at', ''))
|
|
103
|
+
allow_delete = bool(entry.get('allow_delete', False))
|
|
104
|
+
normalized_locks[str(file_path)] = {
|
|
105
|
+
'branch': branch,
|
|
106
|
+
'claimed_at': claimed_at,
|
|
107
|
+
'allow_delete': allow_delete,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {'locks': normalized_locks}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_state(repo_root: Path, state: dict[str, Any]) -> None:
|
|
114
|
+
path = lock_file_path(repo_root)
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
tmp = path.with_suffix(path.suffix + '.tmp')
|
|
117
|
+
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + '\n')
|
|
118
|
+
tmp.replace(path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def now_iso() -> str:
|
|
122
|
+
return datetime.now(timezone.utc).isoformat()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def env_truthy(value: str | None) -> bool:
|
|
126
|
+
if value is None:
|
|
127
|
+
return False
|
|
128
|
+
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def staged_changes(repo_root: Path) -> list[tuple[str, str]]:
|
|
132
|
+
out = run_git(['diff', '--cached', '--name-status', '--diff-filter=ACMRDTUXB'], cwd=repo_root)
|
|
133
|
+
if not out:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
results: list[tuple[str, str]] = []
|
|
137
|
+
for raw_line in out.splitlines():
|
|
138
|
+
line = raw_line.strip()
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
parts = line.split('\t')
|
|
142
|
+
status_token = parts[0]
|
|
143
|
+
status = status_token[0]
|
|
144
|
+
if status in {'R', 'C'}:
|
|
145
|
+
if len(parts) < 3:
|
|
146
|
+
continue
|
|
147
|
+
path = parts[-1]
|
|
148
|
+
else:
|
|
149
|
+
if len(parts) < 2:
|
|
150
|
+
continue
|
|
151
|
+
path = parts[1]
|
|
152
|
+
normalized = normalize_repo_path(repo_root, path)
|
|
153
|
+
results.append((status, normalized))
|
|
154
|
+
return results
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_claim(args: argparse.Namespace, repo_root: Path) -> int:
|
|
158
|
+
state = load_state(repo_root)
|
|
159
|
+
locks: dict[str, dict[str, Any]] = state['locks']
|
|
160
|
+
|
|
161
|
+
files = [normalize_repo_path(repo_root, p) for p in args.files]
|
|
162
|
+
conflicts: list[tuple[str, str]] = []
|
|
163
|
+
|
|
164
|
+
for file_path in files:
|
|
165
|
+
existing = locks.get(file_path)
|
|
166
|
+
if existing and existing.get('branch') != args.branch:
|
|
167
|
+
conflicts.append((file_path, str(existing.get('branch'))))
|
|
168
|
+
|
|
169
|
+
if conflicts:
|
|
170
|
+
print('[agent-file-locks] Cannot claim files already locked by other branches:', file=sys.stderr)
|
|
171
|
+
for file_path, owner_branch in conflicts:
|
|
172
|
+
print(f' - {file_path} (locked by {owner_branch})', file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
for file_path in files:
|
|
176
|
+
existing = locks.get(file_path, {})
|
|
177
|
+
existing_allow_delete = bool(existing.get('allow_delete', False))
|
|
178
|
+
locks[file_path] = LockEntry(
|
|
179
|
+
branch=args.branch,
|
|
180
|
+
claimed_at=now_iso(),
|
|
181
|
+
allow_delete=args.allow_delete or existing_allow_delete,
|
|
182
|
+
).__dict__
|
|
183
|
+
|
|
184
|
+
write_state(repo_root, state)
|
|
185
|
+
delete_note = ' (delete-approved)' if args.allow_delete else ''
|
|
186
|
+
print(f"[agent-file-locks] Claimed {len(files)} file(s) for {args.branch}{delete_note}.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_allow_delete(args: argparse.Namespace, repo_root: Path) -> int:
|
|
191
|
+
state = load_state(repo_root)
|
|
192
|
+
locks: dict[str, dict[str, Any]] = state['locks']
|
|
193
|
+
files = [normalize_repo_path(repo_root, p) for p in args.files]
|
|
194
|
+
|
|
195
|
+
missing: list[str] = []
|
|
196
|
+
foreign: list[tuple[str, str]] = []
|
|
197
|
+
for file_path in files:
|
|
198
|
+
entry = locks.get(file_path)
|
|
199
|
+
if not entry:
|
|
200
|
+
missing.append(file_path)
|
|
201
|
+
continue
|
|
202
|
+
owner = str(entry.get('branch', ''))
|
|
203
|
+
if owner != args.branch:
|
|
204
|
+
foreign.append((file_path, owner))
|
|
205
|
+
continue
|
|
206
|
+
entry['allow_delete'] = True
|
|
207
|
+
|
|
208
|
+
if missing or foreign:
|
|
209
|
+
if missing:
|
|
210
|
+
print('[agent-file-locks] Cannot enable delete: files are not claimed yet:', file=sys.stderr)
|
|
211
|
+
for file_path in missing:
|
|
212
|
+
print(f' - {file_path}', file=sys.stderr)
|
|
213
|
+
if foreign:
|
|
214
|
+
print('[agent-file-locks] Cannot enable delete: files are owned by another branch:', file=sys.stderr)
|
|
215
|
+
for file_path, owner in foreign:
|
|
216
|
+
print(f' - {file_path} (owner: {owner})', file=sys.stderr)
|
|
217
|
+
return 1
|
|
218
|
+
|
|
219
|
+
write_state(repo_root, state)
|
|
220
|
+
print(f"[agent-file-locks] Enabled delete approval for {len(files)} file(s) on {args.branch}.")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_release(args: argparse.Namespace, repo_root: Path) -> int:
|
|
225
|
+
state = load_state(repo_root)
|
|
226
|
+
locks: dict[str, dict[str, Any]] = state['locks']
|
|
227
|
+
|
|
228
|
+
to_release: set[str]
|
|
229
|
+
if args.files:
|
|
230
|
+
requested = {normalize_repo_path(repo_root, p) for p in args.files}
|
|
231
|
+
to_release = {p for p in requested if locks.get(p, {}).get('branch') == args.branch}
|
|
232
|
+
else:
|
|
233
|
+
to_release = {p for p, entry in locks.items() if entry.get('branch') == args.branch}
|
|
234
|
+
|
|
235
|
+
for file_path in to_release:
|
|
236
|
+
locks.pop(file_path, None)
|
|
237
|
+
|
|
238
|
+
write_state(repo_root, state)
|
|
239
|
+
print(f"[agent-file-locks] Released {len(to_release)} file(s) for {args.branch}.")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_status(args: argparse.Namespace, repo_root: Path) -> int:
|
|
244
|
+
state = load_state(repo_root)
|
|
245
|
+
locks: dict[str, dict[str, Any]] = state['locks']
|
|
246
|
+
|
|
247
|
+
rows: list[tuple[str, str, str, bool]] = []
|
|
248
|
+
for file_path, entry in sorted(locks.items()):
|
|
249
|
+
branch = str(entry.get('branch', ''))
|
|
250
|
+
if args.branch and branch != args.branch:
|
|
251
|
+
continue
|
|
252
|
+
claimed_at = str(entry.get('claimed_at', ''))
|
|
253
|
+
allow_delete = bool(entry.get('allow_delete', False))
|
|
254
|
+
rows.append((file_path, branch, claimed_at, allow_delete))
|
|
255
|
+
|
|
256
|
+
if not rows:
|
|
257
|
+
print('[agent-file-locks] No active locks.')
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
print('[agent-file-locks] Active locks:')
|
|
261
|
+
for file_path, branch, claimed_at, allow_delete in rows:
|
|
262
|
+
delete_flag = ' delete-ok' if allow_delete else ''
|
|
263
|
+
print(f' - {file_path} | {branch} | {claimed_at}{delete_flag}')
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_validate(args: argparse.Namespace, repo_root: Path) -> int:
|
|
268
|
+
state = load_state(repo_root)
|
|
269
|
+
locks: dict[str, dict[str, Any]] = state['locks']
|
|
270
|
+
|
|
271
|
+
if args.staged:
|
|
272
|
+
file_changes = staged_changes(repo_root)
|
|
273
|
+
else:
|
|
274
|
+
file_changes = [('M', normalize_repo_path(repo_root, p)) for p in args.files]
|
|
275
|
+
|
|
276
|
+
file_changes = [
|
|
277
|
+
(status, file_path)
|
|
278
|
+
for status, file_path in file_changes
|
|
279
|
+
if file_path and file_path != LOCK_FILE_RELATIVE.as_posix()
|
|
280
|
+
]
|
|
281
|
+
if not file_changes:
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
missing: list[str] = []
|
|
285
|
+
foreign: list[tuple[str, str]] = []
|
|
286
|
+
delete_not_allowed: list[str] = []
|
|
287
|
+
guardrail_delete_blocked: list[str] = []
|
|
288
|
+
|
|
289
|
+
allow_guardrail_delete = env_truthy(os.environ.get(ALLOW_GUARDRAIL_DELETE_ENV))
|
|
290
|
+
|
|
291
|
+
for status, file_path in file_changes:
|
|
292
|
+
entry = locks.get(file_path)
|
|
293
|
+
if not entry:
|
|
294
|
+
missing.append(file_path)
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
owner = str(entry.get('branch', ''))
|
|
298
|
+
if owner != args.branch:
|
|
299
|
+
foreign.append((file_path, owner))
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
if status == 'D':
|
|
303
|
+
if file_path in CRITICAL_GUARDRAIL_PATHS and not allow_guardrail_delete:
|
|
304
|
+
guardrail_delete_blocked.append(file_path)
|
|
305
|
+
|
|
306
|
+
allow_delete = bool(entry.get('allow_delete', False))
|
|
307
|
+
if not allow_delete:
|
|
308
|
+
delete_not_allowed.append(file_path)
|
|
309
|
+
|
|
310
|
+
if not missing and not foreign and not delete_not_allowed and not guardrail_delete_blocked:
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
print('[agent-file-locks] Commit blocked: staged files must be safely claimed by this branch first.', file=sys.stderr)
|
|
314
|
+
if missing:
|
|
315
|
+
print(' Unclaimed files:', file=sys.stderr)
|
|
316
|
+
for file_path in missing:
|
|
317
|
+
print(f' - {file_path}', file=sys.stderr)
|
|
318
|
+
if foreign:
|
|
319
|
+
print(' Files claimed by another branch:', file=sys.stderr)
|
|
320
|
+
for file_path, owner in foreign:
|
|
321
|
+
print(f' - {file_path} (owner: {owner})', file=sys.stderr)
|
|
322
|
+
if delete_not_allowed:
|
|
323
|
+
print(' Delete not approved for claimed files:', file=sys.stderr)
|
|
324
|
+
for file_path in delete_not_allowed:
|
|
325
|
+
print(f' - {file_path}', file=sys.stderr)
|
|
326
|
+
print(' Approve explicit deletions with one of:', file=sys.stderr)
|
|
327
|
+
print(
|
|
328
|
+
f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" --allow-delete <file...>',
|
|
329
|
+
file=sys.stderr,
|
|
330
|
+
)
|
|
331
|
+
print(
|
|
332
|
+
f' python3 scripts/agent-file-locks.py allow-delete --branch "{args.branch}" <file...>',
|
|
333
|
+
file=sys.stderr,
|
|
334
|
+
)
|
|
335
|
+
if guardrail_delete_blocked:
|
|
336
|
+
print(' Critical guardrail file deletion blocked:', file=sys.stderr)
|
|
337
|
+
for file_path in guardrail_delete_blocked:
|
|
338
|
+
print(f' - {file_path}', file=sys.stderr)
|
|
339
|
+
print(
|
|
340
|
+
f' To intentionally allow this rare operation, set {ALLOW_GUARDRAIL_DELETE_ENV}=1 for the commit command.',
|
|
341
|
+
file=sys.stderr,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
print('\nClaim files with:', file=sys.stderr)
|
|
345
|
+
print(f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" <file...>', file=sys.stderr)
|
|
346
|
+
return 1
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
350
|
+
parser = argparse.ArgumentParser(description='Concurrent agent file-lock utility')
|
|
351
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
352
|
+
|
|
353
|
+
claim = sub.add_parser('claim', help='Claim file locks for a branch')
|
|
354
|
+
claim.add_argument('--branch', required=True, help='Owner branch name (e.g., agent/foo/...)')
|
|
355
|
+
claim.add_argument(
|
|
356
|
+
'--allow-delete',
|
|
357
|
+
action='store_true',
|
|
358
|
+
help='Mark these files as explicitly approved for deletion by this branch',
|
|
359
|
+
)
|
|
360
|
+
claim.add_argument('files', nargs='+', help='Files to claim (repo-relative or absolute)')
|
|
361
|
+
|
|
362
|
+
allow_delete = sub.add_parser('allow-delete', help='Enable delete approval on already claimed files')
|
|
363
|
+
allow_delete.add_argument('--branch', required=True, help='Owner branch name')
|
|
364
|
+
allow_delete.add_argument('files', nargs='+', help='Files to mark as delete-approved')
|
|
365
|
+
|
|
366
|
+
release = sub.add_parser('release', help='Release file locks for a branch')
|
|
367
|
+
release.add_argument('--branch', required=True, help='Owner branch name')
|
|
368
|
+
release.add_argument('files', nargs='*', help='Optional files; omit to release all branch locks')
|
|
369
|
+
|
|
370
|
+
status = sub.add_parser('status', help='Show lock status')
|
|
371
|
+
status.add_argument('--branch', help='Filter by branch')
|
|
372
|
+
|
|
373
|
+
validate = sub.add_parser('validate', help='Validate staged files are locked by branch')
|
|
374
|
+
validate.add_argument('--branch', required=True, help='Owner branch name')
|
|
375
|
+
validate.add_argument('--staged', action='store_true', help='Validate staged files from git index')
|
|
376
|
+
validate.add_argument('files', nargs='*', help='Files to validate when --staged is not used')
|
|
377
|
+
|
|
378
|
+
return parser
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main() -> int:
|
|
382
|
+
parser = build_parser()
|
|
383
|
+
args = parser.parse_args()
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
repo_root = resolve_repo_root()
|
|
387
|
+
if args.command == 'claim':
|
|
388
|
+
return cmd_claim(args, repo_root)
|
|
389
|
+
if args.command == 'allow-delete':
|
|
390
|
+
return cmd_allow_delete(args, repo_root)
|
|
391
|
+
if args.command == 'release':
|
|
392
|
+
return cmd_release(args, repo_root)
|
|
393
|
+
if args.command == 'status':
|
|
394
|
+
return cmd_status(args, repo_root)
|
|
395
|
+
if args.command == 'validate':
|
|
396
|
+
if not args.staged and not args.files:
|
|
397
|
+
raise LockError('validate requires --staged or one or more file paths')
|
|
398
|
+
return cmd_validate(args, repo_root)
|
|
399
|
+
raise LockError(f'Unknown command: {args.command}')
|
|
400
|
+
except LockError as exc:
|
|
401
|
+
print(f'[agent-file-locks] {exc}', file=sys.stderr)
|
|
402
|
+
return 2
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == '__main__':
|
|
406
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
BASE_BRANCH="dev"
|
|
5
|
+
DRY_RUN=0
|
|
6
|
+
|
|
7
|
+
while [[ $# -gt 0 ]]; do
|
|
8
|
+
case "$1" in
|
|
9
|
+
--base)
|
|
10
|
+
BASE_BRANCH="${2:-dev}"
|
|
11
|
+
shift 2
|
|
12
|
+
;;
|
|
13
|
+
--dry-run)
|
|
14
|
+
DRY_RUN=1
|
|
15
|
+
shift
|
|
16
|
+
;;
|
|
17
|
+
*)
|
|
18
|
+
echo "[agent-worktree-prune] Unknown argument: $1" >&2
|
|
19
|
+
echo "Usage: $0 [--base <branch>] [--dry-run]" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
26
|
+
echo "[agent-worktree-prune] Not inside a git repository." >&2
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
repo_root="$(git rev-parse --show-toplevel)"
|
|
31
|
+
current_pwd="$(pwd -P)"
|
|
32
|
+
worktree_root="${repo_root}/.omx/agent-worktrees"
|
|
33
|
+
|
|
34
|
+
if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
|
|
35
|
+
echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
run_cmd() {
|
|
40
|
+
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
41
|
+
echo "[agent-worktree-prune] [dry-run] $*"
|
|
42
|
+
return 0
|
|
43
|
+
fi
|
|
44
|
+
"$@"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
branch_has_worktree() {
|
|
48
|
+
local branch="$1"
|
|
49
|
+
git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
removed_worktrees=0
|
|
53
|
+
removed_branches=0
|
|
54
|
+
skipped_active=0
|
|
55
|
+
|
|
56
|
+
process_entry() {
|
|
57
|
+
local wt="$1"
|
|
58
|
+
local branch_ref="$2"
|
|
59
|
+
|
|
60
|
+
[[ -z "$wt" ]] && return
|
|
61
|
+
[[ "$wt" != "${worktree_root}"/* ]] && return
|
|
62
|
+
|
|
63
|
+
local branch=""
|
|
64
|
+
if [[ -n "$branch_ref" ]]; then
|
|
65
|
+
branch="${branch_ref#refs/heads/}"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if [[ "$wt" == "$current_pwd" ]]; then
|
|
69
|
+
skipped_active=$((skipped_active + 1))
|
|
70
|
+
echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}"
|
|
71
|
+
return
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
local remove_reason=""
|
|
75
|
+
|
|
76
|
+
if [[ -z "$branch_ref" ]]; then
|
|
77
|
+
remove_reason="detached-worktree"
|
|
78
|
+
elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then
|
|
79
|
+
remove_reason="missing-branch"
|
|
80
|
+
elif [[ "$branch" == agent/* ]]; then
|
|
81
|
+
if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
|
|
82
|
+
remove_reason="merged-agent-branch"
|
|
83
|
+
fi
|
|
84
|
+
elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
|
|
85
|
+
remove_reason="temporary-worktree"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
if [[ -z "$remove_reason" ]]; then
|
|
89
|
+
return
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}"
|
|
93
|
+
run_cmd git -C "$repo_root" worktree remove "$wt" --force
|
|
94
|
+
removed_worktrees=$((removed_worktrees + 1))
|
|
95
|
+
|
|
96
|
+
if [[ -z "$branch" ]]; then
|
|
97
|
+
return
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then
|
|
101
|
+
if [[ "$branch" == agent/* ]]; then
|
|
102
|
+
if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
|
|
103
|
+
removed_branches=$((removed_branches + 1))
|
|
104
|
+
echo "[agent-worktree-prune] Deleted merged branch: ${branch}"
|
|
105
|
+
fi
|
|
106
|
+
elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
|
|
107
|
+
run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1 || true
|
|
108
|
+
removed_branches=$((removed_branches + 1))
|
|
109
|
+
echo "[agent-worktree-prune] Deleted temporary branch: ${branch}"
|
|
110
|
+
fi
|
|
111
|
+
fi
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
current_wt=""
|
|
115
|
+
current_branch_ref=""
|
|
116
|
+
|
|
117
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
118
|
+
if [[ -z "$line" ]]; then
|
|
119
|
+
process_entry "$current_wt" "$current_branch_ref"
|
|
120
|
+
current_wt=""
|
|
121
|
+
current_branch_ref=""
|
|
122
|
+
continue
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
case "$line" in
|
|
126
|
+
worktree\ *)
|
|
127
|
+
current_wt="${line#worktree }"
|
|
128
|
+
;;
|
|
129
|
+
branch\ *)
|
|
130
|
+
current_branch_ref="${line#branch }"
|
|
131
|
+
;;
|
|
132
|
+
esac
|
|
133
|
+
done < <(git -C "$repo_root" worktree list --porcelain)
|
|
134
|
+
|
|
135
|
+
process_entry "$current_wt" "$current_branch_ref"
|
|
136
|
+
|
|
137
|
+
while IFS= read -r branch; do
|
|
138
|
+
[[ -z "$branch" ]] && continue
|
|
139
|
+
if branch_has_worktree "$branch"; then
|
|
140
|
+
continue
|
|
141
|
+
fi
|
|
142
|
+
if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
|
|
143
|
+
if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
|
|
144
|
+
removed_branches=$((removed_branches + 1))
|
|
145
|
+
echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
|
|
146
|
+
fi
|
|
147
|
+
fi
|
|
148
|
+
done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent)
|
|
149
|
+
|
|
150
|
+
run_cmd git -C "$repo_root" worktree prune
|
|
151
|
+
|
|
152
|
+
echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}"
|
|
153
|
+
if [[ "$skipped_active" -gt 0 ]]; then
|
|
154
|
+
echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2
|
|
155
|
+
fi
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
TASK_NAME="${MUSAFETY_TASK_NAME:-task}"
|
|
5
|
+
AGENT_NAME="${MUSAFETY_AGENT_NAME:-agent}"
|
|
6
|
+
BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}"
|
|
7
|
+
BASE_BRANCH_EXPLICIT=0
|
|
8
|
+
CODEX_BIN="${MUSAFETY_CODEX_BIN:-codex}"
|
|
9
|
+
|
|
10
|
+
if [[ -n "$BASE_BRANCH" ]]; then
|
|
11
|
+
BASE_BRANCH_EXPLICIT=1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
while [[ $# -gt 0 ]]; do
|
|
15
|
+
case "$1" in
|
|
16
|
+
--task)
|
|
17
|
+
TASK_NAME="${2:-$TASK_NAME}"
|
|
18
|
+
shift 2
|
|
19
|
+
;;
|
|
20
|
+
--agent)
|
|
21
|
+
AGENT_NAME="${2:-$AGENT_NAME}"
|
|
22
|
+
shift 2
|
|
23
|
+
;;
|
|
24
|
+
--base)
|
|
25
|
+
BASE_BRANCH="${2:-$BASE_BRANCH}"
|
|
26
|
+
BASE_BRANCH_EXPLICIT=1
|
|
27
|
+
shift 2
|
|
28
|
+
;;
|
|
29
|
+
--codex-bin)
|
|
30
|
+
CODEX_BIN="${2:-$CODEX_BIN}"
|
|
31
|
+
shift 2
|
|
32
|
+
;;
|
|
33
|
+
--)
|
|
34
|
+
shift
|
|
35
|
+
break
|
|
36
|
+
;;
|
|
37
|
+
-*)
|
|
38
|
+
break
|
|
39
|
+
;;
|
|
40
|
+
*)
|
|
41
|
+
TASK_NAME="$1"
|
|
42
|
+
shift
|
|
43
|
+
if [[ $# -gt 0 && "${1:-}" != -* ]]; then
|
|
44
|
+
AGENT_NAME="$1"
|
|
45
|
+
shift
|
|
46
|
+
fi
|
|
47
|
+
if [[ $# -gt 0 && "${1:-}" != -* ]]; then
|
|
48
|
+
BASE_BRANCH="$1"
|
|
49
|
+
BASE_BRANCH_EXPLICIT=1
|
|
50
|
+
shift
|
|
51
|
+
fi
|
|
52
|
+
break
|
|
53
|
+
;;
|
|
54
|
+
esac
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
|
|
58
|
+
echo "[codex-agent] --base requires a non-empty branch name." >&2
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then
|
|
63
|
+
echo "[codex-agent] Missing Codex CLI command: $CODEX_BIN" >&2
|
|
64
|
+
echo "[codex-agent] Install Codex first, then retry." >&2
|
|
65
|
+
exit 127
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if [[ ! -x "scripts/agent-branch-start.sh" ]]; then
|
|
69
|
+
echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
start_args=("$TASK_NAME" "$AGENT_NAME")
|
|
74
|
+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then
|
|
75
|
+
start_args+=("$BASE_BRANCH")
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
start_output="$(bash scripts/agent-branch-start.sh "${start_args[@]}")"
|
|
79
|
+
printf '%s\n' "$start_output"
|
|
80
|
+
|
|
81
|
+
worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)"
|
|
82
|
+
if [[ -z "$worktree_path" ]]; then
|
|
83
|
+
echo "[codex-agent] Could not determine sandbox worktree path from agent-branch-start output." >&2
|
|
84
|
+
exit 1
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
if [[ ! -d "$worktree_path" ]]; then
|
|
88
|
+
echo "[codex-agent] Reported worktree path does not exist: $worktree_path" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path"
|
|
93
|
+
cd "$worktree_path"
|
|
94
|
+
exec "$CODEX_BIN" "$@"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
5
|
+
if [[ -z "$repo_root" ]]; then
|
|
6
|
+
echo "[install-agent-git-hooks] Not inside a git repository." >&2
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
hooks_dir="$repo_root/.githooks"
|
|
11
|
+
if [[ ! -d "$hooks_dir" ]]; then
|
|
12
|
+
echo "[install-agent-git-hooks] Missing hooks directory: $hooks_dir" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
chmod +x "$hooks_dir"/* 2>/dev/null || true
|
|
17
|
+
|
|
18
|
+
git -C "$repo_root" config core.hooksPath .githooks
|
|
19
|
+
|
|
20
|
+
echo "[install-agent-git-hooks] Installed repo hooks path: .githooks"
|
|
21
|
+
echo "[install-agent-git-hooks] Branch protection hook is now active for this repo clone."
|