From 1901980b22a5b0555dcb31c9c8a8a6a41fb5048f Mon Sep 17 00:00:00 2001 From: Edbert Chan <1916223+EdbertChan@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:04:33 -0700 Subject: [PATCH 1/4] Block a CAT-UNVERIFIED tag that hands a read-only check back as 'say X' The ledger now refuses a reply that tags a claim and offers 'say ...' or 'tell me to test it' for a read-only check. The frustration watchdog accepts 'I am running X now' as a valid next step. Co-Authored-By: Claude Sonnet 5.5 Change-Id: I3b5ef1297fe1f20eec746fd4f74ccc44c54516ca --- .../frustration-watchdog/claude_stop_check.py | 9 +++- .../frustration-watchdog/tests/test_hooks.py | 17 +++++++ engine/hooks/unverified-tag-ledger/detect.py | 47 +++++++++++++++++++ .../unverified-tag-ledger/tests/test_hooks.py | 28 +++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/engine/hooks/frustration-watchdog/claude_stop_check.py b/engine/hooks/frustration-watchdog/claude_stop_check.py index 8b9e6f513..134b5744f 100755 --- a/engine/hooks/frustration-watchdog/claude_stop_check.py +++ b/engine/hooks/frustration-watchdog/claude_stop_check.py @@ -86,6 +86,11 @@ r"i(?:'ll| will) (?:handle|do|take|run|fix|keep|watch))\b" ) +CHECK_IN_FLIGHT_RE = re.compile( + r"(?i)\bi(?:'m| am) (?:running|checking|testing|probing|reading|querying|fetching) " + r"[^.\n]{1,100}?\bnow\b" +) + HOOK_REFUSAL_RE = re.compile(r"\s*PreToolUse:\S+ hook error\b") """How Claude Code records a tool call a PreToolUse hook refused: an is_error tool_result whose text opens "PreToolUse:Bash hook error: []: ...".""" @@ -209,7 +214,9 @@ def ends_the_wait(message): stripped = message.rstrip() if stripped.endswith("?"): return True # a direct question is a handoff - return bool(NEXT_STEP_RE.search(message) or ETA_RE.search(message)) + return bool( + NEXT_STEP_RE.search(message) or ETA_RE.search(message) or CHECK_IN_FLIGHT_RE.search(message) + ) def detect(data): diff --git a/engine/hooks/frustration-watchdog/tests/test_hooks.py b/engine/hooks/frustration-watchdog/tests/test_hooks.py index cac956e73..79e633d50 100755 --- a/engine/hooks/frustration-watchdog/tests/test_hooks.py +++ b/engine/hooks/frustration-watchdog/tests/test_hooks.py @@ -72,6 +72,23 @@ def test_waiting_user_with_concrete_action_passes(self): finally: os.unlink(path) + def test_check_in_flight_counts_as_next_step(self): + path = transcript_with([("2026-08-18T02:13:30Z", "i am waiting for you to do something")]) + try: + blocked, _ = run_hook(path, "I am running the GraphQL probe now. Result in this turn.") + self.assertFalse(blocked) + finally: + os.unlink(path) + + def test_narrated_past_work_is_not_a_check_in_flight(self): + path = transcript_with([("2026-08-18T02:13:30Z", "i am waiting for you to do something")]) + try: + blocked, err = run_hook(path, "I ran the GraphQL probe earlier and it looked fine.") + self.assertTrue(blocked) + self.assertIn("impatience-shaped", err) + finally: + os.unlink(path) + def test_direct_question_counts_as_handoff(self): path = transcript_with([("2026-08-18T02:13:30Z", "WHY ARE WE NOT LAUNCHING A ZOOM MEETING RIGHT NOW")]) try: diff --git a/engine/hooks/unverified-tag-ledger/detect.py b/engine/hooks/unverified-tag-ledger/detect.py index 475ec645b..d60df8f7c 100644 --- a/engine/hooks/unverified-tag-ledger/detect.py +++ b/engine/hooks/unverified-tag-ledger/detect.py @@ -66,7 +66,20 @@ re.IGNORECASE | re.DOTALL, ) +HANDBACK_RE = re.compile( + r"\b(?:say|reply|type|tell me to)\s+(?:the word\s+)?(?:[\"'\u2018\u201c][^\"'\u2019\u201d]{1,60}[\"'\u2019\u201d]" + r"|(?:go|test|test it|run it|check it|yes|ok|okay|probe it)\b)", + re.IGNORECASE, +) +WRITE_ACTION_RE = re.compile( + r"\b(?:push|deploy|delete|merge|commit|post|send|apply|write|create|publish|" + r"overwrite|force|restart|kill|install|submit|approve|release)\w*\b", + re.IGNORECASE, +) +SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+|\n+") + RULE_NO_VERIFICATION_TOOL = "unverified-tag-ledger.no-verification-tool" +RULE_HANDBACK = "unverified-tag-ledger.handback" RULE_DO_NOT_EMIT = "unverified-tag-ledger.do-not-emit" RULE_PROMPT_REMINDER = "unverified-tag-ledger.prompt-reminder" @@ -242,6 +255,22 @@ def emitted_tags(message: str) -> list[str]: return [match.group(0) for match in markers.TAG_RE.finditer(prose)] +def handback_offers(message: str) -> list[str]: + """Sentences outside code that hand a read-only check back to the user. + + "say 'test it'" and "tell me to test it" make the user the trigger for a + check the agent can run itself. An offer whose sentence names a write + (push, deploy, delete, ...) is a real approval gate and is left alone. + """ + prose = INLINE_CODE_RE.sub("", FENCED_RE.sub("", message or "")) + offers = [] + for sentence in SENTENCE_SPLIT_RE.split(prose): + sentence = " ".join(sentence.split()) + if HANDBACK_RE.search(sentence) and not WRITE_ACTION_RE.search(sentence): + offers.append(sentence) + return offers + + def evaluate(payload: dict, mode: str | None = None) -> dict: """Record the turn, then decide whether it may end. @@ -290,6 +319,14 @@ def evaluate(payload: dict, mode: str | None = None) -> dict: if not new_claims: return {"note": "\n".join(notes), "block": ""} + offers = handback_offers(message) + if offers: + return {"note": "", "block": ( + "unverified-tag-ledger: this reply tags a claim as unverified and hands the check back " + f"to the user: {'; '.join(offers[:MAX_LISTED])}. The check is read-only, so the user " + "does not need to trigger it. Run the check now (Bash/Read/Grep/WebFetch) and paste its " + "output. This block is not released by stop_hook_active: deleting the offer always ends the loop.")} + if tools is None: notes.append( f"unverified-tag-ledger: logged {len(new_claims)} CAT-UNVERIFIED claim(s), but this " @@ -339,6 +376,16 @@ def _stop_findings(event: dict[str, object]) -> list[Finding]: if not block: return [] message = _last_assistant_text(payload) + if "hands the check back" in block: + return [ + Finding( + rule_id=RULE_HANDBACK, + subject=offer, + message=block, + evidence=offer, + ) + for offer in handback_offers(message) + ] if behavior == "do_not_emit": tags = emitted_tags(message) return [ diff --git a/engine/hooks/unverified-tag-ledger/tests/test_hooks.py b/engine/hooks/unverified-tag-ledger/tests/test_hooks.py index cfd4633a3..eef7c4dde 100644 --- a/engine/hooks/unverified-tag-ledger/tests/test_hooks.py +++ b/engine/hooks/unverified-tag-ledger/tests/test_hooks.py @@ -179,6 +179,34 @@ def test_rewrite_turn_is_released_so_the_block_cannot_loop(self) -> None: self.payload(REAL_TAG_1, tools=False, stop_hook_active=True)) self.assertEqual(verdict["block"], "") + def test_tag_plus_say_offer_is_blocked_even_after_a_read(self) -> None: + reply = ( + f"{REAL_TAG_1}\n\nIf you want me to settle it, say 'test it' and I will run the probe.") + verdict = self.detect.evaluate(self.payload(reply, tools=True)) + self.assertIn("hands the check back", verdict["block"]) + self.assertIn("run the check now", verdict["block"].lower()) + + def test_tag_plus_tell_me_to_test_offer_is_blocked(self) -> None: + reply = f"{REAL_TAG_1}\n\nTell me to test it and I will." + verdict = self.detect.evaluate(self.payload(reply, tools=True)) + self.assertIn("hands the check back", verdict["block"]) + + def test_say_offer_block_is_not_released_by_stop_hook_active(self) -> None: + reply = f"{REAL_TAG_1}\n\nSay 'go' and I will run it." + verdict = self.detect.evaluate( + self.payload(reply, tools=True, stop_hook_active=True)) + self.assertIn("hands the check back", verdict["block"]) + + def test_tag_plus_say_offer_for_a_write_is_allowed(self) -> None: + reply = f"{REAL_TAG_1}\n\nSay 'push it' and I will push the branch to origin." + verdict = self.detect.evaluate(self.payload(reply, tools=True)) + self.assertEqual(verdict["block"], "") + + def test_say_offer_without_a_tag_is_allowed(self) -> None: + verdict = self.detect.evaluate( + self.payload("Ran the probe: 3 passed. Say 'next' for the follow-up.", tools=True)) + self.assertEqual(verdict["block"], "") + def test_untagged_turn_with_no_tools_is_never_blocked(self) -> None: verdict = self.detect.evaluate( self.payload("Short answer, nothing claimed.", tools=False)) From ae9861b827fe1b5f6dafebe4d69ded967dea9d3b Mon Sep 17 00:00:00 2001 From: Edbert Chan <1916223+EdbertChan@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:13:52 -0700 Subject: [PATCH 2/4] cat-mode: local-only repos end in a local merge, never GitHub Co-Authored-By: Claude Sonnet 5.5 Change-Id: I26c788b179793511a9191bede9861bd8521bc347 --- corpus/skills/cat-mode/SKILL.md | 3 ++- .../cat-mode/tests/fires_local_only_no_github.md | 2 ++ .../tests/stays_silent_remote_repo_publishes.md | 2 ++ corpus/skills/cat-mode/tests/test_artifact_sweeps.py | 10 ++++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 corpus/skills/cat-mode/tests/fires_local_only_no_github.md create mode 100644 corpus/skills/cat-mode/tests/stays_silent_remote_repo_publishes.md diff --git a/corpus/skills/cat-mode/SKILL.md b/corpus/skills/cat-mode/SKILL.md index 3464274d4..35b869244 100644 --- a/corpus/skills/cat-mode/SKILL.md +++ b/corpus/skills/cat-mode/SKILL.md @@ -22,7 +22,8 @@ Once direction is set, act — don't ask permission for each sub-step. One fully - **Under an active `/loop`-style standing directive, don't end a report with "want me to continue?"** A trailing question is a permission request. Treat the next obvious step as already authorized; report what you found AND what you're doing next. Ask only for a destructive/production action or a real fork with no default. - **Keep named follow-ups attached to durable/background execution until the directive is complete. Completion includes every invoked skill's required landing phase.** Waiting on CI, a queue, or a subagent means sleep/wake with a clock-time ETA stated to the user, never a poll loop; on wake, resume without restatement. Arming a watcher and yielding is not waiting. -- **Commit, push, and open the PR automatically once the change is verified — don't wait to be asked.** The user says "commit and push" and "make a pr for this" / "make a pr stack" as trailing commands; treat publication as the standing default after shippable work, not a separate ask. Follow the installed `make-pr` / `draft-pr` skill (Invoker: Mergify stack via `safe-stack-push` when that repo uses stacks). "Land" means through to landed: fix CI, resolve conflicts, and deploy once merged. Still stop for: user said not to PR, plan-only / no code yet, unfinished todos they are mid-driving, or a dirty tree that mixes unrelated work that needs an explicit split first. Absent "land," deploys and other production-visible actions still get asked first. +- **Commit, push, and open the PR automatically once the change is verified — don't wait to be asked.** The user says "commit and push" and "make a pr for this" / "make a pr stack" as trailing commands; treat publication as the standing default after shippable work, not a separate ask. Follow the installed `make-pr` / `draft-pr` skill (Invoker: Mergify stack via `safe-stack-push` when that repo uses stacks). "Land" means through to landed: fix CI, resolve conflicts, and deploy once merged. Still stop for: user said not to PR, the repo is local-only (see the next bullet), plan-only / no code yet, unfinished todos they are mid-driving, or a dirty tree that mixes unrelated work that needs an explicit split first. Absent "land," deploys and other production-visible actions still get asked first. +- **A local-only repo ends in a local branch and a local merge, never GitHub.** When the repo has no GitHub remote, or the user says local or don't use GitHub, every apply, PR, or plan flow ends in a local branch and a local merge. Never push, run `gh`, or probe GitHub credentials. Ask once if unsure. Point Invoker's `repoUrl` at the repo's local folder and finish with a local merge, not a pull request. - **A named multi-step done-gate stays locked until receipt.** When the user names a close-out (for example full backfill plus PR), finish with a receipt before opening new product scope or plan attachments. - **Do not race publication against a live long job in the same thread.** Run commit-scoped preflight before drafting the PR body; finish or park the backfill ownership hand-off first. - **Cursor `/pr-skill` is not Invoker's merge-gate.** Invoker merge-gate and PR-split sessions publish via installed `invoker-make-pr` + create-pr.mjs / `gh api`; implement/slice tasks do not publish PRs. Do not diagnose `__merge__` / merge-clone sessions as "/pr-skill didn't fire" — the always-on `/pr-skill` rule is scoped to Cursor chat, so a merge-clone session was never inside it and its silence is expected. diff --git a/corpus/skills/cat-mode/tests/fires_local_only_no_github.md b/corpus/skills/cat-mode/tests/fires_local_only_no_github.md new file mode 100644 index 000000000..6bda9d6d8 --- /dev/null +++ b/corpus/skills/cat-mode/tests/fires_local_only_no_github.md @@ -0,0 +1,2 @@ +User: "Apply this plan. The repo is local, don't use GitHub." +Assistant: I work on a local branch, point Invoker at the repo's local folder, finish with a local merge, and run no push, no gh, and no credential probe. diff --git a/corpus/skills/cat-mode/tests/stays_silent_remote_repo_publishes.md b/corpus/skills/cat-mode/tests/stays_silent_remote_repo_publishes.md new file mode 100644 index 000000000..490f77bd2 --- /dev/null +++ b/corpus/skills/cat-mode/tests/stays_silent_remote_repo_publishes.md @@ -0,0 +1,2 @@ +User: "Verified. Ship it." (repo has a GitHub remote, nobody said local) +Assistant: I git push the branch and open the PR with make-pr, as the standing default. diff --git a/corpus/skills/cat-mode/tests/test_artifact_sweeps.py b/corpus/skills/cat-mode/tests/test_artifact_sweeps.py index 3ef67b1fd..75d2925b7 100644 --- a/corpus/skills/cat-mode/tests/test_artifact_sweeps.py +++ b/corpus/skills/cat-mode/tests/test_artifact_sweeps.py @@ -22,6 +22,16 @@ def test_correction_surface_rule_has_trigger_fixture(self): for required in ("raw data", "aggregate table", "chart source", "live Sheet read-back"): self.assertIn(required, fixture) + def test_local_only_rule_has_fixtures(self): + skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") + fires = (SKILL_DIR / "tests" / "fires_local_only_no_github.md").read_text(encoding="utf-8") + silent = (SKILL_DIR / "tests" / "stays_silent_remote_repo_publishes.md").read_text(encoding="utf-8") + + self.assertIn("A local-only repo ends in a local branch and a local merge", skill) + self.assertIn("local merge", fires) + self.assertNotIn("git push", fires.split("Assistant:")[1]) + self.assertIn("git push", silent) + if __name__ == "__main__": unittest.main() From df8f867340fd875be63f9ac0a3cb7588319293cb Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 13 Oct 2026 06:05:52 +0000 Subject: [PATCH 3/4] cat-mode: avoid backticked gh reference --- corpus/skills/cat-mode/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus/skills/cat-mode/SKILL.md b/corpus/skills/cat-mode/SKILL.md index 35b869244..3c924ed27 100644 --- a/corpus/skills/cat-mode/SKILL.md +++ b/corpus/skills/cat-mode/SKILL.md @@ -23,7 +23,7 @@ Once direction is set, act — don't ask permission for each sub-step. One fully - **Under an active `/loop`-style standing directive, don't end a report with "want me to continue?"** A trailing question is a permission request. Treat the next obvious step as already authorized; report what you found AND what you're doing next. Ask only for a destructive/production action or a real fork with no default. - **Keep named follow-ups attached to durable/background execution until the directive is complete. Completion includes every invoked skill's required landing phase.** Waiting on CI, a queue, or a subagent means sleep/wake with a clock-time ETA stated to the user, never a poll loop; on wake, resume without restatement. Arming a watcher and yielding is not waiting. - **Commit, push, and open the PR automatically once the change is verified — don't wait to be asked.** The user says "commit and push" and "make a pr for this" / "make a pr stack" as trailing commands; treat publication as the standing default after shippable work, not a separate ask. Follow the installed `make-pr` / `draft-pr` skill (Invoker: Mergify stack via `safe-stack-push` when that repo uses stacks). "Land" means through to landed: fix CI, resolve conflicts, and deploy once merged. Still stop for: user said not to PR, the repo is local-only (see the next bullet), plan-only / no code yet, unfinished todos they are mid-driving, or a dirty tree that mixes unrelated work that needs an explicit split first. Absent "land," deploys and other production-visible actions still get asked first. -- **A local-only repo ends in a local branch and a local merge, never GitHub.** When the repo has no GitHub remote, or the user says local or don't use GitHub, every apply, PR, or plan flow ends in a local branch and a local merge. Never push, run `gh`, or probe GitHub credentials. Ask once if unsure. Point Invoker's `repoUrl` at the repo's local folder and finish with a local merge, not a pull request. +- **A local-only repo ends in a local branch and a local merge, never GitHub.** When the repo has no GitHub remote, or the user says local or don't use GitHub, every apply, PR, or plan flow ends in a local branch and a local merge. Never push, run the GitHub CLI, or probe GitHub credentials. Ask once if unsure. Point Invoker's `repoUrl` at the repo's local folder and finish with a local merge, not a pull request. - **A named multi-step done-gate stays locked until receipt.** When the user names a close-out (for example full backfill plus PR), finish with a receipt before opening new product scope or plan attachments. - **Do not race publication against a live long job in the same thread.** Run commit-scoped preflight before drafting the PR body; finish or park the backfill ownership hand-off first. - **Cursor `/pr-skill` is not Invoker's merge-gate.** Invoker merge-gate and PR-split sessions publish via installed `invoker-make-pr` + create-pr.mjs / `gh api`; implement/slice tasks do not publish PRs. Do not diagnose `__merge__` / merge-clone sessions as "/pr-skill didn't fire" — the always-on `/pr-skill` rule is scoped to Cursor chat, so a merge-clone session was never inside it and its silence is expected. From c79e01980b803505a20717af27d4dbaca303d98a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 13 Oct 2026 06:06:11 +0000 Subject: [PATCH 4/4] =?UTF-8?q?invoker:=20wf-1791870257543-107/repair=20?= =?UTF-8?q?=E2=80=94=20Repair=20PR=20#1378:=20failed=5Fchecks:=20test;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0