From b95c4472f1188085d2bbc2d92f36ed47506525b6 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Sat, 12 Sep 2026 12:13:43 -0700 Subject: [PATCH 1/6] cat-mode: inject on every enabled prompt --- .../cat-mode-default/claude_prompt_submit.py | 2 +- engine/hooks/cat-mode-default/detect.py | 38 ++------------- .../tests/fixtures/silent_ack_ok.json | 4 +- .../cat-mode-default/tests/test_hooks.py | 47 +++++++++---------- 4 files changed, 28 insertions(+), 63 deletions(-) diff --git a/engine/hooks/cat-mode-default/claude_prompt_submit.py b/engine/hooks/cat-mode-default/claude_prompt_submit.py index f6da8f15..d94e6b1d 100644 --- a/engine/hooks/cat-mode-default/claude_prompt_submit.py +++ b/engine/hooks/cat-mode-default/claude_prompt_submit.py @@ -2,7 +2,7 @@ """Claude Code UserPromptSubmit: inject the cat-mode default context. Fail-open. No LLM. Never denies. Silent unless CATSTACK_CAT_MODE_DEFAULT -resolves to on and the prompt is an investigation or execution. +resolves to on and the prompt does not contain a typed /cat-mode. """ from __future__ import annotations diff --git a/engine/hooks/cat-mode-default/detect.py b/engine/hooks/cat-mode-default/detect.py index 397f4f43..e921529f 100644 --- a/engine/hooks/cat-mode-default/detect.py +++ b/engine/hooks/cat-mode-default/detect.py @@ -11,10 +11,8 @@ Files are parsed as plain `KEY=VALUE` lines. They are never sourced, and no key other than the flag is read back or printed. -2. Is the prompt an investigation or execution? A bare slash command, a - one-word acknowledgement ("ok", "thanks"), or a prompt that already - invokes /cat-mode is not. Anything longer than a short phrase, or that - carries a work verb (why, how, fix, build, run, ...), is. +2. Does the prompt already contain a typed /cat-mode? Every other prompt gets + the context when the flag is on. The text injected names the installed cat-mode SKILL.md so the model reads the real file rather than a summary. When the skill is not installed the @@ -31,19 +29,6 @@ SKILL_RELPATH = os.path.join(".claude", "skills", "cat-mode", "SKILL.md") TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) -MIN_WORK_LENGTH = 12 -ACKS = frozenset({ - "ok", "okay", "k", "kk", "yes", "y", "yep", "yup", "no", "nope", "sure", - "thanks", "thank you", "thx", "ty", "cool", "great", "nice", "good", - "done", "go", "continue", "proceed", "got it", "lgtm", "np", "fine", -}) -WORK_VERBS = ( - "why", "how", "what", "where", "fix", "build", "run", "land", "make", - "investigate", "check", "debug", "test", "repro", "find", "explain", - "add", "remove", "delete", "refactor", "write", "implement", "deploy", - "ship", "merge", "open", "update", "review", "compare", "verify", -) -WORK_VERB_RE = re.compile(r"\b(?:" + "|".join(WORK_VERBS) + r")\b", re.IGNORECASE) CAT_MODE_COMMAND_RE = re.compile(r"(?:^|\s)/cat-mode\b") ENV_LINE_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$") @@ -140,23 +125,6 @@ def typed_cat_mode(prompt: str) -> bool: return bool(CAT_MODE_COMMAND_RE.search(prompt or "")) -def is_work_prompt(prompt: str) -> bool: - text = (prompt or "").strip() - if not text: - return False - if typed_cat_mode(text): - return False - tokens = text.split() - if text.startswith("/") and len(tokens) == 1: - return False - normalized = re.sub(r"[^a-z ]", "", text.lower()).strip() - if normalized in ACKS: - return False - if len(text) > MIN_WORK_LENGTH: - return True - return bool(WORK_VERB_RE.search(text)) - - def installed_skill_path(home: str | None = None) -> str | None: home_dir = home or os.path.expanduser("~") path = os.path.join(home_dir, SKILL_RELPATH) @@ -176,7 +144,7 @@ def decide(payload: dict, environ: dict | None = None, home: str | None = None) """Return the additionalContext to inject, or None to stay silent.""" env = os.environ if environ is None else environ prompt = extract_prompt_text(payload if isinstance(payload, dict) else {}) - if not is_work_prompt(prompt): + if typed_cat_mode(prompt): return None cwd = payload.get("cwd") if isinstance(payload, dict) else None if not flag_on(env, cwd or os.getcwd(), home): diff --git a/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json b/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json index 4641ae3b..983087d7 100644 --- a/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json +++ b/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json @@ -1,6 +1,6 @@ { - "why": "flag on, but the prompt is a one-word acknowledgement, not work", - "expect": "silent", + "why": "flag on, so the prompt receives the default even though it is a one-word acknowledgement", + "expect": "fires", "environ": {"CATSTACK_CAT_MODE_DEFAULT": "1"}, "env_file": null, "payload": {"hook_event_name": "UserPromptSubmit", "prompt": "ok"} diff --git a/engine/hooks/cat-mode-default/tests/test_hooks.py b/engine/hooks/cat-mode-default/tests/test_hooks.py index af7031ba..97b0ff69 100644 --- a/engine/hooks/cat-mode-default/tests/test_hooks.py +++ b/engine/hooks/cat-mode-default/tests/test_hooks.py @@ -130,21 +130,33 @@ def test_silent_when_flag_off(self) -> None: self.assertEqual(fixture["payload"]["prompt"], REAL_PROMPT) self.assertIsNone(context) - def test_silent_on_one_word_ack(self) -> None: + def test_fires_on_one_word_ack(self) -> None: _fixture, context = self.run_fixture("silent_ack_ok.json") - self.assertIsNone(context) + self.assertIsNotNone(context) def test_silent_when_user_typed_cat_mode(self) -> None: _fixture, context = self.run_fixture("silent_typed_cat_mode.json") self.assertIsNone(context) - def test_effectiveness_same_prompt_flag_on_vs_off(self) -> None: - payload = {"prompt": REAL_PROMPT, "cwd": self.box.cwd} - on = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "1"}), self.box.home)) - off = injected_context(run_entrypoint(payload, self.box.environ(), self.box.home)) - self.assertIsNotNone(on) - self.assertIn("read and apply", on) - self.assertIsNone(off) + def test_effectiveness_prompt_matrix(self) -> None: + prompts = ( + "ok", + "thanks", + "yes do it", + "go ahead", + "investigate why the build fails on main", + "/cat-mode fix this", + ) + for prompt in prompts: + payload = {"prompt": prompt, "cwd": self.box.cwd} + on = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "1"}), self.box.home)) + off = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "0"}), self.box.home)) + if prompt == "/cat-mode fix this": + self.assertIsNone(on, prompt) + else: + self.assertIsNotNone(on, prompt) + self.assertIn("read and apply", on) + self.assertIsNone(off, prompt) class FlagResolutionCase(unittest.TestCase): @@ -219,22 +231,7 @@ def test_true_values_turn_on(self) -> None: self.assertTrue(detect.flag_on(self.box.environ({detect.FLAG: value}), self.box.cwd, self.box.home), value) -class PromptClassificationCase(unittest.TestCase): - def test_fires_on_work_prompts(self) -> None: - for prompt in ( - REAL_PROMPT, - "fix it", - "run tests", - "how do I land this stack", - "/loop 5m check the PR queue and repair failures", - "Investigate the flaky e2e on main", - ): - self.assertTrue(detect.is_work_prompt(prompt), prompt) - - def test_silent_on_acks_and_bare_commands(self) -> None: - for prompt in ("ok", "OK!", "yes", "thanks", "Thank you.", "/clear", "/cat-mode", "", " "): - self.assertFalse(detect.is_work_prompt(prompt), prompt) - +class PromptCommandCase(unittest.TestCase): def test_silent_when_cat_mode_typed_anywhere(self) -> None: self.assertTrue(detect.typed_cat_mode("/cat-mode fix it")) self.assertTrue(detect.typed_cat_mode("please /cat-mode fix it")) From fd29a8e55003fa4cb028bda22a16d36f2de49995 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Sat, 12 Sep 2026 12:14:18 -0700 Subject: [PATCH 2/6] =?UTF-8?q?invoker:=20wf-1789240296417-25/cat-mode-eve?= =?UTF-8?q?ry-prompt=20=E2=80=94=20Review=20claim:=20cat-mode-default=20in?= =?UTF-8?q?jects=20on=20every=20prompt=20when=20the=20flag=20is=20on,=20ex?= =?UTF-8?q?cept=20a=20prompt=20containing=20a=20typed=20/cat-mode,=20and?= =?UTF-8?q?=20the=20regex=20classifier=20is=20gone.=20Review=20lane:=20beh?= =?UTF-8?q?avior=20Safety=20invariant:=20With=20the=20flag=20on,=20every?= =?UTF-8?q?=20prompt=20gets=20cat-mode=20unless=20it=20already=20contains?= =?UTF-8?q?=20`/cat-mode`;=20with=20the=20flag=20off,=20no=20prompt=20gets?= =?UTF-8?q?=20it;=20nothing=20guesses=20meaning=20with=20regex.=20Effectiv?= =?UTF-8?q?eness=20measurement:=20Entrypoint=20tests=20on=20six=20prompts?= =?UTF-8?q?=20with=20the=20flag=20on=20and=20off=20assert=20inject=20or=20?= =?UTF-8?q?silent=20for=20each.=20Slice=20rationale:=20A=20single=20hook's?= =?UTF-8?q?=20decision=20rule,=20reviewable=20alone.=20Architectural=20eff?= =?UTF-8?q?ect:=20cat-mode=20context=20reaches=20acknowledgement=20and=20s?= =?UTF-8?q?hort=20execution=20turns=20too;=20the=20Agent-tool=20companion?= =?UTF-8?q?=20is=20unchanged.=20Goal:=20Remove=20ACKS,=20WORK=5FVERBS,=20W?= =?UTF-8?q?ORK=5FVERB=5FRE,=20MIN=5FWORK=5FLENGTH=20and=20is=5Fwork=5Fprom?= =?UTF-8?q?pt,=20and=20update=20the=20tests.=20Motivation:=20The=20regex?= =?UTF-8?q?=20skips=20execution=20turns=20like=20"yes=20do=20it".=20Altern?= =?UTF-8?q?ative=20considerations:=20A=20background=20llm-judge=20was=20re?= =?UTF-8?q?jected=20because=20its=20verdict=20arrives=20on=20the=20next=20?= =?UTF-8?q?prompt;=20a=20blocking=20judge=20was=20rejected=20because=20the?= =?UTF-8?q?=20user's=20standing=20rule=20is=20never=20block=20on=20the=20j?= =?UTF-8?q?udge.=20Implementation=20details:=20decide()=20returns=20the=20?= =?UTF-8?q?context=20whenever=20the=20flag=20is=20on,=20apart=20from=20a?= =?UTF-8?q?=20typed=20/cat-mode,=20which=20stays=20because=20a=20slash=20c?= =?UTF-8?q?ommand=20is=20a=20fixed=20machine=20format.=20Non-goals:=20No?= =?UTF-8?q?=20change=20to=20the=20Agent-tool=20companion=20(agent=5Fupdate?= =?UTF-8?q?d=5Finput,=20mentions=5Fcat=5Fmode),=20flag=20resolution,=20or?= =?UTF-8?q?=20the=20injected=20text;=20no=20corpus/=20skill=20edits.=20Lay?= =?UTF-8?q?er:=20domain=20Feature=20state:=20active=20Files:=20-=20engine/?= =?UTF-8?q?hooks/cat-mode-default/detect.py=20-=20engine/hooks/cat-mode-de?= =?UTF-8?q?fault/tests/test=5Fhooks.py=20-=20engine/hooks/cat-mode-default?= =?UTF-8?q?/tests/fixtures/=20Change=20types:=20-=20engine/hooks/cat-mode-?= =?UTF-8?q?default/detect.py:=20modify=20-=20engine/hooks/cat-mode-default?= =?UTF-8?q?/tests/test=5Fhooks.py:=20modify=20-=20engine/hooks/cat-mode-de?= =?UTF-8?q?fault/tests/fixtures/:=20modify=20Acceptance=20criteria:=20-=20?= =?UTF-8?q?`python3=20-m=20unittest=20discover=20-s=20engine/hooks/cat-mod?= =?UTF-8?q?e-default/tests=20-v`=20exits=200.=20-=20`python3=20scripts/che?= =?UTF-8?q?ck=5Fhook=5Ftest=5Fcoverage.py=20engine/hooks/cat-mode-default`?= =?UTF-8?q?=20exits=200.=20-=20`git=20grep=20-n=20-e=20is=5Fwork=5Fprompt?= =?UTF-8?q?=20-e=20WORK=5FVERB=20-e=20MIN=5FWORK=5FLENGTH=20--=20engine=20?= =?UTF-8?q?tests`=20prints=20nothing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From 21fdacd36c9b926248ee69e6ea2dc4338fefe9e1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 12 Sep 2026 19:16:49 +0000 Subject: [PATCH 3/6] docs: describe cat-mode every-prompt default --- docs/ecosystem.md | 2 +- engine/hooks/cat-mode-default/README.md | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 13380747..dea86601 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -69,7 +69,7 @@ again. | `bug-complaint-leak` | hook | | `publish-act-guard` | hook | | `categorical-scope-guard` | hook (PreToolUse on `Bash`; blocks a status-narrowed mutation when the live turn said all/every/each) | -| `cat-mode-default` | hook (UserPromptSubmit + PreToolUse on `Agent`; applies `cat-mode` on work turns and subagent prompts when `CATSTACK_CAT_MODE_DEFAULT=1`) | +| `cat-mode-default` | hook (UserPromptSubmit + PreToolUse on `Agent`; applies `cat-mode` on every prompt and on subagent prompts when `CATSTACK_CAT_MODE_DEFAULT=1`) | | `demo-freeze` | hook | | `explicit-failures` | hook (advisory; always on) | | `external-claim-gate` | hook (PreToolUse on `Bash`; blocks a gh issue/comment/release/api write whose body claims a cause or fix with no evidence; blocks as UNCHECKED when the body cannot be read) | diff --git a/engine/hooks/cat-mode-default/README.md b/engine/hooks/cat-mode-default/README.md index aa0230f9..d0710b15 100644 --- a/engine/hooks/cat-mode-default/README.md +++ b/engine/hooks/cat-mode-default/README.md @@ -1,12 +1,12 @@ # cat-mode-default -Claude Code `UserPromptSubmit` hook. When the flag is on and the prompt is -an investigation or execution, it injects one line of context telling the -model to read and apply the installed `cat-mode` skill for that turn. +Claude Code `UserPromptSubmit` hook. When the flag is on, it injects one line +of context telling the model to read and apply the installed `cat-mode` skill +for that turn. `cat-mode` ships with `disable-model-invocation: true`, so on its own it -applies when typed as `/cat-mode`. This hook makes it the default for -work turns without flipping that frontmatter flag. +applies when typed as `/cat-mode`. This hook makes it the default for every +prompt without flipping that frontmatter flag. ## Turning it on @@ -30,11 +30,10 @@ tolerated). They are never sourced, and no other key is read or printed. ## When it fires -Prompt is treated as work when it is longer than a short phrase or carries a -work verb (why, how, fix, build, run, land, make, investigate, check, debug, -...). It stays silent for a bare slash command (`/clear`), a one-word -acknowledgement (`ok`, `thanks`), or any prompt that already contains -`/cat-mode`, so the skill is not applied twice in one turn. +With the flag on, every prompt gets the context unless it already contains a +typed `/cat-mode`, so the skill is not applied twice in one turn. Other slash +commands, acknowledgements, and short execution prompts all get the same +default context. Injected text names the installed file (`~/.claude/skills/cat-mode/SKILL.md`) so the model reads the real skill. If that file is missing the line says @@ -54,7 +53,7 @@ second copy). Same flag resolution as the prompt hook. ## Files -- `detect.py`: flag resolution, prompt classification, context text. +- `detect.py`: flag resolution, typed `/cat-mode` detection, context text. - `claude_prompt_submit.py`: the Claude entrypoint; fail-open, never denies. - `claude.prompt.hook.json`: settings fragment `install_claude_hook.py` merges. - `claude_pretooluse_agent.py` + `claude.agent.hook.json`: the `PreToolUse` @@ -66,4 +65,5 @@ second copy). Same flag resolution as the prompt hook. Related but different: `CAT_MODE_AUTO_INVOKE=true` in catstack's own `.env` makes `install.sh` materialize a cat-mode copy with model invocation enabled, which leaves the choice to the model each turn. This hook is deterministic: -flag on plus a work prompt means the context is injected. +flag on means the context is injected unless the prompt already contains a +typed `/cat-mode`. From 6554fefb5cb87c82813a613af59bc8e7ac0c694e Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:17:46 +0000 Subject: [PATCH 4/6] =?UTF-8?q?invoker:=20wf-1789240296417-25/describe-eve?= =?UTF-8?q?ry-prompt=20=E2=80=94=20Review=20claim:=20engine/hooks/cat-mode?= =?UTF-8?q?-default/README.md=20and=20the=20docs/ecosystem.md=20row=20say?= =?UTF-8?q?=20cat-mode=20applies=20on=20every=20prompt=20when=20the=20flag?= =?UTF-8?q?=20is=20on.=20Review=20lane:=20docs=20Safety=20invariant:=20Onl?= =?UTF-8?q?y=20the=20two=20Markdown=20files=20change;=20no=20code,=20test,?= =?UTF-8?q?=20or=20config=20file=20is=20edited.=20Effectiveness=20measurem?= =?UTF-8?q?ent:=20`git=20grep=20-n=20"work=20turns"=20--=20docs=20engine/h?= =?UTF-8?q?ooks/cat-mode-default`=20prints=20nothing=20after=20the=20chang?= =?UTF-8?q?e.=20Slice=20rationale:=20Prose=20in=20its=20own=20commit=20so?= =?UTF-8?q?=20the=20code=20commit=20stays=20one=20claim.=20Architectural?= =?UTF-8?q?=20effect:=20None;=20prose=20only.=20Goal:=20Replace=20the=20wo?= =?UTF-8?q?rk-prompt=20wording=20with=20the=20every-prompt=20rule.=20Motiv?= =?UTF-8?q?ation:=20The=20README=20and=20ecosystem=20table=20would=20other?= =?UTF-8?q?wise=20describe=20the=20old=20classifier.=20Alternative=20consi?= =?UTF-8?q?derations:=20Code=20comments=20were=20rejected;=20the=20repo=20?= =?UTF-8?q?forbids=20new=20comments.=20Implementation=20details:=20Two=20M?= =?UTF-8?q?arkdown=20edits.=20Non-goals:=20No=20code,=20test,=20or=20confi?= =?UTF-8?q?g=20edits;=20nothing=20under=20corpus/.=20Layer:=20docs=20Featu?= =?UTF-8?q?re=20state:=20active=20Files:=20-=20engine/hooks/cat-mode-defau?= =?UTF-8?q?lt/README.md=20-=20docs/ecosystem.md=20Change=20types:=20-=20en?= =?UTF-8?q?gine/hooks/cat-mode-default/README.md:=20docs-only=20-=20docs/e?= =?UTF-8?q?cosystem.md:=20docs-only=20Acceptance=20criteria:=20-=20`git=20?= =?UTF-8?q?grep=20-n=20"work=20turns"=20--=20docs=20engine/hooks/cat-mode-?= =?UTF-8?q?default`=20prints=20nothing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 5442b6e5-293e-443f-84aa-52f0fe0d0135 From 7db957be358936aaeee2d2f107b2c40807c8be70 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:23:22 +0000 Subject: [PATCH 5/6] =?UTF-8?q?invoker:=20wf-1789240296417-25/verify-cat-m?= =?UTF-8?q?ode-every-prompt=20=E2=80=94=20Review=20claim:=20The=20cat-mode?= =?UTF-8?q?-default=20tests,=20its=20coverage=20gate,=20and=20the=20repo?= =?UTF-8?q?=20suite=20pass,=20and=20no=20classifier=20symbol=20remains.=20?= =?UTF-8?q?Review=20lane:=20proof=20Safety=20invariant:=20Verification=20i?= =?UTF-8?q?s=20read-only=20and=20does=20not=20alter=20any=20repository=20f?= =?UTF-8?q?ile.=20Effectiveness=20measurement:=20The=20commands=20are=20th?= =?UTF-8?q?e=20direct=20measurement.=20Slice=20rationale:=20One=20focused?= =?UTF-8?q?=20proof=20before=20review.=20Architectural=20effect:=20None;?= =?UTF-8?q?=20verification=20only.=20Goal:=20Prove=20every-prompt=20inject?= =?UTF-8?q?ion=20and=20flag-off=20silence.=20Motivation:=20Running=20the?= =?UTF-8?q?=20tests=20is=20the=20proof.=20Alternative=20considerations:=20?= =?UTF-8?q?The=20full=20suite=20catches=20consumers=20of=20deleted=20symbo?= =?UTF-8?q?ls=20elsewhere.=20Implementation=20details:=20Run=20the=20cover?= =?UTF-8?q?age=20gate,=20the=20suite=20(which=20discovers=20the=20hook=20t?= =?UTF-8?q?ests),=20and=20the=20grep.=20Non-goals:=20No=20mutations.=20Lay?= =?UTF-8?q?er:=20app=5Fregression=20Feature=20state:=20active=20Acceptance?= =?UTF-8?q?=20criteria:=20-=20Exits=200=20only=20when=20all=20pass=20and?= =?UTF-8?q?=20the=20grep=20finds=20nothing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: fdb51ec8-9875-493c-8a43-052107f958b5 From 3f4e97cee67859f8a88fbf7cd059f091345533cd Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:24:19 +0000 Subject: [PATCH 6/6] =?UTF-8?q?invoker:=20wf-1789240296417-25/scrub-handof?= =?UTF-8?q?f-artifacts=20=E2=80=94=20Review=20claim:=20No=20ephemeral=20in?= =?UTF-8?q?ter-task=20handoff=20files=20remain=20in=20the=20worktree=20bef?= =?UTF-8?q?ore=20the=20merge=20gate.=20Review=20lane:=20cleanup=20Safety?= =?UTF-8?q?=20invariant:=20The=20scrub=20script=20only=20checks=20for=20kn?= =?UTF-8?q?own=20handoff=20artifact=20names=20and=20never=20touches=20sour?= =?UTF-8?q?ce,=20tests,=20or=20other=20repository=20files.=20Effectiveness?= =?UTF-8?q?=20measurement:=20The=20script=20exits=20non-zero=20if=20any=20?= =?UTF-8?q?handoff=20artifact=20remains.=20Slice=20rationale:=20Required?= =?UTF-8?q?=20terminal=20scrub=20for=20every=20implementation=20workflow.?= =?UTF-8?q?=20Architectural=20effect:=20None;=20hygiene=20only.=20Goal:=20?= =?UTF-8?q?Leave=20the=20branch=20free=20of=20handoff=20artifacts.=20Motiv?= =?UTF-8?q?ation:=20Handoff=20files=20must=20not=20reach=20the=20PR.=20Alt?= =?UTF-8?q?ernative=20considerations:=20Manual=20cleanup=20was=20rejected?= =?UTF-8?q?=20as=20non-deterministic.=20Implementation=20details:=20Run=20?= =?UTF-8?q?scripts/scrub-handoff-artifacts.sh.=20Layer=20exception:=20allo?= =?UTF-8?q?wed=20--=20the=20terminal=20scrub=20must=20run=20after=20every?= =?UTF-8?q?=20task=20in=20the=20workflow,=20including=20the=20docs=20task.?= =?UTF-8?q?=20Non-goals:=20No=20product=20edits.=20Layer:=20app=5Fregressi?= =?UTF-8?q?on=20Feature=20state:=20active=20Acceptance=20criteria:=20-=20`?= =?UTF-8?q?bash=20scripts/scrub-handoff-artifacts.sh`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 03cb1c94-1cac-4310-af56-316f3d1707f6