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.
Files changed (65) hide show
  1. hamingja/__init__.py +16 -0
  2. hamingja/adapters/__init__.py +0 -0
  3. hamingja/adapters/_jsonl_tail.py +76 -0
  4. hamingja/adapters/capabilities.py +95 -0
  5. hamingja/adapters/claude_code/__init__.py +4 -0
  6. hamingja/adapters/claude_code/install.sh +182 -0
  7. hamingja/adapters/claude_code/quota.py +150 -0
  8. hamingja/adapters/claude_code/record.py +76 -0
  9. hamingja/adapters/claude_code/tripwire.py +212 -0
  10. hamingja/adapters/codex/__init__.py +4 -0
  11. hamingja/adapters/codex/install.sh +189 -0
  12. hamingja/adapters/codex/quota.py +216 -0
  13. hamingja/adapters/codex/record.py +79 -0
  14. hamingja/adapters/codex/tripwire.py +285 -0
  15. hamingja/adapters/delegation.py +44 -0
  16. hamingja/adapters/framework_progress.py +170 -0
  17. hamingja/adapters/generic/__init__.py +47 -0
  18. hamingja/adapters/operator_turn.py +39 -0
  19. hamingja/adapters/progress.py +95 -0
  20. hamingja/adapters/read_advisory.py +46 -0
  21. hamingja/cli.py +1866 -0
  22. hamingja/code_atlas.py +358 -0
  23. hamingja/config.default.json +90 -0
  24. hamingja/config.py +579 -0
  25. hamingja/core/__init__.py +0 -0
  26. hamingja/core/api.py +158 -0
  27. hamingja/core/audit.py +166 -0
  28. hamingja/core/budget.py +1204 -0
  29. hamingja/core/delegation.py +109 -0
  30. hamingja/core/engine.py +188 -0
  31. hamingja/core/events.py +292 -0
  32. hamingja/core/progress.py +227 -0
  33. hamingja/core/state.py +126 -0
  34. hamingja/detectors/__init__.py +0 -0
  35. hamingja/detectors/base.py +61 -0
  36. hamingja/detectors/error_streak.py +52 -0
  37. hamingja/detectors/leverage_fallback.py +87 -0
  38. hamingja/detectors/oscillation.py +112 -0
  39. hamingja/detectors/python_command.py +105 -0
  40. hamingja/detectors/read_discipline.py +168 -0
  41. hamingja/detectors/repetition.py +143 -0
  42. hamingja/detectors/workflow_wrapper.py +121 -0
  43. hamingja/hook_lifecycle.py +165 -0
  44. hamingja/ledger.py +507 -0
  45. hamingja/locator.py +391 -0
  46. hamingja/profiles/__init__.py +50 -0
  47. hamingja/profiles/base.md +33 -0
  48. hamingja/profiles/compiler_language.md +26 -0
  49. hamingja/profiles/debugging.md +20 -0
  50. hamingja/profiles/escalation.md +35 -0
  51. hamingja/profiles/non_convergence.md +22 -0
  52. hamingja/profiles/read_discipline.md +32 -0
  53. hamingja/profiles/review_passes.md +20 -0
  54. hamingja/templates/AGENTS.md +20 -0
  55. hamingja/templates/__init__.py +19 -0
  56. hamingja/templates/codex/AGENTS.md +25 -0
  57. hamingja/templates/codex/__init__.py +1 -0
  58. hamingja/workflows.py +1135 -0
  59. hamingja-0.1.0.dist-info/METADATA +859 -0
  60. hamingja-0.1.0.dist-info/RECORD +65 -0
  61. hamingja-0.1.0.dist-info/WHEEL +5 -0
  62. hamingja-0.1.0.dist-info/entry_points.txt +2 -0
  63. hamingja-0.1.0.dist-info/licenses/LICENSE +201 -0
  64. hamingja-0.1.0.dist-info/licenses/NOTICE +14 -0
  65. 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,4 @@
1
+ """Claude Code adapter and its tested capability declaration."""
2
+ from ..capabilities import manifest
3
+
4
+ CAPABILITIES = manifest("claude_code")
@@ -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())