@imdeadpool/guardex 5.0.3 → 5.0.5

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/README.md CHANGED
@@ -107,6 +107,9 @@ gx protect remove release
107
107
  gx sync --check
108
108
  gx sync
109
109
 
110
+ # continuously monitor open PRs targeting current branch and dispatch codex-agent review/merge tasks
111
+ bash scripts/review-bot-watch.sh --interval 30
112
+
110
113
  # cleanup merged agent branches/worktrees
111
114
  gx cleanup
112
115
 
@@ -115,6 +118,23 @@ gx scan
115
118
  gx report scorecard --repo github.com/recodeecom/multiagent-safety
116
119
  ```
117
120
 
121
+ ### Continuous Codex PR monitor (local codex-auth session)
122
+
123
+ Run this in your local shell to keep watching PRs targeting the current branch (or `--base <branch>`):
124
+
125
+ ```sh
126
+ bash scripts/review-bot-watch.sh --interval 30
127
+ ```
128
+
129
+ Useful flags:
130
+
131
+ - `--base main` watch a specific base branch
132
+ - `--only-pr 123` process only one PR
133
+ - `--once` run one polling cycle and exit
134
+ - `--retry-failed` retry failed PRs without waiting for a new head SHA
135
+
136
+ Note: the monitor dispatches Codex through explicit `--task/--agent/--base` flags for compatibility with both older and newer `scripts/codex-agent.sh` argument parsing.
137
+
118
138
  ## Important behavior defaults
119
139
 
120
140
  - No command defaults to `gx status`.
@@ -123,6 +143,8 @@ gx report scorecard --repo github.com/recodeecom/multiagent-safety
123
143
  - `gx setup` checks GitHub CLI (`gh`) and prints install guidance if missing.
124
144
  - Interactive self-update prompt defaults to **No** (`[y/N]`).
125
145
  - In initialized repos, `setup`/`install`/`fix` block protected-base writes unless explicitly overridden.
146
+ - In VS Code Source Control, manual (non-Codex) commits/pushes to protected branches are allowed by default.
147
+ - Codex/agent sessions stay blocked on protected branches and must use `agent/*` branch + PR workflow.
126
148
  - On protected `main`, `gx doctor` auto-runs in a sandbox agent branch/worktree.
127
149
  - `scripts/agent-branch-start.sh` hydrates `scripts/codex-agent.sh` into new sandbox worktrees when missing, so auto-finish launcher flow stays available.
128
150
 
@@ -185,11 +207,13 @@ codex-auth current
185
207
  scripts/agent-branch-start.sh
186
208
  scripts/agent-branch-finish.sh
187
209
  scripts/codex-agent.sh
210
+ scripts/review-bot-watch.sh
188
211
  scripts/agent-worktree-prune.sh
189
212
  scripts/agent-file-locks.py
190
213
  scripts/install-agent-git-hooks.sh
191
214
  scripts/openspec/init-plan-workspace.sh
192
215
  .githooks/pre-commit
216
+ .githooks/pre-push
193
217
  .codex/skills/guardex/SKILL.md
194
218
  .claude/commands/guardex.md
195
219
  .omx/state/agent-file-locks.json
@@ -214,6 +238,14 @@ npm pack --dry-run
214
238
 
215
239
  ## Release notes
216
240
 
241
+ ### v5.0.5
242
+
243
+ - Bumped package version from `5.0.4` to `5.0.5` so npm publish can proceed with the next patch release.
244
+
245
+ ### v5.0.4
246
+
247
+ - Bumped package version from `5.0.3` to `5.0.4` to stay one patch ahead of the current npm published version.
248
+
217
249
  ### v5.0.3
218
250
 
219
251
  - Bumped package version from `5.0.2` to `5.0.3` for the next npm publish.
@@ -19,6 +19,7 @@ const GH_BIN = process.env.MUSAFETY_GH_BIN || 'gh';
19
19
  const REQUIRED_SYSTEM_TOOLS = [
20
20
  {
21
21
  name: 'gh',
22
+ displayName: 'GitHub (gh)',
22
23
  command: GH_BIN,
23
24
  installHint: 'https://cli.github.com/',
24
25
  },
@@ -41,11 +42,13 @@ const TEMPLATE_FILES = [
41
42
  'scripts/agent-branch-start.sh',
42
43
  'scripts/agent-branch-finish.sh',
43
44
  'scripts/codex-agent.sh',
45
+ 'scripts/review-bot-watch.sh',
44
46
  'scripts/agent-worktree-prune.sh',
45
47
  'scripts/agent-file-locks.py',
46
48
  'scripts/install-agent-git-hooks.sh',
47
49
  'scripts/openspec/init-plan-workspace.sh',
48
50
  'githooks/pre-commit',
51
+ 'githooks/pre-push',
49
52
  'codex/skills/guardex/SKILL.md',
50
53
  'claude/commands/guardex.md',
51
54
  ];
@@ -54,16 +57,19 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([
54
57
  'scripts/agent-branch-start.sh',
55
58
  'scripts/agent-branch-finish.sh',
56
59
  'scripts/codex-agent.sh',
60
+ 'scripts/review-bot-watch.sh',
57
61
  'scripts/agent-worktree-prune.sh',
58
62
  'scripts/agent-file-locks.py',
59
63
  'scripts/install-agent-git-hooks.sh',
60
64
  'scripts/openspec/init-plan-workspace.sh',
61
65
  '.githooks/pre-commit',
66
+ '.githooks/pre-push',
62
67
  ]);
63
68
 
64
69
  const CRITICAL_GUARDRAIL_PATHS = new Set([
65
70
  'AGENTS.md',
66
71
  '.githooks/pre-commit',
72
+ '.githooks/pre-push',
67
73
  'scripts/agent-branch-start.sh',
68
74
  'scripts/agent-branch-finish.sh',
69
75
  'scripts/agent-worktree-prune.sh',
@@ -79,11 +85,13 @@ const MANAGED_GITIGNORE_PATHS = [
79
85
  'scripts/agent-branch-start.sh',
80
86
  'scripts/agent-branch-finish.sh',
81
87
  'scripts/codex-agent.sh',
88
+ 'scripts/review-bot-watch.sh',
82
89
  'scripts/agent-worktree-prune.sh',
83
90
  'scripts/agent-file-locks.py',
84
91
  'scripts/install-agent-git-hooks.sh',
85
92
  'scripts/openspec/init-plan-workspace.sh',
86
93
  '.githooks/pre-commit',
94
+ '.githooks/pre-push',
87
95
  'oh-my-codex/',
88
96
  '.codex/skills/guardex/SKILL.md',
89
97
  '.claude/commands/guardex.md',
@@ -139,6 +147,10 @@ const CLI_COMMAND_DESCRIPTIONS = [
139
147
  ['help', 'Show this help output'],
140
148
  ['version', 'Print GuardeX version'],
141
149
  ];
150
+ const AGENT_BOT_DESCRIPTIONS = [
151
+ ['review', 'Monitor open PRs targeting current branch and dispatch codex-agent review flow'],
152
+ ['start', 'bash scripts/review-bot-watch.sh --interval 30'],
153
+ ];
142
154
 
143
155
  const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
144
156
 
@@ -253,9 +265,20 @@ function commandCatalogLines(indent = ' ') {
253
265
  );
254
266
  }
255
267
 
268
+ function agentBotCatalogLines(indent = ' ') {
269
+ const maxCommandLength = AGENT_BOT_DESCRIPTIONS.reduce(
270
+ (max, [command]) => Math.max(max, command.length),
271
+ 0,
272
+ );
273
+ return AGENT_BOT_DESCRIPTIONS.map(
274
+ ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`,
275
+ );
276
+ }
277
+
256
278
  function printToolLogsSummary() {
257
279
  const usageLine = ` $ ${SHORT_TOOL_NAME} <command> [options]`;
258
280
  const commandDetails = commandCatalogLines(' ');
281
+ const agentBotDetails = agentBotCatalogLines(' ');
259
282
 
260
283
  if (!supportsAnsiColors()) {
261
284
  console.log(`${TOOL_NAME}-tools logs:`);
@@ -265,12 +288,17 @@ function printToolLogsSummary() {
265
288
  for (const line of commandDetails) {
266
289
  console.log(line);
267
290
  }
291
+ console.log(' AGENT BOT');
292
+ for (const line of agentBotDetails) {
293
+ console.log(line);
294
+ }
268
295
  return;
269
296
  }
270
297
 
271
298
  const title = colorize(`${TOOL_NAME}-tools logs`, '1;36');
272
299
  const usageHeader = colorize('USAGE', '1');
273
300
  const commandsHeader = colorize('COMMANDS', '1');
301
+ const agentBotHeader = colorize('AGENT BOT', '1');
274
302
  const pipe = colorize('│', '90');
275
303
  const tee = colorize('├', '90');
276
304
  const corner = colorize('└', '90');
@@ -286,6 +314,14 @@ function printToolLogsSummary() {
286
314
  }
287
315
  console.log(` ${pipe}${line.slice(2)}`);
288
316
  }
317
+ console.log(` ${tee}─ ${agentBotHeader}`);
318
+ for (const line of agentBotDetails) {
319
+ if (!line) {
320
+ console.log(` ${pipe}`);
321
+ continue;
322
+ }
323
+ console.log(` ${pipe}${line.slice(2)}`);
324
+ }
289
325
  console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`);
290
326
  }
291
327
 
@@ -303,6 +339,9 @@ USAGE
303
339
  COMMANDS
304
340
  ${commandCatalogLines().join('\n')}
305
341
 
342
+ AGENT BOT
343
+ ${agentBotCatalogLines().join('\n')}
344
+
306
345
  NOTES
307
346
  - Running ${TOOL_NAME} with no command defaults to: ${SHORT_TOOL_NAME} status
308
347
  - Short alias: ${SHORT_TOOL_NAME}
@@ -527,6 +566,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
527
566
 
528
567
  const wantedScripts = {
529
568
  'agent:codex': 'bash ./scripts/codex-agent.sh',
569
+ 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
530
570
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
531
571
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
532
572
  'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
@@ -706,6 +746,7 @@ function hasGuardexBootstrapFiles(repoRoot) {
706
746
  'AGENTS.md',
707
747
  'scripts/agent-branch-start.sh',
708
748
  '.githooks/pre-commit',
749
+ '.githooks/pre-push',
709
750
  LOCK_FILE_RELATIVE,
710
751
  ];
711
752
  return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
@@ -1489,6 +1530,88 @@ function uniquePreserveOrder(items) {
1489
1530
  return result;
1490
1531
  }
1491
1532
 
1533
+ function readConfiguredProtectedBranches(repoRoot) {
1534
+ const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
1535
+ if (result.status !== 0) {
1536
+ return null;
1537
+ }
1538
+ const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
1539
+ if (parsed.length === 0) {
1540
+ return null;
1541
+ }
1542
+ return parsed;
1543
+ }
1544
+
1545
+ function listLocalUserBranches(repoRoot) {
1546
+ const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
1547
+ const branchNames = result.status === 0
1548
+ ? uniquePreserveOrder(
1549
+ String(result.stdout || '')
1550
+ .split('\n')
1551
+ .map((item) => item.trim())
1552
+ .filter(Boolean),
1553
+ )
1554
+ : [];
1555
+
1556
+ const additionalUserBranches = branchNames.filter(
1557
+ (branchName) =>
1558
+ !branchName.startsWith('agent/') &&
1559
+ !DEFAULT_PROTECTED_BRANCHES.includes(branchName),
1560
+ );
1561
+ if (additionalUserBranches.length > 0) {
1562
+ return additionalUserBranches;
1563
+ }
1564
+
1565
+ const current = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
1566
+ if (current.status !== 0) {
1567
+ return [];
1568
+ }
1569
+
1570
+ const branchName = String(current.stdout || '').trim();
1571
+ if (
1572
+ !branchName ||
1573
+ branchName.startsWith('agent/') ||
1574
+ DEFAULT_PROTECTED_BRANCHES.includes(branchName)
1575
+ ) {
1576
+ return [];
1577
+ }
1578
+
1579
+ return [branchName];
1580
+ }
1581
+
1582
+ function ensureSetupProtectedBranches(repoRoot, dryRun) {
1583
+ const localUserBranches = listLocalUserBranches(repoRoot);
1584
+ if (localUserBranches.length === 0) {
1585
+ return {
1586
+ status: 'unchanged',
1587
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
1588
+ note: 'no additional local user branches detected',
1589
+ };
1590
+ }
1591
+
1592
+ const configured = readConfiguredProtectedBranches(repoRoot);
1593
+ const currentBranches = configured || [...DEFAULT_PROTECTED_BRANCHES];
1594
+ const missingBranches = localUserBranches.filter((branchName) => !currentBranches.includes(branchName));
1595
+ if (missingBranches.length === 0) {
1596
+ return {
1597
+ status: 'unchanged',
1598
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
1599
+ note: 'local user branches already protected',
1600
+ };
1601
+ }
1602
+
1603
+ const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
1604
+ if (!dryRun) {
1605
+ writeProtectedBranches(repoRoot, nextBranches);
1606
+ }
1607
+
1608
+ return {
1609
+ status: dryRun ? 'would-update' : 'updated',
1610
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
1611
+ note: `added local user branch(es): ${missingBranches.join(', ')}`,
1612
+ };
1613
+ }
1614
+
1492
1615
  function readProtectedBranches(repoRoot) {
1493
1616
  const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
1494
1617
  if (result.status !== 0) {
@@ -2052,6 +2175,7 @@ function detectRequiredSystemTools() {
2052
2175
  const reason = rawReason.split('\n')[0] || '';
2053
2176
  services.push({
2054
2177
  name: tool.name,
2178
+ displayName: tool.displayName || tool.name,
2055
2179
  command: tool.command,
2056
2180
  installHint: tool.installHint,
2057
2181
  status: active ? 'active' : 'inactive',
@@ -2404,6 +2528,7 @@ function status(rawArgs) {
2404
2528
  ...npmServices,
2405
2529
  ...requiredSystemTools.map((tool) => ({
2406
2530
  name: tool.name,
2531
+ displayName: tool.displayName || tool.name,
2407
2532
  status: tool.status,
2408
2533
  })),
2409
2534
  ];
@@ -2452,11 +2577,14 @@ function status(rawArgs) {
2452
2577
 
2453
2578
  console.log(`[${TOOL_NAME}] Global services:`);
2454
2579
  for (const service of services) {
2455
- console.log(` - ${statusDot(service.status)} ${service.name}: ${service.status}`);
2580
+ const serviceLabel = service.displayName || service.name;
2581
+ console.log(` - ${statusDot(service.status)} ${serviceLabel}: ${service.status}`);
2456
2582
  }
2457
2583
  const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
2458
2584
  if (missingSystemTools.length > 0) {
2459
- const tools = missingSystemTools.map((tool) => tool.name).join(', ');
2585
+ const tools = missingSystemTools
2586
+ .map((tool) => tool.displayName || tool.name)
2587
+ .join(', ');
2460
2588
  console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${tools}`);
2461
2589
  for (const tool of missingSystemTools) {
2462
2590
  const reasonText = tool.reason ? ` (${tool.reason})` : '';
@@ -2474,6 +2602,16 @@ function status(rawArgs) {
2474
2602
 
2475
2603
  if (scanResult.errors === 0 && scanResult.warnings === 0) {
2476
2604
  console.log(`[${TOOL_NAME}] Repo safety service: ${statusDot('active')} active.`);
2605
+ } else if (scanResult.errors === 0) {
2606
+ console.log(
2607
+ `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.warnings} warning(s)).`,
2608
+ );
2609
+ console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' to review warning details.`);
2610
+ } else if (scanResult.warnings === 0) {
2611
+ console.log(
2612
+ `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s)).`,
2613
+ );
2614
+ console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`);
2477
2615
  } else {
2478
2616
  console.log(
2479
2617
  `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
@@ -2743,6 +2881,7 @@ function setup(rawArgs) {
2743
2881
 
2744
2882
  assertProtectedMainWriteAllowed(options, 'setup');
2745
2883
  const installPayload = runInstallInternal(options);
2884
+ installPayload.operations.push(ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)));
2746
2885
  printOperations('Setup/install', installPayload, options.dryRun);
2747
2886
 
2748
2887
  const fixPayload = runFixInternal({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "5.0.3",
3
+ "version": "5.0.5",
4
4
  "description": "GuardeX: the Guardian T-Rex for your repo, with hardened multi-agent git guardrails.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -23,6 +23,11 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}"
23
23
  is_codex_session=1
24
24
  fi
25
25
 
26
+ is_vscode_git_context=0
27
+ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" || "${TERM_PROGRAM:-}" == "vscode" ]]; then
28
+ is_vscode_git_context=1
29
+ fi
30
+
26
31
  protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}"
27
32
  if [[ -z "$protected_branches_raw" ]]; then
28
33
  protected_branches_raw="dev main master"
@@ -106,6 +111,10 @@ MSG
106
111
  fi
107
112
 
108
113
  if [[ "$is_protected_branch" == "1" ]]; then
114
+ if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then
115
+ exit 0
116
+ fi
117
+
109
118
  if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then
110
119
  exit 0
111
120
  fi
@@ -10,8 +10,9 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n
10
10
  is_vscode_git_context=1
11
11
  fi
12
12
 
13
- if [[ "$is_vscode_git_context" == "1" ]]; then
14
- exit 0
13
+ is_codex_session=0
14
+ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then
15
+ is_codex_session=1
15
16
  fi
16
17
 
17
18
  protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}"
@@ -43,6 +44,22 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
43
44
  done
44
45
 
45
46
  if [[ "${#blocked_refs[@]}" -gt 0 ]]; then
47
+ if [[ "$is_codex_session" == "1" ]]; then
48
+ {
49
+ echo "[guardex-preedit-guard] Codex push detected toward protected branch."
50
+ echo "[guardex-preedit-guard] Protected target(s): ${blocked_refs[*]}"
51
+ echo "[guardex-preedit-guard] Run Codex from an agent/* branch and merge via PR."
52
+ echo
53
+ echo "Temporary bypass (not recommended):"
54
+ echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..."
55
+ } >&2
56
+ exit 1
57
+ fi
58
+
59
+ if [[ "$is_vscode_git_context" == "1" ]]; then
60
+ exit 0
61
+ fi
62
+
46
63
  {
47
64
  echo "[agent-branch-guard] Push to protected branch blocked outside VS Code Git context."
48
65
  echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}"
@@ -26,6 +26,7 @@ LOCK_FILE_RELATIVE = Path('.omx/state/agent-file-locks.json')
26
26
  CRITICAL_GUARDRAIL_PATHS = {
27
27
  'AGENTS.md',
28
28
  '.githooks/pre-commit',
29
+ '.githooks/pre-push',
29
30
  'scripts/agent-branch-start.sh',
30
31
  'scripts/agent-branch-finish.sh',
31
32
  'scripts/agent-file-locks.py',
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INTERVAL_SECONDS="${MUSAFETY_REVIEW_BOT_INTERVAL_SECONDS:-30}"
5
+ AGENT_NAME="${MUSAFETY_REVIEW_BOT_AGENT_NAME:-guardex-review-bot}"
6
+ TASK_PREFIX="${MUSAFETY_REVIEW_BOT_TASK_PREFIX:-review-merge}"
7
+ STATE_FILE="${MUSAFETY_REVIEW_BOT_STATE_FILE:-}"
8
+ BASE_BRANCH="${MUSAFETY_REVIEW_BOT_BASE_BRANCH:-}"
9
+ ONLY_PR="${MUSAFETY_REVIEW_BOT_ONLY_PR:-}"
10
+ RETRY_FAILED_RAW="${MUSAFETY_REVIEW_BOT_RETRY_FAILED:-false}"
11
+ INCLUDE_DRAFT_RAW="${MUSAFETY_REVIEW_BOT_INCLUDE_DRAFT:-false}"
12
+
13
+ usage() {
14
+ cat <<'USAGE'
15
+ Usage: bash scripts/review-bot-watch.sh [options]
16
+
17
+ Continuously monitor GitHub pull requests targeting a base branch and dispatch
18
+ one Codex-agent task per newly opened/updated PR.
19
+
20
+ Options:
21
+ --base <branch> Base branch to watch (default: current branch)
22
+ --interval <seconds> Poll interval (default: 30)
23
+ --agent <name> Agent name for codex-agent (default: guardex-review-bot)
24
+ --task-prefix <prefix> Task prefix for codex-agent branches (default: review-merge)
25
+ --state-file <path> State file path (default: .omx/state/review-bot-watch-<base>.tsv)
26
+ --only-pr <number> Watch only one PR number
27
+ --include-draft Include draft PRs
28
+ --retry-failed Retry PRs that previously failed even when SHA is unchanged
29
+ --once Run one poll cycle and exit
30
+ -h, --help Show this help
31
+
32
+ Environment overrides:
33
+ MUSAFETY_REVIEW_BOT_PROMPT_APPEND Additional instructions appended to each Codex prompt
34
+ USAGE
35
+ }
36
+
37
+ normalize_bool() {
38
+ local raw="${1:-}"
39
+ local fallback="${2:-0}"
40
+ case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in
41
+ 1|true|yes|on) printf '1' ;;
42
+ 0|false|no|off) printf '0' ;;
43
+ '') printf '%s' "$fallback" ;;
44
+ *) printf '%s' "$fallback" ;;
45
+ esac
46
+ }
47
+
48
+ ONCE=0
49
+
50
+ while [[ $# -gt 0 ]]; do
51
+ case "$1" in
52
+ --base)
53
+ BASE_BRANCH="${2:-}"
54
+ shift 2
55
+ ;;
56
+ --interval)
57
+ INTERVAL_SECONDS="${2:-}"
58
+ shift 2
59
+ ;;
60
+ --agent)
61
+ AGENT_NAME="${2:-}"
62
+ shift 2
63
+ ;;
64
+ --task-prefix)
65
+ TASK_PREFIX="${2:-}"
66
+ shift 2
67
+ ;;
68
+ --state-file)
69
+ STATE_FILE="${2:-}"
70
+ shift 2
71
+ ;;
72
+ --only-pr)
73
+ ONLY_PR="${2:-}"
74
+ shift 2
75
+ ;;
76
+ --retry-failed)
77
+ RETRY_FAILED_RAW="true"
78
+ shift
79
+ ;;
80
+ --include-draft)
81
+ INCLUDE_DRAFT_RAW="true"
82
+ shift
83
+ ;;
84
+ --once)
85
+ ONCE=1
86
+ shift
87
+ ;;
88
+ -h|--help)
89
+ usage
90
+ exit 0
91
+ ;;
92
+ *)
93
+ echo "[review-bot-watch] Unknown option: $1" >&2
94
+ usage >&2
95
+ exit 1
96
+ ;;
97
+ esac
98
+ done
99
+
100
+ RETRY_FAILED="$(normalize_bool "$RETRY_FAILED_RAW" "0")"
101
+ INCLUDE_DRAFT="$(normalize_bool "$INCLUDE_DRAFT_RAW" "0")"
102
+
103
+ if [[ ! "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 5 ]]; then
104
+ echo "[review-bot-watch] --interval must be an integer >= 5 seconds." >&2
105
+ exit 1
106
+ fi
107
+
108
+ if [[ -n "$ONLY_PR" ]] && [[ ! "$ONLY_PR" =~ ^[0-9]+$ ]]; then
109
+ echo "[review-bot-watch] --only-pr must be a numeric PR id." >&2
110
+ exit 1
111
+ fi
112
+
113
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
114
+ echo "[review-bot-watch] Not inside a git repository." >&2
115
+ exit 1
116
+ fi
117
+ repo_root="$(git rev-parse --show-toplevel)"
118
+
119
+ if [[ -z "$BASE_BRANCH" ]]; then
120
+ BASE_BRANCH="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
121
+ fi
122
+ if [[ -z "$BASE_BRANCH" || "$BASE_BRANCH" == "HEAD" ]]; then
123
+ BASE_BRANCH="main"
124
+ fi
125
+
126
+ if ! command -v gh >/dev/null 2>&1; then
127
+ echo "[review-bot-watch] Missing GitHub CLI (gh)." >&2
128
+ echo "[review-bot-watch] Install gh and run: gh auth login" >&2
129
+ exit 127
130
+ fi
131
+
132
+ if ! command -v codex >/dev/null 2>&1; then
133
+ echo "[review-bot-watch] Missing Codex CLI command: codex" >&2
134
+ exit 127
135
+ fi
136
+
137
+ if [[ ! -x "$repo_root/scripts/codex-agent.sh" ]]; then
138
+ echo "[review-bot-watch] Missing scripts/codex-agent.sh. Run: gx setup" >&2
139
+ exit 1
140
+ fi
141
+
142
+ if ! gh auth status >/dev/null 2>&1; then
143
+ echo "[review-bot-watch] gh is not authenticated. Run: gh auth login" >&2
144
+ exit 1
145
+ fi
146
+
147
+ sanitize_slug() {
148
+ local raw="$1"
149
+ local fallback="$2"
150
+ local slug
151
+ slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
152
+ if [[ -z "$slug" ]]; then
153
+ slug="$fallback"
154
+ fi
155
+ printf '%s' "$slug"
156
+ }
157
+
158
+ base_slug="$(sanitize_slug "$BASE_BRANCH" "base")"
159
+ if [[ -z "$STATE_FILE" ]]; then
160
+ STATE_FILE="$repo_root/.omx/state/review-bot-watch-${base_slug}.tsv"
161
+ fi
162
+ mkdir -p "$(dirname "$STATE_FILE")"
163
+
164
+ declare -A LAST_SHA
165
+
166
+ declare -A LAST_STATUS
167
+
168
+ load_state() {
169
+ if [[ ! -f "$STATE_FILE" ]]; then
170
+ return 0
171
+ fi
172
+ while IFS=$'\t' read -r pr sha status updated_at; do
173
+ if [[ -z "${pr:-}" ]] || [[ "${pr:0:1}" == "#" ]]; then
174
+ continue
175
+ fi
176
+ LAST_SHA["$pr"]="$sha"
177
+ LAST_STATUS["$pr"]="$status"
178
+ done < "$STATE_FILE"
179
+ }
180
+
181
+ save_state() {
182
+ {
183
+ echo "# pr\thead_sha\tstatus\tupdated_at"
184
+ for pr in "${!LAST_SHA[@]}"; do
185
+ printf '%s\t%s\t%s\t%s\n' "${pr}" "${LAST_SHA[$pr]}" "${LAST_STATUS[$pr]:-unknown}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
186
+ done | sort -n
187
+ } > "$STATE_FILE"
188
+ }
189
+
190
+ build_prompt() {
191
+ local pr="$1"
192
+ local head_branch="$2"
193
+ local head_sha="$3"
194
+ local pr_title="$4"
195
+ local pr_url="$5"
196
+
197
+ cat <<PROMPT
198
+ You are the continuous PR review+merge Codex agent.
199
+
200
+ Target PR: #${pr}
201
+ URL: ${pr_url}
202
+ Title: ${pr_title}
203
+ Base branch: ${BASE_BRANCH}
204
+ Head branch: ${head_branch}
205
+ Head SHA: ${head_sha}
206
+
207
+ Strict task:
208
+ 1) Review ONLY this PR's changes using gh CLI context (gh pr view ${pr}, gh pr diff ${pr}).
209
+ 2) If fixes are needed, implement them in your sandbox branch, run verification (at minimum npm test when available), and push your sandbox branch.
210
+ 3) When the PR is ready and checks are green, merge this PR into ${BASE_BRANCH} with:
211
+ gh pr merge ${pr} --squash --delete-branch
212
+ 4) If merge is blocked, explain the blocker and exit non-zero.
213
+ 5) Do not touch unrelated PRs.
214
+ PROMPT
215
+
216
+ if [[ -n "${MUSAFETY_REVIEW_BOT_PROMPT_APPEND:-}" ]]; then
217
+ printf '\n%s\n' "${MUSAFETY_REVIEW_BOT_PROMPT_APPEND}"
218
+ fi
219
+ }
220
+
221
+ list_open_prs() {
222
+ gh pr list \
223
+ --state open \
224
+ --base "$BASE_BRANCH" \
225
+ --json number,headRefName,headRefOid,isDraft,title,url \
226
+ --jq '.[] | "\(.number)\t\(.headRefName)\t\(.headRefOid)\t\(.isDraft)\t\(.title | gsub("\\t"; " "))\t\(.url)"'
227
+ }
228
+
229
+ should_process_pr() {
230
+ local pr="$1"
231
+ local sha="$2"
232
+
233
+ local prev_sha="${LAST_SHA[$pr]:-}"
234
+ local prev_status="${LAST_STATUS[$pr]:-}"
235
+
236
+ if [[ -z "$prev_sha" ]]; then
237
+ return 0
238
+ fi
239
+
240
+ if [[ "$prev_sha" != "$sha" ]]; then
241
+ return 0
242
+ fi
243
+
244
+ if [[ "$prev_status" == "failed" && "$RETRY_FAILED" == "1" ]]; then
245
+ return 0
246
+ fi
247
+
248
+ return 1
249
+ }
250
+
251
+ process_one_pr() {
252
+ local pr="$1"
253
+ local head_branch="$2"
254
+ local sha="$3"
255
+ local title="$4"
256
+ local url="$5"
257
+
258
+ local prompt
259
+ prompt="$(build_prompt "$pr" "$head_branch" "$sha" "$title" "$url")"
260
+
261
+ local task_name="${TASK_PREFIX}-pr-${pr}"
262
+
263
+ echo "[review-bot-watch] Dispatching Codex agent for PR #${pr} (${head_branch})"
264
+ set +e
265
+ bash "$repo_root/scripts/codex-agent.sh" \
266
+ --task "$task_name" \
267
+ --agent "$AGENT_NAME" \
268
+ --base "$BASE_BRANCH" \
269
+ -- exec "$prompt"
270
+ local exit_code="$?"
271
+ set -e
272
+
273
+ LAST_SHA["$pr"]="$sha"
274
+ if [[ "$exit_code" -eq 0 ]]; then
275
+ LAST_STATUS["$pr"]="success"
276
+ echo "[review-bot-watch] PR #${pr}: success"
277
+ else
278
+ LAST_STATUS["$pr"]="failed"
279
+ echo "[review-bot-watch] PR #${pr}: failed (exit=${exit_code})" >&2
280
+ fi
281
+
282
+ save_state
283
+ }
284
+
285
+ load_state
286
+
287
+ echo "[review-bot-watch] Starting monitor"
288
+ echo "[review-bot-watch] Base branch : ${BASE_BRANCH}"
289
+ echo "[review-bot-watch] Interval : ${INTERVAL_SECONDS}s"
290
+ echo "[review-bot-watch] State file : ${STATE_FILE}"
291
+ if [[ -n "$ONLY_PR" ]]; then
292
+ echo "[review-bot-watch] Only PR : #${ONLY_PR}"
293
+ fi
294
+
295
+ trap 'echo "[review-bot-watch] Stopped."; exit 0' INT TERM
296
+
297
+ while true; do
298
+ found=0
299
+ while IFS=$'\t' read -r pr head_branch sha is_draft title url; do
300
+ if [[ -z "${pr:-}" ]]; then
301
+ continue
302
+ fi
303
+
304
+ found=1
305
+
306
+ if [[ -n "$ONLY_PR" && "$pr" != "$ONLY_PR" ]]; then
307
+ continue
308
+ fi
309
+
310
+ if [[ "$INCLUDE_DRAFT" != "1" && "$is_draft" == "true" ]]; then
311
+ continue
312
+ fi
313
+
314
+ if ! should_process_pr "$pr" "$sha"; then
315
+ continue
316
+ fi
317
+
318
+ process_one_pr "$pr" "$head_branch" "$sha" "$title" "$url"
319
+ done < <(list_open_prs || true)
320
+
321
+ if [[ "$found" -eq 0 ]]; then
322
+ echo "[review-bot-watch] No open PRs for base '${BASE_BRANCH}'."
323
+ fi
324
+
325
+ if [[ "$ONCE" -eq 1 ]]; then
326
+ break
327
+ fi
328
+
329
+ sleep "$INTERVAL_SECONDS"
330
+ done