From 649ae1faa3560ac27a7c3df960480349e49827b3 Mon Sep 17 00:00:00 2001 From: Invoker Date: Sat, 12 Sep 2026 07:03:53 +0000 Subject: [PATCH 1/4] Quote routing guard matches --- engine/hooks/agent-routing-guard/detect.py | 49 +++++++++++++------ .../agent-routing-guard/tests/test_hooks.py | 42 +++++++++++++--- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/engine/hooks/agent-routing-guard/detect.py b/engine/hooks/agent-routing-guard/detect.py index c8fc9317..1987fb3e 100644 --- a/engine/hooks/agent-routing-guard/detect.py +++ b/engine/hooks/agent-routing-guard/detect.py @@ -48,6 +48,7 @@ import os import re import shutil +from typing import NamedTuple AGENT_TOOL_NAMES = frozenset({"Agent", "Task"}) INVOKER_CLI = "invoker-cli" @@ -102,9 +103,16 @@ re.compile(r"(?i)\b(?:without|skip|bypass|no)\s+invoker\b"), ) + +class PublicationVerbHit(NamedTuple): + label: str + matched_text: str + window: str + + BLOCK_MESSAGE = ( "agent-routing-guard: this Agent spawn carries publication work ({verbs}) and " - "invoker-cli is on PATH, so the subagent is the wrong vehicle. cat-mode " + "invoker-cli is on PATH, so the subagent is the wrong vehicle.\n{hits}\ncat-mode " "execution routing rule 3 -- an approved plan or durable/parallel work goes to " "Invoker when Invoker is available, not into parallel subagents each landing " "its own commit -- decides this one. Follow the installed {skill} skill to " @@ -116,7 +124,7 @@ UNCHECKED_MESSAGE = ( "agent-routing-guard: this Agent spawn carries publication work ({verbs}) and " - "invoker-cli is on PATH, and the local override could not be checked ({reason}). " + "invoker-cli is on PATH, and the local override could not be checked ({reason}).\n{hits}\n" "An override that cannot be read is not an override, so cat-mode execution " "routing rule 3 stands: an approved plan or durable/parallel work goes to " "Invoker, not into parallel publishing subagents. Follow the installed {skill} " @@ -195,23 +203,28 @@ def _counts_as_action(text: str, start: int, end: int) -> bool: return not (_is_hyphen_joined(text, start, end) or _is_negated(text, start)) -def publication_verbs(prompt: str) -> list[str]: +def _publication_hit(label: str, text: str, match: re.Match[str]) -> PublicationVerbHit: + window_start = max(0, match.start() - 40) + window_end = min(len(text), match.end() + 40) + return PublicationVerbHit(label, match.group(0), text[window_start:window_end]) + + +def publication_verbs(prompt: str) -> list[PublicationVerbHit]: """The publication verbs this prompt uses as actions, deduped and ordered.""" text = prompt or "" - found: list[str] = [] + found: list[PublicationVerbHit] = [] for label, pattern in ACTION_VERB_RES: for match in pattern.finditer(text): if _is_noun_use(text, match.start()): continue if not _counts_as_action(text, match.start(), match.end()): continue - found.append(label) + found.append(_publication_hit(label, text, match)) + break + for match in PR_ACTION_RE.finditer(text): + if _counts_as_action(text, match.start(), match.end()): + found.append(_publication_hit("open a PR", text, match)) break - if any( - _counts_as_action(text, match.start(), match.end()) - for match in PR_ACTION_RE.finditer(text) - ): - found.append("open a PR") return found @@ -288,13 +301,21 @@ def override_state(path: str) -> tuple[str, str]: return OVERRIDE_ABSENT, "" -def block_message(verbs: list[str]) -> str: - return BLOCK_MESSAGE.format(verbs=", ".join(verbs), skill=ROUTING_SKILL) +def _verb_labels(verbs: list[PublicationVerbHit]) -> str: + return ", ".join(verb.label for verb in verbs) + + +def _hit_lines(verbs: list[PublicationVerbHit]) -> str: + return "\n".join(f'{verb.label}: "{verb.window}"' for verb in verbs) + + +def block_message(verbs: list[PublicationVerbHit]) -> str: + return BLOCK_MESSAGE.format(verbs=_verb_labels(verbs), hits=_hit_lines(verbs), skill=ROUTING_SKILL) -def unchecked_message(verbs: list[str], reason: str) -> str: +def unchecked_message(verbs: list[PublicationVerbHit], reason: str) -> str: return UNCHECKED_MESSAGE.format( - verbs=", ".join(verbs), reason=reason, skill=ROUTING_SKILL + verbs=_verb_labels(verbs), hits=_hit_lines(verbs), reason=reason, skill=ROUTING_SKILL ) diff --git a/engine/hooks/agent-routing-guard/tests/test_hooks.py b/engine/hooks/agent-routing-guard/tests/test_hooks.py index f53b313f..e3504a7e 100644 --- a/engine/hooks/agent-routing-guard/tests/test_hooks.py +++ b/engine/hooks/agent-routing-guard/tests/test_hooks.py @@ -43,6 +43,10 @@ def fixture_names() -> list[str]: return sorted(n for n in os.listdir(FIXTURE_DIR) if n.endswith(".json")) +def publication_labels(prompt: str) -> list[str]: + return [hit.label for hit in detect.publication_verbs(prompt)] + + class Sandbox: """A PATH with or without invoker-cli, plus a transcript to read the user's current message from.""" @@ -162,7 +166,7 @@ def test_action_verbs_are_detected(self) -> None: "land the PRs bottom to top": ["open a PR"], } for prompt, expected in cases.items(): - self.assertEqual(detect.publication_verbs(prompt), expected, prompt) + self.assertEqual(publication_labels(prompt), expected, prompt) def test_the_same_words_as_nouns_do_not_count(self) -> None: for prompt in ( @@ -173,7 +177,7 @@ def test_the_same_words_as_nouns_do_not_count(self) -> None: "which PR introduced this?", "report the first push that failed", ): - self.assertEqual(detect.publication_verbs(prompt), [], prompt) + self.assertEqual(publication_labels(prompt), [], prompt) def test_negated_verbs_do_not_count(self) -> None: for prompt in ( @@ -184,7 +188,7 @@ def test_negated_verbs_do_not_count(self) -> None: "Neither edit nor merge anything.", "Finish without committing, pushing, or merging.", ): - self.assertEqual(detect.publication_verbs(prompt), [], prompt) + self.assertEqual(publication_labels(prompt), [], prompt) def test_hyphenated_names_do_not_count(self) -> None: for prompt in ( @@ -193,7 +197,7 @@ def test_hyphenated_names_do_not_count(self) -> None: "Explain how merge-clone works in the repo.", "Check whether auto-merge is enabled on the repo settings.", ): - self.assertEqual(detect.publication_verbs(prompt), [], prompt) + self.assertEqual(publication_labels(prompt), [], prompt) def test_real_publishing_requests_around_negation_still_fire(self) -> None: cases = { @@ -208,7 +212,18 @@ def test_real_publishing_requests_around_negation_still_fire(self) -> None: "re-push the branch": ["push"], } for prompt, expected in cases.items(): - self.assertEqual(detect.publication_verbs(prompt), expected, prompt) + self.assertEqual(publication_labels(prompt), expected, prompt) + + def test_narrated_past_tense_force_push_sentence_keeps_current_verdict(self) -> None: + prompt = "Force-push attempts were blocked twice by the stacking tool's hook" + self.assertEqual(publication_labels(prompt), ["push"]) + + def test_non_git_merge_phrase_keeps_current_verdict(self) -> None: + self.assertEqual(publication_labels("merge overlapping findings"), ["merge"]) + + def test_clean_read_only_sentence_stays_silent(self) -> None: + prompt = "Read the hook and summarize the control flow." + self.assertEqual(publication_labels(prompt), []) class OverrideCase(unittest.TestCase): @@ -325,15 +340,30 @@ def test_read_only_scanner_prompts_are_allowed_through_the_entrypoint(self) -> N self.assertEqual(message, "", prompt) def test_a_publishing_prompt_still_blocks_through_the_entrypoint(self) -> None: + prompt = "commit and push the fix, then open a PR" payload = { "tool_name": "Agent", "transcript_path": self.box.transcript("go"), - "tool_input": {"prompt": "commit and push the fix, then open a PR"}, + "tool_input": {"prompt": prompt}, } blocked, message = run_entrypoint(payload, self.box.environ()) self.assertTrue(blocked) self.assertIn("commit, push, open a PR", message) + def test_each_matched_span_appears_verbatim_in_the_refusal(self) -> None: + prompt = "Please commit the guard change, then push it and open a PR." + payload = { + "tool_name": "Agent", + "transcript_path": self.box.transcript("go"), + "tool_input": {"prompt": prompt}, + } + hits = detect.publication_verbs(prompt) + blocked, message = run_entrypoint(payload, self.box.environ()) + self.assertTrue(blocked, message) + for hit in hits: + self.assertIn(hit.matched_text, message) + self.assertIn(f'{hit.label}: "{hit.window}"', message) + def test_invoker_absent_allows_the_spawn_without_reading_the_transcript(self) -> None: box = Sandbox(invoker_on_path=False) try: From 57819675e5d32fc37efdc1f93c03f9b5cecfc024 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:04:08 +0000 Subject: [PATCH 2/4] =?UTF-8?q?invoker:=20wf-1789196446090-13/implement-gu?= =?UTF-8?q?ard-match-quoting=20=E2=80=94=20Review=20claim:=20the=20routing?= =?UTF-8?q?=20guard's=20refusal=20quotes=20each=20matched=20verb=20with=20?= =?UTF-8?q?its=20surrounding=20words,=20and=20tests=20pin=20its=20verdict?= =?UTF-8?q?=20for=20narrated=20past=20work=20and=20for=20the=20non-git=20s?= =?UTF-8?q?ense=20of=20"merge".=20Review=20lane:=20behavior=20Safety=20inv?= =?UTF-8?q?ariant:=20Refuse-or-allow=20behavior=20is=20unchanged;=20only?= =?UTF-8?q?=20the=20message=20gains=20detail.=20Tests=20assert=20the=20cur?= =?UTF-8?q?rent=20verdicts=20rather=20than=20changing=20them.=20Effectiven?= =?UTF-8?q?ess=20measurement:=20A=20test=20asserts=20each=20matched=20span?= =?UTF-8?q?=20appears=20verbatim=20in=20the=20refusal;=20removing=20the=20?= =?UTF-8?q?span=20from=20the=20message=20fails=20it.=20Slice=20rationale:?= =?UTF-8?q?=20One=20claim=20about=20one=20file's=20output.=20Replacing=20p?= =?UTF-8?q?rompt=20matching=20with=20a=20check=20on=20the=20command=20actu?= =?UTF-8?q?ally=20run=20is=20a=20separate,=20already-open=20change.=20Arch?= =?UTF-8?q?itectural=20effect:=20The=20detector's=20return=20value=20carri?= =?UTF-8?q?es=20the=20matched=20text=20alongside=20the=20label,=20and=20th?= =?UTF-8?q?e=20message=20formatter=20prints=20it.=20Goal:=20Make=20a=20ref?= =?UTF-8?q?usal=20say=20what=20it=20matched.=20Motivation:=20The=20author?= =?UTF-8?q?=20guessed=20twice=20about=20why=20a=20spawn=20was=20refused,?= =?UTF-8?q?=20and=20both=20guesses=20were=20wrong,=20because=20the=20refus?= =?UTF-8?q?al=20named=20only=20verb=20labels.=20Alternative=20consideratio?= =?UTF-8?q?ns:=20Landing=20the=20open=20change=20that=20gates=20the=20publ?= =?UTF-8?q?ishing=20act=20instead=20of=20the=20prompt=20text=20is=20the=20?= =?UTF-8?q?structural=20fix=20and=20is=20preferred;=20this=20slice=20is=20?= =?UTF-8?q?the=20smaller=20one=20that=20helps=20whatever=20remains.=20Wide?= =?UTF-8?q?ning=20the=20negation=20or=20noun=20classes=20was=20rejected:?= =?UTF-8?q?=20the=20verdicts=20here=20are=20arguably=20correct,=20and=20th?= =?UTF-8?q?e=20real=20defect=20is=20that=20the=20author=20could=20not=20se?= =?UTF-8?q?e=20them.=20Implementation=20details:=20In=20engine/hooks/agent?= =?UTF-8?q?-routing-guard/detect.py,=20have=20the=20verb-collecting=20func?= =?UTF-8?q?tion=20return=20each=20hit=20as=20a=20label=20plus=20the=20matc?= =?UTF-8?q?hed=20text=20plus=20a=20short=20window=20of=20surrounding=20cha?= =?UTF-8?q?racters,=20and=20have=20the=20refusal=20formatter=20list=20them?= =?UTF-8?q?=20one=20per=20line;=20keep=20the=20existing=20decision=20logic?= =?UTF-8?q?,=20exit=20codes,=20and=20the=20summary=20wording=20that=20name?= =?UTF-8?q?s=20the=20skill=20to=20follow.=20Update=20every=20caller=20and?= =?UTF-8?q?=20test=20that=20consumes=20the=20old=20shape.=20Add=20fixtures?= =?UTF-8?q?=20in=20engine/hooks/agent-routing-guard/tests/test=5Fhooks.py?= =?UTF-8?q?=20for=20a=20narrated=20past-tense=20sentence=20about=20a=20for?= =?UTF-8?q?ce-push,=20for=20"merge"=20used=20about=20findings,=20and=20for?= =?UTF-8?q?=20a=20sentence=20with=20no=20publication=20verb,=20each=20asse?= =?UTF-8?q?rting=20the=20verdict=20and,=20where=20refused,=20that=20the=20?= =?UTF-8?q?matched=20words=20appear=20in=20the=20message.=20Non-goals:=20N?= =?UTF-8?q?o=20change=20to=20which=20prompts=20are=20refused.=20No=20new?= =?UTF-8?q?=20verb=20classes,=20no=20negation=20or=20noun-context=20change?= =?UTF-8?q?s,=20and=20no=20change=20to=20the=20routing=20advice=20the=20me?= =?UTF-8?q?ssage=20gives.=20Layer:=20domain=20Feature=20state:=20active=20?= =?UTF-8?q?Files:=20-=20engine/hooks/agent-routing-guard/detect.py=20-=20e?= =?UTF-8?q?ngine/hooks/agent-routing-guard/tests/test=5Fhooks.py=20Change?= =?UTF-8?q?=20types:=20-=20engine/hooks/agent-routing-guard/detect.py:=20m?= =?UTF-8?q?odify=20-=20engine/hooks/agent-routing-guard/tests/test=5Fhooks?= =?UTF-8?q?.py:=20modify=20Acceptance=20criteria:=20-=20`python3=20-m=20un?= =?UTF-8?q?ittest=20discover=20-s=20engine/hooks/agent-routing-guard/tests?= =?UTF-8?q?=20-v`=20exits=200.=20-=20`python3=20scripts/check=5Fhook=5Ftes?= =?UTF-8?q?t=5Fcoverage.py=20engine/hooks/agent-routing-guard`=20exits=200?= =?UTF-8?q?.=20-=20`python3=20scripts/check=5Fno=5Fnew=5Fcomments.py=20--b?= =?UTF-8?q?ase=20origin/main`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: e1386d6d-295a-4de7-8c40-bb51229bb8e0 From 001c25cf01cad86d1d8dcf59e220a185d3b476af Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:10:25 +0000 Subject: [PATCH 3/4] =?UTF-8?q?invoker:=20wf-1789196446090-13/verify-guard?= =?UTF-8?q?-match-quoting=20=E2=80=94=20Review=20claim:=20the=20guard's=20?= =?UTF-8?q?tests=20and=20its=20hook-coverage=20gate=20pass.=20Review=20lan?= =?UTF-8?q?e:=20proof=20Safety=20invariant:=20Verification=20is=20read-onl?= =?UTF-8?q?y=20and=20changes=20no=20file.=20Effectiveness=20measurement:?= =?UTF-8?q?=20The=20commands=20are=20the=20measurement.=20Slice=20rational?= =?UTF-8?q?e:=20One=20proof=20for=20one=20claim.=20Architectural=20effect:?= =?UTF-8?q?=20None;=20verification=20only.=20Goal:=20Prove=20the=20guard's?= =?UTF-8?q?=20suite=20passes=20with=20the=20new=20message=20and=20fixtures?= =?UTF-8?q?.=20Motivation:=20A=20message=20change=20that=20breaks=20a=20ca?= =?UTF-8?q?ller=20must=20be=20caught=20here.=20Alternative=20consideration?= =?UTF-8?q?s:=20The=20whole=20suite=20was=20rejected=20as=20slower=20witho?= =?UTF-8?q?ut=20adding=20evidence=20for=20this=20claim.=20Implementation?= =?UTF-8?q?=20details:=20Run=20the=20guard's=20tests=20and=20its=20coverag?= =?UTF-8?q?e=20gate.=20Non-goals:=20No=20edits.=20Layer:=20app=5Fregressio?= =?UTF-8?q?n=20Feature=20state:=20active=20Acceptance=20criteria:=20-=20Ex?= =?UTF-8?q?its=200=20only=20when=20the=20tests=20and=20the=20gate=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: a582d341-dd65-404f-ad0a-dc236a2d3420 From 01b7affa918867d8287123262b9e3c11691cf576 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:11:39 +0000 Subject: [PATCH 4/4] =?UTF-8?q?invoker:=20wf-1789196446090-13/scrub-handof?= =?UTF-8?q?f-artifacts=20=E2=80=94=20Review=20claim:=20no=20ephemeral=20ha?= =?UTF-8?q?ndoff=20files=20remain=20in=20the=20worktree.=20Review=20lane:?= =?UTF-8?q?=20cleanup=20Safety=20invariant:=20The=20scrub=20script=20only?= =?UTF-8?q?=20checks=20for=20known=20handoff=20artifact=20names=20and=20ne?= =?UTF-8?q?ver=20touches=20source=20or=20tests.=20Effectiveness=20measurem?= =?UTF-8?q?ent:=20The=20script=20exits=20non-zero=20if=20any=20handoff=20a?= =?UTF-8?q?rtifact=20remains.=20Slice=20rationale:=20Required=20terminal?= =?UTF-8?q?=20scrub=20for=20every=20implementation=20workflow.=20Architect?= =?UTF-8?q?ural=20effect:=20None;=20hygiene=20only.=20Goal:=20Leave=20the?= =?UTF-8?q?=20branch=20free=20of=20handoff=20artifacts.=20Motivation:=20Ha?= =?UTF-8?q?ndoff=20files=20must=20not=20reach=20the=20PR.=20Alternative=20?= =?UTF-8?q?considerations:=20Manual=20cleanup=20was=20rejected=20as=20non-?= =?UTF-8?q?deterministic.=20Implementation=20details:=20Run=20scripts/scru?= =?UTF-8?q?b-handoff-artifacts.sh.=20Non-goals:=20No=20product=20edits.=20?= =?UTF-8?q?Layer:=20app=5Fregression=20Feature=20state:=20active=20Accepta?= =?UTF-8?q?nce=20criteria:=20-=20`bash=20scripts/scrub-handoff-artifacts.s?= =?UTF-8?q?h`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: a87dbb7c-e3ed-410f-989d-01ae6209b001