@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.
@@ -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."