Skip to content
Merged
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
15 changes: 13 additions & 2 deletions .getzilla/getzilla/claim_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The stop gate reads the agent's last message. Claim phrases come from the risk vocabulary
(`verification-claim`, English base plus language packs). When the message claims tests or
checks passed and no passing verification receipt matches the current tree fingerprint, the stop
gate warns, or in strict mode blocks the stop where the agent supports it (Claude Code, Codex).
gate warns, or in strict mode blocks eligible stops on Claude Code, Codex and Grok Build.
"""
from __future__ import annotations

Expand All @@ -15,10 +15,21 @@
from .risk_terms import load_vocabulary

CLAIM_ID = 'verification-claim'
BLOCKING_HARNESSES = frozenset({'claude', 'codex'})
BLOCKING_HARNESSES = frozenset({'claude', 'codex', 'grok'})
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024


def can_block_stop(harness: str, payload: Mapping[str, Any]) -> bool:
"""Grok permits continuation only for a native end_turn Stop, never session shutdown."""
if harness not in BLOCKING_HARNESSES:
return False
if harness != 'grok':
return True
return payload.get('reason') == 'end_turn' and any(
isinstance(payload.get(key), str) and payload[key].casefold() == 'stop'
for key in ('hook_event_name', 'hookEventName'))


def _text_of(content: Any) -> str:
if isinstance(content, str):
return content
Expand Down
6 changes: 4 additions & 2 deletions .getzilla/getzilla/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

def is_root_stop(payload: dict[str, Any], owner: str | None) -> bool:
"""Use native event and owner identity, not the root's optional agent profile."""
event = payload.get('hook_event_name', payload.get('hookEventName'))
if event not in (None, 'Stop', 'AfterAgent'):
events = [payload[key] for key in ('hook_event_name', 'hookEventName') if payload.get(key) is not None]
if any(not isinstance(event, str) or event.casefold() not in ('stop', 'afteragent') for event in events):
return False
if len({event.casefold() for event in events}) > 1:
return False
if any(payload.get(key) not in (None, '') for key in
('subagent_id', 'subagentId', 'subagent_type', 'subagentType')):
Expand Down
4 changes: 2 additions & 2 deletions .grok/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Project hooks need `/hooks-trust` in the Grok TUI.
## Soft mode (default since v2.0.4)

- **PreToolUse**: real policy when import works; on any error → **allow**
- **Stop**: an owned unfinished root task requests bounded continuation on Claude/Codex, even without a completion claim; unsupported adapters report remaining work, and verification-claim checks remain a separate policy
- **Stop**: an owned unfinished root task requests bounded continuation on Claude/Codex and Grok end_turn, even without a completion claim; unsupported adapters report remaining work, and verification-claim checks remain a separate policy

Hard lockouts (exit 2 / infinite stop loops) are intentional bugs — fixed in 2.0.4.
Canonical source hooks live in `.grok/hooks/`. For older `adaptive.json` files that call `python3 pre_tool_use.py` from a consumer project root, the installer generates nine compatibility aliases from `.getzilla/templates/hook_root_shim.py`; these aliases dispatch to canonical hooks or preserve the fail-open JSON fallback. Generated consumer aliases are not tracked source-root files.
Expand All @@ -23,7 +23,7 @@ Then restart `grok`.

## Task continuation

`UserPromptSubmit` and the route CLI share the same canonical intake operation. Ordinary prompts preserve the unfinished original task and append substantive amendments; explicit `--new-task` and exact `--continue` govern new identity and handoff. `SessionStart` and prompt context show the original, unresolved amendments/rejection, typed scope and evidence gaps as task data, with a 16384 UTF-8 byte display bound and recovery path. Overflow remains visibly NOT admitted until exact synchronization. `Stop` observes unresolved scope, the selected native `tasks.md` checklist and current evidence. Claude/Codex request continuation for the matching root owner independently of the final answer's wording. A current canonical blocked reason allows an explicit pause. Children, interrupts, foreign/unbound sessions and terminal tasks cannot be restarted by this control. Native `stop_hook_active` and at most two requests for the same remaining-work observation bound retries; exhaustion leaves the task visibly unfinished and open. Generated reasons use a passive system envelope so their re-entry cannot add scope. Stop never auto-completes a ready/open task or proves remote deployment. See `docs/hook-health.md` for the complete contract.
`UserPromptSubmit` and the route CLI share the same canonical intake operation. Ordinary prompts preserve the unfinished original task and append substantive amendments; explicit `--new-task` and exact `--continue` govern new identity and handoff. `SessionStart` and prompt context show the original, unresolved amendments/rejection, typed scope and evidence gaps as task data, with a 16384 UTF-8 byte display bound and recovery path. Overflow remains visibly NOT admitted until exact synchronization. `Stop` observes unresolved scope, the selected native `tasks.md` checklist and current evidence. Claude/Codex and Grok request continuation for the matching root owner independently of the final answer's wording. Grok requires a native Stop with `reason: end_turn`; shutdown, channel_closed and absent/unknown reasons remain observe-only. A current canonical blocked reason allows an explicit pause. Children, interrupts, foreign/unbound sessions and terminal tasks cannot be restarted by this control. Native `stop_hook_active`/`stopHookActive` and at most two requests for the same remaining-work observation bound retries; exhaustion leaves the task visibly unfinished and open. Generated reasons use a passive system envelope so their re-entry cannot add scope. Stop never auto-completes a ready/open task or proves remote deployment. See `docs/hook-health.md` for the complete contract.

## Local child lifecycle

Expand Down
12 changes: 8 additions & 4 deletions .grok/hooks/stop_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def main() -> None:
from getzilla.receipts import validate_evidence
from getzilla.state import current_session, get_active_change, get_active_route, increment_stop_attempt, reset_stop_attempt
from getzilla.completion import STOP_ATTEMPT_LIMIT, continuation_reason, is_root_stop, observe_remaining
from getzilla.claim_check import BLOCKING_HARNESSES, can_block_stop, stop_mode, unsupported_claim
except Exception:
emit({})
return
Expand All @@ -35,13 +36,13 @@ def main() -> None:
emit({})
return
owned = bool(route and current_session() and current_session() == route.get('session_id'))
can_block = can_block_stop(harness(), payload)
from getzilla.continuity import TERMINAL
if route and route.get('status') in TERMINAL:
emit({})
return
if route:
try:
from getzilla.claim_check import BLOCKING_HARNESSES, stop_mode, unsupported_claim
from getzilla.hook_health import route_degraded_events

claim = unsupported_claim(root, route, payload)
Expand All @@ -50,10 +51,11 @@ def main() -> None:
record_degraded('stop_gate', error, payload)
claim, degraded = None, []
if owned and claim and stop_mode(root, route) == 'strict' and not stop_hook_active(payload):
if harness() in BLOCKING_HARNESSES:
if can_block:
emit({'decision': 'block', 'reason': continuation_reason([claim])})
return
record_degraded('stop_gate', f'STRICT_BLOCK_UNSUPPORTED:{harness()}', payload)
if harness() not in BLOCKING_HARNESSES:
record_degraded('stop_gate', f'STRICT_BLOCK_UNSUPPORTED:{harness()}', payload)
else:
claim, degraded = None, []
messages = []
Expand Down Expand Up @@ -92,6 +94,8 @@ def main() -> None:
messages.insert(0, 'task paused: ' + observation['blocked_reason'] + '; ' + summary)
elif harness() not in BLOCKING_HARNESSES:
messages.insert(0, summary + '; automatic Stop continuation is unsupported by this harness')
elif not can_block:
messages.insert(0, summary + '; this native event does not permit Stop continuation')
elif stop_hook_active(payload):
messages.insert(0, summary + '; native continuation already used; task remains open')
else:
Expand Down Expand Up @@ -119,7 +123,7 @@ def main() -> None:
emit({})
return

if owned:
if owned and can_block:
reset_stop_attempt(root, route['route_id'])
emit({})
except Exception as error:
Expand Down
17 changes: 12 additions & 5 deletions docs/hook-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,28 @@ does not waive an unchecked plan item. The hook reads existing canonical scope g
the selected package's native `tasks.md` checklist and missing/stale required receipts.
It also reports malformed or unsafe selected package inputs as unresolved work.

Claude Code and Codex receive `decision: block` with a continuation reason. That asks
Claude Code, Codex and eligible Grok Build stops receive `decision: block` with a continuation reason. That asks
the coordinator to read the retained original task and amendments, then perform the
next available, already authorized step. The hook itself runs no delivery command and
grants no permission. Qwen, Grok and Gemini receive an explicit unfinished-work advisory
grants no permission. Qwen and Gemini receive an explicit unfinished-work advisory
under the current adapter capability policy.

Grok requires a native `Stop` (`hook_event_name`) or `stop` (`hookEventName`) event with
`reason: end_turn`. Its `shutdown` and `channel_closed` stops are observe-only; missing or
unknown reasons also cannot block or change the retry counter. This follows the official
[Grok hooks contract](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md).
The native `sessionId`, `stopHookActive` and `lastAssistantMessage` fields are supported.
Contract tests run the actual Grok hook command; they do not prove a live client session.

Only a matching session owner at a root Stop is eligible. Explicit `SubagentStop`,
`Interrupt`, `SessionEnd` and `StopFailure` events, distinct child identities, foreign or
`Interrupt`, `SessionEnd`, `StopCancelled` and `StopFailure` events, distinct child identities, foreign or
unbound sessions, and terminal/cancelled tasks cannot be restarted. A root `agent_type`
profile alone does not make a child. A current matching package transition to `blocked`
with a concrete reason permits an honest pause; missing/stale scope, unsafe inputs or
malformed blocker history cannot inherit that waiver. The reason records the owner's
explanation, not independently verified proof of the external obstacle.

Continuation is bounded in two ways. `stop_hook_active` never triggers another block
Continuation is bounded in two ways. `stop_hook_active` (Grok: `stopHookActive`) never triggers another block
in the native continued turn. Across calls, at most two requests are issued for the
same remaining-work observation, task and authority epoch. Changed recorded scope or
remaining obligations create a new observation. Exhaustion, or unreadable retry state,
Expand Down Expand Up @@ -71,7 +78,7 @@ risk vocabulary, English base plus language packs) and there is no passing verif
for the current tree, the stop gate:

- in `soft` mode (default) adds `unsupported verification claim: ...` to its note;
- in `strict` mode blocks the stop with that reason on Claude Code and Codex, so the agent runs
- in `strict` mode blocks an eligible stop with that reason on Claude Code, Codex and Grok, so the agent runs
verify and reports its real result. Agents without a blocking Stop hook get the warning and a
`HOOK_DEGRADED` event (`STRICT_BLOCK_UNSUPPORTED:<agent>`). A stop that was already continued
once by the hook (`stop_hook_active`) is never blocked again.
Expand Down
67 changes: 67 additions & 0 deletions tests/test_stop_continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,73 @@ def root_stop(self, root, harness='claude', **payload):
return stop(root, {'hook_event_name': 'Stop', 'last_assistant_message':
'Кнопка эксперта готова. Остальные разделы ещё не выложены.', **payload}, harness)

def grok_stop(self, root, mode='soft', **payload):
# Real native aliases, without injected snake defaults masking owner/recursion.
env = {**os.environ, 'GETZILLA_STOP_GATE_MODE': mode,
'PYTHONPATH': os.pathsep.join((str(root), str(root / '.getzilla')))}
data = {'cwd': str(root), 'sessionId': 's1', 'hookEventName': 'stop',
'reason': 'end_turn', 'stopHookActive': False,
'lastAssistantMessage': 'The remaining controls still need delivery.', **payload}
proc = subprocess.run([sys.executable, str(root / '.grok/hooks/stop_gate.py')], cwd=root,
env=env, input=json.dumps(data).encode('utf-8'),
capture_output=True, timeout=20)
self.assertEqual(proc.returncode, 0, proc.stderr)
return json.loads(proc.stdout.decode('utf-8'))

def test_grok_native_end_turn_continues_without_a_claim(self):
with project_copy(git=True) as root:
self.package(root)
before = state.get_active_route(root)
for aliases in ({}, {'hook_event_name': 'Stop'}):
with self.subTest(aliases=aliases):
data = self.grok_stop(root, **aliases)
self.assertEqual(data.get('decision'), 'block', data)
self.assertIn('Deliver the remaining', data['reason'])
exhausted = self.grok_stop(root)
self.assertNotEqual(exhausted.get('decision'), 'block', exhausted)
self.assertIn('unchanged', exhausted.get('systemMessage', ''))
self.assertEqual(state.get_active_route(root), before)

def test_grok_native_boundaries_preserve_retry_state(self):
with project_copy(git=True) as root:
self.package(root)
state.increment_stop_attempt(root, state.get_active_route(root)['route_id'], observation='a' * 64)
before = state.stop_attempts_path(root).read_bytes()
cases = [{'stopHookActive': True}, {'sessionId': 'foreign'}, {'sessionId': None},
{'subagentType': 'reviewer'}, {'hookEventName': None},
{'hook_event_name': 'StopCancelled'},
*({'reason': reason} for reason in ('shutdown', 'channel_closed', None, '', 'unknown')),
*({'hookEventName': event} for event in
('StopCancelled', 'stop_cancelled', 'StopFailure', 'stop_failure',
'SubagentStop', 'subagent_stop', 'SessionEnd'))]
for payload in cases:
for mode in ('soft', 'strict'):
with self.subTest(payload=payload, mode=mode):
data = self.grok_stop(root, mode, lastAssistantMessage='All tests pass.', **payload)
self.assertNotEqual(data.get('decision'), 'block', data)
self.assertEqual(state.stop_attempts_path(root).read_bytes(), before)

def test_grok_strict_claim_uses_native_fields_and_end_turn(self):
with project_copy(git=True) as root:
self.package(root, '- [x] Finished.\n', ('verification',))
state.update_route(root, delivery_expected=False)
data = self.grok_stop(root, 'strict', lastAssistantMessage='All tests pass.')
self.assertEqual(data.get('decision'), 'block', data)
self.assertIn('no passing verification receipt', data['reason'])

@unittest.skipIf(os.name == 'nt', 'POSIX canonical Grok launcher')
def test_canonical_grok_stop_launcher(self):
with project_copy(git=True) as root:
self.package(root)
config = json.loads((PROJECT / '.grok/hooks.json').read_text(encoding='utf-8'))
command = config['hooks']['Stop'][0]['hooks'][0]['command']
proc = subprocess.run(['bash', '-c', command], cwd=root, capture_output=True,
input=json.dumps({'cwd': str(root), 'sessionId': 's1',
'hookEventName': 'stop', 'reason': 'end_turn'}),
text=True, timeout=20)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(json.loads(proc.stdout).get('decision'), 'block', proc.stdout)

def test_claim_free_root_continues_unchecked_native_work(self):
with project_copy(git=True) as root:
self.package(root)
Expand Down
Loading