fix(hooks): presence_gate keys per-branch via hook_data cwd; engine propagates block exit code (FPLAN-0289 P1)
Activation fixes for the single-session presence gate. Two bugs blocked it,
both caught by live testing after all units were green:
1) Wrong branch key. presence_gate used Path.cwd().name, but under the Claude
Code bridge the hook process cwd is the project root, so every session keyed
to "AIPass": the gate never enforced one-live-session-per-branch and would
have rejected sessions project-globally (any 2nd interactive session in any
branch). Now _resolve_branch(hook_data) reads the event payload's cwd (the
real session dir) and walks up to the branch root (.trinity/ or apps/),
mirroring branch_loader. Applied in handle() and handle_stop().
2) Block never reached Claude Code. engine.dispatch() returned only stdout, so
the bridge could not surface a non-zero exit. dispatch() now returns
(stdout, exit_code) and the bridge exits with it on a block. Pre-existing gap
affecting every block hook on every event; now fixed engine-wide. An
intentional block (exit 2 + {"decision":"block"}) propagates; a crashing hook
(exit 2, non-JSON stdout) is logged and falls through, so the gate fails open.
Proven: 110 hooks unit tests pass (6 new for branch resolution); seedgo @hooks
100%, no type errors. Live bridge end-to-end (real live holder + real bridge):
duplicate into a held branch -> exit 2 + block reason naming the branch; a
different free branch -> exit 0 (per-branch isolation intact). Gate remains
dormant: not yet wired into provider settings.
Design: DPLAN-0225 / FPLAN-0289 P1 activation. Build by @hooks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqoxFdbDMirzkQ5kjRVVos
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8d775b4bd2
commit
beb048dadf
@@ -89,17 +89,17 @@ def _matches(matcher: str, value: str) -> bool:
|
||||
return value in matcher.split("|")
|
||||
|
||||
|
||||
def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
|
||||
"""Core dispatch — run hooks for event, return merged stdout."""
|
||||
def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
||||
"""Core dispatch — run hooks for event, return (merged_stdout, exit_code)."""
|
||||
if not config.get("hooks_enabled", True):
|
||||
logger.info("[HOOKS] all hooks disabled")
|
||||
_log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"})
|
||||
return ""
|
||||
return "", 0
|
||||
|
||||
event_hooks = config.get(event_type, {})
|
||||
if not event_hooks:
|
||||
_log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"})
|
||||
return ""
|
||||
return "", 0
|
||||
|
||||
match_value = ""
|
||||
parsed = {}
|
||||
@@ -196,7 +196,7 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
|
||||
"total_ms": round(total_ms, 1),
|
||||
}
|
||||
)
|
||||
return result["stdout"]
|
||||
return result["stdout"], 2
|
||||
|
||||
logger.error(
|
||||
"[HOOKS] %s.%s CRASHED exit=2: %s",
|
||||
@@ -229,7 +229,7 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
|
||||
}
|
||||
)
|
||||
|
||||
return "\n".join(outputs)
|
||||
return "\n".join(outputs), 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user