From 8e19962c41f757867d439a89809df5229a2471f9 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 11 Sep 2026 21:09:52 +0000 Subject: [PATCH 1/7] =?UTF-8?q?invoker:=20wf-1789160835189-3/implement-spl?= =?UTF-8?q?it-scope-proof-section=20=E2=80=94=20Review=20claim:=20-=20The?= =?UTF-8?q?=20catstack=20PR-slicing=20skill=20gains=20the=20one=20guidance?= =?UTF-8?q?=20section=20that=20only=20Invoker's=20copy=20has.=20Review=20l?= =?UTF-8?q?ane:=20-=20docs=20Safety=20invariant:=20-=20Text-only=20skill?= =?UTF-8?q?=20change;=20no=20hook,=20script,=20or=20test=20reads=20this=20?= =?UTF-8?q?section=20by=20name.=20Effectiveness=20measurement:=20-=20`grep?= =?UTF-8?q?=20-q=20"^##=20Proof=20Must=20Match=20the=20Claim"=20product/sk?= =?UTF-8?q?ills/split-scope/SKILL.md`=20exits=200.=20Slice=20rationale:=20?= =?UTF-8?q?-=20One=20guidance=20section=20in=20one=20skill=20file;=20the?= =?UTF-8?q?=20hook=20that=20points=20at=20this=20skill=20is=20a=20separate?= =?UTF-8?q?=20workflow.=20Architectural=20effect:=20-=20None;=20adds=20gui?= =?UTF-8?q?dance=20text=20to=20one=20skill=20file.=20Goal:=20-=20Add=20the?= =?UTF-8?q?=20"Proof=20Must=20Match=20the=20Claim"=20section=20to=20produc?= =?UTF-8?q?t/skills/split-scope/SKILL.md=20so=20catstack's=20copy=20has=20?= =?UTF-8?q?every=20rule=20Invoker's=20review-compression=20has.=20Motivati?= =?UTF-8?q?on:=20-=20That=20section=20is=20the=20only=20rule=20in=20Invoke?= =?UTF-8?q?r's=20review-compression=20with=20no=20catstack=20equivalent.?= =?UTF-8?q?=20Alternative=20considerations:=20-=20Option=20A=20(chosen):?= =?UTF-8?q?=20add=20it=20to=20SKILL.md=20after=20Ordering=20Rules.=20-=20O?= =?UTF-8?q?ption=20B:=20a=20new=20references/=20file;=20rejected=20because?= =?UTF-8?q?=20the=20rule=20is=20short=20and=20applies=20to=20every=20proof?= =?UTF-8?q?=20slice.=20Implementation=20details:=20-=20Insert=20the=20fetc?= =?UTF-8?q?hed=20section=20text=20right=20after=20the=20Ordering=20Rules?= =?UTF-8?q?=20section=20of=20the=20SKILL.md=20file=20named=20in=20Goal.=20?= =?UTF-8?q?Non-goals:=20-=20No=20change=20to=20playbooks/=20or=20reference?= =?UTF-8?q?s/=20files,=20hooks,=20or=20install=20scripts.=20Layer:=20domai?= =?UTF-8?q?n=20Feature=20state:=20active=20Files:=20-=20product/skills/spl?= =?UTF-8?q?it-scope/SKILL.md=20Change=20types:=20-=20product/skills/split-?= =?UTF-8?q?scope/SKILL.md:=20modify=20Acceptance=20criteria:=20-=20Pass=20?= =?UTF-8?q?condition:=20`grep=20-q=20"^##=20Proof=20Must=20Match=20the=20C?= =?UTF-8?q?laim"=20product/skills/split-scope/SKILL.md`=20exits=200.=20-?= =?UTF-8?q?=20Pass=20condition:=20`grep=20-q=20"corpus/skills/principle-pr?= =?UTF-8?q?ove-it/SKILL.md"=20product/skills/split-scope/SKILL.md`=20exits?= =?UTF-8?q?=200.=20-=20Pass=20condition:=20`python3=20scripts/check=5Fskil?= =?UTF-8?q?ls=5Fthree=5Fharnesses.py`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Review claim: - The catstack PR-slicing skill gains the one guidance section that only Invoker's copy has. Review lane: - docs Safety invariant: - Text-only skill change; no hook, script, or test reads this section by name. Effectiveness measurement: - `grep -q "^## Proof Must Match the Claim" product/skills/split-scope/SKILL.md` exits 0. Slice rationale: - One guidance section in one skill file; the hook that points at this skill is a separate workflow. Architectural effect: - None; adds guidance text to one skill file. Goal: - Add the "Proof Must Match the Claim" section to product/skills/split-scope/SKILL.md so catstack's copy has every rule Invoker's review-compression has. Motivation: - That section is the only rule in Invoker's review-compression with no catstack equivalent. Alternative considerations: - Option A (chosen): add it to SKILL.md after Ordering Rules. - Option B: a new references/ file; rejected because the rule is short and applies to every proof slice. Implementation details: - Insert the fetched section text right after the Ordering Rules section of the SKILL.md file named in Goal. Non-goals: - No change to playbooks/ or references/ files, hooks, or install scripts. Layer: domain Feature state: active Files: - product/skills/split-scope/SKILL.md Change types: - product/skills/split-scope/SKILL.md: modify Acceptance criteria: - Pass condition: `grep -q "^## Proof Must Match the Claim" product/skills/split-scope/SKILL.md` exits 0. - Pass condition: `grep -q "corpus/skills/principle-prove-it/SKILL.md" product/skills/split-scope/SKILL.md` exits 0. - Pass condition: `python3 scripts/check_skills_three_harnesses.py` exits 0. Invoker-Finalize-Id: 6d9345ec-208b-418e-9231-3927a90041e2 --- product/skills/split-scope/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/product/skills/split-scope/SKILL.md b/product/skills/split-scope/SKILL.md index ec4040f..c919753 100644 --- a/product/skills/split-scope/SKILL.md +++ b/product/skills/split-scope/SKILL.md @@ -78,6 +78,12 @@ not because a validator will catch you skipping it. decomposition) deletes the old path in the SAME slice as the move — see `playbooks/rehome-relocation.md`. +## Proof Must Match the Claim + +A `proof` slice (repro, regression test, benchmark) exists to demonstrate one specific claim. Before writing it, name the exact property the bug report or Review Claim describes, then check the assertion tests that literal property — not a nearby signal that is easier to check but proves a different thing. "The panel stayed visible" is not evidence for "the camera did not move"; "the request returned 200" is not evidence for "the record was written correctly." A proxy assertion can pass while the real behavior described in the claim is still broken, which lets a broken fix ship as proven. + +When authoring or reviewing a `proof` slice, ask: if I only read this assertion's name and expect, would I know it tests the same thing the Review Claim states? If not, rewrite the assertion against the literal property, even if that property is harder to check. See `corpus/skills/principle-prove-it/SKILL.md` for the same rule applied to PR bodies and live system claims, not just tests. + ## Boundary Rules Split across architectural boundaries unless the downstream edit is required to From ff4ffa3356b768a250928c995b736c55476918d2 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 11 Sep 2026 21:10:32 +0000 Subject: [PATCH 2/7] =?UTF-8?q?invoker:=20wf-1789160835189-3/verify-split-?= =?UTF-8?q?scope-proof-section=20=E2=80=94=20Review=20claim:=20-=20The=20n?= =?UTF-8?q?ew=20section=20is=20present=20with=20its=20catstack=20cross-ref?= =?UTF-8?q?erence=20and=20the=20skill=20still=20installs=20for=20all=20thr?= =?UTF-8?q?ee=20harnesses.=20Review=20lane:=20-=20proof=20Safety=20invaria?= =?UTF-8?q?nt:=20-=20Proof-only;=20this=20read-only=20check=20changes=20no?= =?UTF-8?q?=20files.=20Effectiveness=20measurement:=20-=20The=20command=20?= =?UTF-8?q?below=20exits=200.=20Slice=20rationale:=20-=20One=20proof=20ste?= =?UTF-8?q?p=20for=20the=20one=20section=20added.=20Architectural=20effect?= =?UTF-8?q?:=20-=20None;=20verification=20only.=20Goal:=20-=20Prove=20the?= =?UTF-8?q?=20section=20landed=20as=20written.=20Motivation:=20-=20A=20det?= =?UTF-8?q?erministic=20check=20beats=20reading=20the=20diff=20by=20eye.?= =?UTF-8?q?=20Alternative=20considerations:=20-=20Option=20A=20(chosen):?= =?UTF-8?q?=20grep=20for=20the=20heading=20and=20the=20cross-reference,=20?= =?UTF-8?q?then=20run=20the=20three-harness=20check.=20Implementation=20de?= =?UTF-8?q?tails:=20-=20Run=20the=20grep=20checks=20and=20the=20three-harn?= =?UTF-8?q?ess=20script.=20Non-goals:=20-=20No=20product=20edits=20here.?= =?UTF-8?q?=20Layer:=20app=5Fregression=20Feature=20state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: cd5d511d-3ca5-41d1-b388-764bfc916d54 From 80d35fa3320ff5fd044e0bc9e32ab0c1aec5794f Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 11 Sep 2026 21:11:15 +0000 Subject: [PATCH 3/7] =?UTF-8?q?invoker:=20wf-1789160835189-3/scrub-handoff?= =?UTF-8?q?-artifacts=20=E2=80=94=20Review=20claim:=20-=20No=20ephemeral?= =?UTF-8?q?=20handoff=20files=20are=20left=20behind=20before=20the=20PR=20?= =?UTF-8?q?merge=20gate.=20Review=20lane:=20-=20proof=20Safety=20invariant?= =?UTF-8?q?:=20-=20This=20read-only=20check=20changes=20no=20files.=20Effe?= =?UTF-8?q?ctiveness=20measurement:=20-=20`bash=20scripts/scrub-handoff-ar?= =?UTF-8?q?tifacts.sh`=20exits=200.=20Slice=20rationale:=20-=20The=20scrub?= =?UTF-8?q?=20check=20runs=20last=20and=20stays=20separate=20from=20implem?= =?UTF-8?q?entation.=20Architectural=20effect:=20-=20No=20architecture=20c?= =?UTF-8?q?hange.=20Goal:=20-=20Confirm=20no=20handoff=20artifacts=20remai?= =?UTF-8?q?n.=20Motivation:=20-=20Uncommitted=20handoff=20files=20do=20not?= =?UTF-8?q?=20flow=20across=20worktrees.=20Alternative=20considerations:?= =?UTF-8?q?=20-=20Option=20A=20(chosen):=20a=20read-only=20absence=20check?= =?UTF-8?q?.=20Implementation=20details:=20-=20Run=20the=20handoff=20scrub?= =?UTF-8?q?=20script=20without=20--apply.=20Non-goals:=20-=20Do=20not=20mo?= =?UTF-8?q?dify=20source=20files.=20Layer:=20e2e=5Fregression=20Feature=20?= =?UTF-8?q?state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 48fdab14-0298-46c3-b4c5-65fa60aa4610 From ce6e202f58ee9a611563f45cec731c9a2a15f78a Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Fri, 11 Sep 2026 14:39:54 -0700 Subject: [PATCH 4/7] split-scope: add a fires example for Proof Must Match the Claim The skill test coverage gate requires a tests/ change whenever the skill changes; this example exercises the section added in this PR. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DtqAxtTmdoRE7CD5BXdGbp Change-Id: Iec59c35c5b11f48375ac9b6a770faeb2c59785cf --- product/skills/split-scope/tests/fires_example.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/product/skills/split-scope/tests/fires_example.md b/product/skills/split-scope/tests/fires_example.md index 58fe0fc..fdef5b2 100644 --- a/product/skills/split-scope/tests/fires_example.md +++ b/product/skills/split-scope/tests/fires_example.md @@ -16,3 +16,11 @@ because the router change exists only to read the playbook's new shape". That is a reader plus the thing it reads: two review claims. Split it into a stack, playbook first, router second. The make-pr preflight fails on the unit mix and prints one `split` line per unit. + +Third shape. A stack's `proof` slice claims "the camera no longer jumps +when a task is selected", but its only assertion is that the task panel +is visible after the click. + +The assertion proves a nearby signal, not the claim. Per "Proof Must Match +the Claim", rewrite the proof slice to assert the camera position itself +before the fix slice is reviewed. From 0ece0d9f8c9500f570359217bd73765709aecc9e Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 11 Sep 2026 22:14:43 +0000 Subject: [PATCH 5/7] =?UTF-8?q?invoker:=20wf-1789161335022-5/implement-spl?= =?UTF-8?q?it-scope-hook=20=E2=80=94=20Review=20claim:=20-=20A=20new=20cat?= =?UTF-8?q?stack=20hook=20injects=20a=20reminder=20to=20read=20the=20PR-sl?= =?UTF-8?q?icing=20skill=20on=20prompts=20that=20plan=20multi-PR=20or=20mu?= =?UTF-8?q?lti-slice=20work,=20for=20Claude,=20Codex,=20and=20Cursor.=20Re?= =?UTF-8?q?view=20lane:=20-=20behavior=20Safety=20invariant:=20-=20Inject-?= =?UTF-8?q?only=20and=20fail-open;=20the=20hook=20never=20blocks=20a=20too?= =?UTF-8?q?l=20and=20prints=20nothing=20on=20prompts=20that=20do=20not=20m?= =?UTF-8?q?atch.=20Effectiveness=20measurement:=20-=20`python3=20-m=20unit?= =?UTF-8?q?test=20discover=20-s=20engine/hooks/split-scope/tests=20-v`=20e?= =?UTF-8?q?xits=200=20with=20at=20least=20three=20firing=20prompts=20and?= =?UTF-8?q?=20three=20silent=20prompts.=20Slice=20rationale:=20-=20One=20h?= =?UTF-8?q?ook=20with=20its=20detector,=20entrypoints,=20installers,=20and?= =?UTF-8?q?=20tests;=20the=20skill=20text=20it=20points=20at=20landed=20in?= =?UTF-8?q?=20workflow=201.=20Architectural=20effect:=20-=20Adds=20one=20p?= =?UTF-8?q?rompt-time=20injector=20under=20engine/hooks/split-scope,=20reg?= =?UTF-8?q?istered=20by=20install.sh=20for=20Claude,=20Cursor,=20and=20Cod?= =?UTF-8?q?ex.=20Goal:=20-=20Make=20product/skills/split-scope=20load=20on?= =?UTF-8?q?=20planning=20prompts=20without=20relying=20on=20the=20model=20?= =?UTF-8?q?to=20choose=20it.=20Motivation:=20-=20The=20skill=20is=20only?= =?UTF-8?q?=20description-triggered=20today,=20so=20multi-PR=20plans=20get?= =?UTF-8?q?=20written=20without=20it.=20Alternative=20considerations:=20-?= =?UTF-8?q?=20Option=20A=20(chosen):=20a=20new=20hook=20shaped=20like=20bu?= =?UTF-8?q?ild-the-lever=20(regex=20on=20the=20prompt,=20inject=20a=20remi?= =?UTF-8?q?nder).=20-=20Option=20B:=20extend=20engine/hooks/playbook-route?= =?UTF-8?q?r;=20rejected=20because=20it=20only=20fires=20on=20explicit=20"?= =?UTF-8?q?run=20"=20prompts=20and=20its=20fixtures=20deliberately?= =?UTF-8?q?=20keep=20split-scope=20silent.=20-=20Option=20C:=20remind=20at?= =?UTF-8?q?=20PR-publish=20time=20via=20pr-schema-gate;=20rejected=20becau?= =?UTF-8?q?se=20the=20commits=20already=20exist=20by=20then.=20Implementat?= =?UTF-8?q?ion=20details:=20-=20Create=20the=20hook=20directory=20named=20?= =?UTF-8?q?in=20Files,=20shaped=20like=20engine/hooks/build-the-lever,=20a?= =?UTF-8?q?nd=20wire=20it=20into=20install.sh=20and=20tests/test=5Finstall?= =?UTF-8?q?.py=20the=20same=20way=20build-the-lever=20is=20wired.=20Non-go?= =?UTF-8?q?als:=20-=20No=20change=20to=20product/skills/split-scope,=20bui?= =?UTF-8?q?ld-the-lever,=20playbook-router,=20or=20pr-schema-gate.=20-=20N?= =?UTF-8?q?o=20edits=20to=20the=20repo-root=20README.md=20or=20docs/ecosys?= =?UTF-8?q?tem.md=20tables.=20-=20No=20blocking=20behavior.=20Layer:=20dom?= =?UTF-8?q?ain=20Feature=20state:=20active=20Files:=20-=20engine/hooks/spl?= =?UTF-8?q?it-scope/detect.py=20-=20engine/hooks/split-scope/claude=5Fprom?= =?UTF-8?q?pt=5Fsubmit.py=20-=20engine/hooks/split-scope/codex=5Fprompt=5F?= =?UTF-8?q?submit.py=20-=20engine/hooks/split-scope/cursor=5Fbefore=5Fsubm?= =?UTF-8?q?it.py=20-=20engine/hooks/split-scope/cursor=5Fpost=5Ftool=5Fuse?= =?UTF-8?q?.py=20-=20engine/hooks/split-scope/state.py=20-=20engine/hooks/?= =?UTF-8?q?split-scope/install=5Fclaude=5Fhook.py=20-=20engine/hooks/split?= =?UTF-8?q?-scope/install=5Fcodex=5Fhook.py=20-=20engine/hooks/split-scope?= =?UTF-8?q?/install=5Fcursor=5Fhook.py=20-=20engine/hooks/split-scope/clau?= =?UTF-8?q?de.prompt.hook.json=20-=20engine/hooks/split-scope/codex.hook.j?= =?UTF-8?q?son=20-=20engine/hooks/split-scope/README.md=20-=20engine/hooks?= =?UTF-8?q?/split-scope/tests/test=5Fhooks.py=20-=20install.sh=20-=20tests?= =?UTF-8?q?/test=5Finstall.py=20Change=20types:=20-=20engine/hooks/split-s?= =?UTF-8?q?cope/detect.py:=20create=20-=20engine/hooks/split-scope/claude?= =?UTF-8?q?=5Fprompt=5Fsubmit.py:=20create=20-=20engine/hooks/split-scope/?= =?UTF-8?q?codex=5Fprompt=5Fsubmit.py:=20create=20-=20engine/hooks/split-s?= =?UTF-8?q?cope/cursor=5Fbefore=5Fsubmit.py:=20create=20-=20engine/hooks/s?= =?UTF-8?q?plit-scope/cursor=5Fpost=5Ftool=5Fuse.py:=20create=20-=20engine?= =?UTF-8?q?/hooks/split-scope/state.py:=20create=20-=20engine/hooks/split-?= =?UTF-8?q?scope/install=5Fclaude=5Fhook.py:=20create=20-=20engine/hooks/s?= =?UTF-8?q?plit-scope/install=5Fcodex=5Fhook.py:=20create=20-=20engine/hoo?= =?UTF-8?q?ks/split-scope/install=5Fcursor=5Fhook.py:=20create=20-=20engin?= =?UTF-8?q?e/hooks/split-scope/claude.prompt.hook.json:=20create=20-=20eng?= =?UTF-8?q?ine/hooks/split-scope/codex.hook.json:=20create=20-=20engine/ho?= =?UTF-8?q?oks/split-scope/README.md:=20create=20-=20engine/hooks/split-sc?= =?UTF-8?q?ope/tests/test=5Fhooks.py:=20create=20-=20install.sh:=20modify?= =?UTF-8?q?=20-=20tests/test=5Finstall.py:=20modify=20Acceptance=20criteri?= =?UTF-8?q?a:=20-=20Pass=20condition:=20`python3=20-m=20unittest=20discove?= =?UTF-8?q?r=20-s=20engine/hooks/split-scope/tests=20-v`=20exits=200.=20-?= =?UTF-8?q?=20Pass=20condition:=20`python3=20scripts/check=5Fhook=5Ftest?= =?UTF-8?q?=5Fcoverage.py=20engine/hooks/split-scope`=20exits=200.=20-=20P?= =?UTF-8?q?ass=20condition:=20`python3=20-m=20unittest=20discover=20-s=20t?= =?UTF-8?q?ests=20-p=20"test=5Finstall.py"`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Review claim: - A new catstack hook injects a reminder to read the PR-slicing skill on prompts that plan multi-PR or multi-slice work, for Claude, Codex, and Cursor. Review lane: - behavior Safety invariant: - Inject-only and fail-open; the hook never blocks a tool and prints nothing on prompts that do not match. Effectiveness measurement: - `python3 -m unittest discover -s engine/hooks/split-scope/tests -v` exits 0 with at least three firing prompts and three silent prompts. Slice rationale: - One hook with its detector, entrypoints, installers, and tests; the skill text it points at landed in workflow 1. Architectural effect: - Adds one prompt-time injector under engine/hooks/split-scope, registered by install.sh for Claude, Cursor, and Codex. Goal: - Make product/skills/split-scope load on planning prompts without relying on the model to choose it. Motivation: - The skill is only description-triggered today, so multi-PR plans get written without it. Alternative considerations: - Option A (chosen): a new hook shaped like build-the-lever (regex on the prompt, inject a reminder). - Option B: extend engine/hooks/playbook-router; rejected because it only fires on explicit "run " prompts and its fixtures deliberately keep split-scope silent. - Option C: remind at PR-publish time via pr-schema-gate; rejected because the commits already exist by then. Implementation details: - Create the hook directory named in Files, shaped like engine/hooks/build-the-lever, and wire it into install.sh and tests/test_install.py the same way build-the-lever is wired. Non-goals: - No change to product/skills/split-scope, build-the-lever, playbook-router, or pr-schema-gate. - No edits to the repo-root README.md or docs/ecosystem.md tables. - No blocking behavior. Layer: domain Feature state: active Files: - engine/hooks/split-scope/detect.py - engine/hooks/split-scope/claude_prompt_submit.py - engine/hooks/split-scope/codex_prompt_submit.py - engine/hooks/split-scope/cursor_before_submit.py - engine/hooks/split-scope/cursor_post_tool_use.py - engine/hooks/split-scope/state.py - engine/hooks/split-scope/install_claude_hook.py - engine/hooks/split-scope/install_codex_hook.py - engine/hooks/split-scope/install_cursor_hook.py - engine/hooks/split-scope/claude.prompt.hook.json - engine/hooks/split-scope/codex.hook.json - engine/hooks/split-scope/README.md - engine/hooks/split-scope/tests/test_hooks.py - install.sh - tests/test_install.py Change types: - engine/hooks/split-scope/detect.py: create - engine/hooks/split-scope/claude_prompt_submit.py: create - engine/hooks/split-scope/codex_prompt_submit.py: create - engine/hooks/split-scope/cursor_before_submit.py: create - engine/hooks/split-scope/cursor_post_tool_use.py: create - engine/hooks/split-scope/state.py: create - engine/hooks/split-scope/install_claude_hook.py: create - engine/hooks/split-scope/install_codex_hook.py: create - engine/hooks/split-scope/install_cursor_hook.py: create - engine/hooks/split-scope/claude.prompt.hook.json: create - engine/hooks/split-scope/codex.hook.json: create - engine/hooks/split-scope/README.md: create - engine/hooks/split-scope/tests/test_hooks.py: create - install.sh: modify - tests/test_install.py: modify Acceptance criteria: - Pass condition: `python3 -m unittest discover -s engine/hooks/split-scope/tests -v` exits 0. - Pass condition: `python3 scripts/check_hook_test_coverage.py engine/hooks/split-scope` exits 0. - Pass condition: `python3 -m unittest discover -s tests -p "test_install.py"` exits 0. Invoker-Finalize-Id: e5fb4ad0-5685-4eb6-bffb-64b4ae990aff --- engine/hooks/split-scope/README.md | 47 +++++ .../hooks/split-scope/claude.prompt.hook.json | 15 ++ .../hooks/split-scope/claude_prompt_submit.py | 39 ++++ engine/hooks/split-scope/codex.hook.json | 15 ++ .../hooks/split-scope/codex_prompt_submit.py | 39 ++++ .../hooks/split-scope/cursor_before_submit.py | 27 +++ .../hooks/split-scope/cursor_post_tool_use.py | 29 +++ engine/hooks/split-scope/detect.py | 67 +++++++ .../hooks/split-scope/install_claude_hook.py | 58 ++++++ .../hooks/split-scope/install_codex_hook.py | 65 +++++++ .../hooks/split-scope/install_cursor_hook.py | 111 +++++++++++ engine/hooks/split-scope/state.py | 68 +++++++ engine/hooks/split-scope/tests/test_hooks.py | 172 ++++++++++++++++++ install.sh | 6 + tests/test_install.py | 38 +++- 15 files changed, 795 insertions(+), 1 deletion(-) create mode 100644 engine/hooks/split-scope/README.md create mode 100644 engine/hooks/split-scope/claude.prompt.hook.json create mode 100755 engine/hooks/split-scope/claude_prompt_submit.py create mode 100644 engine/hooks/split-scope/codex.hook.json create mode 100755 engine/hooks/split-scope/codex_prompt_submit.py create mode 100755 engine/hooks/split-scope/cursor_before_submit.py create mode 100755 engine/hooks/split-scope/cursor_post_tool_use.py create mode 100644 engine/hooks/split-scope/detect.py create mode 100755 engine/hooks/split-scope/install_claude_hook.py create mode 100755 engine/hooks/split-scope/install_codex_hook.py create mode 100755 engine/hooks/split-scope/install_cursor_hook.py create mode 100644 engine/hooks/split-scope/state.py create mode 100755 engine/hooks/split-scope/tests/test_hooks.py diff --git a/engine/hooks/split-scope/README.md b/engine/hooks/split-scope/README.md new file mode 100644 index 0000000..d10f524 --- /dev/null +++ b/engine/hooks/split-scope/README.md @@ -0,0 +1,47 @@ +# split-scope + +Inject the existing `split-scope` skill reminder when a prompt plans +multi-slice or multi-PR work. The skill is descriptive, so a hook supplies +the prompt-time nudge before a plan or PR stack is written. + +Fail-open. Inject-only. Never blocks tools. Stays silent on single-file +edits, typo fixes, and questions that only mention split-scope by name. + +## Fires On + +- `pr stack`, `stack of prs`, `stacked prs` +- `multiple prs`, `several prs`, `multi-pr` +- `split this into`, `break this into prs`, `into slices` +- `migration plan`, `plan a migration`, `plan the migration` + +## Silent On + +- one-file edits and typo fixes +- split-scope meta questions such as `what does split-scope do?` +- malformed hook input, which logs to stderr and allows the prompt or tool + +## Reminder + +`split-scope: this prompt plans multi-slice work. Before writing the plan or PR stack, read the split-scope skill (product/skills/split-scope/SKILL.md, or the installed split-scope skill) and give each slice one review claim with a user-confirmed safety invariant.` + +## Files + +- `detect.py` — prompt regexes and shared reminder text +- `state.py` — Cursor-only pending reminder state under `~/.cache/catstack-split-scope` +- `claude_prompt_submit.py` — Claude inject +- `cursor_before_submit.py` / `cursor_post_tool_use.py` — Cursor parity + (`beforeSubmitPrompt` cannot inject; reminder arrives on first `postToolUse`) +- `codex_prompt_submit.py` — Codex inject +- `install_claude_hook.py` / `install_cursor_hook.py` / `install_codex_hook.py` + +## Install + +`./install.sh` from the catstack repo root, then restart Claude Code, Cursor, +and Codex (Codex also needs `/hooks` trust). + +## Tests + +```sh +python3 -m unittest discover -s engine/hooks/split-scope/tests -v +python3 scripts/check_hook_test_coverage.py engine/hooks/split-scope +``` diff --git a/engine/hooks/split-scope/claude.prompt.hook.json b/engine/hooks/split-scope/claude.prompt.hook.json new file mode 100644 index 0000000..37cdb7e --- /dev/null +++ b/engine/hooks/split-scope/claude.prompt.hook.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/split-scope/claude_prompt_submit.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/engine/hooks/split-scope/claude_prompt_submit.py b/engine/hooks/split-scope/claude_prompt_submit.py new file mode 100755 index 0000000..988b603 --- /dev/null +++ b/engine/hooks/split-scope/claude_prompt_submit.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Claude Code UserPromptSubmit entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import extract_prompt_text, plans_multi_slice_work, reminder_text + + +def _fail_open(context: str) -> None: + print(f"split-scope claude_prompt_submit fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + if not plans_multi_slice_work(extract_prompt_text(payload)): + return + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": reminder_text(), + } + } + ) + ) + except Exception: + _fail_open("prompt detection") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/codex.hook.json b/engine/hooks/split-scope/codex.hook.json new file mode 100644 index 0000000..84d8f31 --- /dev/null +++ b/engine/hooks/split-scope/codex.hook.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.codex/hooks/split-scope/codex_prompt_submit.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/engine/hooks/split-scope/codex_prompt_submit.py b/engine/hooks/split-scope/codex_prompt_submit.py new file mode 100755 index 0000000..cbd7e96 --- /dev/null +++ b/engine/hooks/split-scope/codex_prompt_submit.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Codex UserPromptSubmit entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import extract_prompt_text, plans_multi_slice_work, reminder_text + + +def _fail_open(context: str) -> None: + print(f"split-scope codex_prompt_submit fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + if not plans_multi_slice_work(extract_prompt_text(payload)): + return + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": reminder_text(), + } + } + ) + ) + except Exception: + _fail_open("prompt detection") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/cursor_before_submit.py b/engine/hooks/split-scope/cursor_before_submit.py new file mode 100755 index 0000000..622244f --- /dev/null +++ b/engine/hooks/split-scope/cursor_before_submit.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Cursor beforeSubmitPrompt entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import extract_prompt_text, plans_multi_slice_work, remember_cursor_prompt + + +def _fail_open(context: str) -> None: + print(f"split-scope cursor_before_submit fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if isinstance(payload, dict) and plans_multi_slice_work(extract_prompt_text(payload)): + remember_cursor_prompt(payload) + except Exception: + _fail_open("prompt detection") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/cursor_post_tool_use.py b/engine/hooks/split-scope/cursor_post_tool_use.py new file mode 100755 index 0000000..25b6ae4 --- /dev/null +++ b/engine/hooks/split-scope/cursor_post_tool_use.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Cursor postToolUse entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import consume_cursor_prompt, reminder_text + + +def _fail_open(context: str) -> None: + print(f"split-scope cursor_post_tool_use fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + if consume_cursor_prompt(payload): + print(json.dumps({"additional_context": reminder_text()})) + except Exception: + _fail_open("pending reminder delivery") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/detect.py b/engine/hooks/split-scope/detect.py new file mode 100644 index 0000000..d872cbd --- /dev/null +++ b/engine/hooks/split-scope/detect.py @@ -0,0 +1,67 @@ +"""Shared detection for split-scope prompt inject hooks.""" +from __future__ import annotations + +import re + +from state import consume_pending, remember_pending + +SKILL_PATH = "/".join(("product", "skills", "split-scope", "SKILL.md")) +REMINDER = ( + "split-scope: this prompt plans multi-slice work. Before writing the plan " + f"or PR stack, read the split-scope skill ({SKILL_PATH}, or the installed " + "split-scope skill) and give each slice one review claim with a " + "user-confirmed safety invariant." +) + +TRIGGERS = ( + re.compile(r"\bpr\s+stack\b", re.I), + re.compile(r"\bstack\s+of\s+prs\b", re.I), + re.compile(r"\bstacked\s+prs\b", re.I), + re.compile(r"\bmultiple\s+prs\b", re.I), + re.compile(r"\bseveral\s+prs\b", re.I), + re.compile(r"\bmulti[-\s]?pr\b", re.I), + re.compile(r"\bsplit\s+this\s+into\b", re.I), + re.compile(r"\bbreak\s+this\s+into\s+prs\b", re.I), + re.compile(r"\binto\s+slices\b", re.I), + re.compile(r"\bmigration\s+plan\b", re.I), + re.compile(r"\bplan\s+a\s+migration\b", re.I), + re.compile(r"\bplan\s+the\s+migration\b", re.I), +) + + +def reminder_text() -> str: + return REMINDER + + +def extract_prompt_text(payload: dict) -> str: + for key in ("prompt", "user_prompt", "userPrompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value + content = payload.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text") or "")) + return "\n".join(parts) + return "" + + +def plans_multi_slice_work(prompt: str) -> bool: + text = (prompt or "").strip() + if not text: + return False + return any(pattern.search(text) for pattern in TRIGGERS) + + +def remember_cursor_prompt(payload: dict) -> None: + remember_pending(payload) + + +def consume_cursor_prompt(payload: dict) -> bool: + return consume_pending(payload) diff --git a/engine/hooks/split-scope/install_claude_hook.py b/engine/hooks/split-scope/install_claude_hook.py new file mode 100755 index 0000000..880bb2b --- /dev/null +++ b/engine/hooks/split-scope/install_claude_hook.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Merge split-scope Claude hooks into ~/.claude/settings.json without wiping others.""" +from __future__ import annotations + +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json") +HOOK_SPECS = [ + ("UserPromptSubmit", "split-scope/claude_prompt_submit.py", os.path.join(HERE, "claude.prompt.hook.json")), +] + + +def _is_ours(entry: dict, marker: str) -> bool: + return any(marker in h.get("command", "") for h in entry.get("hooks", [])) + + +def merge_hook_type(settings: dict, hook_type: str, marker: str, fragment: dict) -> bool: + entry_list = settings.setdefault("hooks", {}).setdefault(hook_type, []) + new_entries = fragment.get("hooks", {}).get(hook_type, []) + before = json.dumps(entry_list, sort_keys=True) + kept = [e for e in entry_list if not _is_ours(e, marker)] + entry_list[:] = kept + new_entries + return json.dumps(entry_list, sort_keys=True) != before + + +def main() -> None: + settings: dict = {} + if os.path.exists(SETTINGS_PATH): + with open(SETTINGS_PATH, encoding="utf-8") as handle: + settings = json.load(handle) + + any_changed = False + loaded: dict[str, dict] = {} + for hook_type, marker, fragment_path in HOOK_SPECS: + if fragment_path not in loaded: + with open(fragment_path, encoding="utf-8") as handle: + loaded[fragment_path] = json.load(handle) + fragment = loaded[fragment_path] + if merge_hook_type(settings, hook_type, marker, fragment): + any_changed = True + print(f"link claude {hook_type} split-scope merged") + else: + print(f"ok claude {hook_type} split-scope already up to date") + + if not any_changed: + return + + os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True) + with open(SETTINGS_PATH, "w", encoding="utf-8") as handle: + json.dump(settings, handle, indent=2) + handle.write("\n") + print(" (restart Claude Code to pick up the change)") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/install_codex_hook.py b/engine/hooks/split-scope/install_codex_hook.py new file mode 100755 index 0000000..87a1c38 --- /dev/null +++ b/engine/hooks/split-scope/install_codex_hook.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Idempotently merge split-scope into Codex's native lifecycle hooks.""" +from __future__ import annotations + +import copy +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOKS_PATH = os.path.expanduser("~/.codex/hooks.json") +FRAGMENT_PATH = os.path.join(HERE, "codex.hook.json") +MARKERS = { + "UserPromptSubmit": "split-scope/codex_prompt_submit.py", +} +LEGACY_EVENTS = { + "user_prompt_submit": "UserPromptSubmit", +} + + +def _is_ours(entry: dict, marker: str) -> bool: + return any(marker in str(hook.get("command", "")) for hook in entry.get("hooks", [])) + + +def merge_hooks(settings: dict) -> dict: + result = copy.deepcopy(settings) + hooks = result.get("hooks") + if not isinstance(hooks, dict): + hooks = {} + result["hooks"] = hooks + + for legacy_name, event in LEGACY_EVENTS.items(): + entries = result.pop(legacy_name, []) + if isinstance(entries, list): + hooks.setdefault(event, []).extend( + entry for entry in entries if isinstance(entry, dict) + ) + + with open(FRAGMENT_PATH, encoding="utf-8") as handle: + fragment = json.load(handle)["hooks"] + for event, marker in MARKERS.items(): + existing = hooks.get(event, []) + kept = [entry for entry in existing if not _is_ours(entry, marker)] + hooks[event] = kept + fragment[event] + return result + + +def main() -> None: + settings: dict = {} + if os.path.exists(HOOKS_PATH): + with open(HOOKS_PATH, encoding="utf-8") as handle: + settings = json.load(handle) + merged = merge_hooks(settings) + if merged == settings: + print("ok codex split-scope hooks already up to date") + return + os.makedirs(os.path.dirname(HOOKS_PATH), exist_ok=True) + with open(HOOKS_PATH, "w", encoding="utf-8") as handle: + json.dump(merged, handle, indent=2) + handle.write("\n") + print("link codex UserPromptSubmit split-scope merged") + print(" (review with /hooks, trust the definitions, then restart Codex)") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/install_cursor_hook.py b/engine/hooks/split-scope/install_cursor_hook.py new file mode 100755 index 0000000..1678e93 --- /dev/null +++ b/engine/hooks/split-scope/install_cursor_hook.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Merge split-scope Cursor hooks into ~/.cursor/hooks.json without wiping others.""" +from __future__ import annotations + +import json +import os + +HOOKS_PATH = os.path.expanduser("~/.cursor/hooks.json") +FRAGMENT = { + "beforeSubmitPrompt": [ + { + "command": "python3 $HOME/.cursor/hooks/split-scope/cursor_before_submit.py", + "timeout": 10, + } + ], + "postToolUse": [ + { + "command": "python3 $HOME/.cursor/hooks/split-scope/cursor_post_tool_use.py", + "timeout": 5, + } + ], +} +MARKERS = { + "beforeSubmitPrompt": "split-scope/cursor_before_submit.py", + "postToolUse": "split-scope/cursor_post_tool_use.py", +} +DIU_STOP = { + "type": "prompt", + "prompt": ( + "Find the assistant's last response in this conversation and check it against this rule: " + "it should read under ~150 words and be free of unexplained jargon, UNLESS the user's last " + "message explicitly asked for full technical detail, a specific long format (a PR summary, " + "a written plan, a file list), or the response already applies an explicit ELI5 word cap " + "the user gave. Output ONLY a single JSON object and nothing else -- no explanation, no " + "analysis, no markdown fences, before or after it. If it violates the rule and none of " + "those exceptions apply, output exactly: {\"followup_message\": \"Apply diu: rewrite under " + "40 words, plain language.\"}. Otherwise output exactly: {\"followup_message\": \"\"}." + ), + "timeout": 30, +} + + +def _is_ours(entry: dict, marker: str) -> bool: + return marker in str(entry.get("command", "")) + + +def merge_list(existing: list, incoming: list, marker: str) -> list: + kept = [e for e in existing if not _is_ours(e, marker)] + return kept + incoming + + +def load_hooks() -> dict: + if not os.path.exists(HOOKS_PATH): + return {"version": 1, "hooks": {"stop": [DIU_STOP]}} + with open(HOOKS_PATH, encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + return {"version": 1, "hooks": {"stop": [DIU_STOP]}} + data.setdefault("version", 1) + data.setdefault("hooks", {}) + data["hooks"].setdefault("stop", [DIU_STOP]) + return data + + +def materialize_real_file_if_symlink() -> bool: + if not os.path.islink(HOOKS_PATH): + return False + data = load_hooks() + os.unlink(HOOKS_PATH) + os.makedirs(os.path.dirname(HOOKS_PATH), exist_ok=True) + with open(HOOKS_PATH, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + print("fix cursor hooks.json was a symlink; replaced with a real merged file") + return True + + +def main() -> None: + materialize_real_file_if_symlink() + data = load_hooks() + hooks = data.setdefault("hooks", {}) + + changed = False + for key, incoming in FRAGMENT.items(): + before = json.dumps(hooks.get(key, []), sort_keys=True) + hooks[key] = merge_list(list(hooks.get(key, [])), incoming, MARKERS[key]) + after = json.dumps(hooks[key], sort_keys=True) + if before != after: + changed = True + print(f"link cursor {key} split-scope merged") + else: + print(f"ok cursor {key} split-scope already up to date") + + stop = list(hooks.get("stop") or []) + if not any("Apply diu" in str(e.get("prompt", "")) for e in stop): + hooks["stop"] = [DIU_STOP] + stop + changed = True + print("link cursor stop diu entry restored") + + if not changed and not os.path.islink(HOOKS_PATH): + return + + os.makedirs(os.path.dirname(HOOKS_PATH), exist_ok=True) + with open(HOOKS_PATH, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + print(" (restart Cursor to pick up the change)") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/state.py b/engine/hooks/split-scope/state.py new file mode 100644 index 0000000..998eed0 --- /dev/null +++ b/engine/hooks/split-scope/state.py @@ -0,0 +1,68 @@ +"""Session-local state for split-scope Cursor prompt handoff.""" +from __future__ import annotations + +import json +import os +import time +from typing import Any + +STATE_DIR = os.environ.get( + "CATSTACK_SPLIT_SCOPE_STATE_DIR", + os.path.join(os.path.expanduser("~"), ".cache", "catstack-split-scope"), +) +TTL_SECONDS = 2 * 60 * 60 + + +def _session_key(payload: dict) -> str: + for key in ("session_id", "sessionId", "conversation_id", "conversationId", "transcript_path"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip().replace("/", "_")[-80:] + cwd = payload.get("cwd") or payload.get("workspace_roots") or "default" + if isinstance(cwd, list): + cwd = cwd[0] if cwd else "default" + return str(cwd).replace("/", "_")[-80:] + + +def state_path(payload: dict) -> str: + os.makedirs(STATE_DIR, exist_ok=True) + return os.path.join(STATE_DIR, f"{_session_key(payload)}.json") + + +def load_state(payload: dict) -> dict[str, Any]: + path = state_path(payload) + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + return {} + created_at = data.get("created_at") + if not isinstance(created_at, (int, float)): + return {} + if time.time() - created_at > TTL_SECONDS: + return {} + return data + except (OSError, json.JSONDecodeError, TypeError): + return {} + + +def save_state(payload: dict, state: dict[str, Any]) -> None: + path = state_path(payload) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(state, handle) + except OSError: + pass + + +def remember_pending(payload: dict) -> None: + save_state(payload, {"pending": True, "created_at": time.time()}) + + +def consume_pending(payload: dict) -> bool: + state = load_state(payload) + if not state.get("pending"): + return False + save_state(payload, {"pending": False, "created_at": time.time()}) + return True diff --git a/engine/hooks/split-scope/tests/test_hooks.py b/engine/hooks/split-scope/tests/test_hooks.py new file mode 100755 index 0000000..d2739d9 --- /dev/null +++ b/engine/hooks/split-scope/tests/test_hooks.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Unit tests for split-scope inject hooks.""" +from __future__ import annotations + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import patch + +HOOKS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, HOOKS_DIR) + +import claude_prompt_submit # noqa: E402 +import codex_prompt_submit # noqa: E402 +import cursor_before_submit # noqa: E402 +import cursor_post_tool_use # noqa: E402 +import detect # noqa: E402 +import state # noqa: E402 + +SKILL_PATH = "/".join(("product", "skills", "split-scope", "SKILL.md")) +REMINDER = ( + "split-scope: this prompt plans multi-slice work. Before writing the plan " + f"or PR stack, read the split-scope skill ({SKILL_PATH}, or the installed " + "split-scope skill) and give each slice one review claim with a " + "user-confirmed safety invariant." +) + + +def run_main(main, stdin_text: str) -> tuple[str, str, int]: + out = io.StringIO() + err = io.StringIO() + code = 0 + with patch.object(sys, "stdin", io.StringIO(stdin_text)): + with redirect_stdout(out), redirect_stderr(err): + try: + main() + except SystemExit as exc: + code = int(exc.code or 0) + return out.getvalue(), err.getvalue(), code + + +def run_json(main, payload: dict) -> tuple[str, str, int]: + return run_main(main, json.dumps(payload)) + + +class SplitScopeCase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + state.STATE_DIR = self.tmp.name + + def tearDown(self) -> None: + self.tmp.cleanup() + + +class TestDetect(SplitScopeCase): + def test_pr_stack_phrase_fires(self) -> None: + self.assertTrue(detect.plans_multi_slice_work("Plan a PR stack for this change.")) + + def test_split_this_into_phrase_fires(self) -> None: + self.assertTrue(detect.plans_multi_slice_work("Split this into reviewable pieces.")) + + def test_migration_plan_phrase_fires(self) -> None: + self.assertTrue(detect.plans_multi_slice_work("Write a migration plan for the API move.")) + + def test_single_file_edit_stays_silent(self) -> None: + self.assertFalse(detect.plans_multi_slice_work("Edit this one file to add the import.")) + + def test_typo_fix_stays_silent(self) -> None: + self.assertFalse(detect.plans_multi_slice_work("Fix the typo in README.md.")) + + def test_split_scope_name_question_stays_silent(self) -> None: + self.assertFalse(detect.plans_multi_slice_work("What does split-scope do?")) + + +class TestClaudePromptEntrypoint(SplitScopeCase): + def assert_claude_fires(self, prompt: str) -> None: + out, err, code = run_json( + claude_prompt_submit.main, + {"prompt": prompt, "session_id": "claude-positive"}, + ) + self.assertEqual(code, 0, err) + data = json.loads(out) + self.assertEqual( + data["hookSpecificOutput"]["additionalContext"], + REMINDER, + ) + self.assertEqual(err, "") + + def assert_claude_silent(self, prompt: str) -> None: + out, err, code = run_json( + claude_prompt_submit.main, + {"prompt": prompt, "session_id": "claude-negative"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + def test_real_claude_entrypoint_fires_on_multiple_prs(self) -> None: + self.assert_claude_fires("Please plan multiple PRs for this refactor.") + + def test_real_claude_entrypoint_fires_on_stacked_prs(self) -> None: + self.assert_claude_fires("Create stacked PRs for the API migration.") + + def test_real_claude_entrypoint_fires_on_plan_the_migration(self) -> None: + self.assert_claude_fires("Plan the migration before changing code.") + + def test_real_claude_entrypoint_prints_nothing_on_single_file_edit(self) -> None: + self.assert_claude_silent("Change the label in this single file.") + + def test_real_claude_entrypoint_prints_nothing_on_typo_fix(self) -> None: + self.assert_claude_silent("Fix the recieve typo in README.md.") + + def test_real_claude_entrypoint_prints_nothing_on_split_scope_question(self) -> None: + self.assert_claude_silent("What does split-scope do?") + + def test_malformed_json_fails_open_with_empty_stdout(self) -> None: + cases = ( + (claude_prompt_submit.main, "split-scope claude_prompt_submit fail-open"), + (codex_prompt_submit.main, "split-scope codex_prompt_submit fail-open"), + (cursor_before_submit.main, "split-scope cursor_before_submit fail-open"), + (cursor_post_tool_use.main, "split-scope cursor_post_tool_use fail-open"), + ) + for main, marker in cases: + with self.subTest(marker=marker): + out, err, code = run_main(main, "not-json") + self.assertEqual(code, 0) + self.assertEqual(out, "") + self.assertIn(marker, err) + + +class TestCursorHandoff(SplitScopeCase): + def test_cursor_pending_prompt_injects_once_on_first_post_tool_use(self) -> None: + out, err, code = run_json( + cursor_before_submit.main, + {"prompt": "Break this into PRs.", "session_id": "cursor-1"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + out, err, code = run_json( + cursor_post_tool_use.main, + {"session_id": "cursor-1", "tool_name": "Edit"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(json.loads(out)["additional_context"], REMINDER) + self.assertEqual(err, "") + + out, err, code = run_json( + cursor_post_tool_use.main, + {"session_id": "cursor-1", "tool_name": "Edit"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + def test_cursor_before_submit_prints_nothing_on_nonmatch(self) -> None: + out, err, code = run_json( + cursor_before_submit.main, + {"prompt": "Fix this one typo.", "session_id": "cursor-quiet"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/install.sh b/install.sh index 8e127fe..47a3070 100755 --- a/install.sh +++ b/install.sh @@ -231,6 +231,7 @@ link_item "history-claim-check" "$REPO_DIR/engine/hooks/history-claim-check" "$H link_item "external-claim-gate" "$REPO_DIR/engine/hooks/external-claim-gate" "$HOME/.claude/hooks/external-claim-gate" link_item "wrong-check-reflect" "$REPO_DIR/engine/hooks/wrong-check-reflect" "$HOME/.claude/hooks/wrong-check-reflect" link_item "build-the-lever" "$REPO_DIR/engine/hooks/build-the-lever" "$HOME/.claude/hooks/build-the-lever" +link_item "split-scope" "$REPO_DIR/engine/hooks/split-scope" "$HOME/.claude/hooks/split-scope" link_item "no-comments" "$REPO_DIR/engine/hooks/no-comments" "$HOME/.claude/hooks/no-comments" link_item "explicit-failures" "$REPO_DIR/engine/hooks/explicit-failures" "$HOME/.claude/hooks/explicit-failures" link_item "repeat-error-stop" "$REPO_DIR/engine/hooks/repeat-error-stop" "$HOME/.claude/hooks/repeat-error-stop" @@ -270,6 +271,7 @@ link_item "auto-pr" "$REPO_DIR/engine/hooks/auto-pr" "$HOME/.cursor/hooks/auto-p link_item "pr-schema-gate" "$REPO_DIR/engine/hooks/pr-schema-gate" "$HOME/.cursor/hooks/pr-schema-gate" link_item "wrong-check-reflect" "$REPO_DIR/engine/hooks/wrong-check-reflect" "$HOME/.cursor/hooks/wrong-check-reflect" link_item "build-the-lever" "$REPO_DIR/engine/hooks/build-the-lever" "$HOME/.cursor/hooks/build-the-lever" +link_item "split-scope" "$REPO_DIR/engine/hooks/split-scope" "$HOME/.cursor/hooks/split-scope" link_item "repeat-error-stop" "$REPO_DIR/engine/hooks/repeat-error-stop" "$HOME/.cursor/hooks/repeat-error-stop" link_item "ui-input-guard" "$REPO_DIR/engine/hooks/ui-input-guard" "$HOME/.cursor/hooks/ui-input-guard" @@ -281,6 +283,7 @@ link_item "auto-pr" "$REPO_DIR/engine/hooks/auto-pr" "$HOME/.codex/hooks/auto-pr link_item "pr-schema-gate" "$REPO_DIR/engine/hooks/pr-schema-gate" "$HOME/.codex/hooks/pr-schema-gate" link_item "wrong-check-reflect" "$REPO_DIR/engine/hooks/wrong-check-reflect" "$HOME/.codex/hooks/wrong-check-reflect" link_item "build-the-lever" "$REPO_DIR/engine/hooks/build-the-lever" "$HOME/.codex/hooks/build-the-lever" +link_item "split-scope" "$REPO_DIR/engine/hooks/split-scope" "$HOME/.codex/hooks/split-scope" link_item "repeat-error-stop" "$REPO_DIR/engine/hooks/repeat-error-stop" "$HOME/.codex/hooks/repeat-error-stop" link_item "ui-input-guard" "$REPO_DIR/engine/hooks/ui-input-guard" "$HOME/.codex/hooks/ui-input-guard" @@ -338,6 +341,7 @@ python3 "$REPO_DIR/engine/hooks/history-claim-check/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/external-claim-gate/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/wrong-check-reflect/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/build-the-lever/install_claude_hook.py" +python3 "$REPO_DIR/engine/hooks/split-scope/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/no-comments/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/explicit-failures/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_claude_hook.py" @@ -380,6 +384,7 @@ python3 "$REPO_DIR/engine/hooks/auto-pr/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/pr-schema-gate/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/wrong-check-reflect/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/build-the-lever/install_cursor_hook.py" +python3 "$REPO_DIR/engine/hooks/split-scope/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_cursor_hook.py" echo "--- codex notify (\$HOME/.codex/config.toml) ---" @@ -393,6 +398,7 @@ python3 "$REPO_DIR/engine/hooks/pr-schema-gate/install_codex_hook.py" echo "--- codex native scope-lock hooks (\$HOME/.codex/hooks.json) ---" python3 "$REPO_DIR/engine/hooks/scope-lock/install_codex_hook.py" python3 "$REPO_DIR/engine/hooks/build-the-lever/install_codex_hook.py" +python3 "$REPO_DIR/engine/hooks/split-scope/install_codex_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_codex_hook.py" # CLAUDE.md is a dedicated file with no other unrelated config mixed into it diff --git a/tests/test_install.py b/tests/test_install.py index e7426ab..5db7982 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -437,6 +437,41 @@ def test_scope_lock_wired_for_claude_cursor_and_codex(self): self.assertTrue(any("codex_prompt_scope.py" in command for command in codex_prompt_commands)) self.assertTrue(any("codex_pretool_scope.py" in command for command in codex_pretool_commands)) + def test_split_scope_wired_for_claude_cursor_and_codex(self): + for agent_dir in (".claude", ".cursor", ".codex"): + target = os.path.join(self.fake_home, agent_dir, "hooks", "split-scope") + self.assertTrue(os.path.islink(target), target) + self.assertEqual(os.readlink(target), hook_src("split-scope")) + + with open(os.path.join(self.fake_home, ".claude", "settings.json")) as handle: + settings = json.load(handle) + claude_prompt_commands = [ + hook["command"] + for entry in settings["hooks"]["UserPromptSubmit"] + for hook in entry["hooks"] + ] + self.assertTrue(any("split-scope/claude_prompt_submit.py" in command for command in claude_prompt_commands)) + + with open(os.path.join(self.fake_home, ".cursor", "hooks.json")) as handle: + cursor_hooks = json.load(handle)["hooks"] + self.assertTrue(any( + "split-scope/cursor_before_submit.py" in str(entry.get("command", "")) + for entry in cursor_hooks["beforeSubmitPrompt"] + )) + self.assertTrue(any( + "split-scope/cursor_post_tool_use.py" in str(entry.get("command", "")) + for entry in cursor_hooks["postToolUse"] + )) + + with open(os.path.join(self.fake_home, ".codex", "hooks.json")) as handle: + codex_hooks = json.load(handle)["hooks"] + codex_prompt_commands = [ + hook["command"] + for entry in codex_hooks["UserPromptSubmit"] + for hook in entry["hooks"] + ] + self.assertTrue(any("split-scope/codex_prompt_submit.py" in command for command in codex_prompt_commands)) + def test_cursor_hooks_json_seeded_as_real_file(self): target = os.path.join(self.fake_home, ".cursor", "hooks.json") self.assertTrue(os.path.exists(target)) @@ -741,6 +776,7 @@ def test_rerun_reports_already_done_and_produces_no_duplicates(self): for hook_type, marker in ( ("UserPromptSubmit", "build-the-lever/claude_prompt_submit.py"), + ("UserPromptSubmit", "split-scope/claude_prompt_submit.py"), ("PostToolUse", "build-the-lever/claude_posttooluse.py"), ): matching = [ @@ -1125,7 +1161,7 @@ def test_agent_pretooluse_hook_wired_for_claude(self): class TestCursorHooksDanglingLink(unittest.TestCase): - INSTALLERS = ("bug-complaint-leak", "build-the-lever", "pr-schema-gate") + INSTALLERS = ("bug-complaint-leak", "build-the-lever", "split-scope", "pr-schema-gate") DIU_PROMPT_START = "Find the assistant's last response in this conversation" def _seed_link(self, fake_home, target): From 05d573297bf03817481e5bb406a0e5a197ade940 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 11 Sep 2026 22:20:55 +0000 Subject: [PATCH 6/7] =?UTF-8?q?invoker:=20wf-1789161335022-5/verify-split-?= =?UTF-8?q?scope-hook=20=E2=80=94=20Review=20claim:=20-=20The=20hook's=20o?= =?UTF-8?q?wn=20tests,=20the=20hook-coverage=20gate,=20and=20the=20install?= =?UTF-8?q?=20tests=20all=20pass.=20Review=20lane:=20-=20proof=20Safety=20?= =?UTF-8?q?invariant:=20-=20Proof-only;=20this=20read-only=20check=20chang?= =?UTF-8?q?es=20no=20files.=20Effectiveness=20measurement:=20-=20The=20com?= =?UTF-8?q?mand=20below=20exits=200.=20Slice=20rationale:=20-=20One=20proo?= =?UTF-8?q?f=20step=20for=20the=20one=20hook=20added.=20Architectural=20ef?= =?UTF-8?q?fect:=20-=20None;=20verification=20only.=20Goal:=20-=20Prove=20?= =?UTF-8?q?the=20hook=20fires=20and=20stays=20silent=20as=20specified=20an?= =?UTF-8?q?d=20installs=20cleanly.=20Motivation:=20-=20The=20tests=20run?= =?UTF-8?q?=20the=20real=20entrypoint,=20so=20a=20pass=20means=20the=20rem?= =?UTF-8?q?inder=20really=20reaches=20the=20agent.=20Alternative=20conside?= =?UTF-8?q?rations:=20-=20Option=20A=20(chosen):=20run=20the=20hook=20test?= =?UTF-8?q?s,=20the=20coverage=20gate,=20and=20the=20install=20tests=20tog?= =?UTF-8?q?ether.=20Implementation=20details:=20-=20Run=20the=20three=20ch?= =?UTF-8?q?ecks=20in=20order.=20Non-goals:=20-=20No=20product=20edits=20he?= =?UTF-8?q?re.=20Layer:=20app=5Fregression=20Feature=20state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: de2b9576-c043-4ebd-900b-3b6f8c0767a5 From 94617982de552e8c00e657407244e9acaa1ff2ae Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 11 Sep 2026 22:21:36 +0000 Subject: [PATCH 7/7] =?UTF-8?q?invoker:=20wf-1789161335022-5/scrub-handoff?= =?UTF-8?q?-artifacts=20=E2=80=94=20Review=20claim:=20-=20No=20ephemeral?= =?UTF-8?q?=20handoff=20files=20are=20left=20behind=20before=20the=20PR=20?= =?UTF-8?q?merge=20gate.=20Review=20lane:=20-=20proof=20Safety=20invariant?= =?UTF-8?q?:=20-=20This=20read-only=20check=20changes=20no=20files.=20Effe?= =?UTF-8?q?ctiveness=20measurement:=20-=20`bash=20scripts/scrub-handoff-ar?= =?UTF-8?q?tifacts.sh`=20exits=200.=20Slice=20rationale:=20-=20The=20scrub?= =?UTF-8?q?=20check=20runs=20last=20and=20stays=20separate=20from=20implem?= =?UTF-8?q?entation.=20Architectural=20effect:=20-=20No=20architecture=20c?= =?UTF-8?q?hange.=20Goal:=20-=20Confirm=20no=20handoff=20artifacts=20remai?= =?UTF-8?q?n.=20Motivation:=20-=20Uncommitted=20handoff=20files=20do=20not?= =?UTF-8?q?=20flow=20across=20worktrees.=20Alternative=20considerations:?= =?UTF-8?q?=20-=20Option=20A=20(chosen):=20a=20read-only=20absence=20check?= =?UTF-8?q?.=20Implementation=20details:=20-=20Run=20the=20handoff=20scrub?= =?UTF-8?q?=20script=20without=20--apply.=20Non-goals:=20-=20Do=20not=20mo?= =?UTF-8?q?dify=20source=20files.=20Layer:=20e2e=5Fregression=20Feature=20?= =?UTF-8?q?state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 5f7e27db-a80d-42ca-ae0b-1264aef879ba