Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions corpus/skills/cat-mode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,9 @@ agent switch, or resubmit is a fix, and none comes before the repro.

**An interruption or stuck state gets instrument-level proof before a fix, and the fix goes to a subagent.** A poll loop not converging, a process not responding as expected, a restart that doesn't complete — treat this as its own investigation, not something to guess through inline. Gather real evidence first (the target's own logs, `ps -o stat,wchan`, a live query) before naming a cause, then delegate the actual fix to a subagent rather than hand-patching it in the main thread. A DO1 restart once looked hung on a stale PID; the owner's own log showed the real mechanism in two lines — `received SIGTERM, shutting down gracefully` followed 30s later by `process survived SIGTERM for 30000ms after worker stop; restarting worker` — a per-worker watchdog resurrecting mid-shutdown under real task load, not a hang.

**A factual or technical claim gets a real repro script, not a history search.**
Judging an old comment or a "probably confabulated" suspicion needs an actual attempt under the claimed conditions, not a `git log` sweep. No citation means
"never verified," not "false." A live repro proved a dismissed "yauzl hangs" comment was real on the pinned versions.
**UI testing must not disrupt the user's own session.** Prove a UI or surface change somewhere disposable — a test channel or workspace, a throwaway profile, a second display, a VM, a headless run. Driving the user's real keyboard, mouse, or screen is a last resort needing an explicit hands-off window first: state the acceptance test in one line, get the yes, `touch /tmp/.ui-input-window`, and remove it when the window closes; a PreToolUse hook (`engine/hooks/ui-input-guard/`) blocks synthetic input and screen recording while no window is open, the screen is locked, or the user is still typing. Stop at the first sign the session is theirs again (idle time drops, the frontmost app changes, the screen locks), and leave no residue: undo stray messages, pins, or reactions, or say what was left behind.

**A factual or technical claim gets a real repro script, not a history search.** Judging an old comment or a "probably confabulated" suspicion needs an actual attempt under the claimed conditions, not a `git log` sweep. No citation means "never verified," not "false."

**Unhedged root-cause or fix claims about live system behavior need
instrument-level proof in the same message, or `UNVERIFIED:`.** The gate is the claim type ("this is why it's slow," "this is the bug"), not a
Expand Down
1 change: 1 addition & 0 deletions engine/CLAUDE.core.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ These override brevity. If proof makes a message longer, the message gets longer
- Before inviting me to test: run one full end-to-end machine-verified rehearsal of the exact flow I will perform. Pieces verified separately don't count as ready. Never say "go" on assembly alone.
- Before the live test starts, restate the acceptance test in one sentence and get my yes ("the test is: you speak, and X happens"). I should never have to write it myself in caps.
- Once I'm testing: freeze the demo surface. No edits, relaunches, or cosmetic changes to the thing I'm looking at unless I asked or the test is failing. Same session: an unrequested layout edit during the test window corrupted the demo page. Mechanically: when the live window opens, write the demo-surface paths (one absolute path, `dir/` prefix, or glob per line) to `/tmp/.demo-freeze`, and delete the file when the window ends — a PreToolUse hook (`engine/hooks/demo-freeze/`) blocks edits to matching paths while it exists (auto-expires after 2h).
- Prove UI work somewhere disposable (test channel, throwaway profile, second display, VM, headless run) instead of my live session. Driving my real keyboard, mouse, or screen needs an explicit hands-off window: state the acceptance test in one line, get my yes, then `touch /tmp/.ui-input-window` and delete it when the window ends — a PreToolUse hook (`engine/hooks/ui-input-guard/`) blocks synthetic input and screen recording while no window is open, the screen is locked, or I am still typing. Undo stray messages or reactions the run created, or say what was left.
- Every message during a live window ends with exactly one action for me, or "nothing needed from you for ~N minutes". Never leave me waiting without a named next step.
- If the deliverable is a same-day demo, plan the demo path first — the smallest end-to-end visible slice. Product-grade extras (settings UIs, multi-platform parity, test suites) come only after the demo runs.

Expand Down
33 changes: 33 additions & 0 deletions engine/hooks/hook-freshness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# hook-freshness

UserPromptSubmit hook: the installed hooks are only as new as the checkout
behind them. `install.sh` symlinks `~/.claude/hooks/<name>` at a catstack
checkout, so a hook or skill fix merged on `origin/main` does nothing on this
machine while that checkout sits on a feature branch or behind the remote.

Resolves the checkout from the `~/.claude/hooks/diu-stop` symlink (override
with `CATSTACK_HOOKS_REPO`), reads `git branch --show-current` and
`git rev-list --count HEAD..origin/main`, and adds one advisory line to the
turn's context when the checkout is off `main` or behind it. Once per
session, keyed by transcript path.

Advisory only — never blocks. No network by default; set
`CATSTACK_HOOK_FRESHNESS_FETCH=1` to allow a 3-second `git fetch` first, so
the count is not itself stale. `CATSTACK_HOOK_FRESHNESS=0` silences it.
Fails open on every error: no symlink, no git, a detached HEAD, a timeout.

## Files

- `detect.py` — checkout resolution, `repo_state()`, `advisory()`, `decide()`.
- `claude_prompt_submit.py` — Claude UserPromptSubmit entrypoint.
- `claude.prompt.hook.json` / `install_claude_hook.py` — settings.json merge (idempotent).
- `tests/test_hooks.py` — stale branch, behind count, clean checkout, fail-open.

## Env

| Var | Effect |
|-----|--------|
| `CATSTACK_HOOKS_REPO` | Use this checkout instead of resolving the symlink. |
| `CATSTACK_HOOK_FRESHNESS_FETCH=1` | Allow a short `git fetch origin main` first. |
| `CATSTACK_HOOK_FRESHNESS=0` | Silence the advisory. |
| `HOOK_FRESHNESS_STATE_DIR` | Once-per-session marker directory. |
15 changes: 15 additions & 0 deletions engine/hooks/hook-freshness/claude.prompt.hook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/hook-freshness/claude_prompt_submit.py",
"timeout": 10
}
]
}
]
}
}
28 changes: 28 additions & 0 deletions engine/hooks/hook-freshness/claude_prompt_submit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Claude Code UserPromptSubmit: warn once per session when the catstack
checkout behind ~/.claude/hooks is off main or behind origin/main, so merged
hook fixes that are not live here get noticed. Advisory, fail-open, no block.
"""
from __future__ import annotations

import json
import sys

from detect import decide_json


def main() -> None:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, OSError):
return
try:
out = decide_json(payload if isinstance(payload, dict) else {})
except Exception:
return
if out:
print(out)


if __name__ == "__main__":
main()
145 changes: 145 additions & 0 deletions engine/hooks/hook-freshness/detect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""hook-freshness: the installed hooks are only as new as the checkout behind them.

`install.sh` symlinks `~/.claude/hooks/<name>` at a catstack checkout, so a
hook fix that is merged on `origin/main` does nothing on this machine while
that checkout sits on a feature branch or behind the remote. This resolves
the checkout from the `diu-stop` symlink, reads its branch and its distance
from `origin/main`, and returns one advisory line for the turn.

Advisory only: no block, no LLM, no network unless
CATSTACK_HOOK_FRESHNESS_FETCH=1. Fails open on every error.
"""
from __future__ import annotations

import hashlib
import json
import os
import subprocess

STATE_DIR = os.environ.get(
"HOOK_FRESHNESS_STATE_DIR",
os.path.join(os.path.expanduser("~"), ".cache", "catstack-hook-freshness"),
)

ANCHOR_LINK = os.path.join(os.path.expanduser("~"), ".claude", "hooks", "diu-stop")
TRUNK = "origin/main"
FETCH_TIMEOUT_SECS = 3
GIT_TIMEOUT_SECS = 5

MESSAGE = (
"catstack hooks are stale: the checkout behind ~/.claude/hooks is {detail}. "
"Merged hook and skill fixes are not live here until it is updated. Run "
"`git -C {repo} pull --ff-only` (or merge {trunk} into the branch), then "
"`{repo}/install.sh`, and restart the harness."
)


def _run_git(args, cwd, timeout=GIT_TIMEOUT_SECS):
result = subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
return None
return result.stdout.strip()


def resolve_repo(env=None, realpath=os.path.realpath, isdir=os.path.isdir):
"""The catstack checkout the live hooks point at, or None."""
env = env if env is not None else os.environ
override = env.get("CATSTACK_HOOKS_REPO")
if override:
return override if isdir(os.path.join(override, ".git")) else None
try:
target = realpath(ANCHOR_LINK)
except OSError:
return None
repo = os.path.dirname(os.path.dirname(os.path.dirname(target)))
return repo if isdir(os.path.join(repo, ".git")) else None


def repo_state(repo, env=None, run=_run_git):
"""(branch, commits behind trunk) for the checkout, or (None, None)."""
env = env if env is not None else os.environ
try:
if env.get("CATSTACK_HOOK_FRESHNESS_FETCH") == "1":
run(["fetch", "--quiet", "origin", "main"], repo, FETCH_TIMEOUT_SECS)
branch = run(["branch", "--show-current"], repo)
behind_raw = run(["rev-list", "--count", f"HEAD..{TRUNK}"], repo)
except (OSError, subprocess.SubprocessError):
return None, None
if behind_raw is None:
return branch, None
try:
return branch, int(behind_raw)
except ValueError:
return branch, None


def advisory(repo, branch, behind):
"""One line when the checkout is off trunk or behind it, else None."""
if not repo or behind is None:
return None
off_trunk = bool(branch) and branch != "main"
if not off_trunk and behind <= 0:
return None
parts = []
if off_trunk:
parts.append(f"on branch `{branch}`")
if behind > 0:
commit_word = "commit" if behind == 1 else "commits"
parts.append(f"{behind} {commit_word} behind {TRUNK}")
return MESSAGE.format(detail=" and ".join(parts), repo=repo, trunk=TRUNK)


def _state_file(key):
digest = hashlib.sha256((key or "no-transcript").encode("utf-8")).hexdigest()[:16]
return os.path.join(STATE_DIR, f"{digest}.advised")


def already_advised(key):
return os.path.isfile(_state_file(key))


def mark_advised(key):
path = _state_file(key)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
handle.write((key or "") + "\n")
except OSError:
pass


def decide(payload, env=None, run=_run_git, state=True):
"""Advisory context for this prompt, or None. Once per session."""
env = env if env is not None else os.environ
if env.get("CATSTACK_HOOK_FRESHNESS") == "0":
return None
key = payload.get("transcript_path") or payload.get("transcriptPath") or ""
if state and already_advised(key):
return None
repo = resolve_repo(env=env)
if not repo:
return None
branch, behind = repo_state(repo, env=env, run=run)
line = advisory(repo, branch, behind)
if line and state:
mark_advised(key)
return line


def decide_json(payload, env=None, run=_run_git, state=True):
line = decide(payload, env=env, run=run, state=state)
if not line:
return None
return json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": line,
}
})
46 changes: 46 additions & 0 deletions engine/hooks/hook-freshness/install_claude_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Merge hook-freshness into ~/.claude/settings.json UserPromptSubmit hooks. Idempotent."""
from __future__ import annotations

import json
import os

HERE = os.path.dirname(os.path.abspath(__file__))
SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json")
FRAGMENT_PATH = os.path.join(HERE, "claude.prompt.hook.json")
MARKER = "hook-freshness/claude_prompt_submit.py"
EVENT = "UserPromptSubmit"


def _is_ours(entry: dict) -> bool:
return any(MARKER in h.get("command", "") for h in entry.get("hooks", []))


def merge_hook(settings: dict, fragment: dict) -> bool:
entry_list = settings.setdefault("hooks", {}).setdefault(EVENT, [])
new_entries = fragment.get("hooks", {}).get(EVENT, [])
before = json.dumps(entry_list, sort_keys=True)
kept = [e for e in entry_list if not _is_ours(e)]
entry_list[:] = kept + new_entries
return json.dumps(entry_list, sort_keys=True) != before


def main() -> None:
settings: dict = {}
if os.path.exists(SETTINGS_PATH):
with open(SETTINGS_PATH) as handle:
settings = json.load(handle)
with open(FRAGMENT_PATH) as handle:
fragment = json.load(handle)
if not merge_hook(settings, fragment):
print("ok claude UserPromptSubmit hook-freshness already up to date")
return
os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True)
with open(SETTINGS_PATH, "w") as handle:
json.dump(settings, handle, indent=2)
handle.write("\n")
print("added claude UserPromptSubmit hook-freshness")


if __name__ == "__main__":
main()
Loading
Loading