code-jig 0.12.0__py3-none-any.whl

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 (52) hide show
  1. code_jig-0.12.0.dist-info/METADATA +154 -0
  2. code_jig-0.12.0.dist-info/RECORD +52 -0
  3. code_jig-0.12.0.dist-info/WHEEL +4 -0
  4. code_jig-0.12.0.dist-info/entry_points.txt +2 -0
  5. code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
  6. codejig/__init__.py +1 -0
  7. codejig/__main__.py +106 -0
  8. codejig/_core/VERSION +2 -0
  9. codejig/_core/bench/requirements.toml +4 -0
  10. codejig/_core/core/AGENTS.core.md +9 -0
  11. codejig/_core/core/agents/explorer.toml +24 -0
  12. codejig/_core/core/agents/judge.toml +19 -0
  13. codejig/_core/core/agents/reviewer.toml +10 -0
  14. codejig/_core/core/tiers.toml +4 -0
  15. codejig/_core/skills/bug-hunt/SKILL.md +131 -0
  16. codejig/_core/skills/code-analysis/SKILL.md +72 -0
  17. codejig/_core/skills/codegen/SKILL.md +46 -0
  18. codejig/_core/skills/cross-review/SKILL.md +32 -0
  19. codejig/_core/skills/grilling/SKILL.md +60 -0
  20. codejig/_core/skills/hld-trace/SKILL.md +69 -0
  21. codejig/_core/skills/improve/SKILL.md +38 -0
  22. codejig/_core/skills/redacao/SKILL.md +114 -0
  23. codejig/_core/skills/redacao/checar.py +75 -0
  24. codejig/_core/skills/redacao/reference.md +118 -0
  25. codejig/_core/skills/regression-tests/SKILL.md +53 -0
  26. codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
  27. codejig/_core/skills/skill-validate/SKILL.md +58 -0
  28. codejig/_core/skills/web-consensus/SKILL.md +55 -0
  29. codejig/analyze.py +499 -0
  30. codejig/atomicwrite.py +49 -0
  31. codejig/collect.py +311 -0
  32. codejig/doctor.py +370 -0
  33. codejig/evidence.py +418 -0
  34. codejig/feedback.py +165 -0
  35. codejig/gitgate.py +278 -0
  36. codejig/hooks.py +64 -0
  37. codejig/humanize.py +206 -0
  38. codejig/install.py +381 -0
  39. codejig/managed.py +64 -0
  40. codejig/migrate.py +515 -0
  41. codejig/overlay.py +43 -0
  42. codejig/paths.py +40 -0
  43. codejig/probe.py +136 -0
  44. codejig/review.py +73 -0
  45. codejig/scrub.py +255 -0
  46. codejig/skillcheck.py +400 -0
  47. codejig/skilllint.py +99 -0
  48. codejig/sources.py +248 -0
  49. codejig/tiers.py +63 -0
  50. codejig/tokens.py +118 -0
  51. codejig/trace.py +137 -0
  52. codejig/watermark.py +147 -0
codejig/collect.py ADDED
@@ -0,0 +1,311 @@
1
+ import json
2
+ import re
3
+ import sqlite3
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from codejig import paths
8
+
9
+ SCHEMA = """
10
+ CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, offset INTEGER NOT NULL, size INTEGER NOT NULL, thread_id TEXT, model TEXT, turn_id TEXT, line INTEGER NOT NULL, last_total INTEGER NOT NULL);
11
+ CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, parent_id TEXT, kind TEXT NOT NULL, started TEXT, cwd TEXT);
12
+ CREATE TABLE IF NOT EXISTS usage (thread_id TEXT NOT NULL, key TEXT NOT NULL, turn_id TEXT, ts TEXT, model TEXT, input INTEGER, cached INTEGER, output INTEGER, reasoning INTEGER, PRIMARY KEY (thread_id, key));
13
+ CREATE TABLE IF NOT EXISTS calls (thread_id TEXT NOT NULL, call_id TEXT NOT NULL, turn_id TEXT, ts TEXT, cmd TEXT, escalated INTEGER NOT NULL, PRIMARY KEY (thread_id, call_id));
14
+ CREATE TABLE IF NOT EXISTS compactions (thread_id TEXT NOT NULL, line INTEGER NOT NULL, ts TEXT, PRIMARY KEY (thread_id, line));
15
+ CREATE TABLE IF NOT EXISTS quarantine (path TEXT NOT NULL, line INTEGER NOT NULL, reason TEXT, PRIMARY KEY (path, line));
16
+ CREATE TABLE IF NOT EXISTS call_results (thread_id TEXT NOT NULL, call_id TEXT NOT NULL, exit_code INTEGER, skill TEXT, PRIMARY KEY (thread_id, call_id));
17
+ CREATE TABLE IF NOT EXISTS skill_reads (thread_id TEXT NOT NULL, call_id TEXT NOT NULL, skill TEXT NOT NULL, PRIMARY KEY (thread_id, call_id, skill));
18
+ CREATE TABLE IF NOT EXISTS guardian (thread_id TEXT NOT NULL, line INTEGER NOT NULL, ts TEXT, outcome TEXT NOT NULL, PRIMARY KEY (thread_id, line));
19
+ CREATE VIEW IF NOT EXISTS turn_usage AS
20
+ SELECT u.*, COALESCE(t.kind, 'main') AS kind, t.parent_id AS parent_id
21
+ FROM usage u LEFT JOIN threads t ON t.id = u.thread_id
22
+ WHERE u.key NOT LIKE 'tc:%' OR u.thread_id NOT IN (SELECT thread_id FROM usage WHERE key NOT LIKE 'tc:%');
23
+ DROP VIEW IF EXISTS call_view;
24
+ CREATE VIEW call_view AS
25
+ SELECT c.*, r.exit_code AS exit_code
26
+ FROM calls c LEFT JOIN call_results r ON r.thread_id = c.thread_id AND r.call_id = c.call_id;
27
+ """
28
+ USAGE_KEYS = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens")
29
+ CMD = re.compile(r'\bcmd:\s*"((?:[^"\\]|\\.)*)"')
30
+ EXIT = re.compile(r'"exit_code"\s*:\s*(-?\d+)|Exit code:\s*(-?\d+)|exited with code\s+(-?\d+)')
31
+ SKILL = re.compile(r"skills[/\\]+([A-Za-z0-9_.-]+)[/\\]+SKILL\.md")
32
+ OUTPUTS = ("function_call_output", "custom_tool_call_output")
33
+
34
+
35
+ def db_path() -> Path:
36
+ return paths.state_dir() / "usage.sqlite3"
37
+
38
+
39
+ def connect(path: Path) -> sqlite3.Connection:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ con = sqlite3.connect(path, timeout=30)
42
+ con.executescript(SCHEMA)
43
+ return con
44
+
45
+
46
+ def rollout_files(home: Path) -> list[Path]:
47
+ return sorted([*home.glob("sessions/**/rollout-*.jsonl"), *home.glob("archived_sessions/rollout-*.jsonl")])
48
+
49
+
50
+ def kind_of(meta: dict) -> str:
51
+ src = meta.get("source")
52
+ if meta.get("thread_source") == "guardian_review" or (isinstance(src, dict) and src.get("subagent") == {"other": "guardian"}):
53
+ return "guardian"
54
+ if isinstance(src, dict) and "subagent" in src:
55
+ return "subagent"
56
+ return "main"
57
+
58
+
59
+ def _s(v) -> str | None:
60
+ return v if isinstance(v, str) else None
61
+
62
+
63
+ def _usage(u) -> tuple[int, ...] | None:
64
+ if not isinstance(u, dict) or not all(isinstance(u.get(k), int) and not isinstance(u.get(k), bool) for k in USAGE_KEYS):
65
+ return None
66
+ return tuple(u[k] for k in USAGE_KEYS)
67
+
68
+
69
+ def _custom_call(call_id: str, payload: dict) -> tuple[str, str, bool] | None:
70
+ text = payload.get("input")
71
+ m = CMD.search(text) if isinstance(text, str) else None
72
+ if not m:
73
+ return None
74
+ return call_id, _decode(m.group(1)), "require_escalated" in text
75
+
76
+
77
+ def _function_call(call_id: str, payload: dict) -> tuple[str, str, bool] | None:
78
+ try:
79
+ args = json.loads(payload.get("arguments") or "{}")
80
+ except (TypeError, ValueError):
81
+ return None
82
+ if not isinstance(args, dict):
83
+ return None
84
+ cmd = args.get("cmd", args.get("command"))
85
+ if isinstance(cmd, list):
86
+ cmd = " ".join(str(c) for c in cmd)
87
+ if not isinstance(cmd, str):
88
+ return None
89
+ return call_id, cmd, args.get("sandbox_permissions") == "require_escalated"
90
+
91
+
92
+ def tool_call(payload: dict) -> tuple[str, str, bool] | None:
93
+ call_id = payload.get("call_id")
94
+ if not isinstance(call_id, str):
95
+ return None
96
+ build = {"custom_tool_call": _custom_call, "function_call": _function_call}.get(payload.get("type"))
97
+ return build(call_id, payload) if build else None
98
+
99
+
100
+ def _decode(raw: str) -> str:
101
+ try:
102
+ return json.loads(f'"{raw}"')
103
+ except ValueError:
104
+ return raw
105
+
106
+
107
+ def cell_commands(payload: dict) -> list[str]:
108
+ text = payload.get("input") if payload.get("type") == "custom_tool_call" else None
109
+ return [_decode(m) for m in CMD.findall(text)] if isinstance(text, str) else []
110
+
111
+
112
+ def _output_text(payload: dict) -> str:
113
+ out = payload.get("output")
114
+ if isinstance(out, list):
115
+ return "\n".join(o["text"] for o in out if isinstance(o, dict) and isinstance(o.get("text"), str))
116
+ return out if isinstance(out, str) else ""
117
+
118
+
119
+ def exit_code(payload: dict) -> int | None:
120
+ text = _output_text(payload)
121
+ try:
122
+ meta = json.loads(text).get("metadata")
123
+ except (ValueError, AttributeError):
124
+ meta = None
125
+ if isinstance(meta, dict) and isinstance(meta.get("exit_code"), int):
126
+ return meta["exit_code"]
127
+ m = EXIT.search(text)
128
+ return int(next(g for g in m.groups() if g is not None)) if m else None
129
+
130
+
131
+ def skills_of(cmd: str) -> list[str]:
132
+ return list(dict.fromkeys(SKILL.findall(cmd)))
133
+
134
+
135
+ def guardian_outcome(payload: dict) -> str | None:
136
+ try:
137
+ msg = json.loads(payload.get("last_agent_message") or "")
138
+ except (TypeError, ValueError):
139
+ return None
140
+ return msg["outcome"] if isinstance(msg, dict) and isinstance(msg.get("outcome"), str) else None
141
+
142
+
143
+ def _response_item(con: sqlite3.Connection, thread: str, turn: str | None, ts: str | None, p: dict) -> None:
144
+ call = tool_call(p)
145
+ if call:
146
+ rows = [(call[0], call[1], int(call[2]))]
147
+ rows += [(f"{call[0]}#{i}", cmd, 0) for i, cmd in enumerate(cell_commands(p)[1:], start=2)]
148
+ for call_id, cmd, escalated in rows:
149
+ con.execute("INSERT OR IGNORE INTO calls (thread_id, call_id, turn_id, ts, cmd, escalated) VALUES (?,?,?,?,?,?)",
150
+ (thread, call_id, turn, ts, cmd, escalated))
151
+ for skill in skills_of(cmd):
152
+ con.execute("INSERT OR IGNORE INTO skill_reads VALUES (?,?,?)", (thread, call_id, skill))
153
+ elif p.get("type") in OUTPUTS and isinstance(p.get("call_id"), str):
154
+ code = exit_code(p)
155
+ if code is not None:
156
+ con.execute("INSERT INTO call_results (thread_id, call_id, exit_code) SELECT ?, ?, ? "
157
+ "WHERE EXISTS (SELECT 1 FROM calls WHERE thread_id = ? AND call_id = ?) "
158
+ "AND NOT EXISTS (SELECT 1 FROM calls WHERE thread_id = ? AND call_id = ? || '#2') "
159
+ "ON CONFLICT(thread_id, call_id) DO UPDATE SET exit_code = excluded.exit_code WHERE call_results.exit_code IS NULL",
160
+ (thread, p["call_id"], code, thread, p["call_id"], thread, p["call_id"]))
161
+
162
+
163
+ class _Cursor:
164
+ def __init__(self, thread=None, model=None, turn=None, line=0, last_total=-1):
165
+ self.thread, self.model, self.turn, self.line, self.last_total = thread, model, turn, line, last_total
166
+
167
+
168
+ def _session_meta(con, st, p, ts, quarantine) -> None:
169
+ if st.thread is None and isinstance(p.get("id"), str):
170
+ st.thread = p["id"]
171
+ con.execute("INSERT OR REPLACE INTO threads VALUES (?,?,?,?,?)",
172
+ (st.thread, _s(p.get("parent_thread_id")), kind_of(p), _s(p.get("timestamp")) or ts, _s(p.get("cwd"))))
173
+
174
+
175
+ def _turn_context(con, st, p, ts, quarantine) -> None:
176
+ st.model = _s(p.get("model")) or st.model
177
+ st.turn = _s(p.get("turn_id")) or st.turn
178
+
179
+
180
+ def _token_usage_record(con, st, p, ts, quarantine) -> None:
181
+ u = _usage(p.get("usage"))
182
+ if u is None or not isinstance(p.get("response_id"), str):
183
+ quarantine("token_usage_record shape")
184
+ return
185
+ con.execute("INSERT OR IGNORE INTO usage VALUES (?,?,?,?,?,?,?,?,?)",
186
+ (st.thread, p["response_id"], _s(p.get("turn_id")) or st.turn, ts, st.model, *u))
187
+
188
+
189
+ def _token_count(con, st, p, ts, quarantine) -> None:
190
+ info = p.get("info")
191
+ if info is None:
192
+ return
193
+ total = (info.get("total_token_usage") or {}).get("total_tokens") if isinstance(info, dict) else None
194
+ u = _usage(info.get("last_token_usage")) if isinstance(info, dict) else None
195
+ if u is None or not isinstance(total, int):
196
+ quarantine("token_count shape")
197
+ return
198
+ if total == st.last_total:
199
+ return
200
+ st.last_total = total
201
+ con.execute("INSERT OR IGNORE INTO usage VALUES (?,?,?,?,?,?,?,?,?)",
202
+ (st.thread, f"tc:{st.line}", st.turn, ts, st.model, *u))
203
+
204
+
205
+ def _compacted(con, st, p, ts, quarantine) -> None:
206
+ con.execute("INSERT OR IGNORE INTO compactions VALUES (?,?,?)", (st.thread, st.line, ts))
207
+
208
+
209
+ def _response(con, st, p, ts, quarantine) -> None:
210
+ _response_item(con, st.thread, st.turn, ts, p)
211
+
212
+
213
+ def _task_complete(con, st, p, ts, quarantine) -> None:
214
+ outcome = guardian_outcome(p)
215
+ if outcome and con.execute("SELECT 1 FROM threads WHERE id=? AND kind='guardian'", (st.thread,)).fetchone():
216
+ con.execute("INSERT OR IGNORE INTO guardian VALUES (?,?,?,?)", (st.thread, st.line, ts, outcome))
217
+
218
+
219
+ _RECORDS = {"turn_context": _turn_context, "token_usage_record": _token_usage_record,
220
+ "compacted": _compacted, "response_item": _response}
221
+ _EVENTS = {"token_count": _token_count, "task_complete": _task_complete}
222
+
223
+
224
+ def _handler(t, p):
225
+ if t == "event_msg":
226
+ kind = p.get("type")
227
+ return _EVENTS.get(kind) if isinstance(kind, str) else None
228
+ return _RECORDS.get(t) if isinstance(t, str) else None
229
+
230
+
231
+ def _record(con, st, raw, quarantine) -> None:
232
+ try:
233
+ o = json.loads(raw)
234
+ except ValueError:
235
+ quarantine("not JSON")
236
+ return
237
+ p = o.get("payload") if isinstance(o, dict) else None
238
+ if not isinstance(p, dict):
239
+ quarantine("no payload")
240
+ return
241
+ t, ts = o.get("type"), _s(o.get("timestamp"))
242
+ if t == "session_meta":
243
+ _session_meta(con, st, p, ts, quarantine)
244
+ elif st.thread is None:
245
+ quarantine("before session_meta")
246
+ else:
247
+ handler = _handler(t, p)
248
+ if handler:
249
+ handler(con, st, p, ts, quarantine)
250
+
251
+
252
+ def _scan_file(con: sqlite3.Connection, path: Path) -> tuple[int, int]:
253
+ size = path.stat().st_size
254
+ row = con.execute("SELECT offset, thread_id, model, turn_id, line, last_total FROM files WHERE path=?", (str(path),)).fetchone()
255
+ offset, *state = row or (0, None, None, None, 0, -1)
256
+ if size < offset:
257
+ offset, state = 0, [None, None, None, 0, -1]
258
+ if size == offset:
259
+ return 0, 0
260
+ with open(path, "rb") as f:
261
+ f.seek(offset)
262
+ data = f.read()
263
+ end = data.rfind(b"\n") + 1
264
+ st = _Cursor(*state)
265
+ count = bad = 0
266
+ for raw in data[:end].split(b"\n")[:-1]:
267
+ st.line += 1
268
+ count += 1
269
+
270
+ def quarantine(reason: str, line: int = st.line) -> None:
271
+ nonlocal bad
272
+ bad += 1
273
+ con.execute("INSERT OR IGNORE INTO quarantine VALUES (?,?,?)", (str(path), line, reason))
274
+
275
+ try:
276
+ _record(con, st, raw, quarantine)
277
+ except Exception as e:
278
+ quarantine(type(e).__name__)
279
+ con.execute("INSERT OR REPLACE INTO files VALUES (?,?,?,?,?,?,?,?)",
280
+ (str(path), offset + end, size, st.thread, st.model, st.turn, st.line, st.last_total))
281
+ return count, bad
282
+
283
+
284
+ def scan(con: sqlite3.Connection, home: Path, rescan: bool = False) -> dict[str, int]:
285
+ if rescan:
286
+ with con:
287
+ for table in ("files", "threads", "usage", "calls", "compactions", "quarantine", "guardian", "call_results", "skill_reads"):
288
+ con.execute(f"DELETE FROM {table}")
289
+ summary = {"files": 0, "lines": 0, "quarantined": 0}
290
+ for path in rollout_files(home):
291
+ with con:
292
+ lines, bad = _scan_file(con, path)
293
+ summary["files"] += 1
294
+ summary["lines"] += lines
295
+ summary["quarantined"] += bad
296
+ return summary
297
+
298
+
299
+ def cli(args) -> int:
300
+ try:
301
+ home = paths.codex_home(args.codex_home)
302
+ con = connect(db_path())
303
+ try:
304
+ summary = scan(con, home, rescan=args.rescan)
305
+ finally:
306
+ con.close()
307
+ print(f"code-jig collect: {summary['files']} files, {summary['lines']} new lines, {summary['quarantined']} quarantined", file=sys.stderr)
308
+ except Exception as e:
309
+ print(f"code-jig collect: {type(e).__name__}: {e}", file=sys.stderr)
310
+ print("{}")
311
+ return 0
codejig/doctor.py ADDED
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mmap
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tomllib
9
+ from pathlib import Path
10
+
11
+ from codejig import managed, migrate, paths, probe, tiers
12
+ from codejig.install import (
13
+ HOOK_STATUS,
14
+ ROLE_TIERS,
15
+ _git_config,
16
+ _same_path,
17
+ release_digest,
18
+ render_agents,
19
+ render_profile,
20
+ role_files,
21
+ skill_sources,
22
+ valid_hook_groups,
23
+ )
24
+ from codejig.overlay import Overlay, load
25
+
26
+ Check = tuple[str, str, str]
27
+
28
+ PACK_KEYS = (
29
+ "project_doc_max_bytes",
30
+ "approvals_reviewer",
31
+ "model_auto_compact_token_limit",
32
+ "tool_output_token_limit",
33
+ "model_reasoning_effort",
34
+ "extra_policy",
35
+ "allowed_approvals_reviewers",
36
+ "ignore_rules",
37
+ "allow_managed_hooks_only",
38
+ "guardian_extra_policy",
39
+ "developer_instructions",
40
+ )
41
+ SHIM_MAX_BYTES = 1_000_000
42
+
43
+
44
+ def resolve_codex_bin(arg: str | None) -> Path | None:
45
+ value = arg or os.environ.get("CODEX_BIN") or shutil.which("codex")
46
+ return Path(value) if value else None
47
+
48
+
49
+ def check_binary(bin_path: Path | None, keys=PACK_KEYS) -> list[Check]:
50
+ name = "codex keys"
51
+ if bin_path is None or not bin_path.is_file():
52
+ return [("FAIL", name, "codex binary not found; pass --codex-bin PATH or set CODEX_BIN")]
53
+ try:
54
+ if bin_path.stat().st_size < SHIM_MAX_BYTES:
55
+ return [("FAIL", name, f"{bin_path} is a launcher, not the native binary; pass --codex-bin PATH")]
56
+ with open(bin_path, "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as m:
57
+ missing = [k for k in keys if m.find(k.encode()) == -1]
58
+ except OSError as e:
59
+ return [("FAIL", name, f"cannot read {bin_path}: {e.strerror or e}")]
60
+ if missing:
61
+ return [("FAIL", name, f"not in {bin_path.name}: {', '.join(missing)}")]
62
+ return [("ok", name, f"{len(keys)} keys present in {bin_path.name}")]
63
+
64
+
65
+ def report(checks: list[Check]) -> int:
66
+ for level, name, detail in checks:
67
+ print(f"{level} {name}: {detail}")
68
+ return 1 if any(c[0] == "FAIL" for c in checks) else 0
69
+
70
+
71
+ DEFAULT_DOC_MAX = 32768
72
+
73
+
74
+ def _pick(directory: Path) -> Path | None:
75
+ for name in ("AGENTS.override.md", "AGENTS.md"):
76
+ f = directory / name
77
+ if f.is_file() and f.stat().st_size > 0:
78
+ return f
79
+ return None
80
+
81
+
82
+ def _git_root(cwd: Path) -> Path:
83
+ for d in (cwd, *cwd.parents):
84
+ if (d / ".git").exists():
85
+ return d
86
+ return cwd
87
+
88
+
89
+ def chain_files(home: Path, cwd: Path) -> list[Path]:
90
+ files = [f for f in [_pick(home)] if f]
91
+ root = _git_root(cwd)
92
+ dirs = [root, *[d for d in reversed(cwd.parents) if root in d.parents], cwd] if cwd != root else [root]
93
+ seen = set()
94
+ for d in dirs:
95
+ if d in seen:
96
+ continue
97
+ seen.add(d)
98
+ f = _pick(d)
99
+ if f:
100
+ files.append(f)
101
+ return files
102
+
103
+
104
+ def read_config(home: Path) -> dict:
105
+ try:
106
+ return tomllib.loads((home / "config.toml").read_text(encoding="utf-8-sig"))
107
+ except (OSError, ValueError):
108
+ return {}
109
+
110
+
111
+ def _doc_max(home: Path) -> int:
112
+ value = read_config(home).get("project_doc_max_bytes", DEFAULT_DOC_MAX)
113
+ if isinstance(value, bool) or not isinstance(value, int):
114
+ return DEFAULT_DOC_MAX
115
+ return value
116
+
117
+
118
+ def check_chain(home: Path, cwd: Path) -> list[Check]:
119
+ cap = _doc_max(home)
120
+ total = sum(f.stat().st_size for f in chain_files(home, cwd))
121
+ detail = f"{total} of {cap} bytes"
122
+ if total > cap:
123
+ return [("FAIL", "AGENTS.md chain", detail + "; Codex silently stops adding files past the cap")]
124
+ if total > cap * 0.8:
125
+ return [("warn", "AGENTS.md chain", detail)]
126
+ return [("ok", "AGENTS.md chain", detail)]
127
+
128
+
129
+ def check_global(home: Path, core: Path, ov: Overlay) -> list[Check]:
130
+ name = "global AGENTS.md"
131
+ override = home / "AGENTS.override.md"
132
+ if override.is_file() and override.stat().st_size > 0:
133
+ return [("FAIL", name, f"{override} is non-empty; Codex loads it instead of the generated AGENTS.md")]
134
+ target = home / "AGENTS.md"
135
+ if not target.is_file():
136
+ return [("warn", name, f"{target} is missing; run code-jig install")]
137
+ if target.read_bytes() != render_agents(core, ov.root).encode("utf-8"):
138
+ return [("warn", name, f"{target} is stale; run code-jig install")]
139
+ return [("ok", name, f"{target} is current")]
140
+
141
+
142
+ def catalog(home: Path) -> dict[str, set[str]] | None:
143
+ try:
144
+ data = json.loads((home / "models_cache.json").read_text(encoding="utf-8"))
145
+ except (OSError, ValueError):
146
+ return None
147
+ models = data.get("models") if isinstance(data, dict) else None
148
+ if not isinstance(models, list):
149
+ return None
150
+ out = {}
151
+ for m in models:
152
+ if isinstance(m, dict) and isinstance(m.get("slug"), str):
153
+ levels = m.get("supported_reasoning_levels")
154
+ out[m["slug"]] = {lv["effort"] for lv in levels if isinstance(lv, dict) and isinstance(lv.get("effort"), str)} if isinstance(levels, list) else set()
155
+ return out
156
+
157
+
158
+ def family(slug: str) -> str:
159
+ return slug.rsplit("-", 1)[0]
160
+
161
+
162
+ def _legacy_check(home: Path, ts, cfg: dict) -> Check:
163
+ profiles = cfg.get("profiles") if isinstance(cfg.get("profiles"), dict) else {}
164
+ legacy = [t.name for t in ts if cfg.get("profile") == t.name or t.name in profiles]
165
+ return ("FAIL", "legacy profiles", f"{home / 'config.toml'} has profile = or [profiles.*] for {', '.join(legacy)}; Codex refuses --profile with them") \
166
+ if legacy else ("ok", "legacy profiles", "none")
167
+
168
+
169
+ def _profile_check(home: Path, t, policy, ov: Overlay) -> Check:
170
+ f = home / f"{t.name}.config.toml"
171
+ name = f"tier {t.name} profile"
172
+ if not f.is_file():
173
+ return ("warn", name, f"{f} is missing; Codex silently uses the base config; run code-jig install")
174
+ if f.read_bytes() != render_profile(t, policy, ov.auto_compact_token_limit).encode("utf-8"):
175
+ return ("warn", name, f"{f} is stale or edited; run code-jig install")
176
+ return ("ok", name, f"{t.model} at {t.effort}")
177
+
178
+
179
+ def _model_check(t, cat: dict) -> Check:
180
+ name = f"tier {t.name} model"
181
+ if t.model not in cat:
182
+ return ("FAIL", name, f"{t.model} is not in this account's catalog; candidates: {', '.join(sorted(cat))}")
183
+ if cat[t.model] and t.effort not in cat[t.model]:
184
+ return ("FAIL", name, f"{t.model} does not support {t.effort}; supported: {', '.join(sorted(cat[t.model]))}")
185
+ return ("ok", name, f"{t.model} supports {t.effort}")
186
+
187
+
188
+ def _family_checks(ts) -> list[Check]:
189
+ by = {t.name: t for t in ts}
190
+ if "review" not in by or "default" not in by:
191
+ return []
192
+ same = family(by["review"].model) == family(by["default"].model)
193
+ return [("warn", "review family", f"review and default are both {family(by['default'].model)}; cross-family review catches different bugs")
194
+ if same else ("ok", "review family", f"{by['review'].model} vs {by['default'].model}")]
195
+
196
+
197
+ def check_tiers(home: Path, core: Path, ov: Overlay) -> list[Check]:
198
+ try:
199
+ ts = tiers.load(core, ov.root)
200
+ except ValueError as e:
201
+ return [("FAIL", "tiers", str(e))]
202
+ checks: list[Check] = [_legacy_check(home, ts, read_config(home))]
203
+ policy = tiers.extra_policy(ov.root)
204
+ checks += [_profile_check(home, t, policy, ov) for t in ts]
205
+ cat = catalog(home)
206
+ if cat is None:
207
+ checks.append(("warn", "model catalog", f"{home / 'models_cache.json'} missing or unreadable; start codex once"))
208
+ else:
209
+ checks += [_model_check(t, cat) for t in ts]
210
+ return checks + _family_checks(ts)
211
+
212
+
213
+ def check_roles(home: Path, core: Path, ov: Overlay) -> list[Check]:
214
+ try:
215
+ ts = tiers.load(core, ov.root)
216
+ except ValueError:
217
+ return []
218
+ by = {t.name: t for t in ts}
219
+ checks: list[Check] = []
220
+ for name, text in role_files(core, ts).items():
221
+ role = name.removesuffix(".toml")
222
+ t = by[ROLE_TIERS[role]]
223
+ f = home / "agents" / name
224
+ if not f.is_file():
225
+ checks.append(("warn", f"{role} role", f"{f} is missing; subagents of that kind use the parent's model; run code-jig install"))
226
+ elif f.read_bytes() != text.encode("utf-8"):
227
+ checks.append(("warn", f"{role} role", f"{f} is stale or edited; run code-jig install"))
228
+ elif "default" in by and t.model == by["default"].model and t.effort == by["default"].effort:
229
+ checks.append(("warn", f"{role} role", f"{t.name} tier is the same as default ({t.model} at {t.effort}); the role saves nothing"))
230
+ else:
231
+ checks.append(("ok", f"{role} role", f"{role}s run {t.model} at {t.effort}"))
232
+ return checks
233
+
234
+
235
+ def check_git_gate(ov: Overlay, cwd: Path) -> list[Check]:
236
+ target = paths.state_dir() / "git-hooks"
237
+ current = _git_config("--get", "core.hooksPath") or ""
238
+ if not ov.git_gate:
239
+ return [("warn", "git gate", f"off in overlay.toml but core.hooksPath still points at {target}; run code-jig install")
240
+ if _same_path(current, target) else ("ok", "git gate", "off")]
241
+ if not _same_path(current, target):
242
+ return [("warn", "git gate", f"not active: global core.hooksPath is {current or 'unset'}, expected {target}")]
243
+ local = subprocess.run(["git", "-C", str(cwd), "config", "--local", "--get", "core.hooksPath"],
244
+ capture_output=True, text=True, encoding="utf-8", check=False).stdout.strip()
245
+ if local:
246
+ return [("warn", "git gate", f"this repo sets core.hooksPath = {local}, which bypasses the gate here")]
247
+ return [("ok", "git gate", f"active{' with the prose gate' if ov.prose_gate else ''}")]
248
+
249
+
250
+ def check_hook(home: Path) -> list[Check]:
251
+ path = home / "hooks.json"
252
+ if not path.is_file():
253
+ return [("warn", "stop hook", f"{path} missing; run code-jig install")]
254
+ try:
255
+ groups = json.loads(path.read_text(encoding="utf-8-sig"))["hooks"]["Stop"]
256
+ if not valid_hook_groups(groups):
257
+ raise ValueError('expected {"hooks": {"Stop": [{"hooks": [...]}]}}')
258
+ except (OSError, ValueError, KeyError, TypeError) as e:
259
+ return [("FAIL", "stop hook", f"{path}: {e}")]
260
+ found = isinstance(groups, list) and any(
261
+ isinstance(g, dict) and any(isinstance(h, dict) and h.get("statusMessage") == HOOK_STATUS for h in g.get("hooks") or [])
262
+ for g in groups)
263
+ return [("ok", "stop hook", "registered; Codex asks to trust it once") if found
264
+ else ("warn", "stop hook", "not registered; run code-jig install")]
265
+
266
+
267
+ def check_knowledge(ov: Overlay) -> list[Check]:
268
+ kb = ov.root / "knowledge"
269
+ index = kb / "INDEX.md"
270
+ if not kb.is_dir():
271
+ return [("ok", "knowledge", "no knowledge base")]
272
+ if not index.is_file():
273
+ return [("warn", "knowledge", f"{kb} has no INDEX.md; Codex is not pointed at it")]
274
+ n = len(index.read_text(encoding="utf-8-sig").splitlines())
275
+ return [("ok", "knowledge", f"index has {n} of 150 lines") if n <= 150
276
+ else ("warn", "knowledge", f"{index} has {n} lines; merge or delete facts to stay at 150")]
277
+
278
+
279
+ def check_superpowers(home: Path) -> list[Check]:
280
+ plugins = read_config(home).get("plugins")
281
+ if isinstance(plugins, dict) and any(k.startswith("superpowers@") for k in plugins) \
282
+ or any((home / "plugins" / "cache").glob("*/superpowers")):
283
+ return [("ok", "superpowers", "installed")]
284
+ return [("warn", "superpowers", "not installed; run: codex plugin add superpowers@openai-curated-remote")]
285
+
286
+
287
+ def _git(core: Path, *args: str) -> subprocess.CompletedProcess | None:
288
+ try:
289
+ return subprocess.run(["git", "-C", str(core), *args], capture_output=True, text=True, encoding="utf-8", check=False)
290
+ except OSError:
291
+ return None
292
+
293
+
294
+ def check_release(core: Path, ov: Overlay) -> list[Check]:
295
+ strict = "FAIL" if not ov.core_writable else "warn"
296
+ try:
297
+ words = (core / "VERSION").read_text(encoding="utf-8", errors="replace").split()
298
+ except OSError as e:
299
+ return [("FAIL", "core pin", f"cannot read VERSION: {e}")]
300
+ tag, digest = (words + ["", ""])[:2]
301
+ pin = ("ok", "core pin", tag) if tag == ov.core_version else (
302
+ strict, "core pin", f"core is release {tag or 'unknown'}, overlay pins {ov.core_version}")
303
+ if ov.core_writable:
304
+ return [pin, ("ok", "core clean", "release core; local edits allowed")]
305
+ if digest and digest == release_digest(core):
306
+ return [pin, ("ok", "core clean", "matches its release digest")]
307
+ return [pin, ("FAIL", "core clean", "core differs from its release digest but core_writable = false")]
308
+
309
+
310
+ def _git_failure(core: Path, ov: Overlay, strict: str, status) -> list[Check]:
311
+ reason = f"{core} is not a readable git repo or git is missing"
312
+ first = (status.stderr.strip().splitlines() or [""])[0] if status else ""
313
+ if first:
314
+ reason += f": {first}"
315
+ pin = (strict, "core pin", f"cannot be checked; overlay pins {ov.core_version}")
316
+ return [pin, ("FAIL", "core clean", reason)]
317
+
318
+
319
+ def check_core(core: Path, ov: Overlay) -> list[Check]:
320
+ if (core / "VERSION").is_file() and not (core / ".git").exists():
321
+ return check_release(core, ov)
322
+ strict = "FAIL" if not ov.core_writable else "warn"
323
+ described = _git(core, "describe", "--tags", "--exact-match", "HEAD")
324
+ tag = described.stdout.strip() if described and described.returncode == 0 else ""
325
+ status = _git(core, "status", "--porcelain")
326
+ if status is None or status.returncode != 0:
327
+ return _git_failure(core, ov, strict, status)
328
+ pin = ("ok", "core pin", tag) if tag == ov.core_version else (
329
+ strict, "core pin", f"core is at {tag or 'an untagged commit'}, overlay pins {ov.core_version}")
330
+ dirty = status.stdout.strip()
331
+ clean = ("FAIL", "core clean", "core has local edits but core_writable = false") if dirty and not ov.core_writable \
332
+ else ("ok", "core clean", "local edits allowed" if dirty else "no local edits")
333
+ return [pin, clean]
334
+
335
+
336
+ def cli(args) -> int:
337
+ bin_path = resolve_codex_bin(args.codex_bin)
338
+ home = paths.codex_home(args.codex_home)
339
+ checks = [("FAIL", "requirements", "git not found")] if not shutil.which("git") else []
340
+ checks += check_binary(bin_path)
341
+ if not args.check_binary:
342
+ try:
343
+ ov = load(paths.overlay_dir(args.overlay))
344
+ except ValueError as e:
345
+ checks.append(("FAIL", "overlay", str(e)))
346
+ else:
347
+ checks += check_core(paths.CORE, ov)
348
+ checks += check_global(home, paths.CORE, ov)
349
+ checks += check_tiers(home, paths.CORE, ov)
350
+ checks += check_roles(home, paths.CORE, ov)
351
+ checks += check_hook(home)
352
+ checks += check_git_gate(ov, Path.cwd())
353
+ checks += check_knowledge(ov)
354
+ checks += check_superpowers(home)
355
+ try:
356
+ ts = tiers.load(paths.CORE, ov.root)
357
+ except ValueError:
358
+ ts = []
359
+ checks += managed.check_requirements(managed.requirements_path(), paths.CORE / "bench" / "requirements.toml", ts)
360
+ try:
361
+ skills = sorted(skill_sources(paths.CORE, ov.root))
362
+ except ValueError:
363
+ skills = []
364
+ checks += probe.check_prompt(bin_path, home, Path.cwd(), ts, skills)
365
+ checks += probe.check_rules(bin_path, home, Path.cwd())
366
+ if args.live:
367
+ checks += probe.check_live(bin_path, home, Path.cwd(), ts)
368
+ checks += check_chain(home, Path.cwd())
369
+ checks += [("FAIL", "old state", f"{line}; run code-jig migrate") for line in migrate.pending(home)]
370
+ return report(checks)