claude-dev-env 8.73.1 → 8.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.agents/agents/test_agent_frontmatter.py +23 -23
  2. package/.agents/agents-archived/clean-coder.md +5 -5
  3. package/.agents/skills/correction/playbooks/fix.md +1 -1
  4. package/.agents/skills/eli5/SKILL.md +3 -3
  5. package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
  6. package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
  7. package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
  8. package/_shared/advisor/reference/third-party-bind.md +1 -1
  9. package/bin/cde.mjs +1 -0
  10. package/bin/cde.test.mjs +1 -0
  11. package/bin/install.mjs +1 -0
  12. package/bin/install.policy-lint.test.mjs +1 -0
  13. package/bin/install.profile-root.test.mjs +1 -1
  14. package/bin/install.settings-defaults.test.mjs +10 -2
  15. package/bin/install.test.mjs +1 -0
  16. package/bin/merge_settings_defaults.mjs +34 -1
  17. package/bin/merge_settings_defaults.test.mjs +53 -0
  18. package/commands/fix-followups.md +1 -1
  19. package/commands/sr-loop.md +1 -1
  20. package/docs/references/prose-style-enforcement.md +1 -1
  21. package/docs/references/working-style.md +1 -1
  22. package/docs/rule-guides/asd-ste100-language.md +9 -1
  23. package/docs/rule-guides/cleanup-temp-files.md +8 -2
  24. package/docs/rule-guides/destructive-commands.md +13 -3
  25. package/docs/rule-guides/explore-thoroughly.md +9 -1
  26. package/docs/rule-guides/features-start-with-an-eval.md +8 -2
  27. package/docs/rule-guides/filesystem-search.md +7 -1
  28. package/docs/rule-guides/flag-non-breaking-findings.md +3 -0
  29. package/docs/rule-guides/long-horizon-autonomy.md +1 -1
  30. package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
  31. package/docs/rule-guides/no-contrast-framing.md +10 -4
  32. package/docs/rule-guides/prefer-existing-tools.md +2 -2
  33. package/docs/rule-guides/proof-before-pull-request.md +5 -1
  34. package/docs/rule-guides/request-scope.md +5 -1
  35. package/docs/rule-guides/research-mode.md +9 -1
  36. package/docs/rule-guides/session-title.md +7 -1
  37. package/docs/rule-guides/shell-invocation.md +9 -1
  38. package/docs/rule-guides/verify-before-asking.md +7 -1
  39. package/docs/rule-guides/verify-runtime-state.md +7 -1
  40. package/hooks/blocking/code_rules_enforcer.py +16 -6
  41. package/hooks/blocking/code_rules_shared.py +23 -4
  42. package/hooks/blocking/haiku_spawn_slim_gate.py +96 -0
  43. package/hooks/blocking/reply_length_gate.py +74 -14
  44. package/hooks/blocking/state_description_blocker.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
  46. package/hooks/blocking/test_code_rules_shared.py +33 -0
  47. package/hooks/blocking/test_haiku_spawn_slim_gate.py +65 -0
  48. package/hooks/blocking/test_reply_length_gate.py +184 -5
  49. package/hooks/blocking/test_state_description_blocker.py +1 -1
  50. package/hooks/features/conduct-gates.md +3 -6
  51. package/hooks/features/spawn-routing.md +5 -1
  52. package/hooks/followup_ledger.py +52 -2
  53. package/hooks/hooks.json +11 -6
  54. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
  55. package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +29 -0
  56. package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
  57. package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
  58. package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
  59. package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
  60. package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
  61. package/hooks/hooks_constants/windows_filesystem.py +51 -0
  62. package/hooks/session/session_env_cleanup.py +4 -30
  63. package/hooks/test_followup_ledger.py +58 -0
  64. package/hooks/test_visual_reply_rules.py +164 -0
  65. package/hooks/visual_reply_rules.py +268 -0
  66. package/hooks/workflow/test_auto_formatter.py +4 -25
  67. package/package.json +1 -1
  68. package/rules/index.md +30 -0
  69. package/rules/visual-reply-rules.json +41 -0
  70. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  71. package/scripts/conftest.py +13 -5
  72. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
  73. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  74. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
  75. package/scripts/followup_cli.py +25 -1
  76. package/scripts/policy_lint/adapter_configuration.py +0 -5
  77. package/scripts/policy_lint/config/constants.py +1 -1
  78. package/scripts/repository_checks/config/constants.py +6 -0
  79. package/scripts/repository_checks/followup_ledger_duplicates.py +55 -0
  80. package/scripts/repository_checks/runner.py +7 -0
  81. package/scripts/test_active_capability_references.py +0 -5
  82. package/scripts/test_broker_state_isolation.py +3 -3
  83. package/scripts/test_digest_check.py +0 -5
  84. package/scripts/test_durable_post_lint.py +1 -5
  85. package/scripts/test_followup_cli.py +18 -6
  86. package/scripts/test_resolve_worker_spawn.py +5 -10
  87. package/scripts/test_setup_project_paths.py +0 -12
  88. package/scripts/test_shared_tree_paths.py +2 -6
  89. package/scripts/test_validate_instruction_pairs.py +0 -7
  90. package/scripts/test_verify_installable_package.py +0 -5
  91. package/scripts/tests/test_contrast_framing.py +3 -3
  92. package/scripts/tests/test_followup_ledger_duplicates.py +58 -0
  93. package/scripts/tests/test_repository_policy.py +2 -0
  94. package/scripts/tests/test_rule_index_shape.py +59 -5
  95. package/scripts/tests/test_rule_load_scopes.py +1 -16
  96. package/settings.json +9 -0
  97. package/system-prompts/software-engineer.xml +4 -4
  98. package/hooks/blocking/artifact_look_gate.py +0 -223
  99. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  100. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  101. package/rules/asd-ste100-language.md +0 -9
  102. package/rules/cleanup-temp-files.md +0 -9
  103. package/rules/destructive-commands.md +0 -11
  104. package/rules/explore-thoroughly.md +0 -9
  105. package/rules/features-start-with-an-eval.md +0 -9
  106. package/rules/filesystem-search.md +0 -9
  107. package/rules/memory-stores-durable-facts.md +0 -9
  108. package/rules/no-contrast-framing.md +0 -9
  109. package/rules/proof-before-pull-request.md +0 -7
  110. package/rules/request-scope.md +0 -7
  111. package/rules/research-mode.md +0 -9
  112. package/rules/session-title.md +0 -9
  113. package/rules/shell-invocation.md +0 -9
  114. package/rules/skill-pointers.md +0 -3
  115. package/rules/verify-before-asking.md +0 -9
  116. package/rules/verify-runtime-state.md +0 -9
@@ -0,0 +1,65 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import haiku_spawn_slim_gate
9
+ from hooks_constants.haiku_spawn_slim_gate_constants import SLIM_PROFILE_DENY_REASON
10
+
11
+ HOOK_SCRIPT = Path(__file__).resolve().parent / "haiku_spawn_slim_gate.py"
12
+
13
+
14
+ def _run_hook(payload: dict[str, object]) -> str:
15
+ completed = subprocess.run(
16
+ [sys.executable, str(HOOK_SCRIPT)],
17
+ input=json.dumps(payload),
18
+ capture_output=True,
19
+ text=True,
20
+ check=True,
21
+ )
22
+ return completed.stdout
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ ("tool_name", "model"),
27
+ [
28
+ ("Agent", "haiku"),
29
+ ("Task", "Haiku"),
30
+ ("Agent", "claude-haiku-5-5"),
31
+ ("Agent", "claude-haiku-4-5-20251001"),
32
+ ("mcp__hearthbot__start_thread_session", "claude-haiku-5-5"),
33
+ ],
34
+ )
35
+ def test_should_deny_a_haiku_spawn_with_the_headless_command(tool_name: str, model: str) -> None:
36
+ payload = {"tool_name": tool_name, "tool_input": {"prompt": "Review.", "model": model}}
37
+ hook_output = json.loads(_run_hook(payload))["hookSpecificOutput"]
38
+ assert hook_output["hookEventName"] == "PreToolUse"
39
+ assert hook_output["permissionDecision"] == "deny"
40
+ assert hook_output["permissionDecisionReason"] == SLIM_PROFILE_DENY_REASON
41
+
42
+
43
+ @pytest.mark.parametrize("model", ["opus", "sonnet", "claude-opus-5-5", "fable", None, "", 7])
44
+ def test_should_pass_a_spawn_that_names_no_haiku_model(model: object) -> None:
45
+ tool_input = {"prompt": "Review."}
46
+ if model is not None:
47
+ tool_input["model"] = model
48
+ assert _run_hook({"tool_name": "Agent", "tool_input": tool_input}) == ""
49
+
50
+
51
+ def test_should_pass_a_payload_without_tool_input() -> None:
52
+ assert haiku_spawn_slim_gate.decide_hook_output(None) is None
53
+
54
+
55
+ def test_should_name_every_slim_profile_flag_in_the_deny_reason() -> None:
56
+ for each_flag in (
57
+ "account_broker.py",
58
+ "--model haiku",
59
+ "--setting-sources local",
60
+ "--strict-mcp-config",
61
+ "--autocompact 100k",
62
+ "CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
63
+ "POSIX shell",
64
+ ):
65
+ assert each_flag in SLIM_PROFILE_DENY_REASON
@@ -22,12 +22,15 @@ def run_gate(
22
22
  capsys: pytest.CaptureFixture[str],
23
23
  tool_name: str,
24
24
  tool_input: dict[str, object],
25
+ transcript_path: Path | None = None,
25
26
  ) -> tuple[int, str]:
26
- hook_input = {
27
+ hook_input: dict[str, object] = {
27
28
  "hook_event_name": "PreToolUse",
28
29
  "tool_name": tool_name,
29
30
  "tool_input": tool_input,
30
31
  }
32
+ if transcript_path is not None:
33
+ hook_input["transcript_path"] = str(transcript_path)
31
34
  monkeypatch.setattr(
32
35
  "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
33
36
  )
@@ -39,6 +42,9 @@ def run_gate(
39
42
  def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
40
43
  monkeypatch.setenv("HOME", str(tmp_path))
41
44
  monkeypatch.setenv("USERPROFILE", str(tmp_path))
45
+ switch_path = tmp_path / ".claude" / "visual-reply-mode.json"
46
+ switch_path.parent.mkdir(exist_ok=True)
47
+ switch_path.write_text('{"enabled": true}', encoding="utf-8")
42
48
 
43
49
 
44
50
  def test_should_allow_three_short_sentences(
@@ -48,7 +54,7 @@ def test_should_allow_three_short_sentences(
48
54
  monkeypatch,
49
55
  capsys,
50
56
  REPLY_TOOL_NAME,
51
- {"text": "The fix is in. Tests pass on CI. It ships with the next release."},
57
+ {"text": "The fix is in. Tests pass. It ships with the next release."},
52
58
  )
53
59
  assert (exit_code, stderr_text) == (0, "")
54
60
 
@@ -292,7 +298,7 @@ def test_should_replace_the_defaults_with_the_configured_list(
292
298
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
293
299
  ) -> None:
294
300
  config_path = tmp_path / ".claude" / "reply-banned-words.json"
295
- config_path.parent.mkdir()
301
+ config_path.parent.mkdir(exist_ok=True)
296
302
  config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
297
303
  likely_exit_code, _ = run_gate(
298
304
  monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."}
@@ -318,7 +324,7 @@ def test_should_keep_the_defaults_when_the_config_file_is_malformed(
318
324
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
319
325
  ) -> None:
320
326
  config_path = tmp_path / ".claude" / "reply-banned-words.json"
321
- config_path.parent.mkdir()
327
+ config_path.parent.mkdir(exist_ok=True)
322
328
  config_path.write_text("{not json", encoding="utf-8")
323
329
  exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
324
330
  assert exit_code == 2
@@ -426,7 +432,7 @@ def test_should_apply_the_configured_list_to_decision_cards(
426
432
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
427
433
  ) -> None:
428
434
  config_path = tmp_path / ".claude" / "reply-banned-words.json"
429
- config_path.parent.mkdir()
435
+ config_path.parent.mkdir(exist_ok=True)
430
436
  config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
431
437
  probably_exit_code, _ = run_gate(
432
438
  monkeypatch,
@@ -489,6 +495,179 @@ def test_should_deny_an_em_dash_and_allow_one_in_code(
489
495
  assert "em dash" in denied_text
490
496
 
491
497
 
498
+ WIDGET_TOOL_NAME = "mcp__hearthbot__post_widget"
499
+ TWO_SENTENCE_REPLY = "The deploy stopped on a missing file. The page shows each step."
500
+
501
+
502
+ def write_transcript(transcript_path: Path, all_entries: list[dict[str, object]]) -> Path:
503
+ transcript_path.write_text(
504
+ "\n".join(json.dumps(each_entry) for each_entry in all_entries), encoding="utf-8"
505
+ )
506
+ return transcript_path
507
+
508
+
509
+ def prompt_entry(text: str) -> dict[str, object]:
510
+ return {"type": "user", "message": {"role": "user", "content": text}}
511
+
512
+
513
+ def tool_call_entry(tool_name: str) -> dict[str, object]:
514
+ return {
515
+ "type": "assistant",
516
+ "message": {"content": [{"type": "tool_use", "id": "t1", "name": tool_name, "input": {}}]},
517
+ }
518
+
519
+
520
+ def turn_mode_off(tmp_path: Path) -> None:
521
+ switch_path = tmp_path / ".claude" / "visual-reply-mode.json"
522
+ switch_path.parent.mkdir(exist_ok=True)
523
+ switch_path.write_text('{"enabled": false}', encoding="utf-8")
524
+
525
+
526
+ def test_should_deny_an_abbreviation_in_a_reply(
527
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
528
+ ) -> None:
529
+ exit_code, stderr_text = run_gate(
530
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The API deploy is fixed."}
531
+ )
532
+ assert exit_code == 2
533
+ assert 'Abbreviation "API"' in stderr_text
534
+
535
+
536
+ def test_should_allow_an_abbreviation_inside_a_pull_request_link(
537
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
538
+ ) -> None:
539
+ exit_code, _ = run_gate(
540
+ monkeypatch,
541
+ capsys,
542
+ REPLY_TOOL_NAME,
543
+ {"text": "It merged in [PR 5256](https://github.com/owner/repo/pull/5256)."},
544
+ )
545
+ assert exit_code == 0
546
+
547
+
548
+ def test_should_deny_a_tracker_item_number(
549
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
550
+ ) -> None:
551
+ exit_code, stderr_text = run_gate(
552
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Item 6 is ready for you."}
553
+ )
554
+ assert exit_code == 2
555
+ assert 'Tracker number "Item 6"' in stderr_text
556
+
557
+
558
+ def test_should_deny_an_abbreviation_in_a_decision_card(
559
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
560
+ ) -> None:
561
+ exit_code, stderr_text = run_gate(
562
+ monkeypatch,
563
+ capsys,
564
+ DECISION_TOOL_NAME,
565
+ {"question": "Ship the API fix?", "options": [{"label": "Ship it", "consequence": "It merges."}]},
566
+ )
567
+ assert exit_code == 2
568
+ assert 'Abbreviation "API"' in stderr_text
569
+
570
+
571
+ def test_should_deny_two_sentences_with_no_visual_this_turn(
572
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
573
+ ) -> None:
574
+ transcript_path = write_transcript(
575
+ tmp_path / "t.jsonl",
576
+ [prompt_entry("why did the deploy stop"), tool_call_entry("Bash")],
577
+ )
578
+ exit_code, stderr_text = run_gate(
579
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
580
+ )
581
+ assert exit_code == 2
582
+ assert "no visual this turn" in stderr_text
583
+
584
+
585
+ def test_should_allow_two_sentences_after_a_widget_this_turn(
586
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
587
+ ) -> None:
588
+ transcript_path = write_transcript(
589
+ tmp_path / "t.jsonl",
590
+ [prompt_entry("why did the deploy stop"), tool_call_entry(WIDGET_TOOL_NAME)],
591
+ )
592
+ exit_code, _ = run_gate(
593
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
594
+ )
595
+ assert exit_code == 0
596
+
597
+
598
+ def test_should_deny_when_the_only_widget_came_before_the_last_prompt(
599
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
600
+ ) -> None:
601
+ transcript_path = write_transcript(
602
+ tmp_path / "t.jsonl",
603
+ [
604
+ prompt_entry("why did the deploy stop"),
605
+ tool_call_entry(WIDGET_TOOL_NAME),
606
+ prompt_entry("and the logs"),
607
+ ],
608
+ )
609
+ exit_code, _ = run_gate(
610
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
611
+ )
612
+ assert exit_code == 2
613
+
614
+
615
+ def test_should_allow_one_sentence_with_no_visual(
616
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
617
+ ) -> None:
618
+ transcript_path = write_transcript(tmp_path / "t.jsonl", [prompt_entry("merge it")])
619
+ exit_code, _ = run_gate(
620
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It merged."}, transcript_path
621
+ )
622
+ assert exit_code == 0
623
+
624
+
625
+ def test_should_deny_an_anchor_link_inside_widget_code(
626
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
627
+ ) -> None:
628
+ exit_code, stderr_text = run_gate(
629
+ monkeypatch,
630
+ capsys,
631
+ WIDGET_TOOL_NAME,
632
+ {"widget_code": '<div><a href="https://claude.ai/artifact/x">Review page</a></div>'},
633
+ )
634
+ assert exit_code == 2
635
+ assert "does not open in the Claude app" in stderr_text
636
+
637
+
638
+ def test_should_allow_widget_code_with_no_anchor(
639
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
640
+ ) -> None:
641
+ exit_code, _ = run_gate(
642
+ monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": "<div>Three steps</div>"}
643
+ )
644
+ assert exit_code == 0
645
+
646
+
647
+ def test_should_skip_the_mode_checks_when_the_switch_turns_the_mode_off(
648
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
649
+ ) -> None:
650
+ turn_mode_off(tmp_path)
651
+ transcript_path = write_transcript(tmp_path / "t.jsonl", [prompt_entry("status")])
652
+ all_exit_codes = [
653
+ run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The API deploy is fixed."})[0],
654
+ run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Which build ships first?"})[0],
655
+ run_gate(
656
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
657
+ )[0],
658
+ run_gate(monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": '<a href="x">go</a>'})[0],
659
+ ]
660
+ assert all_exit_codes == [0, 0, 0, 0]
661
+
662
+
663
+ def test_should_keep_the_length_check_when_the_mode_is_off(
664
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
665
+ ) -> None:
666
+ turn_mode_off(tmp_path)
667
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "One. Two. Three. Four."})
668
+ assert exit_code == 2
669
+
670
+
492
671
  def test_should_deny_the_quoted_cause_with_no_source(
493
672
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
494
673
  ) -> None:
@@ -741,7 +741,7 @@ def test_additional_context_contains_examples():
741
741
  ctx = output["hookSpecificOutput"].get("additionalContext", "")
742
742
  assert "BAD:" in ctx
743
743
  assert "GOOD:" in ctx
744
- assert "~/.claude/rules/asd-ste100-language.md" in ctx
744
+ assert "~/.claude/docs/rule-guides/asd-ste100-language.md" in ctx
745
745
 
746
746
 
747
747
  def test_handles_non_dict_stdin():
@@ -4,7 +4,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
4
4
 
5
5
  ## Checks
6
6
 
7
- - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link.
7
+ - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
8
8
  - `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
9
9
  - `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
10
10
  - `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
@@ -12,12 +12,11 @@ This family stops a chat reply, edited message, or tool call when its payload br
12
12
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
13
13
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
14
14
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
15
- - `blocking/artifact_look_gate.py` denies an Artifact publish of an HTML page until an image was opened with Read after the page file was last written, so the agent looks at its page before it ships.
16
15
  - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
17
16
 
18
17
  ## When it fires
19
18
 
20
- - `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message`, timeout `10` seconds in `hooks.json`.
19
+ - `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
21
20
  - `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
22
21
  - `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
23
22
  - `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
@@ -25,7 +24,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
25
24
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
26
25
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
27
26
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
28
- - `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
29
27
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
30
28
 
31
29
  ## Proving it
@@ -34,7 +32,7 @@ Preconditions:
34
32
 
35
33
  - Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
36
34
 
37
- - **Reply length.** Input is a reply with four sentences or a plain pull request number. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count or link reason.
35
+ - **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason.
38
36
  - **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
39
37
  - **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
40
38
  - **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
@@ -52,5 +50,4 @@ Preconditions:
52
50
  - The edit marker gate scans replacement cards as well as the message text.
53
51
  - The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
54
52
  - The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
55
- - **Looked-at page.** Input is a transcript that writes a page and publishes it with no screenshot read after the write. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_look_gate.py -q`. The adjacent test observes a denial that names the screenshot command; a Read of a PNG after the last write passes.
56
53
  - The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
@@ -1,6 +1,6 @@
1
1
  # Spawn routing
2
2
 
3
- This family reminds the agent to gather context before a spawn, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
3
+ This family reminds the agent to gather context before a spawn, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, selects an allowed subagent model, and sends a Haiku spawn to a headless slim-profile run.
4
4
 
5
5
  ## Checks
6
6
 
@@ -8,6 +8,7 @@ This family reminds the agent to gather context before a spawn, tells it that it
8
8
  - `routing/spawn_oversight_hook.py` adds the orchestrator oversight directive to every spawn: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. The spawn still runs.
9
9
  - `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
10
10
  - `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
11
+ - `blocking/haiku_spawn_slim_gate.py` denies an Agent, Task, or thread spawn whose `model` names Haiku. The deny reason gives the headless `claude -p` command that loads the slim profile through the account broker.
11
12
 
12
13
  ## When it fires
13
14
 
@@ -15,6 +16,7 @@ This family reminds the agent to gather context before a spawn, tells it that it
15
16
  - `routing/spawn_oversight_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, `mcp__hearthbot__start_thread_session`, and `mcp__hearthbot__start_rc_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
16
17
  - `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
17
18
  - `routing/subagent_model_routing.mjs` runs on `PreToolUse`, matcher `multi_agent_v1__spawn_agent`, timeout `10` seconds through the quoted Node command in `hooks.json`.
19
+ - `blocking/haiku_spawn_slim_gate.py` runs on `PreToolUse` with matchers `Agent|Task` and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`.
18
20
 
19
21
  ## Proving it
20
22
 
@@ -25,6 +27,7 @@ Preconditions:
25
27
  - **Spawn readiness.** Input is a spawn after a read, a question, and its answer. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_readiness_hook.py -q`. The adjacent test observes no output for a ready spawn and reminder context for each missing step.
26
28
  - **Spawn oversight.** Input is a spawn from each registered tool, a subagent's own spawn, and a non-spawn call. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_oversight_hook.py -q`. The adjacent test observes the directive for every spawn and no output for the other calls.
27
29
  - **Thread pace.** Input is a thread spawn with usage over pace. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_thread_spawn_pace_hook.py -q`. The adjacent test observes updated tool input with the selected model, effort, and advisor line.
30
+ - **Haiku slim gate.** Input is a spawn with a Haiku alias or model ID, and spawns on other models or with no model. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_haiku_spawn_slim_gate.py -q`. The adjacent test observes a deny with the headless command for each Haiku spawn and no output for the rest.
28
31
  - **Model routing.** Input is a `multi_agent_v1__spawn_agent` payload with a model choice. Run `node --test packages/claude-dev-env/hooks/routing/subagent_model_routing.test.mjs`. The adjacent test observes an allow or deny decision with the routed model.
29
32
 
30
33
  ## Gotchas
@@ -32,4 +35,5 @@ Preconditions:
32
35
  - `routing/spawn_readiness_hook.py` also runs after the pace hook on thread spawns. A reshaped request still gets the readiness check.
33
36
  - The oversight hook fires inside subagents too, so each level of the tree oversees the level below it. It emits `additionalContext` only and never blocks.
34
37
  - The readiness hook emits `additionalContext` only, so it never blocks. It passes subagent calls, read-only helper types, a non-object tool input, and unreadable transcripts.
38
+ - The Haiku slim gate reads only the spawn's `model` field. A spawn that names no model passes, even when its agent definition or `CLAUDE_CODE_SUBAGENT_MODEL` resolves it to Haiku.
35
39
  - The model router allows an advisor flag to bypass the Luna restriction. Its ordinary path denies unresolved or unsupported models.
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import sys
17
+ from collections.abc import Sequence
17
18
  from pathlib import Path
18
19
  from typing import NamedTuple
19
20
 
@@ -74,6 +75,52 @@ class FollowupFinding(NamedTuple):
74
75
  return (self.check_id or self.rule_id, self.file_path, self.message)
75
76
 
76
77
 
78
+ def deduplicated_lines(all_lines: Sequence[str]) -> list[str]:
79
+ """Return the lines with every exact repeat removed.
80
+
81
+ The first occurrence of each line keeps its place, so the ledger keeps its
82
+ record order after a ``merge=union`` merge writes both sides' lines.
83
+
84
+ Args:
85
+ all_lines: The ledger lines, in file order.
86
+
87
+ Returns:
88
+ The lines in file order, each exact line once.
89
+ """
90
+ return list(dict.fromkeys(all_lines))
91
+
92
+
93
+ def deduplicate_ledger(repository_root: Path) -> int:
94
+ """Rewrite the repository's ledger without exact repeated lines.
95
+
96
+ An absent or unreadable ledger, and a ledger that cannot be rewritten, are
97
+ left as they are, so a ledger failure never becomes a gate failure.
98
+
99
+ Args:
100
+ repository_root: The repository whose ledger to clean.
101
+
102
+ Returns:
103
+ How many lines the rewrite removed.
104
+ """
105
+ ledger_path = followup_ledger_path(repository_root)
106
+ try:
107
+ all_ledger_lines = ledger_path.read_text(encoding=LEDGER_ENCODING).splitlines()
108
+ except (OSError, UnicodeError):
109
+ return 0
110
+ all_unique_lines = deduplicated_lines(all_ledger_lines)
111
+ removed_line_count = len(all_ledger_lines) - len(all_unique_lines)
112
+ if not removed_line_count:
113
+ return 0
114
+ try:
115
+ ledger_path.write_text(
116
+ "".join(each_line + "\n" for each_line in all_unique_lines),
117
+ encoding=LEDGER_ENCODING,
118
+ )
119
+ except OSError:
120
+ return 0
121
+ return removed_line_count
122
+
123
+
77
124
  def followup_ledger_path(repository_root: Path) -> Path:
78
125
  """Return the ledger path for one repository.
79
126
 
@@ -89,8 +136,10 @@ def followup_ledger_path(repository_root: Path) -> Path:
89
136
  def record_followup_finding(repository_root: Path, finding: FollowupFinding) -> None:
90
137
  """Append one finding to the repository's ledger, once.
91
138
 
92
- A finding already present in the ledger is left alone, so a gate that runs
93
- on every commit records a standing smell a single time. The ledger
139
+ The ledger first loses every exact repeated line, so lines a
140
+ ``merge=union`` merge wrote twice leave on the next write. A finding
141
+ already present in the ledger is left alone, so a gate that runs on every
142
+ commit records a standing smell a single time. The ledger
94
143
  directory carries a ``.gitignore`` matching every file in it, so the
95
144
  ledger stays out of ``git status`` in any repository. Every filesystem
96
145
  error is swallowed, so a ledger failure leaves the caller's gate decision
@@ -100,6 +149,7 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
100
149
  repository_root: The repository whose ledger receives the finding.
101
150
  finding: The non-breaking finding to record.
102
151
  """
152
+ deduplicate_ledger(repository_root)
103
153
  all_recorded_keys = {
104
154
  each_finding.tracking_key()
105
155
  for each_finding in all_recorded_findings(repository_root)
package/hooks/hooks.json CHANGED
@@ -10,6 +10,11 @@
10
10
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
11
11
  "timeout": 10
12
12
  },
13
+ {
14
+ "type": "command",
15
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/haiku_spawn_slim_gate.py",
16
+ "timeout": 10
17
+ },
13
18
  {
14
19
  "type": "command",
15
20
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
@@ -118,7 +123,7 @@
118
123
  ]
119
124
  },
120
125
  {
121
- "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision",
126
+ "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget",
122
127
  "hooks": [
123
128
  {
124
129
  "type": "command",
@@ -174,17 +179,17 @@
174
179
  "type": "command",
175
180
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/artifact_template_pointer.py",
176
181
  "timeout": 10
177
- },
178
- {
179
- "type": "command",
180
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_look_gate.py",
181
- "timeout": 10
182
182
  }
183
183
  ]
184
184
  },
185
185
  {
186
186
  "matcher": "mcp__hearthbot__start_thread_session",
187
187
  "hooks": [
188
+ {
189
+ "type": "command",
190
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/haiku_spawn_slim_gate.py",
191
+ "timeout": 10
192
+ },
188
193
  {
189
194
  "type": "command",
190
195
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/thread_spawn_pace_hook.py",
@@ -42,7 +42,7 @@ ALL_AGENT_HOME_TOOLING_PATTERNS = {"/.grok/"}
42
42
  ALL_HOOK_INFRASTRUCTURE_PATTERNS = {
43
43
  "/.claude/hooks/",
44
44
  "/packages/claude-dev-env/hooks/",
45
- } | ALL_AGENT_HOME_TOOLING_PATTERNS
45
+ }
46
46
  ALL_WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "_tab.py", "/states.py", "/modules.py"}
47
47
  ALL_MIGRATION_PATH_PATTERNS = {"/migrations/"}
48
48
 
@@ -0,0 +1,29 @@
1
+ """Constants for the Haiku spawn slim-profile PreToolUse gate.
2
+
3
+ Groups: the model family the gate denies, the hook event name, the hook input and output keys, and
4
+ the deny reason that gives the headless slim-profile command.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ HAIKU_MODEL_FAMILY = "haiku"
10
+ HOOK_EVENT_NAME = "PreToolUse"
11
+ TOOL_INPUT_KEY = "tool_input"
12
+ MODEL_INPUT_KEY = "model"
13
+ DENY_DECISION = "deny"
14
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
15
+ SLIM_PROFILE_DENY_REASON = (
16
+ "BLOCKED: A Haiku agent started from this session runs with this session's settings, plugins, "
17
+ "MCP servers, and CLAUDE.md files. An Agent, Task, or thread spawn has no field for setting sources, "
18
+ "a plugin directory, or an MCP config, so it cannot load the slim profile. "
19
+ "Start Haiku as a headless run with the slim profile. Set CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 and "
20
+ "CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 in the environment, then run this from Bash or another POSIX shell, "
21
+ "because PowerShell strips the double quotes inside the single-quoted JSON: "
22
+ 'python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <report.json> -- '
23
+ "claude -p --model haiku --setting-sources local "
24
+ '--settings \'{"enabledPlugins": {}, "autoMemoryEnabled": false, "autoCompactWindow": 100000}\' '
25
+ "--strict-mcp-config --mcp-config '{\"mcpServers\": {}}' --autocompact 100k. "
26
+ "Send the task on standard input. In a cloud session, run the same claude -p command without the broker. "
27
+ "Where the efficient-review skill is installed, /efficient-review headless or /efficient-review auto builds "
28
+ "this profile. To keep the agent in this session, spawn it on opus or sonnet."
29
+ )
@@ -13,6 +13,7 @@ ALL_DECISION_OPTION_PROSE_KEYS = ("label", "consequence")
13
13
  TOOL_NAME_KEY = "tool_name"
14
14
  TOOL_INPUT_KEY = "tool_input"
15
15
  TEXT_KEY = "text"
16
+ TRANSCRIPT_PATH_KEY = "transcript_path"
16
17
  HOOK_EVENT_NAME = "PreToolUse"
17
18
  ALLOW_EXIT_CODE = 0
18
19
  BLOCK_EXIT_CODE = 2
@@ -11,8 +11,6 @@ SECONDS_PER_DAY = 24 * 60 * 60
11
11
  STALE_AGE_DAYS = 7
12
12
  STALE_AGE_SECONDS = STALE_AGE_DAYS * SECONDS_PER_DAY
13
13
 
14
- ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS: tuple[int, int] = (3, 12)
15
-
16
14
  SESSION_ID_PAYLOAD_KEY: str = "session_id"
17
15
 
18
16
  SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
@@ -0,0 +1,15 @@
1
+ from hooks_constants import visual_reply_rules_constants as constants
2
+
3
+
4
+ def test_rules_file_path_points_at_the_shipped_rule_list() -> None:
5
+ assert constants.RULES_FILE_PATH.is_file()
6
+
7
+
8
+ def test_capitalized_abbreviation_pattern_needs_two_capitals() -> None:
9
+ assert constants.CAPITALIZED_ABBREVIATION_PATTERN.findall("CI and PRs, not Item or I") == ["CI", "PRs"]
10
+
11
+
12
+ def test_tracker_number_pattern_reads_items_issues_and_cards() -> None:
13
+ assert [
14
+ each.group(0) for each in constants.TRACKER_NUMBER_PATTERN.finditer("issue 5735, item #6, card 2")
15
+ ] == ["issue 5735", "item #6", "card 2"]