From 28c8a824298e6d9ec2a7fe9891aaac14ce7116f0 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 18:14:31 +0000 Subject: [PATCH 1/3] =?UTF-8?q?invoker:=20wf-1789236467583-23/require-beha?= =?UTF-8?q?vior-evidence-for-a-scope-correction=20=E2=80=94=20Record=20a?= =?UTF-8?q?=20scope=20correction=20only=20when=20unrequested=20mutating=20?= =?UTF-8?q?work=20actually=20happened.=20Review=20claim:=20A=20correction?= =?UTF-8?q?=20phrasing=20alone=20no=20longer=20records=20a=20correction;?= =?UTF-8?q?=20the=20agent=20must=20also=20have=20performed=20mutating=20wo?= =?UTF-8?q?rk=20in=20the=20turn=20opened=20by=20the=20user's=20previous=20?= =?UTF-8?q?message.=20Review=20lane:=20behavior=20Safety=20invariant:=20A?= =?UTF-8?q?=20correction=20phrasing=20accompanied=20by=20real=20mutating?= =?UTF-8?q?=20work=20records=20a=20correction=20exactly=20as=20it=20does?= =?UTF-8?q?=20today,=20at=20the=20same=20stage,=20with=20the=20same=20mess?= =?UTF-8?q?age.=20Explicit=20expansion=20stays=20excluded.=20The=20enforce?= =?UTF-8?q?ment=20side,=20the=20contract=20wording,=20and=20the=20hard-sto?= =?UTF-8?q?p=20release=20conditions=20are=20untouched.=20Effectiveness=20m?= =?UTF-8?q?easurement:=20`python3=20engine/hooks/scope-lock/tests/test=5Fh?= =?UTF-8?q?ooks.py`=20exits=200,=20with=20a=20new=20case=20that=20exits=20?= =?UTF-8?q?non-zero=20before=20this=20change:=20a=20correction=20phrasing?= =?UTF-8?q?=20plus=20a=20transcript=20showing=20zero=20mutating=20work=20r?= =?UTF-8?q?ecords=20nothing.=20Slice=20rationale:=20One=20conceptual=20uni?= =?UTF-8?q?t:=20what=20evidence=20`correction=5Fclass`=20requires=20before?= =?UTF-8?q?=20returning=20a=20correction.=20Architectural=20effect:=20`cor?= =?UTF-8?q?rection=5Fclass`=20gains=20a=20parameter=20for=20the=20corrobor?= =?UTF-8?q?ating=20evidence,=20so=20the=20decision=20stops=20being=20a=20p?= =?UTF-8?q?ure=20function=20of=20the=20user's=20text.=20Callers=20pass=20w?= =?UTF-8?q?hat=20the=20module=20already=20reads.=20Goal:=20Stop=20a=20stat?= =?UTF-8?q?us=20question=20from=20being=20treated=20as=20proof=20that=20th?= =?UTF-8?q?e=20agent=20drifted.=20Motivation:=20Two=20status=20questions?= =?UTF-8?q?=20escalated=20to=20a=20hard=20stop=20that=20froze=20a=20sessio?= =?UTF-8?q?n=20for=20hours,=20while=20the=20measured=20mutating-work=20cou?= =?UTF-8?q?nt=20in=20both=20turns=20was=20zero.=20The=20escalation=20was?= =?UTF-8?q?=20unearned=20and=20the=20agent=20then=20invented=20a=20matchin?= =?UTF-8?q?g=20confession.=20Alternative=20considerations:=20Adding=20the?= =?UTF-8?q?=20question=20shapes=20to=20the=20expansion=20exclusion=20was?= =?UTF-8?q?=20set=20aside=20--=20it=20suppresses=20the=20phrasing=20rather?= =?UTF-8?q?=20than=20asking=20for=20evidence,=20and=20a=20genuinely=20drif?= =?UTF-8?q?ting=20agent=20asked=20"what=20are=20you=20doing"=20would=20the?= =?UTF-8?q?n=20go=20uncaught.=20Comparing=20work=20against=20a=20stated=20?= =?UTF-8?q?scope=20was=20set=20aside=20for=20this=20slice=20--=20it=20need?= =?UTF-8?q?s=20a=20recorded=20contract=20to=20compare=20against,=20which?= =?UTF-8?q?=20only=20exists=20after=20the=20first=20stage,=20so=20it=20can?= =?UTF-8?q?not=20gate=20the=20first=20stage.=20Implementation=20details:?= =?UTF-8?q?=20Give=20`correction=5Fclass`=20the=20corroborating=20evidence?= =?UTF-8?q?=20as=20an=20argument=20rather=20than=20computing=20it=20inline?= =?UTF-8?q?,=20so=20it=20stays=20testable=20as=20a=20pure=20decision.=20De?= =?UTF-8?q?rive=20that=20evidence=20from=20the=20transcript=20the=20module?= =?UTF-8?q?=20already=20receives:=20whether=20any=20mutating=20tool=20call?= =?UTF-8?q?=20appears=20after=20the=20user's=20previous=20message.=20Treat?= =?UTF-8?q?=20unreadable=20or=20absent=20transcript=20evidence=20as=20its?= =?UTF-8?q?=20own=20outcome=20rather=20than=20as=20either=20answer,=20per?= =?UTF-8?q?=20the=20repo's=20rule=20that=20a=20check=20which=20could=20not?= =?UTF-8?q?=20run=20is=20not=20a=20pass;=20record=20which=20way=20it=20res?= =?UTF-8?q?olves=20and=20pin=20it=20with=20a=20case.=20Non-goals:=20No=20c?= =?UTF-8?q?hange=20to=20enforcement,=20to=20which=20tools=20are=20blocked?= =?UTF-8?q?=20at=20each=20stage,=20to=20the=20contract=20wording,=20or=20t?= =?UTF-8?q?o=20the=20hard-stop=20release=20conditions.=20No=20change=20to?= =?UTF-8?q?=20the=20correction=20phrasing=20list.=20No=20new=20hook.=20Lay?= =?UTF-8?q?er:=20domain=20Feature=20state:=20active=20Files:=20engine/hook?= =?UTF-8?q?s/scope-lock/detect.py,=20engine/hooks/scope-lock/claude=5Fprom?= =?UTF-8?q?pt=5Fscope.py,=20engine/hooks/scope-lock/tests/test=5Fhooks.py,?= =?UTF-8?q?=20engine/hooks/scope-lock/README.md=20Change=20types:=20-=20en?= =?UTF-8?q?gine/hooks/scope-lock/detect.py:=20modify=20-=20engine/hooks/sc?= =?UTF-8?q?ope-lock/claude=5Fprompt=5Fscope.py:=20modify=20-=20engine/hook?= =?UTF-8?q?s/scope-lock/tests/test=5Fhooks.py:=20modify=20-=20engine/hooks?= =?UTF-8?q?/scope-lock/README.md:=20modify=20Acceptance=20criteria:=20-=20?= =?UTF-8?q?`python3=20engine/hooks/scope-lock/tests/test=5Fhooks.py`=20exi?= =?UTF-8?q?ts=200=20after=20the=20change,=20with=20all=2054=20existing=20c?= =?UTF-8?q?ases=20still=20passing.=20-=20A=20correction=20phrasing=20with?= =?UTF-8?q?=20zero=20mutating=20work=20since=20the=20user's=20previous=20m?= =?UTF-8?q?essage=20records=20nothing.=20-=20The=20same=20phrasing=20with?= =?UTF-8?q?=20real=20mutating=20work=20records=20a=20correction,=20unchang?= =?UTF-8?q?ed=20from=20today.=20-=20Transcript=20evidence=20that=20cannot?= =?UTF-8?q?=20be=20read=20resolves=20to=20a=20named=20third=20outcome,=20p?= =?UTF-8?q?inned=20by=20a=20case.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Record a scope correction only when unrequested mutating work actually happened. Review claim: A correction phrasing alone no longer records a correction; the agent must also have performed mutating work in the turn opened by the user's previous message. Review lane: behavior Safety invariant: A correction phrasing accompanied by real mutating work records a correction exactly as it does today, at the same stage, with the same message. Explicit expansion stays excluded. The enforcement side, the contract wording, and the hard-stop release conditions are untouched. Effectiveness measurement: `python3 engine/hooks/scope-lock/tests/test_hooks.py` exits 0, with a new case that exits non-zero before this change: a correction phrasing plus a transcript showing zero mutating work records nothing. Slice rationale: One conceptual unit: what evidence `correction_class` requires before returning a correction. Architectural effect: `correction_class` gains a parameter for the corroborating evidence, so the decision stops being a pure function of the user's text. Callers pass what the module already reads. Goal: Stop a status question from being treated as proof that the agent drifted. Motivation: Two status questions escalated to a hard stop that froze a session for hours, while the measured mutating-work count in both turns was zero. The escalation was unearned and the agent then invented a matching confession. Alternative considerations: Adding the question shapes to the expansion exclusion was set aside -- it suppresses the phrasing rather than asking for evidence, and a genuinely drifting agent asked "what are you doing" would then go uncaught. Comparing work against a stated scope was set aside for this slice -- it needs a recorded contract to compare against, which only exists after the first stage, so it cannot gate the first stage. Implementation details: Give `correction_class` the corroborating evidence as an argument rather than computing it inline, so it stays testable as a pure decision. Derive that evidence from the transcript the module already receives: whether any mutating tool call appears after the user's previous message. Treat unreadable or absent transcript evidence as its own outcome rather than as either answer, per the repo's rule that a check which could not run is not a pass; record which way it resolves and pin it with a case. Non-goals: No change to enforcement, to which tools are blocked at each stage, to the contract wording, or to the hard-stop release conditions. No change to the correction phrasing list. No new hook. Layer: domain Feature state: active Files: engine/hooks/scope-lock/detect.py, engine/hooks/scope-lock/claude_prompt_scope.py, engine/hooks/scope-lock/tests/test_hooks.py, engine/hooks/scope-lock/README.md Change types: - engine/hooks/scope-lock/detect.py: modify - engine/hooks/scope-lock/claude_prompt_scope.py: modify - engine/hooks/scope-lock/tests/test_hooks.py: modify - engine/hooks/scope-lock/README.md: modify Acceptance criteria: - `python3 engine/hooks/scope-lock/tests/test_hooks.py` exits 0 after the change, with all 54 existing cases still passing. - A correction phrasing with zero mutating work since the user's previous message records nothing. - The same phrasing with real mutating work records a correction, unchanged from today. - Transcript evidence that cannot be read resolves to a named third outcome, pinned by a case. Invoker-Finalize-Id: 5a0246f5-8c0f-402b-91e3-ae8e12197dfd --- engine/hooks/scope-lock/README.md | 14 ++- engine/hooks/scope-lock/detect.py | 104 ++++++++++++++++-- engine/hooks/scope-lock/tests/test_hooks.py | 111 ++++++++++++++++---- 3 files changed, 202 insertions(+), 27 deletions(-) diff --git a/engine/hooks/scope-lock/README.md b/engine/hooks/scope-lock/README.md index b4cc0fc5..5f6fb6f2 100644 --- a/engine/hooks/scope-lock/README.md +++ b/engine/hooks/scope-lock/README.md @@ -16,6 +16,14 @@ the product: | Proposal | `should we do this the same way we did in invoker?` | | Substitution | `surprised we elected to use subagents instead of invoker` | +A recognised shape records a correction only when the transcript also shows a +non-read-only tool call in the agent turn opened by the user's previous +message. Read-only lookups do not corroborate a correction. Missing, +unreadable, or unparseable transcript evidence is a distinct unknown result, +not zero work; an unknown does not record a correction. This avoids imposing a +lock when the check could not run, at the accepted cost that drift which exists +only in a stated plan, before any mutating call, is no longer caught. + The last three shapes are the ones a user reaches for first, before they get blunt, so leaving them out costs the whole early warning. Each one needs three things in the same sentence before it counts: `you` or `we` as the actor doing @@ -28,9 +36,9 @@ session. The state machine is per harness session: -1. First same-class correction records a persistent lock. Local read-only - tools remain available, but mutating, shell, delegated, and external tools - are blocked until the transcript contains one standalone line: +1. First corroborated same-class correction records a persistent lock. Local + read-only tools remain available, but mutating, shell, delegated, and + external tools are blocked until the transcript contains one standalone line: `SCOPE CONTRACT: `. 2. The contract releases the first tool gate but stays in session state. Apologies and unmarked restatements never clear it. diff --git a/engine/hooks/scope-lock/detect.py b/engine/hooks/scope-lock/detect.py index 92311128..c49aff32 100644 --- a/engine/hooks/scope-lock/detect.py +++ b/engine/hooks/scope-lock/detect.py @@ -149,9 +149,17 @@ def extract_prompt_text(payload: dict[str, Any]) -> str: return "" -def correction_class(text: str) -> str | None: - """Return the stable correction class, excluding explicit expansions.""" - if not text or AUTOMATED_NOTIFICATION_RE.match(text): +def _normalized_tool_name(name: Any) -> str: + return re.sub(r"[^a-z_]", "", str(name or "").lower()) + + +def _is_local_read_only_tool(name: Any) -> bool: + return _normalized_tool_name(name) in LOCAL_READ_ONLY_TOOLS + + +def correction_class(text: str, mutating_work: bool | None) -> str | None: + """Return the stable correction class when transcript evidence corroborates it.""" + if mutating_work is not True or not text or AUTOMATED_NOTIFICATION_RE.match(text): return None window = text[-CORRECTION_SCAN_TAIL_CHARS:] if EXPANSION_RE.search(window): @@ -240,6 +248,90 @@ def _transcript_path(payload: dict[str, Any]) -> str: return value if isinstance(value, str) else "" +def _transcript_payload(data: dict[str, Any]) -> dict[str, Any]: + nested = data.get("payload") + if data.get("type") == "response_item" and isinstance(nested, dict): + return nested + return data + + +def _message_parts(data: dict[str, Any]) -> tuple[str, Any]: + entry = _transcript_payload(data) + message = entry.get("message") + if isinstance(message, dict): + return str(message.get("role") or ""), message.get("content") + return str(entry.get("role") or ""), entry.get("content") + + +def _user_text(data: dict[str, Any]) -> str | None: + entry = _transcript_payload(data) + role, content = _message_parts(data) + if entry.get("type") != "user" and role != "user": + return None + if isinstance(content, str): + return content if content.strip() else None + if not isinstance(content, list): + return None + text = "\n".join( + str(block.get("text") or "") + for block in content + if isinstance(block, dict) and block.get("type") in {"text", "input_text"} + ) + return text if text.strip() else None + + +def _tool_names(data: dict[str, Any]) -> list[str]: + entry = _transcript_payload(data) + if entry.get("type") in {"custom_tool_call", "function_call"}: + return [str(entry.get("name") or "")] + + role, content = _message_parts(data) + if entry.get("type") != "assistant" and role != "assistant": + return [] + if not isinstance(content, list): + return [] + return [ + str(block.get("name") or "") + for block in content + if isinstance(block, dict) + and block.get("type") in {"tool_use", "toolCall", "custom_tool_call", "function_call"} + ] + + +def mutating_work_after_previous_user( + payload: dict[str, Any], current_prompt: str +) -> bool | None: + """Return mutating-work evidence for the prior user turn, or None if unavailable.""" + path = _transcript_path(payload) + if not path: + return None + + turns: list[tuple[str, list[str]]] = [] + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + try: + data = json.loads(line) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + text = _user_text(data) + if text is not None: + turns.append((text, [])) + continue + if turns: + turns[-1][1].extend(_tool_names(data)) + except (OSError, UnicodeError): + return None + + if not turns or turns[-1][0].strip() != current_prompt.strip(): + return None + if len(turns) < 2: + return False + return any(not _is_local_read_only_tool(name) for name in turns[-2][1]) + + def _line_count(path: str) -> int: try: with open(path, encoding="utf-8") as handle: @@ -323,7 +415,8 @@ def process_prompt(payload: dict[str, Any]) -> dict[str, Any]: save_state(payload, state) return state - correction = correction_class(prompt) + mutating_work = mutating_work_after_previous_user(payload, prompt) + correction = correction_class(prompt, mutating_work) if not correction: return state @@ -387,7 +480,6 @@ def tool_block_reason(payload: dict[str, Any]) -> tuple[bool, str]: save_state(payload, state) return False, "" - tool = re.sub(r"[^a-z_]", "", _tool_name(payload).lower()) - if tool in LOCAL_READ_ONLY_TOOLS: + if _is_local_read_only_tool(_tool_name(payload)): return False, "" return True, FIRST_GATE diff --git a/engine/hooks/scope-lock/tests/test_hooks.py b/engine/hooks/scope-lock/tests/test_hooks.py index 2d4fcfdb..7a37b4ad 100644 --- a/engine/hooks/scope-lock/tests/test_hooks.py +++ b/engine/hooks/scope-lock/tests/test_hooks.py @@ -102,6 +102,28 @@ def test_missing_transcript_counts_zero_lines_rather_than_guessing(self): payload = {"session_id": "session-3", "transcript_path": "/nonexistent/session.jsonl"} self.assertEqual(detect._line_count(detect._transcript_path(payload)), 0) + def test_unreadable_transcript_evidence_records_no_correction(self): + transcript = os.path.join(self.tmp.name, "unreadable.jsonl") + payload = { + "session_id": "session-unreadable-transcript", + "transcript_path": transcript, + "prompt": "what are you doing", + } + real_open = open + + def open_except_transcript(path, *args, **kwargs): + if path == transcript: + raise PermissionError("transcript is unreadable") + return real_open(path, *args, **kwargs) + + with patch("builtins.open", side_effect=open_except_transcript): + evidence = detect.mutating_work_after_previous_user(payload, payload["prompt"]) + result = detect.process_prompt(payload) + + self.assertIsNone(evidence) + self.assertNotIn("phase", result) + self.assertNotIn("correction_counts", result) + class ScopeLockCase(unittest.TestCase): def setUp(self) -> None: @@ -110,11 +132,15 @@ def setUp(self) -> None: self.transcript = os.path.join(self.tmp.name, "session.jsonl") open(self.transcript, "w", encoding="utf-8").close() self.base = {"session_id": "session-1", "transcript_path": self.transcript} + self.append_user("Please complete the requested work.") + self.append_tool("Write") def tearDown(self) -> None: self.tmp.cleanup() - def prompt(self, text: str) -> dict: + def prompt(self, text: str, *, mutating_work: bool = True) -> dict: + if mutating_work: + self.append_tool("Write") with open(self.transcript, "a", encoding="utf-8") as handle: handle.write(json.dumps({ "type": "user", @@ -122,6 +148,16 @@ def prompt(self, text: str) -> dict: }) + "\n") return detect.process_prompt({**self.base, "prompt": text}) + def append_tool(self, name: str) -> None: + with open(self.transcript, "a", encoding="utf-8") as handle: + handle.write(json.dumps({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "tool_use", "name": name, "input": {}}], + }, + }) + "\n") + def append_user(self, text: str) -> None: with open(self.transcript, "a", encoding="utf-8") as handle: handle.write(json.dumps({ @@ -136,44 +172,46 @@ def tool(self, name: str = "Bash") -> tuple[bool, str]: class TestDetection(ScopeLockCase): def test_repeated_drift_fixture_detects_same_class(self): messages = fixture_messages("repeated_drift.jsonl") - self.assertEqual([detect.correction_class(m) for m in messages], ["scope", "scope"]) + self.assertEqual([detect.correction_class(m, True) for m in messages], ["scope", "scope"]) def test_ordinary_product_confusion_does_not_trigger(self): [message] = fixture_messages("ordinary_product_confusion.jsonl") - self.assertIsNone(detect.correction_class(message)) + self.assertIsNone(detect.correction_class(message, True)) def test_explicit_scope_expansion_does_not_trigger(self): [message] = fixture_messages("explicit_scope_expansion.jsonl") - self.assertIsNone(detect.correction_class(message)) + self.assertIsNone(detect.correction_class(message, True)) def test_execution_routing_fixture_detects_every_real_correction(self): messages = fixture_messages("execution_routing_correction.jsonl") self.assertEqual(len(messages), 4) self.assertEqual( - [detect.correction_class(m) for m in messages], + [detect.correction_class(m, True) for m in messages], ["scope", "scope", "scope", "scope"], ) def test_interrogative_correction_triggers(self): self.assertEqual( - detect.correction_class("wait why are you running this locally and not in invoker?"), + detect.correction_class("wait why are you running this locally and not in invoker?", True), "scope", ) self.assertEqual( - detect.correction_class("why did we do this with subagents rather than the queue?"), + detect.correction_class("why did we do this with subagents rather than the queue?", True), "scope", ) def test_proposal_shaped_correction_triggers(self): self.assertEqual( detect.correction_class( - "if we are backtesting this, should we doing this the same way we did in invoker?" + "if we are backtesting this, should we doing this the same way we did in invoker?", + True, ), "scope", ) self.assertEqual( detect.correction_class( - "we should parallelize these with invoker instead. we shouldn't do this locally." + "we should parallelize these with invoker instead. we shouldn't do this locally.", + True, ), "scope", ) @@ -181,7 +219,8 @@ def test_proposal_shaped_correction_triggers(self): def test_substitution_correction_triggers(self): self.assertEqual( detect.correction_class( - "also im a bit surprised we elected to use subagents instead of invoker execution. why?" + "also im a bit surprised we elected to use subagents instead of invoker execution. why?", + True, ), "scope", ) @@ -189,7 +228,10 @@ def test_substitution_correction_triggers(self): def test_genuine_question_fixture_does_not_trigger(self): messages = fixture_messages("genuine_question.jsonl") self.assertEqual(len(messages), 4) - self.assertEqual([detect.correction_class(m) for m in messages], [None, None, None, None]) + self.assertEqual( + [detect.correction_class(m, True) for m in messages], + [None, None, None, None], + ) def test_question_about_an_artifact_rather_than_the_agent_does_not_trigger(self): for message in ( @@ -198,7 +240,7 @@ def test_question_about_an_artifact_rather_than_the_agent_does_not_trigger(self) "can you explain why the contract has to land in a prior turn?", "does invoker support this, or do we need to run it locally first?", ): - self.assertIsNone(detect.correction_class(message), message) + self.assertIsNone(detect.correction_class(message, True), message) def test_pasted_transcript_trigger_phrase_far_from_end_does_not_trigger(self): # Mirrors a real session: a pasted terminal transcript quoting a @@ -211,11 +253,11 @@ def test_pasted_transcript_trigger_phrase_far_from_end_does_not_trigger(self): + ' the model wrote "do not use Invoker" in its own gate text ' + filler ) - self.assertIsNone(detect.correction_class(text)) + self.assertIsNone(detect.correction_class(text, True)) def test_trigger_phrase_within_tail_window_still_triggers(self): text = "x" * 200 + " ok whatever, just do it locally" - self.assertEqual(detect.correction_class(text), "scope") + self.assertEqual(detect.correction_class(text, True), "scope") def test_automated_task_notification_never_triggers_correction(self): text = ( @@ -224,7 +266,7 @@ def test_automated_task_notification_never_triggers_correction(self): "\"just do it locally\" in the quoted transcript.\n" "" ) - self.assertIsNone(detect.correction_class(text)) + self.assertIsNone(detect.correction_class(text, True)) def test_automated_task_notification_never_satisfies_reflection_check(self): text = ( @@ -242,6 +284,33 @@ def test_bare_reflect_without_leading_slash_still_satisfies_check(self): class TestStateMachine(ScopeLockCase): + def test_correction_wording_with_mutating_work_records_correction(self): + self.append_user("Please delegate the requested implementation.") + self.append_tool("Agent") + + result = self.prompt("what are you doing", mutating_work=False) + + self.assertEqual(result["phase"], "contract_required") + self.assertEqual(result["correction_counts"], {"scope": 1}) + + def test_correction_wording_without_mutating_work_records_nothing(self): + self.append_user("Please inspect the current status.") + self.append_tool("Read") + + result = self.prompt("what are you doing", mutating_work=False) + + self.assertNotIn("phase", result) + self.assertNotIn("correction_counts", result) + + def test_explicit_expansion_with_mutating_work_stays_excluded(self): + result = self.prompt( + "what are you doing? Also include the deployment scripts.", + mutating_work=True, + ) + + self.assertNotIn("phase", result) + self.assertNotIn("correction_counts", result) + def test_real_conversation_contract_then_ok_do_it_allows_write(self): rows = fixture_rows("contract_continuation.jsonl") request = rows[0]["message"]["content"] @@ -485,7 +554,9 @@ def test_first_gate_instructs_ending_the_turn(self): class TestHarnessWrappers(ScopeLockCase): def test_claude_prompt_injects_scope_contract_instruction(self): - payload = {**self.base, "prompt": "wtf are you doing? Just fix it locally."} + prompt = "wtf are you doing? Just fix it locally." + self.append_user(prompt) + payload = {**self.base, "prompt": prompt} code, out, _ = run_main(claude_prompt_scope.main, payload) self.assertEqual(code, 0) body = json.loads(out) @@ -498,7 +569,9 @@ def test_claude_pretool_blocks_with_exit_two(self): self.assertIn("SCOPE CONTRACT:", err) def test_cursor_before_submit_records_lock(self): - payload = {**self.base, "prompt": "wtf are you doing? Just fix it locally."} + prompt = "wtf are you doing? Just fix it locally." + self.append_user(prompt) + payload = {**self.base, "prompt": prompt} code, out, _ = run_main(cursor_before_submit.main, payload) self.assertEqual(code, 0) self.assertEqual(json.loads(out), {"continue": True}) @@ -513,7 +586,9 @@ def test_cursor_pretool_blocks(self): self.assertIn("SCOPE CONTRACT:", body["user_message"]) def test_codex_prompt_injects_scope_contract_instruction(self): - payload = {**self.base, "prompt": "wtf are you doing? Just fix it locally."} + prompt = "wtf are you doing? Just fix it locally." + self.append_user(prompt) + payload = {**self.base, "prompt": prompt} code, out, _ = run_main(codex_prompt_scope.main, payload) self.assertEqual(code, 0) body = json.loads(out) From bdd458a8deb25cbe08d1d6ea726d4c7825fdb9f6 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 18:15:22 +0000 Subject: [PATCH 2/3] =?UTF-8?q?invoker:=20wf-1789236467583-23/verify-scope?= =?UTF-8?q?-correction-evidence=20=E2=80=94=20Run=20the=20hook's=20own=20s?= =?UTF-8?q?uite=20as=20the=20shared=20repro=20for=20this=20slice.=20Review?= =?UTF-8?q?=20claim:=20The=20suite=20exits=200=20only=20when=20a=20correct?= =?UTF-8?q?ion=20phrasing=20without=20corroborating=20work=20records=20not?= =?UTF-8?q?hing=20and=20one=20with=20it=20still=20records=20a=20correction?= =?UTF-8?q?.=20Review=20lane:=20proof=20Safety=20invariant:=20The=20repro?= =?UTF-8?q?=20is=20identical=20before=20and=20after;=20the=20new=20negativ?= =?UTF-8?q?e=20case=20exits=20non-zero=20before=20and=200=20after.=20Effec?= =?UTF-8?q?tiveness=20measurement:=20The=20exit=20status=20of=20the=20suit?= =?UTF-8?q?e=20is=20the=20effectiveness=20signal=20for=20this=20slice.=20S?= =?UTF-8?q?lice=20rationale:=20One=20conceptual=20unit:=20the=20determinis?= =?UTF-8?q?tic=20repro=20only.=20Architectural=20effect:=20None.=20Goal:?= =?UTF-8?q?=20Deterministically=20prove=20the=20change.=20Motivation:=2054?= =?UTF-8?q?=20cases=20exist=20and=20none=20of=20them=20asserts=20that=20a?= =?UTF-8?q?=20correction=20needs=20corroborating=20work,=20which=20is=20wh?= =?UTF-8?q?y=20wording=20alone=20could=20freeze=20a=20session.=20Alternati?= =?UTF-8?q?ve=20considerations:=20Asserting=20only=20the=20negative=20case?= =?UTF-8?q?=20was=20set=20aside=20--=20without=20the=20positive=20one,=20a?= =?UTF-8?q?=20change=20that=20disables=20detection=20entirely=20would=20st?= =?UTF-8?q?ill=20exit=200.=20Implementation=20details:=20Execute=20the=20s?= =?UTF-8?q?uite=20as=20the=20terminal=20gate.=20Non-goals:=20No=20product?= =?UTF-8?q?=20edits=20here.=20Layer:=20e2e=5Fregression=20Feature=20state:?= =?UTF-8?q?=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 2e537274-f32f-48a9-8fb7-61dffeb18567 From d7eaaee17893e7ff0dbe018fe416e919dd3bb0ab Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 18:16:13 +0000 Subject: [PATCH 3/3] =?UTF-8?q?invoker:=20wf-1789236467583-23/scrub-handof?= =?UTF-8?q?f-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confirm?= =?UTF-8?q?ing=20no=20ephemeral=20handoff=20files=20were=20left=20behind.?= =?UTF-8?q?=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only=20--=20never=20deletes=20fil?= =?UTF-8?q?es,=20alters=20the=20index,=20or=20commits=20caller=20work.=20E?= =?UTF-8?q?ffectiveness=20measurement:=20A=20non-zero=20exit=20when=20ephe?= =?UTF-8?q?meral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20conceptual=20unit:=20the=20hygiene=20gate?= =?UTF-8?q?.=20Architectural=20effect:=20None.=20Goal:=20Confirm=20no=20ep?= =?UTF-8?q?hemeral=20handoff=20files=20remain=20after=20every=20other=20ta?= =?UTF-8?q?sk=20finishes.=20Motivation:=20Ephemeral=20inter-task=20files?= =?UTF-8?q?=20leak=20into=20the=20diff=20and=20read=20as=20part=20of=20the?= =?UTF-8?q?=20change.=20Alternative=20considerations:=20Manual=20inspectio?= =?UTF-8?q?n=20was=20set=20aside=20as=20non-deterministic.=20Implementatio?= =?UTF-8?q?n=20details:=20Run=20scripts/scrub-handoff-artifacts.sh=20witho?= =?UTF-8?q?ut=20--apply.=20Non-goals:=20No=20deletion,=20no=20index=20chan?= =?UTF-8?q?ges,=20no=20commits.=20Layer:=20e2e=5Fregression=20Feature=20st?= =?UTF-8?q?ate:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 7603b996-8545-498f-a695-0ba70ed69c6c