From 60083321cc79ee1446b94b6d9b58dbe933768e74 Mon Sep 17 00:00:00 2001 From: Ali Raza Date: Tue, 21 Apr 2026 14:25:00 +0300 Subject: [PATCH] Merge pull request #1 from aliirz/fix/claude-code-hook-rich-episodic fix(claude-code): replace hardcoded hook with rich episodic logging --- .agent/harness/hooks/claude_code_post_tool.py | 482 +++++++++++++++++ .agent/harness/hooks/on_failure.py | 13 +- .agent/harness/hooks/post_execution.py | 13 +- .agent/protocols/hook_patterns.json | 66 +++ .agent/tools/memory_reflect.py | 11 +- adapters/claude-code/CLAUDE.md | 119 ++++- adapters/claude-code/README.md | 83 ++- adapters/claude-code/settings.json | 4 +- test_claude_code_hook.py | 499 ++++++++++++++++++ 9 files changed, 1247 insertions(+), 43 deletions(-) create mode 100644 .agent/harness/hooks/claude_code_post_tool.py create mode 100644 .agent/protocols/hook_patterns.json create mode 100644 test_claude_code_hook.py diff --git a/.agent/harness/hooks/claude_code_post_tool.py b/.agent/harness/hooks/claude_code_post_tool.py new file mode 100644 index 0000000..750a5d7 --- /dev/null +++ b/.agent/harness/hooks/claude_code_post_tool.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Smart PostToolUse hook for Claude Code. + +Claude Code calls this script after every matched tool use and passes a +JSON payload via stdin: + + { + "session_id": "...", + "tool_name": "Bash", + "tool_input": {"command": "supabase db push"}, + "tool_response": {"output": "...", "exit_code": 0, "error": ""} + } + +The old hook called memory_reflect.py with hardcoded "post-tool ok" — +every entry looked identical so content_cluster() found nothing and the +dream cycle produced zero candidates. This version: + + - reads tool_name / tool_input / tool_response from stdin + - falls back to CLAUDE_TOOL_NAME / CLAUDE_TOOL_INPUT env vars + - detects failures from exit codes, error fields, and stderr content + - scores importance by domain (deploy/migrate/schema = 8, edit = 5, etc.) + - generates a non-empty reflection the dream cycle can actually cluster on + - calls the same log_execution / on_failure path as the rest of the harness + +Drop-in for the old command in settings.json: + "command": "python3 .agent/harness/hooks/claude_code_post_tool.py" +""" +import json, os, re, sys + +# Resolve .agent/ root from this file's location: +# __file__ = .agent/harness/hooks/claude_code_post_tool.py +# UP 1 = .agent/harness/hooks/ +# UP 2 = .agent/harness/ +# UP 3 = .agent/ +HERE = os.path.dirname(os.path.abspath(__file__)) +AGENT_ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +sys.path.insert(0, os.path.join(AGENT_ROOT, "harness")) +sys.path.insert(0, os.path.join(AGENT_ROOT, "tools")) + +from hooks.post_execution import log_execution # noqa: E402 +from hooks.on_failure import on_failure # noqa: E402 + + +# --------------------------------------------------------------------------- +# Importance scoring +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Importance patterns — universal core + user-configurable extras +# --------------------------------------------------------------------------- + +# Patterns that are high-stakes on ANY stack. +# Rule of thumb: if getting it wrong on a project you've never seen before +# would cause data loss, a production outage, or a security incident, it +# belongs here. Service names (supabase, stripe, vercel…) do NOT belong +# here — put those in .agent/protocols/hook_patterns.json. +_UNIVERSAL_HIGH = [ + r'deploy|deployment|release|rollback', + r'migration|migrate', + r'schema|alter\s+table|drop\s+table|create\s+table|truncate', + r'production|prod\b|staging\b', + r'force.?push|push\s+--force', + r'secret|credential', +] + +# Patterns that matter but are recoverable on any stack. +_UNIVERSAL_MEDIUM = [ + r'commit|push|merge|rebase', + r'test|spec|build|bundle|compile', + r'install|upgrade|uninstall', + r'delete|remove|unlink', + r'chmod|chown|cron|systemctl', +] + + +def _load_user_patterns() -> tuple[list[str], list[str]]: + """Read extra high/medium patterns from .agent/protocols/hook_patterns.json. + + Returns (high_extras, medium_extras) — lists of raw regex fragments. + Missing file or bad JSON is silently ignored so the hook never fails + because a config file is absent or malformed. + + The config file lives at .agent/protocols/hook_patterns.json and is + owned entirely by the user. Add your own service names, CLI tools, and + domain terms there — not in this file. + """ + config_path = os.path.join(AGENT_ROOT, "protocols", "hook_patterns.json") + if not os.path.isfile(config_path): + return [], [] + try: + with open(config_path) as f: + cfg = json.load(f) + except (OSError, json.JSONDecodeError): + return [], [] + high = [str(p) for p in cfg.get("high_stakes", []) if p] + medium = [str(p) for p in cfg.get("medium_stakes", []) if p] + return high, medium + + +def _build_pattern(fragments: list[str]) -> re.Pattern | None: + if not fragments: + return None + combined = r'\b(' + '|'.join(fragments) + r')\b' + return re.compile(combined, re.IGNORECASE) + + +# Build once at import time. User patterns are merged in here so there's +# no per-call file I/O. +_user_high, _user_medium = _load_user_patterns() +_HIGH = _build_pattern(_UNIVERSAL_HIGH + _user_high) +_MEDIUM = _build_pattern(_UNIVERSAL_MEDIUM + _user_medium) + + +def _importance(tool_name: str, tool_input_str: str) -> int: + if _HIGH and _HIGH.search(tool_input_str): + return 9 + if tool_name in ("Edit", "MultiEdit", "Write"): + if _MEDIUM and _MEDIUM.search(tool_input_str): + return 6 + return 5 + if _MEDIUM and _MEDIUM.search(tool_input_str): + return 6 + return 3 + + +def _pain_score(importance: int, success: bool) -> int: + """Pain score calibrated so high-importance recurring successes cross + the dream-cycle promotion threshold (7.0). + + For a cluster of 3 high-importance successes: + salience = recency(10) × pain(0.5) × importance(0.9) × recurrence(3) = 13.5 + → comfortably clears 7.0. + + Routine successes (importance ≤ 6) stay at pain=2 so they don't flood + the candidate queue. + """ + if not success: + return 8 if importance < 9 else 10 + if importance >= 8: + return 5 # significant success — recurring pattern should promote + if importance >= 6: + return 3 + return 2 + + +# --------------------------------------------------------------------------- +# Failure detection +# --------------------------------------------------------------------------- + +_ERROR_SIGNALS = re.compile( + r'\b(error|exception|traceback|failed|failure|' + r'denied|forbidden|unauthorized|' + r'ENOENT|EACCES|EPERM|ECONNREFUSED|' + r'cannot|could not|unable to|not found)\b', + re.IGNORECASE, +) + + +def _is_success(tool_name: str, resp: dict) -> bool: + """Detect failure from the tool_response dict. Conservative — only fails + on unambiguous signals so we don't discard genuine successes.""" + if not isinstance(resp, dict): + return True + + # Explicit error flag + if resp.get("is_error", False): + return False + + # Bash-specific: exit code and error stream + if tool_name == "Bash": + exit_code = resp.get("exit_code") + if exit_code is not None and exit_code != 0: + return False + if resp.get("interrupted", False): + return False + stderr = resp.get("error", "") or resp.get("stderr", "") or "" + if len(stderr) > 30 and _ERROR_SIGNALS.search(stderr): + return False + + # Generic output error heuristic (for non-Bash tools) + output = _extract_output(resp) + if output and _ERROR_SIGNALS.search(output[:200]): + # Only fail if the very start of output looks like an error, + # not just because the word "error" appears mid-output. + first_line = output.strip().splitlines()[0] if output.strip() else "" + if _ERROR_SIGNALS.search(first_line): + return False + + return True + + +# --------------------------------------------------------------------------- +# Output extraction (handles multiple Claude Code response shapes) +# --------------------------------------------------------------------------- + +def _extract_output(resp: dict) -> str: + """Pull plain text from whatever shape tool_response comes in.""" + if not isinstance(resp, dict): + return str(resp)[:300] + + # Shape 1: direct string fields + for key in ("output", "stdout", "result", "text"): + if isinstance(resp.get(key), str): + return resp[key][:500] + + # Shape 2: content array (newer Claude Code versions) + content = resp.get("content") + if isinstance(content, list): + texts = [ + c.get("text", "") for c in content + if isinstance(c, dict) and c.get("type") == "text" + ] + return " ".join(texts)[:500] + + # Shape 3: raw string response + if isinstance(resp, str): + return resp[:500] + + return "" + + +def _extract_error(resp: dict) -> str: + if not isinstance(resp, dict): + return "" + for key in ("error", "stderr", "error_message"): + v = resp.get(key) + if isinstance(v, str) and v.strip(): + return v.strip()[:300] + return "" + + +# --------------------------------------------------------------------------- +# Action label (short, searchable) +# --------------------------------------------------------------------------- + +def _action_label(tool_name: str, tool_input: dict) -> str: + """First-word summary. Ends up in the `action` field of the episodic entry.""" + if tool_name == "Bash": + cmd = tool_input.get("command", "").strip() + # Take first logical line, strip shell boilerplate + first = re.sub(r"\s+", " ", cmd.split("\n")[0].split(";")[0])[:80] + return f"bash: {first}" + + if tool_name in ("Edit", "MultiEdit"): + path = (tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("new_path") + or "?") + return f"edit: {path}" + + if tool_name == "Write": + path = tool_input.get("file_path") or tool_input.get("path") or "?" + return f"write: {path}" + + if tool_name == "Read": + path = tool_input.get("file_path") or tool_input.get("path") or "?" + return f"read: {path}" + + if tool_name == "TodoWrite": + todos = tool_input.get("todos", []) + pending = [t for t in todos if isinstance(t, dict) + and t.get("status") == "in_progress"] + if pending: + desc = pending[0].get("content", "")[:60] + return f"todo-update: {desc}" + return "todo: updated task list" + + if tool_name == "Task": + desc = (tool_input.get("description") or "")[:60] + return f"task: {desc}" + + if tool_name == "WebFetch": + url = (tool_input.get("url") or "")[:60] + return f"fetch: {url}" + + return f"tool:{tool_name}" + + +# --------------------------------------------------------------------------- +# Reflection generation (this is what the dream cycle clusters on) +# --------------------------------------------------------------------------- + +def _reflection(tool_name: str, tool_input: dict, + tool_response: dict, success: bool) -> str: + """ + Produce a non-empty, content-rich reflection string. This is the most + important field for the dream cycle — content_cluster() calls word_set() + on it. An empty reflection means zero clustering signal. + + Rules: + 1. Describe WHAT happened in domain terms. + 2. For failures: include the command and the first error line. + 3. For high-stakes ops: include the matched keyword (deploy, migration, + or whatever the user configured in hook_patterns.json). + 4. Keep under ~200 chars so detail field carries the rest. + """ + parts = [] + inp_str = json.dumps(tool_input) + + # --- Bash --- + if tool_name == "Bash": + cmd = tool_input.get("command", "").strip() + short_cmd = re.sub(r"\s+", " ", cmd.split("\n")[0])[:100] + + m = _HIGH.search(cmd) + if m: + domain = m.group(0).lower().replace(" ", "-") + if success: + parts.append(f"High-stakes op completed ({domain}): {short_cmd}") + else: + parts.append(f"High-stakes op FAILED ({domain}): {short_cmd}") + err = _extract_error(tool_response) + if err: + parts.append(f"Error: {err[:120]}") + elif not success: + parts.append(f"Command failed: {short_cmd}") + err = _extract_error(tool_response) + if err: + parts.append(f"Error: {err[:120]}") + else: + parts.append(f"Ran: {short_cmd}") + + # --- Edit --- + elif tool_name in ("Edit", "MultiEdit"): + path = tool_input.get("file_path") or tool_input.get("path") or "?" + old = (tool_input.get("old_string") or "")[:50] + new = (tool_input.get("new_string") or "")[:50] + if old and new: + parts.append( + f"Edited {path}: replaced {repr(old[:30])} " + f"with {repr(new[:30])}" + ) + else: + parts.append(f"Edited {path}") + if not success: + parts.append("Edit failed") + + # --- Write --- + elif tool_name == "Write": + path = tool_input.get("file_path") or tool_input.get("path") or "?" + content = tool_input.get("content") or "" + lines = content.count("\n") + 1 if content else 0 + parts.append(f"Wrote {path} ({lines} lines)") + if not success: + parts.append("Write failed") + + # --- TodoWrite --- + elif tool_name == "TodoWrite": + todos = tool_input.get("todos", []) + done = [t for t in todos if isinstance(t, dict) + and t.get("status") == "completed"] + in_prog = [t for t in todos if isinstance(t, dict) + and t.get("status") == "in_progress"] + if done: + parts.append( + f"Completed todo: {done[-1].get('content','')[:60]}" + ) + if in_prog: + parts.append( + f"Now working on: {in_prog[0].get('content','')[:60]}" + ) + if not parts: + parts.append(f"Updated todo list ({len(todos)} items)") + + # --- fallback --- + else: + status = "successfully" if success else "with failure" + parts.append(f"Tool {tool_name} completed {status}") + if inp_str and len(inp_str) < 80: + parts.append(inp_str) + + return ". ".join(parts) if parts else f"Tool {tool_name} ran" + + +# --------------------------------------------------------------------------- +# Detail field — what went in / what came out +# --------------------------------------------------------------------------- + +def _detail(tool_name: str, tool_input: dict, + tool_response: dict, success: bool) -> str: + """ + Stored in `detail`. More verbose than reflection. Truncated to 500 chars + by log_execution anyway. + """ + output = _extract_output(tool_response) + inp_str = json.dumps(tool_input, separators=(",", ":"))[:300] + + if tool_name == "Bash": + cmd = tool_input.get("command", "")[:120] + if not success: + err = _extract_error(tool_response) + return f"cmd={cmd!r} | exit≠0 | err={err[:200]}" + out_snip = output[:200] if output else "" + return f"cmd={cmd!r}" + (f" | out={out_snip}" if out_snip else "") + + return inp_str + (f" | {output[:150]}" if output else "") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + # --- read payload from stdin --- + try: + raw = sys.stdin.read() + payload = json.loads(raw) if raw.strip() else {} + except (json.JSONDecodeError, OSError): + payload = {} + + # Fallback to env vars (older Claude Code versions, or empty stdin) + tool_name = ( + payload.get("tool_name") + or os.environ.get("CLAUDE_TOOL_NAME") + or "Unknown" + ) + + tool_input = payload.get("tool_input") or {} + if isinstance(tool_input, str): + try: + tool_input = json.loads(tool_input) + except (json.JSONDecodeError, ValueError): + tool_input = {"raw": tool_input} + + # Env-var fallback for tool_input + if not tool_input: + raw_input_env = os.environ.get("CLAUDE_TOOL_INPUT", "") + if raw_input_env: + try: + tool_input = json.loads(raw_input_env) + except (json.JSONDecodeError, ValueError): + tool_input = {"raw": raw_input_env} + + tool_response = payload.get("tool_response") or {} + if isinstance(tool_response, str): + try: + tool_response = json.loads(tool_response) + except (json.JSONDecodeError, ValueError): + tool_response = {"raw": tool_response} + + # Env-var fallback for tool_response + if not tool_response: + raw_resp_env = os.environ.get("CLAUDE_TOOL_RESPONSE", "") + if raw_resp_env: + try: + tool_response = json.loads(raw_resp_env) + except (json.JSONDecodeError, ValueError): + tool_response = {"raw": raw_resp_env} + + # --- derive everything --- + success = _is_success(tool_name, tool_response) + importance = _importance(tool_name, json.dumps(tool_input)) + action = _action_label(tool_name, tool_input) + reflection = _reflection(tool_name, tool_input, tool_response, success) + detail = _detail(tool_name, tool_input, tool_response, success) + + # --- write episodic entry --- + pscore = _pain_score(importance, success) + if success: + log_execution( + skill_name="claude-code", + action=action, + result=detail, + success=True, + reflection=reflection, + importance=importance, + confidence=0.7, + pain_score=pscore, + ) + else: + on_failure( + skill_name="claude-code", + action=action, + error=reflection, + context=detail, + confidence=0.7, + ) + + +if __name__ == "__main__": + main() diff --git a/.agent/harness/hooks/on_failure.py b/.agent/harness/hooks/on_failure.py index 850ff7f..80a1420 100644 --- a/.agent/harness/hooks/on_failure.py +++ b/.agent/harness/hooks/on_failure.py @@ -33,6 +33,16 @@ def _count_recent_failures(skill_name): def on_failure(skill_name, action, error, context="", confidence=0.9, evidence_ids=None): + # Format reflection without the noisy `type(error).__name__:` prefix + # when the caller passes a pre-formatted string (the common case for + # hook callers). Only include the type name for actual Exception objects + # where it carries diagnostic value. + if isinstance(error, Exception): + _refl = (f"FAILURE in {skill_name}: {type(error).__name__}: " + f"{str(error)[:200]}") + else: + _refl = f"FAILURE in {skill_name}: {str(error)[:200]}" + entry = { "timestamp": datetime.datetime.now().isoformat(), "skill": skill_name, @@ -41,8 +51,7 @@ def on_failure(skill_name, action, error, context="", confidence=0.9, "detail": str(error)[:500], "pain_score": 8, "importance": 7, - "reflection": f"FAILURE in {skill_name}: {type(error).__name__}: " - f"{str(error)[:200]}", + "reflection": _refl, "context": context[:300], "confidence": confidence, "source": build_source(skill_name), diff --git a/.agent/harness/hooks/post_execution.py b/.agent/harness/hooks/post_execution.py index 7bc90f4..92bacf9 100644 --- a/.agent/harness/hooks/post_execution.py +++ b/.agent/harness/hooks/post_execution.py @@ -7,15 +7,24 @@ def log_execution(skill_name, action, result, success, reflection="", - importance=5, confidence=0.5, evidence_ids=None): + importance=5, confidence=0.5, evidence_ids=None, + pain_score=None): + """Log a structured episodic entry. + + pain_score: override the default (2 for success, 7 for failure). Pass + a higher value (e.g. 5) for high-importance successful operations so + recurring patterns cross the dream-cycle promotion threshold (7.0). + """ os.makedirs(os.path.dirname(EPISODIC), exist_ok=True) + if pain_score is None: + pain_score = 2 if success else 7 entry = { "timestamp": datetime.datetime.now().isoformat(), "skill": skill_name, "action": action[:200], "result": "success" if success else "failure", "detail": str(result)[:500], - "pain_score": 2 if success else 7, + "pain_score": pain_score, "importance": importance, "reflection": reflection, "confidence": confidence, diff --git a/.agent/protocols/hook_patterns.json b/.agent/protocols/hook_patterns.json new file mode 100644 index 0000000..de5df2c --- /dev/null +++ b/.agent/protocols/hook_patterns.json @@ -0,0 +1,66 @@ +{ + "_comment": [ + "Extra patterns for the PostToolUse hook importance scorer.", + "", + "high_stakes -> importance=9 (pain_score=5 for successes, 8+ for failures)", + "medium_stakes -> importance=6", + "", + "HOW IMPORTANCE IS DECIDED:", + " The hook matches the OPERATION, not the service brand.", + " 'vercel deploy' is high-stakes because of 'deploy', not 'vercel'.", + " 'supabase db push' is medium because 'push' -- add 'supabase' to", + " high_stakes here if you want every supabase command scored as 9.", + "", + "ALREADY HIGH-STAKES (built-in, no config needed):", + " deploy, release, rollback, migration, migrate, schema,", + " alter/drop/create table, truncate, production, staging,", + " force-push, push --force, secret, credential.", + "", + "ALREADY MEDIUM-STAKES (built-in):", + " commit, push, merge, rebase, test, spec, build, bundle,", + " compile, install, upgrade, delete, remove, chmod, cron.", + "", + "Add service names or domain-specific terms your project uses.", + "Values are word-boundary regex fragments (case-insensitive).", + "Restart Claude Code after editing -- patterns load at hook startup.", + "", + "Copy entries from _examples into high_stakes / medium_stakes to activate." + ], + "high_stakes": [], + "medium_stakes": [], + "_examples": { + "_comment": "Copy entries from here into high_stakes / medium_stakes above.", + "high_stakes": [ + "supabase", + "vercel", + "railway", + "fly\\.io", + "render\\.com", + "aws\\s+", + "gcloud\\s+", + "kubectl", + "heroku", + "docker\\s+push", + "npm\\s+publish", + "pip\\s+publish", + "stripe", + "twilio", + "sendgrid", + "resend", + "planetscale", + "neon", + "turso", + "upstash", + "cloudflare\\s+", + "firebase" + ], + "medium_stakes": [ + "jest", + "pytest", + "vitest", + "mocha", + "cypress", + "playwright" + ] + } +} \ No newline at end of file diff --git a/.agent/tools/memory_reflect.py b/.agent/tools/memory_reflect.py index 7101bb0..fd6ae6b 100644 --- a/.agent/tools/memory_reflect.py +++ b/.agent/tools/memory_reflect.py @@ -7,12 +7,14 @@ def reflect(skill_name, action, outcome, success=True, importance=5, - reflection="", error=None, confidence=None, evidence_ids=None): + reflection="", error=None, confidence=None, evidence_ids=None, + pain_score=None): if success: return log_execution(skill_name, action, outcome, True, reflection=reflection, importance=importance, confidence=0.5 if confidence is None else confidence, - evidence_ids=evidence_ids) + evidence_ids=evidence_ids, + pain_score=pain_score) return on_failure(skill_name, action, error or outcome, context=reflection, confidence=0.9 if confidence is None else confidence, @@ -31,8 +33,11 @@ def reflect(skill_name, action, outcome, success=True, importance=5, p.add_argument("--confidence", type=float, default=None) p.add_argument("--evidence", nargs="*", default=None, help="Space-separated episode/lesson IDs this entry builds on.") + p.add_argument("--pain", type=int, default=None, + help="Override pain_score (2=routine, 5=significant success, " + "8=failure, 10=incident). Default: 2 for success, 7 for --fail.") args = p.parse_args() print(reflect(args.skill, args.action, args.outcome, success=not args.fail, importance=args.importance, reflection=args.note, confidence=args.confidence, - evidence_ids=args.evidence)) + evidence_ids=args.evidence, pain_score=args.pain)) diff --git a/adapters/claude-code/CLAUDE.md b/adapters/claude-code/CLAUDE.md index 9b816e9..91f0302 100644 --- a/adapters/claude-code/CLAUDE.md +++ b/adapters/claude-code/CLAUDE.md @@ -3,41 +3,106 @@ This project uses the **agentic-stack** portable brain. All memory, skills, and protocols live in `.agent/`. -## Before doing anything -1. Read `.agent/AGENTS.md` — it's the map. -2. Read `.agent/memory/personal/PREFERENCES.md` — how the user works. -3. Read `.agent/memory/semantic/LESSONS.md` — what we've learned. -4. Read `.agent/protocols/permissions.md` — what you can and cannot do. +## Session start — read in this order +1. `.agent/AGENTS.md` — the map of the whole brain +2. `.agent/memory/personal/PREFERENCES.md` — how the user works +3. `.agent/memory/working/REVIEW_QUEUE.md` — pending lessons awaiting review +4. `.agent/memory/semantic/LESSONS.md` — what we've already learned +5. `.agent/protocols/permissions.md` — hard constraints, read before any tool call + +## Before every non-trivial action — recall first -## Before every non-trivial task — recall first For any task involving **deploy**, **ship**, **release**, **migration**, -**schema change**, **timestamp** / **timezone** / **date**, **failing test**, -**debug**, **investigate**, or **refactor**, run recall FIRST and present -the surfaced lessons to yourself before acting: +**schema change**, **supabase**, **edge function**, **timestamp** / +**timezone** / **date**, **failing test**, **debug**, **investigate**, or +**refactor**, run recall FIRST and present the results before acting: ```bash python3 .agent/tools/recall.py "" ``` -If the output contains a "Consulted lessons for intent:" block with one or -more results, show them to the user in a `Consulted lessons before acting:` -block and adjust your plan to respect them. If a surfaced lesson would be -violated by your intended action, stop and explain. - -This is how graduated lessons actually change behavior across harnesses. -Skip it and the system is just files on disk. +Show the output in a `Consulted lessons before acting:` block. If a surfaced +lesson would be violated by your intended action, stop and explain why. ## While working -- Consult `.agent/skills/_index.md` and load the full `SKILL.md` for any - skill whose triggers match the task. -- Update `.agent/memory/working/WORKSPACE.md` as the task evolves. -- Log significant actions to `.agent/memory/episodic/AGENT_LEARNINGS.jsonl` - via `.agent/tools/memory_reflect.py`. -- Quick state check any time: `python3 .agent/tools/show.py`. -- Teach the agent a new rule in one shot: - `python3 .agent/tools/learn.py "" --rationale ""`. - -## Rules that override defaults + +### Skills +Read `.agent/skills/_index.md` and load the full `SKILL.md` for any skill +whose triggers match the task. Don't skip this — skills carry constraints +the permissions file doesn't cover. + +### Workspace +Update `.agent/memory/working/WORKSPACE.md` when: +- You start a new task (write the goal and first step) +- Your hypothesis changes +- You complete or abandon a task (clear it so the next session is clean) + +### Brain state +Quick overview any time: +```bash +python3 .agent/tools/show.py +``` + +### Teaching the agent a new rule +When you discover something that should never happen again: +```bash +python3 .agent/tools/learn.py "" \ + --rationale "" +``` + +## Manual memory logging — when and how + +The PostToolUse hook captures every tool call automatically, but its +reflections are mechanical. For **significant events** you must call +`memory_reflect.py` explicitly with a rich `--note`. These are the entries +the dream cycle promotes into lessons. + +### When to log manually +- After completing a major feature or fixing a bug that took real investigation +- After any rollback, incident, or unexpected failure +- After any architectural decision (why you chose approach A over B) +- After discovering a project-specific constraint (e.g. "this table has a + trigger that fires on every insert — don't bulk insert") +- After a Supabase migration, RLS policy change, or edge function deploy +- Any time you think "I wish I had known this an hour ago" + +### How to write a good entry + +```bash +# Good: specific, domain-rich, future-oriented +python3 .agent/tools/memory_reflect.py \ + "supabase-migration" \ + "applied add_user_tier_column migration" \ + "migration succeeded; 847 rows backfilled to tier=free" \ + --importance 8 \ + --note "RLS policy on user_profiles must be updated whenever a new column is added that affects row visibility. Missed this, caused 401s in staging for 20 minutes." + +# Good: failure with root cause +python3 .agent/tools/memory_reflect.py \ + "edge-function" \ + "deployed notify-on-signup" \ + "deploy failed: missing RESEND_API_KEY in production env" \ + --fail \ + --importance 9 \ + --note "Production env vars for edge functions must be set in supabase secrets, not .env. The .env file is ignored at deploy time." + +# Bad: vague, no content words for clustering +python3 .agent/tools/memory_reflect.py \ + "claude-code" "did stuff" "ok" --importance 3 +``` + +### Importance guide +| Value | When | +|---|---| +| 9–10 | Production incident, data migration, rollback, security issue | +| 7–8 | Deploy, schema change, architectural decision, non-obvious constraint | +| 5–6 | Refactor, significant bug fix, API contract change | +| 3–4 | Routine edit, file creation, test run | + +## Rules that override all defaults - Never force push to `main`, `production`, or `staging`. - Never delete episodic or semantic memory entries — archive them. -- Never modify `.agent/protocols/permissions.md`. +- Never modify `.agent/protocols/permissions.md` — only humans edit it. +- Never hand-edit `.agent/memory/semantic/LESSONS.md` — use `graduate.py`. +- If `REVIEW_QUEUE.md` shows pending > 10 or oldest > 7 days, review + candidates before starting substantive work. diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index bd420ed..971f37c 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -16,12 +16,81 @@ Or let the top-level install script do it: ``` ## What it wires up -- `CLAUDE.md` tells Claude Code to read `.agent/` before acting. -- `.claude/settings.json` adds: - - A **PostToolUse** hook that logs every Bash/Edit/Write call to episodic memory. - - A **Stop** hook that runs the dream cycle when a session ends. - - Permission denies for the most destructive operations (force push, `rm -rf /`). + +- **`CLAUDE.md`** — boot instructions at project root. Claude Code reads this + before every session. Tells the model to read the brain in the correct order, + run `recall.py` before high-stakes operations, and call `memory_reflect.py` + manually for significant events. + +- **`.claude/settings.json`** — two hooks + permission denies: + + | Hook | Trigger | Script | + |---|---|---| + | `PostToolUse` | `Bash\|Edit\|MultiEdit\|Write\|Task\|TodoWrite` | `.agent/harness/hooks/claude_code_post_tool.py` | + | `Stop` | `*` (session end) | `.agent/memory/auto_dream.py` | + +### Why `claude_code_post_tool.py` and not `memory_reflect.py` + +The old hook called `memory_reflect.py claude-code post-tool ok` — every +entry was identical (action="post-tool", detail="ok", reflection=""). The +dream cycle clusters on the `reflection` field; an empty reflection means +zero candidates staged regardless of how many tool calls fire. + +`claude_code_post_tool.py` reads the JSON payload Claude Code sends via +**stdin** on every PostToolUse event: + +```json +{ + "tool_name": "Bash", + "tool_input": {"command": "supabase db push --db-url $DATABASE_URL"}, + "tool_response": {"output": "Applied 1 migration.", "exit_code": 0} +} +``` + +It then: +- Maps `tool_name` + `tool_input` to a meaningful action label +- Scores `importance` by domain (deploy/migrate/supabase/edge-function = 9) +- Detects failures from `exit_code`, `error` stream, and `is_error` +- Generates a non-empty `reflection` the dream cycle can cluster on +- Sets `pain_score=5` for high-importance successes so recurring patterns + cross the promotion threshold (7.0); routine ops stay at `pain_score=2` ## Verify -Open Claude Code in the project and ask: "What's in my lessons file?" -If it reads `.agent/memory/semantic/LESSONS.md`, the wiring works. + +1. Open Claude Code in your project. +2. Run one Bash command. +3. Check the last line of `.agent/memory/episodic/AGENT_LEARNINGS.jsonl`: + - `action` should describe the actual command, not `"post-tool"` + - `reflection` should be non-empty + - `importance` should be 9 for deploy/supabase ops, 3 for `git status` + +```bash +tail -1 .agent/memory/episodic/AGENT_LEARNINGS.jsonl | python3 -m json.tool +``` + +4. Check brain state: +```bash +python3 .agent/tools/show.py +``` + +## Troubleshooting + +- **Hook doesn't fire at all:** run `claude settings` and confirm your + `.claude/settings.json` appears in the merged config. Claude Code merges + project-level settings with global `~/.claude/settings.json`. + +- **`stdin` is empty / payload is `{}`:** older Claude Code versions may not + pass the JSON payload. The hook falls back to `CLAUDE_TOOL_NAME` / + `CLAUDE_TOOL_INPUT` env vars. The action label will still be correct; the + detail and output capture will be empty. Upgrade Claude Code to get full + stdin payloads. + +- **`python3` not found:** add `AGENT_PYTHON=python` to your shell profile + and edit the hook commands in `.claude/settings.json` accordingly. + +- **Dream cycle stages nothing:** after a session, run + `python3 .agent/memory/auto_dream.py` manually and check the output line. + If `patterns=0`, the episodic log is either empty or all entries have + empty reflections (old hook). If `patterns=N staged=0`, salience is too + low — check that `importance` and `pain_score` are non-trivial in your + entries. diff --git a/adapters/claude-code/settings.json b/adapters/claude-code/settings.json index fed32e4..a0065c7 100644 --- a/adapters/claude-code/settings.json +++ b/adapters/claude-code/settings.json @@ -3,11 +3,11 @@ "hooks": { "PostToolUse": [ { - "matcher": "Bash|Edit|Write", + "matcher": "Bash|Edit|MultiEdit|Write|Task|TodoWrite", "hooks": [ { "type": "command", - "command": "python3 .agent/tools/memory_reflect.py claude-code post-tool ok" + "command": "python3 .agent/harness/hooks/claude_code_post_tool.py" } ] } diff --git a/test_claude_code_hook.py b/test_claude_code_hook.py new file mode 100644 index 0000000..15261b5 --- /dev/null +++ b/test_claude_code_hook.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +""" +Validation suite for the Claude Code hook fix. + +Run this from any project that has .agent/ installed: + + python3 /path/to/agentic-stack/test_claude_code_hook.py + +Or run it from the agentic-stack repo itself: + + python3 test_claude_code_hook.py + +Exit 0 = all tests passed. Non-zero = something is broken. + +Tests: + 1. Hook imports correctly (path resolution works from project root) + 2. Empty stdin doesn't crash (graceful fallback) + 3. action label, importance, success detection, reflection for each tool type + 4. pain_score calibration (high-importance success = 5, failure = 8) + 5. Full write path: hook writes a real entry to AGENT_LEARNINGS.jsonl + 6. Dream cycle produces staged candidates from rich entries + 7. memory_reflect.py CLI --pain flag works + 8. post_execution.py pain_score parameter is accepted + 9. on_failure reflection has no 'str:' prefix for string errors +""" + +import json, os, shutil, subprocess, sys, tempfile, textwrap + +# ── find .agent/ ───────────────────────────────────────────────────────────── + +def find_agent_root(): + """Walk up from cwd until we find .agent/""" + cur = os.path.abspath(".") + for _ in range(5): + if os.path.isdir(os.path.join(cur, ".agent")): + return cur + cur = os.path.dirname(cur) + # last resort: look next to this script + here = os.path.dirname(os.path.abspath(__file__)) + if os.path.isdir(os.path.join(here, ".agent")): + return here + return None + +PROJECT_ROOT = find_agent_root() +if not PROJECT_ROOT: + print("FATAL: .agent/ not found. Run from your project root or the agentic-stack repo.") + sys.exit(1) + +AGENT_DIR = os.path.join(PROJECT_ROOT, ".agent") +HOOK_SCRIPT = os.path.join(AGENT_DIR, "harness", "hooks", "claude_code_post_tool.py") +EPISODIC = os.path.join(AGENT_DIR, "memory", "episodic", "AGENT_LEARNINGS.jsonl") + +sys.path.insert(0, os.path.join(AGENT_DIR, "harness")) +sys.path.insert(0, os.path.join(AGENT_DIR, "memory")) +sys.path.insert(0, os.path.join(AGENT_DIR, "tools")) + +# ── helpers ─────────────────────────────────────────────────────────────────── + +PASS = "\033[32m✓\033[0m" +FAIL = "\033[31m✗\033[0m" +WARN = "\033[33m~\033[0m" + +_results = [] + +def ok(name): + _results.append((True, name)) + print(f" {PASS} {name}") + +def fail(name, detail=""): + _results.append((False, name)) + msg = f" {FAIL} {name}" + if detail: + msg += f"\n {detail}" + print(msg) + +def section(title): + print(f"\n\033[1m{title}\033[0m") + +def run_hook(payload): + """Run the hook script with a JSON payload on stdin. Returns (returncode, last_entry_or_None).""" + before = _last_entry() + r = subprocess.run( + [sys.executable, HOOK_SCRIPT], + input=json.dumps(payload), + capture_output=True, text=True, + cwd=PROJECT_ROOT, + ) + after = _last_entry() + new_entry = after if after != before else None + return r.returncode, new_entry, r.stderr + +def _last_entry(): + if not os.path.exists(EPISODIC): + return None + lines = [l.strip() for l in open(EPISODIC) if l.strip()] + if not lines: + return None + try: + return json.loads(lines[-1]) + except json.JSONDecodeError: + return None + +# ── import the hook module once ─────────────────────────────────────────────── + +import importlib.util + +def _load_hook(): + spec = importlib.util.spec_from_file_location("claude_code_post_tool", HOOK_SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +# ── tests ───────────────────────────────────────────────────────────────────── + +def test_hook_exists(): + section("1. File existence") + if os.path.isfile(HOOK_SCRIPT): + ok("claude_code_post_tool.py exists") + else: + fail("claude_code_post_tool.py NOT FOUND", + f"expected: {HOOK_SCRIPT}") + +def test_hook_imports(): + section("2. Import / path resolution") + try: + mod = _load_hook() + ok("hook imports without error") + return mod + except Exception as e: + fail("hook import failed", str(e)) + return None + +def test_empty_stdin(): + section("3. Empty stdin — graceful fallback") + r = subprocess.run( + [sys.executable, HOOK_SCRIPT], + input="", capture_output=True, text=True, cwd=PROJECT_ROOT, + ) + if r.returncode == 0: + ok("empty stdin exits 0") + else: + fail("empty stdin crashed", r.stderr[:200]) + +def test_action_labels(mod): + section("4. Action labels") + cases = [ + ("Bash", {"command": "supabase db push --db-url $URL"}, + "bash: supabase db push"), + ("Edit", {"file_path": "src/App.tsx", "old_string": "v1", "new_string": "v2"}, + "edit: src/App.tsx"), + ("Write", {"file_path": "src/new.ts", "content": "export {}"}, + "write: src/new.ts"), + ("Bash", {"command": "git status"}, + "bash: git status"), + ] + for tool, inp, expected_prefix in cases: + label = mod._action_label(tool, inp) + if label.startswith(expected_prefix): + ok(f"action label: {tool} → {label!r}") + else: + fail(f"action label: {tool}", f"got {label!r}, expected prefix {expected_prefix!r}") + +def test_importance(mod): + section("5. Importance scoring") + # Importance is driven by the OPERATION, not the service brand. + # 'vercel deploy' → 9 because 'deploy' is universal high, not because of 'vercel'. + # 'supabase db push' → 6 because 'push' is universal medium. + # Service names only change importance when added to hook_patterns.json. + cases = [ + ("Bash", '{"command":"deploy to production"}', 9, "deploy (universal high)"), + ("Bash", '{"command":"python migrate.py"}', 9, "migrate (universal high)"), + ("Bash", '{"command":"git push --force"}', 9, "force push (universal high)"), + ("Bash", '{"command":"vercel deploy"}', 9, "vercel deploy → high via 'deploy'"), + ("Bash", '{"command":"supabase db push"}', 6, "supabase db push → medium via 'push'"), + ("Bash", '{"command":"stripe listen"}', 3, "stripe listen → low (no op match)"), + ("Bash", '{"command":"npm test"}', 6, "npm test (universal medium)"), + ("Bash", '{"command":"git status"}', 3, "git status (low)"), + ("Edit", '{"file_path":"src/App.tsx"}', 5, "plain edit"), + ] + for tool, inp, expected, label in cases: + got = mod._importance(tool, inp) + if got == expected: + ok(f"importance: {label} → {got}") + else: + fail(f"importance: {label}", f"expected {expected}, got {got}") + +def test_pain_score(mod): + section("6. Pain score calibration") + # high importance success → 5 (so clusters can cross 7.0 threshold) + ps = mod._pain_score(9, True) + if ps == 5: + ok("pain_score(importance=9, success=True) → 5") + else: + fail("pain_score high-importance success", f"expected 5, got {ps}") + + # routine success → 2 + ps = mod._pain_score(3, True) + if ps == 2: + ok("pain_score(importance=3, success=True) → 2") + else: + fail("pain_score routine success", f"expected 2, got {ps}") + + # failure → 8 or 10 + ps = mod._pain_score(7, False) + if ps in (8, 10): + ok(f"pain_score(importance=7, success=False) → {ps}") + else: + fail("pain_score failure", f"expected 8 or 10, got {ps}") + +def test_failure_detection(mod): + section("7. Failure detection") + cases = [ + ("Bash", {"exit_code": 1, "error": "command not found"}, False, "exit_code=1"), + ("Bash", {"exit_code": 0, "error": ""}, True, "exit_code=0"), + ("Bash", {"exit_code": 0, "is_error": True}, False, "is_error=True"), + ("Edit", {"is_error": False}, True, "edit ok"), + ("Bash", {"interrupted": True}, False, "interrupted"), + ] + for tool, resp, expected, label in cases: + got = mod._is_success(tool, resp) + if got == expected: + ok(f"success detection: {label} → {got}") + else: + fail(f"success detection: {label}", f"expected {expected}, got {got}") + +def test_reflection_non_empty(mod): + section("8. Reflection is non-empty (dream cycle can cluster on it)") + cases = [ + ("Bash", {"command": "supabase db push"}, {}, True), + ("Bash", {"command": "supabase db push"}, {"exit_code": 1, "error": "no migrations"}, False), + ("Edit", {"file_path": "src/x.ts", "old_string": "a", "new_string": "b"}, {}, True), + ("Write", {"file_path": "src/y.ts", "content": "export {}"}, {}, True), + ("Bash", {"command": "git status"}, {"output": "nothing"}, True), + ] + for tool, inp, resp, success in cases: + ref = mod._reflection(tool, inp, resp, success) + if ref and len(ref) >= 10: + ok(f"reflection non-empty: {tool} {'fail' if not success else 'ok'} — {ref[:60]!r}") + else: + fail(f"reflection empty: {tool}", f"got: {ref!r}") + +def test_full_write(mod): + section("9. Full write path — entry actually lands in AGENT_LEARNINGS.jsonl") + # Use a universally high-stakes command (deploy) so importance=9 regardless + # of what the user has in hook_patterns.json. + payload = { + "tool_name": "Bash", + "tool_input": {"command": "npm run deploy --env production"}, + "tool_response": {"output": "Deployed successfully.", "exit_code": 0, "error": ""} + } + rc, entry, stderr = run_hook(payload) + if rc != 0: + fail("hook exited non-zero", stderr[:200]) + return + if entry is None: + fail("no new entry written to AGENT_LEARNINGS.jsonl") + return + ok("entry written to AGENT_LEARNINGS.jsonl") + + checks = [ + ("action starts with 'bash:'", entry.get("action","").startswith("bash:")), + ("reflection non-empty", bool(entry.get("reflection",""))), + ("importance == 9", entry.get("importance") == 9), + ("pain_score == 5", entry.get("pain_score") == 5), + ("result == success", entry.get("result") == "success"), + ] + for label, passed in checks: + (ok if passed else fail)(f" entry.{label}") + +def test_failure_write(mod): + section("10. Failure write path — failed tool call is logged correctly") + payload = { + "tool_name": "Bash", + "tool_input": {"command": "supabase functions deploy payment-webhook"}, + "tool_response": { + "output": "", + "exit_code": 1, + "error": "Error: STRIPE_SECRET_KEY not found in production secrets" + } + } + rc, entry, stderr = run_hook(payload) + if entry is None: + fail("no entry written for failure case") + return + ok("failure entry written") + + checks = [ + ("result == failure", entry.get("result") == "failure"), + ("pain_score >= 8", entry.get("pain_score", 0) >= 8), + ("reflection mentions FAIL or FAILURE", + "FAIL" in entry.get("reflection", "").upper()), + ("reflection has no 'str:' prefix", + ": str:" not in entry.get("reflection", "")), + ("error captured in reflection", + "STRIPE" in entry.get("reflection", "")), + ] + for label, passed in checks: + (ok if passed else fail)(f" entry.{label}") + +def test_dream_cycle(): + section("11. Dream cycle produces staged candidates from rich entries") + # Use a universally high-stakes command so importance=9 / pain_score=5 + # regardless of what the user has configured in hook_patterns.json. + # 4 identical entries → cluster_size=4, salience=10*0.5*0.9*3=13.5 > 7.0 threshold. + payload = { + "tool_name": "Bash", + "tool_input": {"command": "npm run deploy --env production"}, + "tool_response": {"output": "Deployed successfully", "exit_code": 0} + } + for _ in range(4): + run_hook(payload) + + r = subprocess.run( + [sys.executable, os.path.join(AGENT_DIR, "memory", "auto_dream.py")], + capture_output=True, text=True, cwd=PROJECT_ROOT, + ) + line = r.stdout.strip() + ok(f"auto_dream.py ran: {line}") + + # Check for staged candidates + import re + m = re.search(r"staged=(\d+)", line) + staged = int(m.group(1)) if m else 0 + if staged > 0: + ok(f"dream cycle staged {staged} candidate(s)") + else: + # Could also already be in graduated/ from earlier test run — not a failure + m2 = re.search(r"pending_review=(\d+)", line) + pending = int(m2.group(1)) if m2 else 0 + if pending > 0: + ok(f"pending_review={pending} (candidates exist from earlier run)") + else: + fail("dream cycle staged 0 candidates", + "Check that importance=9 and pain_score=5 entries were written") + +def test_memory_reflect_pain_flag(): + section("12. memory_reflect.py --pain flag") + r = subprocess.run( + [sys.executable, + os.path.join(AGENT_DIR, "tools", "memory_reflect.py"), + "test-skill", "test-action", "test-outcome", + "--importance", "9", "--pain", "5", + "--note", "explicit pain score test"], + capture_output=True, text=True, cwd=PROJECT_ROOT, + ) + if r.returncode != 0: + fail("memory_reflect.py --pain exited non-zero", r.stderr[:200]) + return + ok("memory_reflect.py --pain 5 ran without error") + entry = _last_entry() + if entry and entry.get("pain_score") == 5: + ok(" pain_score=5 written correctly") + else: + ps = entry.get("pain_score") if entry else "no entry" + fail(" pain_score not 5", f"got: {ps}") + +def test_post_execution_pain_param(): + section("13. post_execution.log_execution accepts pain_score kwarg") + try: + from hooks.post_execution import log_execution + import inspect + sig = inspect.signature(log_execution) + if "pain_score" in sig.parameters: + ok("log_execution has pain_score parameter") + else: + fail("log_execution missing pain_score parameter", + "post_execution.py was not updated") + except Exception as e: + fail("could not inspect log_execution", str(e)) + +def test_hook_patterns_config(): + section("14. hook_patterns.json — user config overrides work") + config_path = os.path.join(AGENT_DIR, "protocols", "hook_patterns.json") + if not os.path.exists(config_path): + fail("hook_patterns.json not found", f"expected: {config_path}") + return + ok("hook_patterns.json exists") + + try: + cfg = json.load(open(config_path)) + except json.JSONDecodeError as e: + fail("hook_patterns.json is invalid JSON", str(e)) + return + ok("hook_patterns.json is valid JSON") + + # Must have the right keys + for key in ("high_stakes", "medium_stakes", "_examples"): + if key in cfg: + ok(f" has '{key}' key") + else: + fail(f" missing '{key}' key") + + # high_stakes must be a list (empty by default) + if isinstance(cfg.get("high_stakes"), list): + ok(f" high_stakes is a list ({len(cfg['high_stakes'])} entries by default)") + else: + fail(" high_stakes is not a list") + + # Verify user additions are picked up: temporarily add a pattern, reload + orig_high = cfg["high_stakes"][:] + cfg["high_stakes"] = ["mycustomcli"] + with open(config_path, "w") as f: + json.dump(cfg, f, indent=2) + try: + mod2 = _load_hook() + got = mod2._importance("Bash", '{"command":"mycustomcli run"}') + if got == 9: + ok(" user-added pattern correctly scores importance=9") + else: + fail(" user-added pattern did not score 9", f"got {got}") + finally: + cfg["high_stakes"] = orig_high + with open(config_path, "w") as f: + json.dump(cfg, f, indent=2) + + +def test_settings_json(): + section("14. settings.json points to new hook") + settings_path = os.path.join(PROJECT_ROOT, ".claude", "settings.json") + adapter_path = os.path.join(PROJECT_ROOT, "adapters", "claude-code", "settings.json") + + for label, path in [("adapter settings.json", adapter_path), + (".claude/settings.json (project)", settings_path)]: + if not os.path.exists(path): + print(f" {WARN} {label}: not found (skip)") + continue + try: + s = json.load(open(path)) + except json.JSONDecodeError as e: + fail(f"{label}: invalid JSON", str(e)) + continue + + hooks = (s.get("hooks", {}) + .get("PostToolUse", [])) + cmds = [h.get("command", "") for entry in hooks + for h in entry.get("hooks", [])] + uses_new = any("claude_code_post_tool" in c for c in cmds) + uses_old = any("post-tool ok" in c for c in cmds) + + if uses_new: + ok(f"{label}: uses claude_code_post_tool.py") + elif uses_old: + fail(f"{label}: still uses old hardcoded 'post-tool ok'", + f"Run: cp adapters/claude-code/settings.json .claude/settings.json") + else: + print(f" {WARN} {label}: hook command not recognized — check manually") + +# ── summary ─────────────────────────────────────────────────────────────────── + +def main(): + print(f"\n\033[1magentic-stack claude-code hook validation\033[0m") + print(f"project root: {PROJECT_ROOT}") + print(f"agent dir: {AGENT_DIR}") + + test_hook_exists() + mod = test_hook_imports() + if mod is None: + print("\n\033[31mCannot continue — hook did not import.\033[0m") + sys.exit(1) + + test_empty_stdin() + test_action_labels(mod) + test_importance(mod) + test_pain_score(mod) + test_failure_detection(mod) + test_reflection_non_empty(mod) + test_full_write(mod) + test_failure_write(mod) + test_dream_cycle() + test_memory_reflect_pain_flag() + test_post_execution_pain_param() + test_hook_patterns_config() + test_settings_json() + + passed = sum(1 for ok, _ in _results if ok) + failed = sum(1 for ok, _ in _results if not ok) + total = len(_results) + + print(f"\n{'─'*50}") + if failed == 0: + print(f"\033[32m {passed}/{total} passed — all good\033[0m") + print(f"\n Next steps:") + print(f" 1. Install into a real project: ./install.sh claude-code /path/to/project") + print(f" 2. Open Claude Code, run a Supabase or deploy command") + print(f" 3. tail -1 .agent/memory/episodic/AGENT_LEARNINGS.jsonl | python3 -m json.tool") + print(f" 4. Verify action/reflection/importance are non-trivial") + print(f" 5. Submit PR when satisfied\n") + sys.exit(0) + else: + print(f"\033[31m {failed}/{total} failed\033[0m ({passed} passed)") + print() + for ok_flag, name in _results: + if not ok_flag: + print(f" {FAIL} {name}") + sys.exit(1) + + +if __name__ == "__main__": + main()