@mirnoorata/codexa 0.2.1 → 0.3.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/README.md +93 -29
- package/dist/cache-lock.js +72 -12
- package/dist/cache-lock.js.map +1 -1
- package/dist/cli/hooks.js +11 -6
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli.js +13 -4
- package/dist/cli.js.map +1 -1
- package/dist/graph.js +4 -2
- package/dist/graph.js.map +1 -1
- package/dist/implicit-baseline.d.ts +8 -0
- package/dist/implicit-baseline.js +94 -0
- package/dist/implicit-baseline.js.map +1 -0
- package/dist/init.d.ts +3 -0
- package/dist/init.js +124 -15
- package/dist/init.js.map +1 -1
- package/dist/mcp/compaction.d.ts +1 -0
- package/dist/mcp/compaction.js +24 -0
- package/dist/mcp/compaction.js.map +1 -1
- package/dist/mcp/envelope.d.ts +4 -1
- package/dist/mcp/envelope.js +45 -5
- package/dist/mcp/envelope.js.map +1 -1
- package/dist/mcp/prompts.d.ts +1 -1
- package/dist/mcp/prompts.js +5 -2
- package/dist/mcp/prompts.js.map +1 -1
- package/dist/mcp/tool-registry.d.ts +1 -0
- package/dist/mcp/tool-registry.js +5 -0
- package/dist/mcp/tool-registry.js.map +1 -1
- package/dist/mcp/tools.d.ts +1 -0
- package/dist/mcp/tools.js +6 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp-tool-catalog.d.ts +1 -1
- package/dist/mcp-tool-catalog.js +1 -1
- package/dist/mcp-tool-catalog.js.map +1 -1
- package/dist/mcp.js +31 -5
- package/dist/mcp.js.map +1 -1
- package/dist/query/post-edit/decision.d.ts +1 -0
- package/dist/query/post-edit/decision.js +13 -4
- package/dist/query/post-edit/decision.js.map +1 -1
- package/dist/query/post-edit.js +10 -2
- package/dist/query/post-edit.js.map +1 -1
- package/dist/query/verification/masking.d.ts +8 -0
- package/dist/query/verification/masking.js +82 -0
- package/dist/query/verification/masking.js.map +1 -0
- package/dist/query/verification/script-credit.d.ts +14 -0
- package/dist/query/verification/script-credit.js +232 -0
- package/dist/query/verification/script-credit.js.map +1 -0
- package/dist/query/verification/shell.d.ts +5 -1
- package/dist/query/verification/shell.js +288 -9
- package/dist/query/verification/shell.js.map +1 -1
- package/dist/query/verification.js +187 -21
- package/dist/query/verification.js.map +1 -1
- package/dist/query/workflow.js +2 -2
- package/dist/query/workflow.js.map +1 -1
- package/dist/repo-files.js +74 -14
- package/dist/repo-files.js.map +1 -1
- package/dist/resolver.js +20 -2
- package/dist/resolver.js.map +1 -1
- package/dist/retrieval.js +5 -5
- package/dist/retrieval.js.map +1 -1
- package/dist/task-snapshots.js +29 -0
- package/dist/task-snapshots.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +1 -0
- package/dist/util.js +7 -0
- package/dist/util.js.map +1 -1
- package/integrations/.claude-plugin/marketplace.json +23 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +16 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/README.md +177 -0
- package/integrations/claude-code/commands/codexa-brief.md +14 -0
- package/integrations/claude-code/commands/codexa-impact.md +14 -0
- package/integrations/claude-code/commands/codexa-plan.md +20 -0
- package/integrations/claude-code/commands/codexa-review.md +23 -0
- package/integrations/claude-code/commands/codexa-status.md +10 -0
- package/integrations/claude-code/hooks/hooks.json +39 -0
- package/integrations/claude-code/scripts/cmd/brief.sh +18 -0
- package/integrations/claude-code/scripts/cmd/impact.sh +35 -0
- package/integrations/claude-code/scripts/cmd/lib.sh +136 -0
- package/integrations/claude-code/scripts/cmd/plan.sh +52 -0
- package/integrations/claude-code/scripts/cmd/review.sh +66 -0
- package/integrations/claude-code/scripts/cmd/status.sh +52 -0
- package/integrations/claude-code/scripts/codexa-mcp.js +111 -0
- package/integrations/claude-code/scripts/lib/codexa-repo.sh +773 -0
- package/integrations/claude-code/scripts/pre-edit.sh +116 -0
- package/integrations/claude-code/scripts/session-start.sh +201 -0
- package/integrations/claude-code/scripts/stop.sh +443 -0
- package/integrations/claude-code/tests/cmd-smoke.sh +310 -0
- package/integrations/claude-code/tests/hook-smoke.sh +1412 -0
- package/package.json +4 -2
- 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
|