hamingja 0.1.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.
- hamingja/__init__.py +16 -0
- hamingja/adapters/__init__.py +0 -0
- hamingja/adapters/_jsonl_tail.py +76 -0
- hamingja/adapters/capabilities.py +95 -0
- hamingja/adapters/claude_code/__init__.py +4 -0
- hamingja/adapters/claude_code/install.sh +182 -0
- hamingja/adapters/claude_code/quota.py +150 -0
- hamingja/adapters/claude_code/record.py +76 -0
- hamingja/adapters/claude_code/tripwire.py +212 -0
- hamingja/adapters/codex/__init__.py +4 -0
- hamingja/adapters/codex/install.sh +189 -0
- hamingja/adapters/codex/quota.py +216 -0
- hamingja/adapters/codex/record.py +79 -0
- hamingja/adapters/codex/tripwire.py +285 -0
- hamingja/adapters/delegation.py +44 -0
- hamingja/adapters/framework_progress.py +170 -0
- hamingja/adapters/generic/__init__.py +47 -0
- hamingja/adapters/operator_turn.py +39 -0
- hamingja/adapters/progress.py +95 -0
- hamingja/adapters/read_advisory.py +46 -0
- hamingja/cli.py +1866 -0
- hamingja/code_atlas.py +358 -0
- hamingja/config.default.json +90 -0
- hamingja/config.py +579 -0
- hamingja/core/__init__.py +0 -0
- hamingja/core/api.py +158 -0
- hamingja/core/audit.py +166 -0
- hamingja/core/budget.py +1204 -0
- hamingja/core/delegation.py +109 -0
- hamingja/core/engine.py +188 -0
- hamingja/core/events.py +292 -0
- hamingja/core/progress.py +227 -0
- hamingja/core/state.py +126 -0
- hamingja/detectors/__init__.py +0 -0
- hamingja/detectors/base.py +61 -0
- hamingja/detectors/error_streak.py +52 -0
- hamingja/detectors/leverage_fallback.py +87 -0
- hamingja/detectors/oscillation.py +112 -0
- hamingja/detectors/python_command.py +105 -0
- hamingja/detectors/read_discipline.py +168 -0
- hamingja/detectors/repetition.py +143 -0
- hamingja/detectors/workflow_wrapper.py +121 -0
- hamingja/hook_lifecycle.py +165 -0
- hamingja/ledger.py +507 -0
- hamingja/locator.py +391 -0
- hamingja/profiles/__init__.py +50 -0
- hamingja/profiles/base.md +33 -0
- hamingja/profiles/compiler_language.md +26 -0
- hamingja/profiles/debugging.md +20 -0
- hamingja/profiles/escalation.md +35 -0
- hamingja/profiles/non_convergence.md +22 -0
- hamingja/profiles/read_discipline.md +32 -0
- hamingja/profiles/review_passes.md +20 -0
- hamingja/templates/AGENTS.md +20 -0
- hamingja/templates/__init__.py +19 -0
- hamingja/templates/codex/AGENTS.md +25 -0
- hamingja/templates/codex/__init__.py +1 -0
- hamingja/workflows.py +1135 -0
- hamingja-0.1.0.dist-info/METADATA +859 -0
- hamingja-0.1.0.dist-info/RECORD +65 -0
- hamingja-0.1.0.dist-info/WHEEL +5 -0
- hamingja-0.1.0.dist-info/entry_points.txt +2 -0
- hamingja-0.1.0.dist-info/licenses/LICENSE +201 -0
- hamingja-0.1.0.dist-info/licenses/NOTICE +14 -0
- hamingja-0.1.0.dist-info/top_level.txt +1 -0
hamingja/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""hamingja — fail-open partner rails for Codex and Claude Code.
|
|
2
|
+
|
|
3
|
+
The core question every detector answers is "is this agent flailing rather
|
|
4
|
+
than making progress?" — and the architecture keeps that question separate
|
|
5
|
+
from any particular agent harness:
|
|
6
|
+
|
|
7
|
+
core/ normalized event schema + session state + the engine
|
|
8
|
+
detectors/ pluggable signals (repetition, error-streak, ...) — the rules
|
|
9
|
+
adapters/ thin per-harness glue (claude_code, generic, ...) — the I/O
|
|
10
|
+
|
|
11
|
+
Add a guardrail = a new file in detectors/. Add a harness = a new folder in
|
|
12
|
+
adapters/. Guardrail evaluation fails OPEN: internal uncertainty defaults to
|
|
13
|
+
allowing the tool call, never inventing a denial.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Shared fail-open tail reader for append-only JSONL session logs.
|
|
2
|
+
|
|
3
|
+
Both harness quota probes (Codex rollout, Claude transcript) need to read the
|
|
4
|
+
*newest* record from a large, live, append-only JSONL file without parsing the
|
|
5
|
+
whole thing. The consistency rules are identical and safety-critical, so they
|
|
6
|
+
live here once rather than drifting between two copies:
|
|
7
|
+
|
|
8
|
+
* CHEAP — seek to a bounded window at the tail; never read the body. Grow the
|
|
9
|
+
window (doubling, up to a cap) only if the caller keeps consuming without
|
|
10
|
+
finding its record; past the cap, stop (the caller then fails open).
|
|
11
|
+
* CONSISTENT — the file is written by a live process, so a window can start
|
|
12
|
+
mid-line and the final line can be a partial write. Only lines fully
|
|
13
|
+
delimited by newlines *inside* the window are trusted: the fragment before
|
|
14
|
+
the first ``\\n`` (possibly truncated by the window) and the fragment after
|
|
15
|
+
the last ``\\n`` (possibly an in-flight write) are both discarded.
|
|
16
|
+
|
|
17
|
+
FAIL-OPEN: any error yields no lines (an empty generator), never raises.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Iterator
|
|
23
|
+
|
|
24
|
+
# 64 KiB comfortably covers a normal turn's trailing data; the cap bounds
|
|
25
|
+
# worst-case cost when a giant line precedes the record of interest.
|
|
26
|
+
DEFAULT_INITIAL = 64 * 1024
|
|
27
|
+
DEFAULT_CAP = 2 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def iter_complete_lines_reversed(
|
|
31
|
+
path: Path,
|
|
32
|
+
initial: int = DEFAULT_INITIAL,
|
|
33
|
+
cap: int = DEFAULT_CAP,
|
|
34
|
+
) -> Iterator[bytes]:
|
|
35
|
+
"""Yield complete JSONL lines from the tail of ``path``, newest first.
|
|
36
|
+
|
|
37
|
+
Grows the read window up to ``cap`` if the caller exhausts it. Callers
|
|
38
|
+
should stop at the first line they want; the overlap re-yielded on each
|
|
39
|
+
growth step is bounded by ``cap``.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
size = path.stat().st_size
|
|
43
|
+
except Exception:
|
|
44
|
+
return
|
|
45
|
+
if size <= 0:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
window = max(1, int(initial))
|
|
49
|
+
cap = max(window, int(cap))
|
|
50
|
+
while True:
|
|
51
|
+
read_from = max(0, size - window)
|
|
52
|
+
try:
|
|
53
|
+
with path.open("rb") as fh:
|
|
54
|
+
fh.seek(read_from)
|
|
55
|
+
blob = fh.read(size - read_from)
|
|
56
|
+
except Exception:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
parts = blob.split(b"\n")
|
|
60
|
+
# Final element follows the last newline: an in-flight partial write (or
|
|
61
|
+
# empty if the file ends in \n). Never trust it.
|
|
62
|
+
parts = parts[:-1]
|
|
63
|
+
# If we did not reach the start of the file, the first element may be a
|
|
64
|
+
# line truncated by the window boundary. Drop it; a wider window
|
|
65
|
+
# recovers it if needed.
|
|
66
|
+
reached_start = read_from == 0
|
|
67
|
+
if not reached_start and parts:
|
|
68
|
+
parts = parts[1:]
|
|
69
|
+
|
|
70
|
+
for raw in reversed(parts):
|
|
71
|
+
if raw.strip():
|
|
72
|
+
yield raw
|
|
73
|
+
|
|
74
|
+
if reached_start or window >= cap:
|
|
75
|
+
return
|
|
76
|
+
window = min(window * 2, cap)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Static, versioned runtime capability declarations.
|
|
2
|
+
|
|
3
|
+
Declarations are promises established by adapter fixtures. Runtime probes may
|
|
4
|
+
downgrade a claim for the current session, but callers must never upgrade one.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_MANIFESTS = {
|
|
12
|
+
"codex": {
|
|
13
|
+
"version": 3,
|
|
14
|
+
"runtime": "codex",
|
|
15
|
+
"pre_tool_enforcement": "partial",
|
|
16
|
+
"post_tool_outcomes": "partial",
|
|
17
|
+
"quota_probe": True,
|
|
18
|
+
"quota_ttl_seconds": 300,
|
|
19
|
+
"context_probe": True,
|
|
20
|
+
"operator_turn_observed": True,
|
|
21
|
+
"delegation_spawn": True,
|
|
22
|
+
"delegation_completion": True,
|
|
23
|
+
"delegation_identity": True,
|
|
24
|
+
"delegation_lineage": False,
|
|
25
|
+
"delegation_fallback": "monotonic_grants",
|
|
26
|
+
},
|
|
27
|
+
"claude_code": {
|
|
28
|
+
"version": 3,
|
|
29
|
+
"runtime": "claude_code",
|
|
30
|
+
"pre_tool_enforcement": "full",
|
|
31
|
+
"post_tool_outcomes": "full",
|
|
32
|
+
"quota_probe": False,
|
|
33
|
+
"quota_ttl_seconds": None,
|
|
34
|
+
"context_probe": True,
|
|
35
|
+
"operator_turn_observed": True,
|
|
36
|
+
"delegation_spawn": True,
|
|
37
|
+
"delegation_completion": True,
|
|
38
|
+
"delegation_identity": True,
|
|
39
|
+
"delegation_lineage": False,
|
|
40
|
+
"delegation_fallback": "monotonic_grants",
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def manifest(runtime: str, downgrades: dict | None = None) -> dict:
|
|
46
|
+
"""Return a copy of a first-class manifest, with fail-open downgrades only."""
|
|
47
|
+
try:
|
|
48
|
+
base = deepcopy(_MANIFESTS[str(runtime)])
|
|
49
|
+
if not isinstance(downgrades, dict):
|
|
50
|
+
return base
|
|
51
|
+
for key, value in downgrades.items():
|
|
52
|
+
if key not in base or key in {"version", "runtime"}:
|
|
53
|
+
continue
|
|
54
|
+
current = base[key]
|
|
55
|
+
if isinstance(current, bool) and value is False:
|
|
56
|
+
base[key] = False
|
|
57
|
+
elif current == "full" and value in {"partial", "none"}:
|
|
58
|
+
base[key] = value
|
|
59
|
+
elif current == "partial" and value == "none":
|
|
60
|
+
base[key] = value
|
|
61
|
+
return base
|
|
62
|
+
except Exception:
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def delegation_observation(runtime: str, payload: object) -> dict | None:
|
|
67
|
+
"""Return only delegation facts the runtime payload actually proves."""
|
|
68
|
+
try:
|
|
69
|
+
if not isinstance(payload, dict):
|
|
70
|
+
return None
|
|
71
|
+
if runtime in {"codex", "claude_code"} and payload.get("hook_event_name") in {
|
|
72
|
+
"SubagentStart", "SubagentStop",
|
|
73
|
+
}:
|
|
74
|
+
agent_id = payload.get("agent_id")
|
|
75
|
+
agent_type = payload.get("agent_type")
|
|
76
|
+
session_id = payload.get("session_id")
|
|
77
|
+
if (not isinstance(agent_id, str) or not agent_id
|
|
78
|
+
or not isinstance(agent_type, str) or not agent_type
|
|
79
|
+
or not isinstance(session_id, str) or not session_id):
|
|
80
|
+
return None
|
|
81
|
+
return {
|
|
82
|
+
"event": "spawn" if payload["hook_event_name"] == "SubagentStart" else "complete",
|
|
83
|
+
"agent_id": agent_id,
|
|
84
|
+
"agent_type": agent_type,
|
|
85
|
+
"session_id": session_id,
|
|
86
|
+
"turn_id": payload.get("turn_id") if isinstance(payload.get("turn_id"), str) else "",
|
|
87
|
+
"spawn_observed": payload["hook_event_name"] == "SubagentStart",
|
|
88
|
+
"identity_observed": True,
|
|
89
|
+
"completion_observed": payload["hook_event_name"] == "SubagentStop",
|
|
90
|
+
"lineage_observed": False,
|
|
91
|
+
"enforcement": "session_concurrency_advisory",
|
|
92
|
+
}
|
|
93
|
+
return None
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Install the hamingja Claude Code adapter into your global settings.json.
|
|
4
|
+
#
|
|
5
|
+
# Registers:
|
|
6
|
+
# PreToolUse -> tripwire.py (allow / nudge / block)
|
|
7
|
+
# PostToolUse -> record.py (record success, heuristic fallback)
|
|
8
|
+
# PostToolUseFailure-> record.py (record failure, authoritative)
|
|
9
|
+
# SubagentStart/Stop-> delegation.py (identity + active-child lifecycle)
|
|
10
|
+
# UserPromptSubmit -> operator_turn.py (prompt-free operator recency)
|
|
11
|
+
# all for matcher "*".
|
|
12
|
+
#
|
|
13
|
+
# Behavior:
|
|
14
|
+
# * MERGES into existing settings (never overwrites); preserves other hooks.
|
|
15
|
+
# * Idempotent AND self-healing: an existing entry that references our script
|
|
16
|
+
# (matched by basename) is UPDATED in place, so moving/renaming the repo
|
|
17
|
+
# refreshes the path instead of leaving a dead duplicate.
|
|
18
|
+
# * Backs up settings ONLY when a change is actually written (no backup litter
|
|
19
|
+
# on no-op re-runs).
|
|
20
|
+
# * Default detector mode is "observe"; operator resource authority is
|
|
21
|
+
# configured separately.
|
|
22
|
+
#
|
|
23
|
+
# Override the settings path with CLAUDE_SETTINGS=/path/to/settings.json.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
27
|
+
SETTINGS="${CLAUDE_SETTINGS:-$HOME/.claude/settings.json}"
|
|
28
|
+
PRE="$REPO_ROOT/hamingja/adapters/claude_code/tripwire.py"
|
|
29
|
+
POST="$REPO_ROOT/hamingja/adapters/claude_code/record.py"
|
|
30
|
+
LIFECYCLE="$REPO_ROOT/hamingja/adapters/delegation.py"
|
|
31
|
+
OPERATOR="$REPO_ROOT/hamingja/adapters/operator_turn.py"
|
|
32
|
+
|
|
33
|
+
PYBIN="${HAMINGJA_PYTHON:-}"
|
|
34
|
+
if [ -z "$PYBIN" ]; then
|
|
35
|
+
PYBIN="$(command -v python3 || command -v python || true)"
|
|
36
|
+
fi
|
|
37
|
+
if [ -z "$PYBIN" ]; then
|
|
38
|
+
echo "error: python3 not found on PATH" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
case "$PYBIN" in
|
|
43
|
+
*/shims/*|*/.venv/*|*/venv/*|*/conda/*|*/envs/*|*/miniconda*|*/anaconda*)
|
|
44
|
+
echo "warning: python interpreter '$PYBIN' looks like a pyenv/venv/conda shim." >&2
|
|
45
|
+
echo " If that environment is changed or removed, the hooks silently stop" >&2
|
|
46
|
+
echo " working (they fail open). Consider a stable system python." >&2
|
|
47
|
+
;;
|
|
48
|
+
esac
|
|
49
|
+
|
|
50
|
+
mkdir -p "$(dirname "$SETTINGS")"
|
|
51
|
+
[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
|
|
52
|
+
|
|
53
|
+
BACKUP="$SETTINGS.bak.$(date +%s).$$"
|
|
54
|
+
cp "$SETTINGS" "$BACKUP"
|
|
55
|
+
|
|
56
|
+
set +e
|
|
57
|
+
RESULT="$("$PYBIN" - "$SETTINGS" "$PRE" "$POST" "$LIFECYCLE" "$OPERATOR" "$PYBIN" <<'PY'
|
|
58
|
+
import json, os, shlex, sys, tempfile
|
|
59
|
+
|
|
60
|
+
settings_path, pre, post, lifecycle, operator, pybin = sys.argv[1:7]
|
|
61
|
+
settings_path = os.path.realpath(settings_path)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with open(settings_path, encoding="utf-8") as fh:
|
|
65
|
+
cfg = json.load(fh)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
print(f"error: refusing to modify malformed settings: {exc}", file=sys.stderr)
|
|
68
|
+
raise SystemExit(2)
|
|
69
|
+
if not isinstance(cfg, dict):
|
|
70
|
+
print("error: refusing to modify settings whose top level is not an object", file=sys.stderr)
|
|
71
|
+
raise SystemExit(2)
|
|
72
|
+
|
|
73
|
+
before = json.dumps(cfg, sort_keys=True)
|
|
74
|
+
|
|
75
|
+
hooks = cfg.get("hooks")
|
|
76
|
+
if hooks is None:
|
|
77
|
+
hooks = {}
|
|
78
|
+
cfg["hooks"] = hooks
|
|
79
|
+
elif not isinstance(hooks, dict):
|
|
80
|
+
print("error: refusing to modify settings whose hooks field is not an object", file=sys.stderr)
|
|
81
|
+
raise SystemExit(2)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def quote(p):
|
|
85
|
+
return '"' + p.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def owns_command(value, base):
|
|
89
|
+
try:
|
|
90
|
+
parts = shlex.split(str(value))
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
return any(
|
|
94
|
+
os.path.basename(part) == base
|
|
95
|
+
and any(
|
|
96
|
+
owned in part.replace("\\", "/")
|
|
97
|
+
for owned in ("hamingja/adapters/", "agent_rails/adapters/")
|
|
98
|
+
)
|
|
99
|
+
for part in parts
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def upsert(event, script):
|
|
104
|
+
cmd = quote(pybin) + " " + quote(script)
|
|
105
|
+
base = os.path.basename(script)
|
|
106
|
+
entries = hooks.get(event)
|
|
107
|
+
if entries is None:
|
|
108
|
+
entries = []
|
|
109
|
+
hooks[event] = entries
|
|
110
|
+
elif not isinstance(entries, list):
|
|
111
|
+
print(f"error: refusing to replace malformed {event} hooks", file=sys.stderr)
|
|
112
|
+
raise SystemExit(2)
|
|
113
|
+
# refresh any existing entry that references our script (by basename)
|
|
114
|
+
for matcher_obj in entries:
|
|
115
|
+
if not isinstance(matcher_obj, dict):
|
|
116
|
+
continue
|
|
117
|
+
hk = matcher_obj.get("hooks")
|
|
118
|
+
if not isinstance(hk, list):
|
|
119
|
+
continue
|
|
120
|
+
for h in hk:
|
|
121
|
+
if not isinstance(h, dict):
|
|
122
|
+
continue
|
|
123
|
+
if owns_command(h.get("command", ""), base):
|
|
124
|
+
h["command"] = cmd
|
|
125
|
+
h["type"] = "command"
|
|
126
|
+
return
|
|
127
|
+
entries.append({"matcher": "*", "hooks": [{"type": "command", "command": cmd}]})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
upsert("PreToolUse", pre)
|
|
131
|
+
upsert("PostToolUse", post)
|
|
132
|
+
upsert("PostToolUseFailure", post)
|
|
133
|
+
upsert("SubagentStart", lifecycle)
|
|
134
|
+
upsert("SubagentStop", lifecycle)
|
|
135
|
+
upsert("UserPromptSubmit", operator)
|
|
136
|
+
|
|
137
|
+
after = json.dumps(cfg, sort_keys=True)
|
|
138
|
+
if after == before:
|
|
139
|
+
print("UNCHANGED")
|
|
140
|
+
else:
|
|
141
|
+
directory = os.path.dirname(settings_path) or "."
|
|
142
|
+
fd, temporary = tempfile.mkstemp(prefix=".hamingja-install-", dir=directory)
|
|
143
|
+
try:
|
|
144
|
+
os.fchmod(fd, os.stat(settings_path).st_mode & 0o7777)
|
|
145
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
146
|
+
json.dump(cfg, fh, indent=2)
|
|
147
|
+
fh.write("\n")
|
|
148
|
+
fh.flush()
|
|
149
|
+
os.fsync(fh.fileno())
|
|
150
|
+
os.replace(temporary, settings_path)
|
|
151
|
+
except Exception:
|
|
152
|
+
try:
|
|
153
|
+
os.unlink(temporary)
|
|
154
|
+
except OSError:
|
|
155
|
+
pass
|
|
156
|
+
raise
|
|
157
|
+
print("CHANGED")
|
|
158
|
+
PY
|
|
159
|
+
)"
|
|
160
|
+
RC=$?
|
|
161
|
+
set -e
|
|
162
|
+
if [ "$RC" -ne 0 ]; then
|
|
163
|
+
if [ "$RC" -eq 2 ]; then
|
|
164
|
+
rm -f "$BACKUP"
|
|
165
|
+
else
|
|
166
|
+
echo "backup preserved after installer failure: $BACKUP" >&2
|
|
167
|
+
fi
|
|
168
|
+
exit "$RC"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
if [ "$RESULT" = "CHANGED" ]; then
|
|
172
|
+
echo "updated: $SETTINGS"
|
|
173
|
+
echo "backup: $BACKUP"
|
|
174
|
+
else
|
|
175
|
+
rm -f "$BACKUP"
|
|
176
|
+
echo "no change: $SETTINGS already up to date"
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
echo "detectors: observe by default (operator resource authority is separate)"
|
|
180
|
+
echo
|
|
181
|
+
echo "Opt out per repo: touch .hamingja-off in that project's root."
|
|
182
|
+
echo "Uninstall: hamingja uninstall claude"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Claude Code quota probe — context-fill from the session transcript.
|
|
2
|
+
|
|
3
|
+
Unlike Codex, Claude Code does **not** persist a rate-limit used-percent to disk
|
|
4
|
+
(that lives on API response headers). The transcript
|
|
5
|
+
(``~/.claude/projects/<encoded-cwd>/<session_id>.jsonl``) does carry, per
|
|
6
|
+
assistant message, a ``usage`` block:
|
|
7
|
+
|
|
8
|
+
"usage": {"input_tokens": ..., "cache_read_input_tokens": ...,
|
|
9
|
+
"cache_creation_input_tokens": ..., "output_tokens": ...}
|
|
10
|
+
|
|
11
|
+
So the one real signal we can recover is **context occupancy** — how full the
|
|
12
|
+
model's context window is — which is itself a top CLI cost (a bloated context is
|
|
13
|
+
re-sent every turn). We therefore populate only ``context_used_pct``;
|
|
14
|
+
``window_used_pct`` / ``weekly_used_pct`` stay None, which means this reading can
|
|
15
|
+
*nudge* on context fill but never grants checkpoint relief (that requires the
|
|
16
|
+
rate-limit signal Codex has and Claude does not).
|
|
17
|
+
|
|
18
|
+
Two honest limitations, both fail-safe:
|
|
19
|
+
|
|
20
|
+
* The transcript does not record the context-window *size* (Codex hands us
|
|
21
|
+
``model_context_window``; Claude does not), so occupancy is estimated against
|
|
22
|
+
a configurable denominator (``context_window_tokens``, default 200000). An
|
|
23
|
+
over-large real window merely under-reports fill; it never over-blocks,
|
|
24
|
+
because context fill only ever produces an advisory nudge.
|
|
25
|
+
* We read the newest completed assistant turn; the in-flight turn is not yet
|
|
26
|
+
written. That is the right granularity for an advisory.
|
|
27
|
+
|
|
28
|
+
FAIL-OPEN: every path returns None on any error, missing file, or missing field.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
from .._jsonl_tail import iter_complete_lines_reversed
|
|
38
|
+
|
|
39
|
+
# Shared QuotaReading so the budget gate sees one harness-neutral shape.
|
|
40
|
+
from ..codex.quota import QuotaReading
|
|
41
|
+
|
|
42
|
+
_DEFAULT_CONTEXT_WINDOW = 200_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _claude_home() -> Path:
|
|
46
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_transcript(session_id: str, home: Path, cwd: Optional[str]) -> Optional[Path]:
|
|
50
|
+
"""Locate the transcript for a session id. Returns the newest match.
|
|
51
|
+
|
|
52
|
+
Transcripts are ``<session_id>.jsonl`` under a per-project directory. We glob
|
|
53
|
+
by session id (unique) rather than reconstruct the cwd path-encoding, which
|
|
54
|
+
is brittle. ``cwd`` is accepted for future narrowing but not required.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
sid = str(session_id).strip()
|
|
58
|
+
if not sid:
|
|
59
|
+
return None
|
|
60
|
+
projects = home / "projects"
|
|
61
|
+
if not projects.is_dir():
|
|
62
|
+
return None
|
|
63
|
+
matches = list(projects.glob(f"*/{sid}.jsonl"))
|
|
64
|
+
if not matches:
|
|
65
|
+
return None
|
|
66
|
+
if len(matches) == 1:
|
|
67
|
+
return matches[0]
|
|
68
|
+
return max(matches, key=lambda p: p.stat().st_mtime)
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _as_int(v) -> Optional[int]:
|
|
74
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
75
|
+
return None
|
|
76
|
+
return int(v)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _context_pct(usage: dict, window: int) -> Optional[float]:
|
|
80
|
+
"""Estimate context occupancy from an assistant usage block. None if unusable.
|
|
81
|
+
|
|
82
|
+
Occupancy is the input side of the request — the whole prompt the model saw:
|
|
83
|
+
fresh input + cache-read + cache-creation tokens. output_tokens is the new
|
|
84
|
+
completion (folded into the *next* turn's input, negligible here).
|
|
85
|
+
"""
|
|
86
|
+
if not isinstance(usage, dict) or not window or window <= 0:
|
|
87
|
+
return None
|
|
88
|
+
total = 0
|
|
89
|
+
seen = False
|
|
90
|
+
for key in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"):
|
|
91
|
+
v = _as_int(usage.get(key))
|
|
92
|
+
if v is not None:
|
|
93
|
+
total += max(0, v)
|
|
94
|
+
seen = True
|
|
95
|
+
if not seen:
|
|
96
|
+
return None
|
|
97
|
+
return max(0.0, min(100.0, 100.0 * total / window))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_quota(
|
|
101
|
+
session_id: str,
|
|
102
|
+
cwd: Optional[str] = None,
|
|
103
|
+
context_window_tokens: int = _DEFAULT_CONTEXT_WINDOW,
|
|
104
|
+
home: Optional[Path] = None,
|
|
105
|
+
) -> Optional[QuotaReading]:
|
|
106
|
+
"""Return a context-fill QuotaReading for a Claude session, or None. Fail-open.
|
|
107
|
+
|
|
108
|
+
Tails the session transcript for the newest assistant message carrying a
|
|
109
|
+
``usage`` block and reports ``context_used_pct``. ``window_used_pct`` /
|
|
110
|
+
``weekly_used_pct`` are always None (Claude does not persist them).
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
base = home or _claude_home()
|
|
114
|
+
path = _find_transcript(session_id, base, cwd)
|
|
115
|
+
if path is None:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
window = int(context_window_tokens)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
window = _DEFAULT_CONTEXT_WINDOW
|
|
121
|
+
if window <= 0:
|
|
122
|
+
window = _DEFAULT_CONTEXT_WINDOW
|
|
123
|
+
|
|
124
|
+
for raw in iter_complete_lines_reversed(path):
|
|
125
|
+
# Cheap pre-filter before json.loads: only assistant usage lines
|
|
126
|
+
# matter. Skips large tool-result records.
|
|
127
|
+
if b"usage" not in raw:
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
obj = json.loads(raw)
|
|
131
|
+
except Exception:
|
|
132
|
+
continue
|
|
133
|
+
if not isinstance(obj, dict):
|
|
134
|
+
continue
|
|
135
|
+
message = obj.get("message")
|
|
136
|
+
if not isinstance(message, dict):
|
|
137
|
+
continue
|
|
138
|
+
usage = message.get("usage")
|
|
139
|
+
pct = _context_pct(usage, window) if isinstance(usage, dict) else None
|
|
140
|
+
if pct is not None:
|
|
141
|
+
return QuotaReading(
|
|
142
|
+
window_used_pct=None,
|
|
143
|
+
weekly_used_pct=None,
|
|
144
|
+
context_used_pct=pct,
|
|
145
|
+
plan_type=None,
|
|
146
|
+
source="claude-transcript",
|
|
147
|
+
)
|
|
148
|
+
return None
|
|
149
|
+
except Exception:
|
|
150
|
+
return None
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code recorder — records the outcome of each tool call.
|
|
3
|
+
|
|
4
|
+
Wired as BOTH:
|
|
5
|
+
* PostToolUse -> the call succeeded (record OK), with a payload
|
|
6
|
+
heuristic fallback in case a failure is delivered here
|
|
7
|
+
* PostToolUseFailure -> the call failed (record ERROR), authoritative
|
|
8
|
+
|
|
9
|
+
Detecting errors by the EVENT is deterministic; the per-tool shape of the
|
|
10
|
+
result payload is undocumented, so we don't rely on parsing it. The heuristic
|
|
11
|
+
below is only a best-effort fallback for harness versions that route a failure
|
|
12
|
+
through PostToolUse.
|
|
13
|
+
|
|
14
|
+
It never blocks anything and always exits 0 — recording is pure observation.
|
|
15
|
+
Recording also honors mode=off / .hamingja-off (handled in core.api.record),
|
|
16
|
+
so an opted-out repo stays fully inert.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# make `hamingja` importable when run as a standalone hook script
|
|
25
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
26
|
+
|
|
27
|
+
from hamingja.core.api import record # noqa: E402
|
|
28
|
+
from hamingja.adapters.progress import record_workflow_progress # noqa: E402
|
|
29
|
+
from hamingja.adapters.framework_progress import record_framework_progress # noqa: E402
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _looks_like_error(result) -> bool:
|
|
33
|
+
"""Best-effort fallback error detection from an undocumented result shape."""
|
|
34
|
+
if isinstance(result, dict):
|
|
35
|
+
if result.get("is_error") is True:
|
|
36
|
+
return True
|
|
37
|
+
if result.get("success") is False:
|
|
38
|
+
return True
|
|
39
|
+
err = result.get("error")
|
|
40
|
+
if isinstance(err, str) and err.strip():
|
|
41
|
+
return True
|
|
42
|
+
if err is True:
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> int:
|
|
48
|
+
try:
|
|
49
|
+
payload = json.load(sys.stdin)
|
|
50
|
+
except Exception:
|
|
51
|
+
return 0 # fail-open: unparseable payload, record nothing
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
event = str(payload.get("hook_event_name", ""))
|
|
55
|
+
session_id = str(payload.get("session_id", "default"))
|
|
56
|
+
tool = str(payload.get("tool_name", "unknown"))
|
|
57
|
+
tool_input = payload.get("tool_input", {})
|
|
58
|
+
cwd = payload.get("cwd")
|
|
59
|
+
|
|
60
|
+
result = payload.get("tool_response", payload.get("tool_output"))
|
|
61
|
+
if event == "PostToolUseFailure":
|
|
62
|
+
ok = False # authoritative: this event only fires on failure
|
|
63
|
+
else:
|
|
64
|
+
ok = not _looks_like_error(result)
|
|
65
|
+
|
|
66
|
+
record(session_id, tool, tool_input, ok, project_dir=cwd, output=result)
|
|
67
|
+
record_workflow_progress(session_id, tool, tool_input, result, project_dir=cwd)
|
|
68
|
+
record_framework_progress(session_id, tool, tool_input, result, project_dir=cwd, ok=ok)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass # never let recording surface an error to the agent
|
|
71
|
+
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|