@mirnoorata/codexa 0.2.2 → 0.4.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.
Files changed (67) hide show
  1. package/README.md +110 -31
  2. package/dist/cli/hooks.js +11 -6
  3. package/dist/cli/hooks.js.map +1 -1
  4. package/dist/cli.js +13 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/eval/scoring.js +17 -0
  7. package/dist/eval/scoring.js.map +1 -1
  8. package/dist/implicit-baseline.d.ts +8 -0
  9. package/dist/implicit-baseline.js +94 -0
  10. package/dist/implicit-baseline.js.map +1 -0
  11. package/dist/init.d.ts +3 -0
  12. package/dist/init.js +129 -15
  13. package/dist/init.js.map +1 -1
  14. package/dist/mcp/compaction.d.ts +1 -0
  15. package/dist/mcp/compaction.js +24 -0
  16. package/dist/mcp/compaction.js.map +1 -1
  17. package/dist/mcp/envelope.d.ts +4 -1
  18. package/dist/mcp/envelope.js +45 -5
  19. package/dist/mcp/envelope.js.map +1 -1
  20. package/dist/mcp/prompts.d.ts +1 -1
  21. package/dist/mcp/prompts.js +5 -2
  22. package/dist/mcp/prompts.js.map +1 -1
  23. package/dist/mcp/tool-registry.d.ts +20 -19
  24. package/dist/mcp/tool-registry.js +24 -19
  25. package/dist/mcp/tool-registry.js.map +1 -1
  26. package/dist/mcp/tools.d.ts +1 -0
  27. package/dist/mcp/tools.js +11 -2
  28. package/dist/mcp/tools.js.map +1 -1
  29. package/dist/mcp-tool-catalog.d.ts +1 -1
  30. package/dist/mcp-tool-catalog.js +1 -1
  31. package/dist/mcp-tool-catalog.js.map +1 -1
  32. package/dist/mcp.js +10 -5
  33. package/dist/mcp.js.map +1 -1
  34. package/dist/query/post-edit/decision.d.ts +1 -0
  35. package/dist/query/post-edit/decision.js +13 -4
  36. package/dist/query/post-edit/decision.js.map +1 -1
  37. package/dist/query/post-edit.js +46 -16
  38. package/dist/query/post-edit.js.map +1 -1
  39. package/dist/task-snapshots.js +29 -0
  40. package/dist/task-snapshots.js.map +1 -1
  41. package/dist/types.d.ts +2 -0
  42. package/dist/types.js.map +1 -1
  43. package/integrations/.claude-plugin/marketplace.json +23 -0
  44. package/integrations/claude-code/.claude-plugin/plugin.json +16 -0
  45. package/integrations/claude-code/.mcp.json +8 -0
  46. package/integrations/claude-code/README.md +177 -0
  47. package/integrations/claude-code/commands/codexa-brief.md +14 -0
  48. package/integrations/claude-code/commands/codexa-impact.md +14 -0
  49. package/integrations/claude-code/commands/codexa-plan.md +20 -0
  50. package/integrations/claude-code/commands/codexa-review.md +23 -0
  51. package/integrations/claude-code/commands/codexa-status.md +10 -0
  52. package/integrations/claude-code/hooks/hooks.json +39 -0
  53. package/integrations/claude-code/scripts/cmd/brief.sh +18 -0
  54. package/integrations/claude-code/scripts/cmd/impact.sh +35 -0
  55. package/integrations/claude-code/scripts/cmd/lib.sh +136 -0
  56. package/integrations/claude-code/scripts/cmd/plan.sh +52 -0
  57. package/integrations/claude-code/scripts/cmd/review.sh +66 -0
  58. package/integrations/claude-code/scripts/cmd/status.sh +52 -0
  59. package/integrations/claude-code/scripts/codexa-mcp.js +111 -0
  60. package/integrations/claude-code/scripts/lib/codexa-repo.sh +773 -0
  61. package/integrations/claude-code/scripts/pre-edit.sh +116 -0
  62. package/integrations/claude-code/scripts/session-start.sh +201 -0
  63. package/integrations/claude-code/scripts/stop.sh +443 -0
  64. package/integrations/claude-code/tests/cmd-smoke.sh +310 -0
  65. package/integrations/claude-code/tests/hook-smoke.sh +1412 -0
  66. package/package.json +6 -3
  67. package/plugins/codexa/.codex-plugin/plugin.json +1 -1
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook. At the end of every assistant turn, if the session touched
3
+ # (or is sitting above) a codexa-wired repo, run `codexa post-edit-review` for
4
+ # each such repo whose change-plan snapshot is "interesting" (has edits
5
+ # beyond the last review), and print a structured summary to stderr.
6
+ #
7
+ # Two execution modes:
8
+ # (1) cwd is inside a wired repo — review that repo.
9
+ # (2) cwd is above any wired repo but wired child repos exist — rank
10
+ # them by snapshot mtime and review the top N (N=3), skipping any
11
+ # whose fingerprint is already debounced from a prior turn.
12
+ #
13
+ # Rules:
14
+ # - Per-repo review has a 30s hard budget.
15
+ # - Debounced per (session, repo, snapshot-content, dirty-tree-hash).
16
+ # - Always exits 0. When a review's verdict is replan or a blocking
17
+ # inspect, the drift summary is made model-visible through the Stop
18
+ # hook JSON contract ({"decision":"block","reason":...}) so the agent
19
+ # can act on it; clean/advisory verdicts stay stderr-only. Blocking
20
+ # additionally requires BOTH opt-in signals: an explicit change_plan
21
+ # snapshot (implicit hook baselines never block) AND the session
22
+ # working inside the repo (mode 1) — parent-scan reviews of other
23
+ # workspace repos are always stderr-only. The stop_hook_active
24
+ # re-entrancy guard plus the fingerprint debounce bound this to at
25
+ # most one block per stop sequence and per dirty-tree state. Set
26
+ # CLAUDIO_STOP_BLOCK=0 for stderr-only behavior everywhere.
27
+
28
+ set -u
29
+
30
+ CLAUDIO_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd -P)}"
31
+ # shellcheck source=lib/codexa-repo.sh
32
+ . "$CLAUDIO_ROOT/scripts/lib/codexa-repo.sh"
33
+
34
+ MAX_STOP_REPOS_PER_TURN="${CLAUDIO_STOP_MAX_REPOS:-3}"
35
+
36
+ # Run a post-edit review for one repo. Returns 0 in all cases — this is a
37
+ # best-effort helper that never raises. Emits one stderr block per call.
38
+ # block_eligible=1 only when the session is working inside this repo (mode
39
+ # 1): parent-scan reviews of sibling/child repos must never block a session
40
+ # that did not touch them — a stale snapshot in an unrelated workspace repo
41
+ # would otherwise force-block every session rooted above it.
42
+ claudio_stop_review_one() {
43
+ local repo="$1"
44
+ local session_id="$2"
45
+ local data_dir="$3"
46
+ local block_eligible="${4:-0}"
47
+
48
+ if ! claudio_has_snapshot "$repo"; then
49
+ return 0
50
+ fi
51
+
52
+ local snapshot_file="$repo/.codex/cache/codexa-tasks/latest.json"
53
+
54
+ # Content-sensitive fingerprint. See session banner in the file below for
55
+ # the full contract. Returns non-zero when any git step was degraded so
56
+ # the caller never writes a cacheable marker from a trust-less state.
57
+ local fingerprint_tmp
58
+ fingerprint_tmp="$(mktemp)" || return 0
59
+ python3 - "$repo" "$snapshot_file" >"$fingerprint_tmp" 2>/dev/null <<'PY'
60
+ import hashlib
61
+ import os
62
+ import stat
63
+ import subprocess
64
+ import sys
65
+
66
+ repo = sys.argv[1]
67
+ snapshot = sys.argv[2]
68
+
69
+ MAX_UNTRACKED_FILES = 2000
70
+ MAX_UNTRACKED_TOTAL_BYTES = 32 * 1024 * 1024 # 32 MiB
71
+ MAX_SINGLE_FILE_BYTES = 4 * 1024 * 1024 # 4 MiB
72
+ MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024 # 16 MiB per git invocation
73
+ GIT_TIMEOUT_SECONDS = 8
74
+
75
+ degraded = False
76
+
77
+
78
+ def git_out(args):
79
+ global degraded
80
+ try:
81
+ result = subprocess.run(
82
+ ["git", *args],
83
+ cwd=repo,
84
+ capture_output=True,
85
+ timeout=GIT_TIMEOUT_SECONDS,
86
+ )
87
+ except subprocess.TimeoutExpired:
88
+ degraded = True
89
+ return b"__GIT_TIMEOUT__\n", 124
90
+ except OSError:
91
+ degraded = True
92
+ return b"__GIT_UNAVAILABLE__\n", 127
93
+ if result.returncode != 0:
94
+ degraded = True
95
+ out = result.stdout
96
+ if len(out) > MAX_GIT_OUTPUT_BYTES:
97
+ degraded = True
98
+ out = out[:MAX_GIT_OUTPUT_BYTES] + b"\n__GIT_OUTPUT_TRUNCATED__\n"
99
+ return out, result.returncode
100
+
101
+
102
+ h = hashlib.sha256()
103
+ h.update(b"STATUS\n")
104
+ status_bytes, status_rc = git_out(["status", "--short", "--untracked-files=all"])
105
+ h.update(f"rc={status_rc}\n".encode("ascii"))
106
+ h.update(status_bytes)
107
+ h.update(b"\nDIFF\n")
108
+ diff_bytes, diff_rc = git_out(["diff", "--no-color"])
109
+ h.update(f"rc={diff_rc}\n".encode("ascii"))
110
+ h.update(diff_bytes)
111
+ h.update(b"\nCACHED\n")
112
+ cached_bytes, cached_rc = git_out(["diff", "--no-color", "--cached"])
113
+ h.update(f"rc={cached_rc}\n".encode("ascii"))
114
+ h.update(cached_bytes)
115
+ h.update(b"\nUNTRACKED\n")
116
+ raw, ls_rc = git_out(["ls-files", "--others", "--exclude-standard", "-z"])
117
+ h.update(f"rc={ls_rc}\n".encode("ascii"))
118
+ if ls_rc != 0:
119
+ raw = b""
120
+ count = 0
121
+ bytes_read = 0
122
+ for entry in raw.split(b"\0"):
123
+ if not entry:
124
+ continue
125
+ count += 1
126
+ if count > MAX_UNTRACKED_FILES:
127
+ degraded = True
128
+ h.update(b"TRUNCATED_FILE_COUNT\n")
129
+ break
130
+ try:
131
+ rel = os.fsdecode(entry)
132
+ abs_path = os.path.join(repo, rel)
133
+ except (UnicodeDecodeError, TypeError):
134
+ degraded = True
135
+ h.update(b"path_decode_failed:")
136
+ h.update(hashlib.sha256(entry).hexdigest().encode("ascii"))
137
+ h.update(b"\n")
138
+ continue
139
+ try:
140
+ lst = os.lstat(abs_path)
141
+ except OSError:
142
+ degraded = True
143
+ h.update(b"lstat_failed:")
144
+ h.update(hashlib.sha256(entry).hexdigest().encode("ascii"))
145
+ h.update(b"\n")
146
+ continue
147
+ h.update(entry)
148
+ h.update(b":")
149
+ if stat.S_ISLNK(lst.st_mode):
150
+ try:
151
+ target = os.readlink(abs_path).encode("utf-8", "replace")
152
+ except OSError:
153
+ target = b""
154
+ h.update(b"lnk:")
155
+ h.update(hashlib.sha256(target).hexdigest().encode("ascii"))
156
+ h.update(b"\n")
157
+ continue
158
+ if not stat.S_ISREG(lst.st_mode):
159
+ mode = stat.S_IFMT(lst.st_mode)
160
+ h.update(f"special:{mode:o}:{lst.st_size}\n".encode("ascii"))
161
+ continue
162
+ fd = None
163
+ try:
164
+ fd = os.open(
165
+ abs_path,
166
+ os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
167
+ )
168
+ except OSError:
169
+ degraded = True
170
+ h.update(b"unreadable\n")
171
+ continue
172
+ try:
173
+ fst = os.fstat(fd)
174
+ if not stat.S_ISREG(fst.st_mode):
175
+ degraded = True
176
+ mode = stat.S_IFMT(fst.st_mode)
177
+ h.update(f"special_post_swap:{mode:o}:{fst.st_size}\n".encode("ascii"))
178
+ continue
179
+ if (fst.st_dev, fst.st_ino) != (lst.st_dev, lst.st_ino):
180
+ degraded = True
181
+ h.update(b"identity_changed\n")
182
+ continue
183
+ if fst.st_size > MAX_SINGLE_FILE_BYTES:
184
+ degraded = True
185
+ h.update(f"toolarge:{fst.st_size}\n".encode("ascii"))
186
+ continue
187
+ if bytes_read + fst.st_size > MAX_UNTRACKED_TOTAL_BYTES:
188
+ degraded = True
189
+ h.update(b"TRUNCATED_TOTAL_BYTES\n")
190
+ break
191
+ with os.fdopen(fd, "rb") as f:
192
+ fd = None
193
+ data = f.read(MAX_SINGLE_FILE_BYTES + 1)
194
+ except OSError:
195
+ degraded = True
196
+ h.update(b"unreadable\n")
197
+ continue
198
+ finally:
199
+ if fd is not None:
200
+ try:
201
+ os.close(fd)
202
+ except OSError:
203
+ pass
204
+ bytes_read += len(data)
205
+ h.update(hashlib.sha256(data).hexdigest().encode("ascii"))
206
+ h.update(b"\n")
207
+ h.update(b"\nSNAPSHOT\n")
208
+ try:
209
+ with open(snapshot, "rb") as f:
210
+ h.update(hashlib.sha256(f.read()).hexdigest().encode("ascii"))
211
+ h.update(b"\n")
212
+ except OSError:
213
+ h.update(b"missing\n")
214
+ sys.stdout.write(h.hexdigest())
215
+ sys.exit(3 if degraded else 0)
216
+ PY
217
+ local fingerprint_rc=$?
218
+ local fingerprint
219
+ fingerprint="$(cat "$fingerprint_tmp" 2>/dev/null)"
220
+ rm -f "$fingerprint_tmp"
221
+ if [[ -z "$fingerprint" ]]; then
222
+ # GNU stat uses -c; BSD/macOS stat uses -f.
223
+ local snapshot_mtime
224
+ snapshot_mtime="$(stat -c '%Y' "$snapshot_file" 2>/dev/null || stat -f '%m' "$snapshot_file" 2>/dev/null)"
225
+ fingerprint="$(printf '%s:%s' "$repo" "$snapshot_mtime" | shasum -a 256 | awk '{print $1}')"
226
+ fingerprint_rc=3
227
+ fi
228
+
229
+ local marker_key_src marker_key marker
230
+ marker_key_src="$(printf '%s:%s:%s' "${session_id:-nosession}" "$repo" "$fingerprint")"
231
+ marker_key="$(printf '%s' "$marker_key_src" | shasum -a 256 2>/dev/null | awk '{print $1}')"
232
+ if [[ -z "$marker_key" ]]; then
233
+ marker_key="$(printf '%s' "$marker_key_src" | md5sum | awk '{print $1}')"
234
+ fi
235
+ marker="$data_dir/stop-review-v2-$marker_key"
236
+
237
+ if [[ "${fingerprint_rc:-0}" -eq 0 && -f "$marker" ]]; then
238
+ # Already reviewed with this exact fingerprint; caller should NOT
239
+ # count this against the per-turn attempt budget. Distinct return
240
+ # code lets the dispatcher continue past debounced children to the
241
+ # next candidate instead of starving older unreviewed repos.
242
+ return 20
243
+ fi
244
+
245
+ if ! claudio_codexa_available; then
246
+ return 0
247
+ fi
248
+
249
+ local out rc
250
+ out="$(claudio_codexa_run 30 post-edit-review "$repo" --change-type unknown --budget 1600 --limit 8 2>&1)"
251
+ rc=$?
252
+
253
+ local safe_repo
254
+ safe_repo="$(claudio_display_path "$repo")"
255
+
256
+ if [[ $rc -ne 0 ]]; then
257
+ cat >&2 <<EOF
258
+ [codexa] Post-edit review failed: rc=$rc repo=$safe_repo; debounce marker unchanged, next turn retries.
259
+ [codexa] Run the review by hand to see the full output: /codexa-review
260
+ EOF
261
+ return 0
262
+ fi
263
+
264
+ if [[ "${fingerprint_rc:-0}" -eq 0 ]]; then
265
+ touch "$marker" 2>/dev/null || true
266
+ fi
267
+
268
+ if [[ -z "$out" ]]; then
269
+ return 0
270
+ fi
271
+
272
+ local summary_fields
273
+ summary_fields="$(claudio_parse_post_edit_summary "$out")"
274
+ cat >&2 <<EOF
275
+ [codexa] Post-edit review for $safe_repo:
276
+ $(printf '%s\n' "$summary_fields" | sed 's/^/ /')
277
+ [codexa] Full review: run /codexa-review or codexa post-edit-review $safe_repo
278
+ EOF
279
+
280
+ if [[ "$block_eligible" == "1" ]]; then
281
+ claudio_stop_collect_block "$safe_repo" "$out" "$summary_fields"
282
+ fi
283
+
284
+ return 0
285
+ }
286
+
287
+ # When a review's verdict warrants attention (replan, or inspect classified
288
+ # blocking), record one model-facing reason line for the final Stop JSON.
289
+ # Only enum-validated tokens, sanitized paths, and plugin-controlled text
290
+ # are used — raw CLI output never flows into the reason.
291
+ # Blocking is an OPT-IN tied to an explicit change_plan snapshot: reviews
292
+ # against a hook-saved implicit baseline (the user never declared a plan)
293
+ # stay stderr-only, whatever their verdict. Without this gate, installing
294
+ # the plugin would by itself escalate every untested edit into a forced
295
+ # extra turn.
296
+ claudio_stop_collect_block() {
297
+ local safe_repo="$1"
298
+ local out="$2"
299
+ local summary_fields="$3"
300
+ [[ "${CLAUDIO_STOP_BLOCK:-1}" == "0" ]] && return 0
301
+ [[ -z "${_CLAUDIO_BLOCK_FILE:-}" ]] && return 0
302
+ local fields verdict inspect origin
303
+ fields="$(claudio_parse_post_edit_verdict "$out")"
304
+ verdict="$(printf '%s\n' "$fields" | sed -n 's/^verdict=//p')"
305
+ inspect="$(printf '%s\n' "$fields" | sed -n 's/^inspect=//p')"
306
+ origin="$(printf '%s\n' "$fields" | sed -n 's/^origin=//p')"
307
+ if [[ "$origin" == "implicit" ]]; then
308
+ return 0
309
+ fi
310
+ local label
311
+ if [[ "$verdict" == "replan" ]]; then
312
+ label="replan"
313
+ elif [[ "$verdict" == "inspect" && "$inspect" == "blocking" ]]; then
314
+ label="inspect (blocking)"
315
+ else
316
+ return 0
317
+ fi
318
+ # Zero counts are usually a budget-truncation artifact (the summary
319
+ # sections render after the bulk sections and fall off small review
320
+ # budgets), not a signal — drop them rather than mislead the model.
321
+ local counts
322
+ counts="$(printf '%s\n' "$summary_fields" | grep -v ' count=0$' | tr '\n' ' ' | sed 's/ *$//')"
323
+ if [[ -n "$counts" ]]; then
324
+ printf 'Codexa post-edit review for %s: verdict=%s. %s\n' "$safe_repo" "$label" "$counts" >>"$_CLAUDIO_BLOCK_FILE" 2>/dev/null || true
325
+ else
326
+ printf 'Codexa post-edit review for %s: verdict=%s.\n' "$safe_repo" "$label" >>"$_CLAUDIO_BLOCK_FILE" 2>/dev/null || true
327
+ fi
328
+ }
329
+
330
+ # Emit the Stop hook JSON block decision when any reviewed repo produced a
331
+ # blockworthy verdict. Exit code stays 0; the JSON carries the decision.
332
+ claudio_emit_stop_block() {
333
+ [[ -z "${_CLAUDIO_BLOCK_FILE:-}" ]] && return 0
334
+ [[ ! -s "$_CLAUDIO_BLOCK_FILE" ]] && return 0
335
+ local reasons reason_json
336
+ reasons="$(cat "$_CLAUDIO_BLOCK_FILE" 2>/dev/null)"
337
+ [[ -z "$reasons" ]] && return 0
338
+ reason_json="$(claudio_json_escape "[codexa] ${reasons}
339
+ Address the drift or run the recommended verification, then re-check with the post_edit_review MCP tool or /codexa-review. If the remaining drift is intended, briefly state why before finishing. (This drift block fires at most once per stop and per dirty-tree state; it will not loop.)")"
340
+ printf '{"decision":"block","reason":"%s"}\n' "$reason_json"
341
+ }
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Main dispatcher.
345
+
346
+ payload="$(cat)"
347
+ if [[ -z "$payload" ]]; then
348
+ exit 0
349
+ fi
350
+
351
+ cwd="$(printf '%s' "$payload" | claudio_json_field cwd)"
352
+ session_id="$(printf '%s' "$payload" | claudio_json_field session_id)"
353
+ stop_hook_active="$(printf '%s' "$payload" | claudio_json_field stop_hook_active)"
354
+
355
+ # Re-entrancy guard — when a Stop hook's block decision re-triggers Claude,
356
+ # stop_hook_active becomes true; don't loop. JSON booleans round-trip
357
+ # through python's str() as "True"/"False"; normalize case before compare.
358
+ # (claudio_lowercase, not `${var,,}`: bash 3.2 on stock macOS aborts the
359
+ # whole script on the bash-4 substitution.)
360
+ if [[ "$(claudio_lowercase "$stop_hook_active")" == "true" ]]; then
361
+ exit 0
362
+ fi
363
+
364
+ [[ -z "$cwd" ]] && exit 0
365
+
366
+ # Choose a state dir up front. The debounce marker lives OUTSIDE the repo
367
+ # so it never shows up in the repo's own dirty-tree fingerprint.
368
+ default_state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/codexa-claude-code"
369
+ data_dir="${CLAUDE_PLUGIN_DATA:-$default_state_dir}"
370
+ mkdir -p "$data_dir" 2>/dev/null || true
371
+
372
+ # Blockworthy review verdicts accumulate here and are emitted as one Stop
373
+ # JSON decision from the EXIT trap — so a hook-timeout SIGTERM mid-review
374
+ # (worst case: 3 sequential repo reviews can exceed the 35s hook budget)
375
+ # still surfaces whatever was already found instead of failing open.
376
+ # Missing mktemp degrades to stderr-only behavior.
377
+ _CLAUDIO_BLOCK_FILE="$(mktemp 2>/dev/null)" || _CLAUDIO_BLOCK_FILE=""
378
+ _claudio_stop_finalize() {
379
+ claudio_emit_stop_block
380
+ [[ -n "${_CLAUDIO_BLOCK_FILE:-}" ]] && rm -f "$_CLAUDIO_BLOCK_FILE"
381
+ _CLAUDIO_BLOCK_FILE=""
382
+ }
383
+ trap '_claudio_stop_finalize' EXIT
384
+ trap 'exit 124' TERM INT
385
+
386
+ # Mode 1: cwd is inside a wired repo with a snapshot — review that single repo.
387
+ # If a wired workspace parent has no snapshot, fall through to the child scan
388
+ # so a parent `.codex/config.toml` cannot mask active child repo reviews.
389
+ repo="$(claudio_find_codexa_repo "$cwd")"
390
+ if [[ -n "$repo" ]] && claudio_has_snapshot "$repo"; then
391
+ # Defend against a configured state dir that resolves inside the repo —
392
+ # a marker written there would invalidate its own debounce every turn.
393
+ data_dir_real="$(claudio_realpath "$data_dir")"
394
+ repo_real="$(claudio_realpath "$repo")"
395
+ if [[ -n "$data_dir_real" && -n "$repo_real" && "$data_dir_real" == "$repo_real"* ]]; then
396
+ data_dir="$default_state_dir"
397
+ mkdir -p "$data_dir" 2>/dev/null || true
398
+ fi
399
+ claudio_stop_review_one "$repo" "$session_id" "$data_dir" 1
400
+ exit 0
401
+ fi
402
+
403
+ # Mode 2: cwd is above any wired repo. Scan direct children, rank ALL of
404
+ # them by snapshot mtime (bounded at 32), and iterate the ranked list.
405
+ # Each child runs through claudio_stop_review_one, which returns 20 when
406
+ # it silently skips a debounced fingerprint (no actual review happened)
407
+ # and 0 otherwise. The dispatcher counts only 0-returns against the
408
+ # per-turn attempt budget, so debounced top-ranked children never starve
409
+ # older unreviewed repos.
410
+ declare -a _child_repos=()
411
+ while IFS= read -r _c; do
412
+ [[ -z "$_c" ]] && continue
413
+ _child_repos+=("$_c")
414
+ done < <(claudio_list_child_codexa_repos "$cwd")
415
+ if [[ ${#_child_repos[@]} -eq 0 ]]; then
416
+ exit 0
417
+ fi
418
+
419
+ attempts_used=0
420
+ while IFS= read -r _ranked_line; do
421
+ [[ -z "$_ranked_line" ]] && continue
422
+ if (( attempts_used >= MAX_STOP_REPOS_PER_TURN )); then
423
+ break
424
+ fi
425
+ candidate="${_ranked_line%%$'\t'*}"
426
+ [[ -z "$candidate" ]] && continue
427
+ # Protect against a state dir nested inside any candidate repo.
428
+ data_dir_real="$(claudio_realpath "$data_dir")"
429
+ cand_real="$(claudio_realpath "$candidate")"
430
+ if [[ -n "$data_dir_real" && -n "$cand_real" && "$data_dir_real" == "$cand_real"* ]]; then
431
+ data_dir="$default_state_dir"
432
+ mkdir -p "$data_dir" 2>/dev/null || true
433
+ fi
434
+ set +e
435
+ claudio_stop_review_one "$candidate" "$session_id" "$data_dir" 0
436
+ review_rc=$?
437
+ set -e
438
+ if [[ "$review_rc" -ne 20 ]]; then
439
+ attempts_used=$((attempts_used + 1))
440
+ fi
441
+ done < <(claudio_rank_child_repos_by_snapshot 32 "${_child_repos[@]}")
442
+
443
+ exit 0