From c4258d43cac4e269769540106f0e61b738fbf2c2 Mon Sep 17 00:00:00 2001 From: Dimkox Date: Mon, 2 Nov 2026 22:53:30 +0300 Subject: [PATCH] fix: honor native Grok end-turn continuation contract --- .getzilla/getzilla/claim_check.py | 15 ++++++- .getzilla/getzilla/completion.py | 6 ++- .grok/hooks/README.md | 4 +- .grok/hooks/stop_gate.py | 12 ++++-- docs/hook-health.md | 17 +++++--- tests/test_stop_continuation.py | 67 +++++++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/.getzilla/getzilla/claim_check.py b/.getzilla/getzilla/claim_check.py index 1c11832..2ba3041 100644 --- a/.getzilla/getzilla/claim_check.py +++ b/.getzilla/getzilla/claim_check.py @@ -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 @@ -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 diff --git a/.getzilla/getzilla/completion.py b/.getzilla/getzilla/completion.py index 9dc5f75..36cc787 100644 --- a/.getzilla/getzilla/completion.py +++ b/.getzilla/getzilla/completion.py @@ -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')): diff --git a/.grok/hooks/README.md b/.grok/hooks/README.md index bc0e9a3..5c486a7 100644 --- a/.grok/hooks/README.md +++ b/.grok/hooks/README.md @@ -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. @@ -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 diff --git a/.grok/hooks/stop_gate.py b/.grok/hooks/stop_gate.py index f955039..c22b2d2 100644 --- a/.grok/hooks/stop_gate.py +++ b/.grok/hooks/stop_gate.py @@ -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 @@ -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) @@ -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 = [] @@ -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: @@ -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: diff --git a/docs/hook-health.md b/docs/hook-health.md index 758116b..fc67e26 100644 --- a/docs/hook-health.md +++ b/docs/hook-health.md @@ -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, @@ -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:`). A stop that was already continued once by the hook (`stop_hook_active`) is never blocked again. diff --git a/tests/test_stop_continuation.py b/tests/test_stop_continuation.py index 3d0b657..3b7bf1c 100644 --- a/tests/test_stop_continuation.py +++ b/tests/test_stop_continuation.py @@ -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)