claude-dev-env 8.73.1 → 8.73.2

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 (96) 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/install.mjs +1 -0
  10. package/bin/install.policy-lint.test.mjs +1 -0
  11. package/bin/install.profile-root.test.mjs +1 -1
  12. package/bin/install.test.mjs +1 -0
  13. package/commands/fix-followups.md +1 -1
  14. package/commands/sr-loop.md +1 -1
  15. package/docs/references/prose-style-enforcement.md +1 -1
  16. package/docs/references/working-style.md +1 -1
  17. package/docs/rule-guides/asd-ste100-language.md +9 -1
  18. package/docs/rule-guides/cleanup-temp-files.md +8 -2
  19. package/docs/rule-guides/destructive-commands.md +13 -3
  20. package/docs/rule-guides/explore-thoroughly.md +9 -1
  21. package/docs/rule-guides/features-start-with-an-eval.md +8 -2
  22. package/docs/rule-guides/filesystem-search.md +7 -1
  23. package/docs/rule-guides/long-horizon-autonomy.md +1 -1
  24. package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
  25. package/docs/rule-guides/no-contrast-framing.md +10 -4
  26. package/docs/rule-guides/prefer-existing-tools.md +2 -2
  27. package/docs/rule-guides/proof-before-pull-request.md +5 -1
  28. package/docs/rule-guides/request-scope.md +5 -1
  29. package/docs/rule-guides/research-mode.md +9 -1
  30. package/docs/rule-guides/session-title.md +7 -1
  31. package/docs/rule-guides/shell-invocation.md +9 -1
  32. package/docs/rule-guides/verify-before-asking.md +7 -1
  33. package/docs/rule-guides/verify-runtime-state.md +7 -1
  34. package/hooks/blocking/code_rules_enforcer.py +16 -6
  35. package/hooks/blocking/code_rules_shared.py +23 -4
  36. package/hooks/blocking/reply_length_gate.py +74 -14
  37. package/hooks/blocking/state_description_blocker.py +1 -1
  38. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
  39. package/hooks/blocking/test_code_rules_shared.py +33 -0
  40. package/hooks/blocking/test_reply_length_gate.py +184 -5
  41. package/hooks/blocking/test_state_description_blocker.py +1 -1
  42. package/hooks/features/conduct-gates.md +3 -6
  43. package/hooks/hooks.json +1 -6
  44. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
  46. package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
  47. package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
  48. package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
  49. package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
  50. package/hooks/hooks_constants/windows_filesystem.py +51 -0
  51. package/hooks/session/session_env_cleanup.py +4 -30
  52. package/hooks/test_visual_reply_rules.py +164 -0
  53. package/hooks/visual_reply_rules.py +268 -0
  54. package/hooks/workflow/test_auto_formatter.py +4 -25
  55. package/package.json +1 -1
  56. package/rules/index.md +30 -0
  57. package/rules/visual-reply-rules.json +41 -0
  58. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  59. package/scripts/conftest.py +13 -5
  60. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
  62. package/scripts/policy_lint/adapter_configuration.py +0 -5
  63. package/scripts/policy_lint/config/constants.py +0 -1
  64. package/scripts/test_active_capability_references.py +0 -5
  65. package/scripts/test_broker_state_isolation.py +3 -3
  66. package/scripts/test_digest_check.py +0 -5
  67. package/scripts/test_durable_post_lint.py +1 -5
  68. package/scripts/test_followup_cli.py +0 -6
  69. package/scripts/test_resolve_worker_spawn.py +5 -10
  70. package/scripts/test_setup_project_paths.py +0 -12
  71. package/scripts/test_shared_tree_paths.py +2 -6
  72. package/scripts/test_validate_instruction_pairs.py +0 -7
  73. package/scripts/test_verify_installable_package.py +0 -5
  74. package/scripts/tests/test_contrast_framing.py +3 -3
  75. package/scripts/tests/test_rule_index_shape.py +59 -5
  76. package/scripts/tests/test_rule_load_scopes.py +1 -16
  77. package/system-prompts/software-engineer.xml +4 -4
  78. package/hooks/blocking/artifact_look_gate.py +0 -223
  79. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  80. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  81. package/rules/asd-ste100-language.md +0 -9
  82. package/rules/cleanup-temp-files.md +0 -9
  83. package/rules/destructive-commands.md +0 -11
  84. package/rules/explore-thoroughly.md +0 -9
  85. package/rules/features-start-with-an-eval.md +0 -9
  86. package/rules/filesystem-search.md +0 -9
  87. package/rules/memory-stores-durable-facts.md +0 -9
  88. package/rules/no-contrast-framing.md +0 -9
  89. package/rules/proof-before-pull-request.md +0 -7
  90. package/rules/request-scope.md +0 -7
  91. package/rules/research-mode.md +0 -9
  92. package/rules/session-title.md +0 -9
  93. package/rules/shell-invocation.md +0 -9
  94. package/rules/skill-pointers.md +0 -3
  95. package/rules/verify-before-asking.md +0 -9
  96. package/rules/verify-runtime-state.md +0 -9
@@ -0,0 +1,268 @@
1
+ """The visual reply mode rule list and the checks the reply length gate runs from it.
2
+
3
+ One JSON file, ``rules/visual-reply-rules.json``, holds the rules. The gate,
4
+ the reminder and the evaluation read the same file, so all three name each
5
+ rule with the same label and the same reminder text.
6
+
7
+ The mode is off unless ``~/.claude/visual-reply-mode.json`` holds
8
+ ``{"enabled": true}``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from collections.abc import Iterable
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from json_file_reader import read_json_object
20
+ from hooks_constants.visual_reply_rules_constants import (
21
+ ABBREVIATION_MESSAGE,
22
+ ABBREVIATIONS_RULE_KEY,
23
+ ALL_REPLY_TOOL_NAME_SUFFIXES,
24
+ ALL_VISUAL_TOOL_NAME_SUFFIXES,
25
+ ALLOWED_CAPITALIZED_WORDS_KEY,
26
+ ASSISTANT_ENTRY_TYPE,
27
+ BLOCK_ID_KEY,
28
+ BLOCK_NAME_KEY,
29
+ BLOCK_TYPE_KEY,
30
+ CAPITALIZED_ABBREVIATION_PATTERN,
31
+ CONTENT_KEY,
32
+ ENTRY_TYPE_KEY,
33
+ IS_ERROR_KEY,
34
+ IS_META_KEY,
35
+ LOWERCASE_ABBREVIATION_TEMPLATE,
36
+ LOWERCASE_ABBREVIATIONS_KEY,
37
+ MAXIMUM_REPLIES_PER_TURN,
38
+ MAXIMUM_SENTENCES_WITHOUT_VISUAL,
39
+ MESSAGE_KEY,
40
+ MODE_SWITCH_ENABLED_KEY,
41
+ MODE_SWITCH_FILE_NAME,
42
+ NO_PICTURE_RULE_KEY,
43
+ NO_VISUAL_MESSAGE,
44
+ ONE_ITEM_RULE_KEY,
45
+ RULE_KEY_FIELD,
46
+ RULE_LABEL_FIELD,
47
+ RULE_REMINDER_FIELD,
48
+ RULES_FILE_ENCODING,
49
+ RULES_FILE_PATH,
50
+ RULES_LIST_KEY,
51
+ SECOND_REPLY_MESSAGE,
52
+ TEXT_BLOCK_TYPE,
53
+ TOOL_RESULT_BLOCK_TYPE,
54
+ TOOL_USE_BLOCK_TYPE,
55
+ TOOL_USE_ID_KEY,
56
+ TRACKER_NUMBER_MESSAGE,
57
+ TRACKER_NUMBER_PATTERN,
58
+ TRANSCRIPT_ENCODING,
59
+ USER_ENTRY_TYPE,
60
+ WIDGET_ANCHOR_MESSAGE,
61
+ WIDGET_ANCHOR_PATTERN,
62
+ WIDGET_TOOL_NAME_SUFFIX,
63
+ )
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ReplyRule:
68
+ """One rule of the mode: its key, the label a flag shows, and the reminder text."""
69
+
70
+ key: str
71
+ label: str
72
+ reminder: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class VisualReplyRules:
77
+ """The parsed rule file."""
78
+
79
+ all_rules: tuple[ReplyRule, ...]
80
+ all_allowed_capitalized_words: frozenset[str]
81
+ all_lowercase_abbreviations: tuple[str, ...]
82
+
83
+ def reminder(self, rule_key: str) -> str:
84
+ """Return the reminder text for one rule key."""
85
+ return next(each.reminder for each in self.all_rules if each.key == rule_key)
86
+
87
+
88
+ def _string_list(field_value: object) -> list[object]:
89
+ return field_value if isinstance(field_value, list) else []
90
+
91
+
92
+ def load_rules(rules_path: Path = RULES_FILE_PATH) -> VisualReplyRules | None:
93
+ """Parse the rule file, or return None when it is missing or malformed."""
94
+ document = read_json_object(rules_path, RULES_FILE_ENCODING)
95
+ if document is None:
96
+ return None
97
+ all_rule_entries = document.get(RULES_LIST_KEY)
98
+ if not isinstance(all_rule_entries, list):
99
+ return None
100
+ all_rules = tuple(
101
+ ReplyRule(
102
+ key=str(each[RULE_KEY_FIELD]),
103
+ label=str(each[RULE_LABEL_FIELD]),
104
+ reminder=str(each[RULE_REMINDER_FIELD]),
105
+ )
106
+ for each in all_rule_entries
107
+ if isinstance(each, dict)
108
+ and all(field in each for field in (RULE_KEY_FIELD, RULE_LABEL_FIELD, RULE_REMINDER_FIELD))
109
+ )
110
+ all_needed_keys = {ONE_ITEM_RULE_KEY, NO_PICTURE_RULE_KEY, ABBREVIATIONS_RULE_KEY}
111
+ if not all_needed_keys <= {each.key for each in all_rules}:
112
+ return None
113
+ return VisualReplyRules(
114
+ all_rules=all_rules,
115
+ all_allowed_capitalized_words=frozenset(
116
+ str(each) for each in _string_list(document.get(ALLOWED_CAPITALIZED_WORDS_KEY))
117
+ ),
118
+ all_lowercase_abbreviations=tuple(
119
+ str(each) for each in _string_list(document.get(LOWERCASE_ABBREVIATIONS_KEY))
120
+ ),
121
+ )
122
+
123
+
124
+ def mode_enabled(claude_home: Path) -> bool:
125
+ """Return True only when the switch file turns the mode on."""
126
+ switch_document = read_json_object(claude_home / MODE_SWITCH_FILE_NAME, RULES_FILE_ENCODING)
127
+ if switch_document is None:
128
+ return False
129
+ return switch_document.get(MODE_SWITCH_ENABLED_KEY) is True
130
+
131
+
132
+ def abbreviation_violation(prose_text: str, rules: VisualReplyRules) -> str | None:
133
+ """Return the deny reason for the first abbreviation in prose with links and code removed."""
134
+ reminder = rules.reminder(ABBREVIATIONS_RULE_KEY)
135
+ for each_match in CAPITALIZED_ABBREVIATION_PATTERN.finditer(prose_text):
136
+ if each_match.group(0) not in rules.all_allowed_capitalized_words:
137
+ return ABBREVIATION_MESSAGE.format(abbreviation=each_match.group(0), reminder=reminder)
138
+ for each_abbreviation in rules.all_lowercase_abbreviations:
139
+ pattern = LOWERCASE_ABBREVIATION_TEMPLATE.format(abbreviation=re.escape(each_abbreviation))
140
+ if re.search(pattern, prose_text, re.IGNORECASE):
141
+ return ABBREVIATION_MESSAGE.format(abbreviation=each_abbreviation, reminder=reminder)
142
+ tracker_match = TRACKER_NUMBER_PATTERN.search(prose_text)
143
+ if tracker_match is not None:
144
+ return TRACKER_NUMBER_MESSAGE.format(reference=tracker_match.group(0), reminder=reminder)
145
+ return None
146
+
147
+
148
+ def widget_anchor_violation(tool_name: str, all_tool_input: dict[str, object]) -> str | None:
149
+ """Return the deny reason for an anchor link inside widget code."""
150
+ if not tool_name.endswith(WIDGET_TOOL_NAME_SUFFIX):
151
+ return None
152
+ serialized_input = json.dumps(all_tool_input)
153
+ return WIDGET_ANCHOR_MESSAGE if WIDGET_ANCHOR_PATTERN.search(serialized_input) else None
154
+
155
+
156
+ def _parsed_entries(all_transcript_lines: Iterable[str]) -> list[dict[str, object]]:
157
+ all_entries = []
158
+ for each_line in all_transcript_lines:
159
+ try:
160
+ parsed_entry = json.loads(each_line)
161
+ except json.JSONDecodeError:
162
+ continue
163
+ if isinstance(parsed_entry, dict):
164
+ all_entries.append(parsed_entry)
165
+ return all_entries
166
+
167
+
168
+ def _is_prompt_entry(all_entry_fields: dict[str, object]) -> bool:
169
+ if all_entry_fields.get(ENTRY_TYPE_KEY) != USER_ENTRY_TYPE or all_entry_fields.get(IS_META_KEY):
170
+ return False
171
+ message = all_entry_fields.get(MESSAGE_KEY)
172
+ content = message.get(CONTENT_KEY) if isinstance(message, dict) else None
173
+ if isinstance(content, str):
174
+ return bool(content.strip())
175
+ return isinstance(content, list) and any(
176
+ isinstance(each_block, dict) and each_block.get(BLOCK_TYPE_KEY) == TEXT_BLOCK_TYPE
177
+ for each_block in content
178
+ )
179
+
180
+
181
+ def _content_blocks(all_entry_fields: dict[str, object], entry_type: str) -> list[dict[str, object]]:
182
+ if all_entry_fields.get(ENTRY_TYPE_KEY) != entry_type:
183
+ return []
184
+ message = all_entry_fields.get(MESSAGE_KEY)
185
+ content = message.get(CONTENT_KEY) if isinstance(message, dict) else None
186
+ return [each for each in (content if isinstance(content, list) else []) if isinstance(each, dict)]
187
+
188
+
189
+ def _tool_uses(all_entry_fields: dict[str, object]) -> list[dict[str, object]]:
190
+ return [
191
+ each_block
192
+ for each_block in _content_blocks(all_entry_fields, ASSISTANT_ENTRY_TYPE)
193
+ if each_block.get(BLOCK_TYPE_KEY) == TOOL_USE_BLOCK_TYPE
194
+ ]
195
+
196
+
197
+ def _entries_this_turn(all_transcript_lines: Iterable[str]) -> list[dict[str, object]]:
198
+ all_entries = _parsed_entries(all_transcript_lines)
199
+ last_prompt_index = max(
200
+ (index for index, each in enumerate(all_entries) if _is_prompt_entry(each)), default=-1
201
+ )
202
+ return all_entries[last_prompt_index + 1 :]
203
+
204
+
205
+ def _read_transcript_lines(transcript_path: object) -> list[str] | None:
206
+ if not isinstance(transcript_path, str):
207
+ return None
208
+ try:
209
+ return Path(transcript_path).read_text(encoding=TRANSCRIPT_ENCODING).splitlines()
210
+ except OSError:
211
+ return None
212
+
213
+
214
+ def _result_ids(all_entries: list[dict[str, object]], is_error: bool) -> set[object]:
215
+ return {
216
+ each_block.get(TOOL_USE_ID_KEY)
217
+ for each_entry in all_entries
218
+ for each_block in _content_blocks(each_entry, USER_ENTRY_TYPE)
219
+ if each_block.get(BLOCK_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE
220
+ and bool(each_block.get(IS_ERROR_KEY)) is is_error
221
+ }
222
+
223
+
224
+ def visual_shown_this_turn(all_transcript_lines: Iterable[str]) -> bool:
225
+ """Return True when a widget or page call after the last prompt did not come back with an error."""
226
+ all_entries = _entries_this_turn(all_transcript_lines)
227
+ all_errored_ids = _result_ids(all_entries, is_error=True)
228
+ return any(
229
+ str(each_use.get(BLOCK_NAME_KEY)).endswith(ALL_VISUAL_TOOL_NAME_SUFFIXES)
230
+ and each_use.get(BLOCK_ID_KEY) not in all_errored_ids
231
+ for each_entry in all_entries
232
+ for each_use in _tool_uses(each_entry)
233
+ )
234
+
235
+
236
+ def replies_sent_this_turn(all_transcript_lines: Iterable[str]) -> int:
237
+ """Count the reply calls after the last prompt that came back without an error."""
238
+ all_entries = _entries_this_turn(all_transcript_lines)
239
+ all_delivered_ids = _result_ids(all_entries, is_error=False)
240
+ return sum(
241
+ 1
242
+ for each_entry in all_entries
243
+ for each_use in _tool_uses(each_entry)
244
+ if str(each_use.get(BLOCK_NAME_KEY)).endswith(ALL_REPLY_TOOL_NAME_SUFFIXES)
245
+ and each_use.get(BLOCK_ID_KEY) in all_delivered_ids
246
+ )
247
+
248
+
249
+ def second_reply_violation(transcript_path: object, rules: VisualReplyRules) -> str | None:
250
+ """Return the deny reason for a reply when this turn already delivered one."""
251
+ all_lines = _read_transcript_lines(transcript_path)
252
+ if all_lines is None or replies_sent_this_turn(all_lines) < MAXIMUM_REPLIES_PER_TURN:
253
+ return None
254
+ return SECOND_REPLY_MESSAGE.format(reminder=rules.reminder(ONE_ITEM_RULE_KEY))
255
+
256
+
257
+ def visual_violation(
258
+ sentence_count: int, transcript_path: object, rules: VisualReplyRules
259
+ ) -> str | None:
260
+ """Return the deny reason for a reply of several sentences with no visual this turn."""
261
+ if sentence_count <= MAXIMUM_SENTENCES_WITHOUT_VISUAL:
262
+ return None
263
+ all_lines = _read_transcript_lines(transcript_path)
264
+ if all_lines is None or visual_shown_this_turn(all_lines):
265
+ return None
266
+ return NO_VISUAL_MESSAGE.format(
267
+ sentence_count=sentence_count, reminder=rules.reminder(NO_PICTURE_RULE_KEY)
268
+ )
@@ -9,14 +9,10 @@ The sandbox is rooted under the user's home directory via ``tempfile.mkdtemp``
9
9
  rather than the OS temp directory, matching the sibling workflow-hook tests.
10
10
  """
11
11
 
12
- import contextlib
13
12
  import functools
14
13
  import importlib.util
15
- import inspect
16
14
  import json
17
15
  import os
18
- import shutil
19
- import stat
20
16
  import subprocess
21
17
  import sys
22
18
  import tempfile
@@ -26,6 +22,8 @@ from types import ModuleType
26
22
 
27
23
  import pytest
28
24
 
25
+ from hooks_constants.windows_filesystem import force_rmtree
26
+
29
27
  HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "auto_formatter.py")
30
28
  HOOKS_DIRECTORY_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31
29
  POST_TOOL_USE_DISPATCHER_PATH = os.path.join(
@@ -52,25 +50,6 @@ def build_dispatcher_payload(tool_name: str, file_path: Path) -> str:
52
50
  return json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}})
53
51
 
54
52
 
55
- def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
56
- try:
57
- os.chmod(target_path, stat.S_IWRITE)
58
- removal_function(target_path)
59
- except OSError:
60
- pass
61
-
62
-
63
- _rmtree_supports_onexc = "onexc" in inspect.signature(shutil.rmtree).parameters
64
-
65
-
66
- def _force_rmtree(target_path: str) -> None:
67
- with contextlib.suppress(OSError):
68
- if _rmtree_supports_onexc:
69
- shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
70
- else:
71
- shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)
72
-
73
-
74
53
  @functools.lru_cache(maxsize=1)
75
54
  def _get_sandbox_parent_directory() -> str:
76
55
  return tempfile.mkdtemp(prefix="pytest_auto_formatter_", dir=str(Path.home()))
@@ -80,7 +59,7 @@ def _get_sandbox_parent_directory() -> str:
80
59
  def _cleanup_sandbox_parent_directory() -> Generator[None]:
81
60
  yield
82
61
  if _get_sandbox_parent_directory.cache_info().currsize:
83
- _force_rmtree(_get_sandbox_parent_directory())
62
+ force_rmtree(_get_sandbox_parent_directory())
84
63
  _get_sandbox_parent_directory.cache_clear()
85
64
 
86
65
 
@@ -95,7 +74,7 @@ def git_repository() -> Generator[Path]:
95
74
  env=build_fixture_git_environment(),
96
75
  )
97
76
  yield repository_path
98
- _force_rmtree(str(repository_path))
77
+ force_rmtree(str(repository_path))
99
78
 
100
79
 
101
80
  def _run_hook(tool_name: str, file_path: Path) -> subprocess.CompletedProcess[str]:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.73.1",
3
+ "version": "8.73.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/index.md ADDED
@@ -0,0 +1,30 @@
1
+ # Rules
2
+ Each line gives a rule in brief. When its trigger matches your task, open the guide and follow it in full. Correction lens and question presentation load as their own files.
3
+
4
+ **Writing**
5
+ - **Plain language** ([guide](../docs/rule-guides/asd-ste100-language.md)). Writing chat, narration, or repository prose. Short complete sentences on one topic, active voice, one action per step, each fact said once.
6
+ - **No contrast framing** ([guide](../docs/rule-guides/no-contrast-framing.md)). Any sentence a person reads. State the chosen point and leave out the discarded reading. Remove the six forms: `trailing-comma-not`, `corrective-it-is-not`, `substitution-rather-than`, `additive-not-just`, `comparative-ranking`, `substitution-as-opposed-to`.
7
+
8
+ **Evidence**
9
+ - **Research mode** ([guide](../docs/rule-guides/research-mode.md)). Any claim, recommendation, or advice. Cite a source for each claim, and run the tool that settles a fact before you state it.
10
+ - **Verify before asking** ([guide](../docs/rule-guides/verify-before-asking.md)). Before a clarifying question. Look in files, config, environment, and tools first; ask only for a judgment or a preference.
11
+ - **Verify runtime state** ([guide](../docs/rule-guides/verify-runtime-state.md)). Before you say a component works, is healthy, or is not the cause. Get a live signal this session.
12
+
13
+ **Before building**
14
+ - **Explore thoroughly** ([guide](../docs/rule-guides/explore-thoroughly.md)). Before you log, plan, delegate, or build an ask. Search for existing work; when something exists, stop and report it in four points with the `search-before-acting` skill.
15
+ - **Features start with an eval** ([guide](../docs/rule-guides/features-start-with-an-eval.md)). A new feature or a change in behavior. Your first action is the Skill tool with skill `claude-api` and args `build-eval`.
16
+ - **Request scope** ([guide](../docs/rule-guides/request-scope.md)). Asked whether part of a block is still needed, or asked to change one. Propose removing every line a shipped gate already enforces.
17
+
18
+ **Shell and files**
19
+ - **Shell invocation** ([guide](../docs/rule-guides/shell-invocation.md)). Any Bash command or permission rule. Use `pwsh` on Windows; keep `$(...)`, backticks, and process substitution out of commands.
20
+ - **Destructive commands** ([guide](../docs/rule-guides/destructive-commands.md)). Removing files. Keep `rm` literals out of commands, use absolute targets, use `git rm` for tracked files, and copy the guide's no-rm line into subagent prompts.
21
+ - **Filesystem search** ([guide](../docs/rule-guides/filesystem-search.md)). Finding files by name, path, size, or date. Scope the search to a project, worktree, or filter, never a root.
22
+ - **Clean up temporary files** ([guide](../docs/rule-guides/cleanup-temp-files.md)). Creating scratch files or debug dumps. Track them and remove them when the task ends.
23
+
24
+ **Shipping**
25
+ - **Pull request lifecycle**. Before a commit, push, pull request action, or merge. Invoke the `pr-lifecycle` skill; a hook denies those commands until it loads.
26
+ - **Proof before a pull request** ([guide](../docs/rule-guides/proof-before-pull-request.md)). Before you open a pull request. Run the change with and without it, and quote both under "Proof in practice".
27
+ - **Session title** ([guide](../docs/rule-guides/session-title.md)). The session has a tool whose name ends in `__set_session_title`. Set `<emoji> <name>` before each turn ends.
28
+
29
+ **Memory**
30
+ - **Durable facts only** ([guide](../docs/rule-guides/memory-stores-durable-facts.md)). Writing auto-memory or a `MEMORY.md` entry. Keep only a fact that helps a fresh session weeks later.
@@ -0,0 +1,41 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "key": "one_item",
5
+ "label": "More than one item",
6
+ "reminder": "Show one item per turn: one visual of that item and one short reply beside it. Put progress in the status line, and send the next item after the user answers this one."
7
+ },
8
+ {
9
+ "key": "no_picture",
10
+ "label": "No picture",
11
+ "reminder": "Show the item as one visual first, a widget or a published page. Then send one reply of one to three short sentences that states the outcome and each fact the user needs, and links the page."
12
+ },
13
+ {
14
+ "key": "too_many_words",
15
+ "label": "Too many words",
16
+ "reminder": "Cut the reply to three short sentences or fewer. Keep the outcome and each fact the user needs in the reply; the visual adds detail around them."
17
+ },
18
+ {
19
+ "key": "abbreviations",
20
+ "label": "Abbreviations",
21
+ "reminder": "Write each name in plain descriptive words. Spell out every abbreviation and drop tracker and item numbers. A pull request number stays only inside its link."
22
+ },
23
+ {
24
+ "key": "wall_of_text",
25
+ "label": "Wall of text",
26
+ "reminder": "Put each point, and each step the reader takes, on its own line."
27
+ }
28
+ ],
29
+ "allowed_capitalized_words": [
30
+ "I",
31
+ "OK"
32
+ ],
33
+ "lowercase_abbreviations": [
34
+ "e.g.",
35
+ "i.e.",
36
+ "etc.",
37
+ "vs.",
38
+ "approx.",
39
+ "w/"
40
+ ]
41
+ }
@@ -9,7 +9,7 @@
9
9
  settings.local.json, and settings.local.json.template, and rewrites permission
10
10
  rule strings that invoke powershell / powershell.exe / bash -c / cmd /c
11
11
  into their pwsh equivalents per the migration mapping in
12
- rules/shell-invocation.md. Defaults to dry-run; pass -Apply to write
12
+ docs/rule-guides/shell-invocation.md. Defaults to dry-run; pass -Apply to write
13
13
  changes. Prints exactly one summary line.
14
14
 
15
15
  .PARAMETER Roots
@@ -1,8 +1,10 @@
1
- """Put the scripts directory on ``sys.path`` for every test in this folder.
1
+ """Put the scripts and hooks directories on ``sys.path`` for every test here.
2
2
 
3
3
  The code-review invoker and its constants package live beside these tests
4
4
  rather than on the default pytest path, so each test module can import them
5
- by bare name once this directory is registered.
5
+ by bare name once this directory is registered. The hooks directory and its
6
+ session package are registered too, so a test imports ``followup_ledger``,
7
+ ``hooks_constants``, or ``untracked_repo_detector`` by bare name.
6
8
 
7
9
  ::
8
10
 
@@ -21,9 +23,15 @@ from pathlib import Path
21
23
 
22
24
  import pytest
23
25
 
24
- _scripts_directory = str(Path(__file__).resolve().parent)
25
- if _scripts_directory not in sys.path:
26
- sys.path.insert(0, _scripts_directory)
26
+ _scripts_directory = Path(__file__).resolve().parent
27
+ _hooks_directory = _scripts_directory.parent / "hooks"
28
+ for each_import_directory in (
29
+ _hooks_directory / "session",
30
+ _hooks_directory,
31
+ _scripts_directory,
32
+ ):
33
+ if str(each_import_directory) not in sys.path:
34
+ sys.path.insert(0, str(each_import_directory))
27
35
 
28
36
  _live_broker_state_resolver = importlib.import_module("account_broker_support").broker_state_path
29
37
 
@@ -8,7 +8,7 @@ against a rejected reading::
8
8
  "precision matters more than coverage" -> comparative-ranking
9
9
  ok: "the function runs more than 30 lines"
10
10
 
11
- The rule file ``rules/no-contrast-framing.md`` names each form here in
11
+ The rule guide ``docs/rule-guides/no-contrast-framing.md`` names each form here in
12
12
  backticks, and a test holds the two in step.
13
13
  """
14
14
 
@@ -79,4 +79,4 @@ CONTRAST_FRAMING_QUOTED_LINE_LIMIT: int = 120
79
79
 
80
80
  CONTRAST_FRAMING_FINDING_CODE: str = "contrast-framing"
81
81
 
82
- CONTRAST_FRAMING_RULE_DOCUMENT: str = "rules/no-contrast-framing.md"
82
+ CONTRAST_FRAMING_RULE_DOCUMENT: str = "docs/rule-guides/no-contrast-framing.md"
@@ -2,11 +2,23 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from pathlib import Path
6
+
5
7
  from dev_env_scripts_constants.contrast_framing_constants import (
6
8
  ALL_CONTRAST_FRAMING_FORMS,
7
9
  CONTRAST_FRAMING_MESSAGE_TEMPLATE,
10
+ CONTRAST_FRAMING_RULE_DOCUMENT,
8
11
  )
9
12
 
13
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
14
+
15
+
16
+ def test_the_rule_document_is_a_rule_guide_that_exists() -> None:
17
+ rule_document = PACKAGE_ROOT / CONTRAST_FRAMING_RULE_DOCUMENT
18
+
19
+ assert rule_document.parent.name == "rule-guides"
20
+ assert rule_document.is_file()
21
+
10
22
 
11
23
  def test_every_form_carries_a_distinct_name() -> None:
12
24
  all_names = [each_form.name for each_form in ALL_CONTRAST_FRAMING_FORMS]
@@ -136,7 +136,6 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
137
  hooks/blocking/session_title_stop_gate.py -> exempt
138
138
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
139
- hooks/blocking/artifact_look_gate.py -> exempt
140
139
  hooks/blocking/some_new_blocker.py -> flagged
141
140
 
142
141
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -161,10 +160,6 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
161
160
  The verify-before-acting hook runs after a mutating call has finished. It
162
161
  asks the model to check a hedged claim, and the call it reads stays in place.
163
162
 
164
- The artifact look gate denies an Artifact publish of a page until a
165
- screenshot of it was read. It decides when a page ships and no code or
166
- safety policy.
167
-
168
163
  The session title gates keep the session title in its status format. One
169
164
  denies a malformed title, and the other asks for a title before a turn
170
165
  ends. They decide the shape of a title and no code or safety policy.
@@ -73,7 +73,6 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
73
73
  "blocking/session_title_stop_gate.py",
74
74
  "blocking/image_read_size_gate.py",
75
75
  "blocking/artifact_dark_mode_gate.py",
76
- "blocking/artifact_look_gate.py",
77
76
  )
78
77
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
79
78
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -2,13 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
5
  from pathlib import Path
7
6
 
8
- _SCRIPTS = Path(__file__).resolve().parent
9
- if str(_SCRIPTS) not in sys.path:
10
- sys.path.insert(0, str(_SCRIPTS))
11
-
12
7
  from active_capability_references import (
13
8
  build_capability_inventory,
14
9
  classify_capability_reference,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import subprocess
6
7
  from datetime import datetime, timezone
7
8
  from pathlib import Path
@@ -43,6 +44,5 @@ def test_meter_cache_write_lands_outside_the_home_directory(
43
44
 
44
45
  home_state_path = fake_home / ".claude" / "account-broker" / "state.json"
45
46
  assert not home_state_path.exists()
46
- assert f"claude:only:{account.home}" in isolated_broker_state_path.read_text(
47
- encoding="utf-8"
48
- )
47
+ state = json.loads(isolated_broker_state_path.read_text(encoding="utf-8"))
48
+ assert f"claude:only:{account.home}" in state["meters"]
@@ -2,15 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
5
  from pathlib import Path
7
6
 
8
7
  import pytest
9
8
 
10
- _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
11
- if str(_SCRIPTS_DIRECTORY) not in sys.path:
12
- sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
13
-
14
9
  import digest_check
15
10
  from dev_env_scripts_constants.digest_check_constants import (
16
11
  CARD_WORD_BUDGET,
@@ -9,14 +9,10 @@ from pathlib import Path
9
9
 
10
10
  import pytest
11
11
 
12
- _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
13
- if str(_SCRIPTS_DIRECTORY) not in sys.path:
14
- sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
15
-
16
12
  import durable_post_lint
17
13
  from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
18
14
 
19
- SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
15
+ SCRIPT_PATH = Path(__file__).resolve().parent / "durable_post_lint.py"
20
16
  VALID_PR_BODY = """## Why
21
17
 
22
18
  Why text.
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import io
6
6
  import json
7
- import sys
8
7
  from pathlib import Path
9
8
 
10
9
  from dev_env_scripts_constants.followup_constants import (
@@ -14,11 +13,6 @@ from dev_env_scripts_constants.followup_constants import (
14
13
  FOLLOWUP_BACKLOG_THRESHOLD,
15
14
  )
16
15
  from followup_cli import main
17
-
18
- _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
19
- if _hooks_directory not in sys.path:
20
- sys.path.insert(0, _hooks_directory)
21
-
22
16
  from followup_ledger import (
23
17
  FollowupFinding,
24
18
  all_recorded_findings,
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import subprocess
7
- import sys
8
7
  import threading
9
8
  from dataclasses import dataclass, field
10
9
  from datetime import datetime, timezone
@@ -12,13 +11,9 @@ from pathlib import Path
12
11
 
13
12
  import pytest
14
13
 
15
- _SCRIPTS_DIR = Path(__file__).resolve().parent
16
- if str(_SCRIPTS_DIR) not in sys.path:
17
- sys.path.insert(0, str(_SCRIPTS_DIR))
18
-
19
14
  import account_broker
20
15
  import invoke_code_review as invoker
21
- import resolve_worker_spawn as dispatcher # noqa: E402
16
+ import resolve_worker_spawn as dispatcher
22
17
  from dev_env_scripts_constants.account_broker_constants import (
23
18
  Account,
24
19
  BrokerConfigurationError,
@@ -28,7 +23,7 @@ from dev_env_scripts_constants.account_broker_constants import (
28
23
  ProductAdapter,
29
24
  WAIT_EXIT_CODE,
30
25
  )
31
- from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
26
+ from dev_env_scripts_constants.grok_worker_constants import (
32
27
  AGENT_FLAG,
33
28
  CLI_AGENT_FLAG,
34
29
  ALL_AGENT_FILENAMES_BY_ROLE,
@@ -67,12 +62,12 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
67
62
  TIER_CLAUDE_HEADLESS,
68
63
  TIER_GROK,
69
64
  )
70
- import grok_headless_runner # noqa: E402
71
- from grok_headless_runner import ( # noqa: E402
65
+ import grok_headless_runner
66
+ from grok_headless_runner import (
72
67
  GrokRunnerOutcome,
73
68
  run_headless_worker,
74
69
  )
75
- from grok_worker_preflight import PreflightOutcome # noqa: E402
70
+ from grok_worker_preflight import PreflightOutcome
76
71
 
77
72
  HOST_PROFILE_CLAUDE = "Claude"
78
73
  HOST_PROFILE_THIRD_PARTY = "ThirdParty"