claude-dev-env 8.73.0 → 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 (98) 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__code_review_test_support.py +40 -0
  65. package/scripts/test_active_capability_references.py +0 -5
  66. package/scripts/test_broker_state_isolation.py +3 -3
  67. package/scripts/test_codec_forwarding_test_support.py +28 -0
  68. package/scripts/test_digest_check.py +0 -5
  69. package/scripts/test_durable_post_lint.py +1 -5
  70. package/scripts/test_followup_cli.py +0 -6
  71. package/scripts/test_resolve_worker_spawn.py +5 -10
  72. package/scripts/test_setup_project_paths.py +0 -12
  73. package/scripts/test_shared_tree_paths.py +2 -6
  74. package/scripts/test_validate_instruction_pairs.py +0 -7
  75. package/scripts/test_verify_installable_package.py +0 -5
  76. package/scripts/tests/test_contrast_framing.py +3 -3
  77. package/scripts/tests/test_rule_index_shape.py +59 -5
  78. package/scripts/tests/test_rule_load_scopes.py +1 -16
  79. package/system-prompts/software-engineer.xml +4 -4
  80. package/hooks/blocking/artifact_look_gate.py +0 -223
  81. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  82. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  83. package/rules/asd-ste100-language.md +0 -9
  84. package/rules/cleanup-temp-files.md +0 -9
  85. package/rules/destructive-commands.md +0 -11
  86. package/rules/explore-thoroughly.md +0 -9
  87. package/rules/features-start-with-an-eval.md +0 -9
  88. package/rules/filesystem-search.md +0 -9
  89. package/rules/memory-stores-durable-facts.md +0 -9
  90. package/rules/no-contrast-framing.md +0 -9
  91. package/rules/proof-before-pull-request.md +0 -7
  92. package/rules/request-scope.md +0 -7
  93. package/rules/research-mode.md +0 -9
  94. package/rules/session-title.md +0 -9
  95. package/rules/shell-invocation.md +0 -9
  96. package/rules/skill-pointers.md +0 -3
  97. package/rules/verify-before-asking.md +0 -9
  98. package/rules/verify-runtime-state.md +0 -9
@@ -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"
@@ -3,23 +3,11 @@
3
3
  import inspect
4
4
  import json
5
5
  import subprocess
6
- import sys
7
6
  from pathlib import Path
8
7
  from unittest.mock import patch
9
8
 
10
9
  import pytest
11
10
 
12
- _SCRIPTS_DIR = Path(__file__).resolve().parent
13
- _HOOKS_DIR = _SCRIPTS_DIR.parent / "hooks"
14
- _SESSION_HOOKS_PACKAGE_DIR = _SCRIPTS_DIR.parent / "hooks" / "session"
15
- for each_sys_path_entry in (
16
- str(_SCRIPTS_DIR),
17
- str(_HOOKS_DIR),
18
- str(_SESSION_HOOKS_PACKAGE_DIR),
19
- ):
20
- if each_sys_path_entry not in sys.path:
21
- sys.path.insert(0, each_sys_path_entry)
22
-
23
11
  import setup_project_paths as setup
24
12
  import untracked_repo_detector as detector_module
25
13
  from hooks_constants.project_paths_reader import registry_file_path
@@ -9,12 +9,8 @@ from pathlib import Path
9
9
 
10
10
  import pytest
11
11
 
12
- _SCRIPTS_DIR = Path(__file__).resolve().parent
13
- if str(_SCRIPTS_DIR) not in sys.path:
14
- sys.path.insert(0, str(_SCRIPTS_DIR))
15
-
16
- import shared_tree_paths # noqa: E402
17
- from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
12
+ import shared_tree_paths
13
+ from dev_env_scripts_constants.grok_worker_constants import (
18
14
  CLAUDE_CONFIG_DIR_ENV_VAR,
19
15
  PROCESS_TREE_DIRECTORY_NAME,
20
16
  PROCESS_TREE_KILL_MODULE_FILENAME,
@@ -1,8 +1,6 @@
1
1
  import subprocess
2
2
  from pathlib import Path
3
3
 
4
- import sys
5
-
6
4
  from validate_instruction_pairs import (
7
5
  GATE_FAILED_EXIT_CODE,
8
6
  GATE_PASSED_EXIT_CODE,
@@ -10,11 +8,6 @@ from validate_instruction_pairs import (
10
8
  run_gate,
11
9
  validate_repository,
12
10
  )
13
-
14
- _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
15
- if _hooks_directory not in sys.path:
16
- sys.path.insert(0, _hooks_directory)
17
-
18
11
  from followup_ledger import all_recorded_findings, head_commit
19
12
  from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
20
13
 
@@ -6,14 +6,9 @@ command walking without requiring a full npm pack on every case.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import sys
10
9
  import tarfile
11
10
  from pathlib import Path
12
11
 
13
- _SCRIPTS_DIR = Path(__file__).resolve().parent
14
- if str(_SCRIPTS_DIR) not in sys.path:
15
- sys.path.insert(0, str(_SCRIPTS_DIR))
16
-
17
12
  import verify_installable_package as mod
18
13
  from dev_env_scripts_constants.verify_installable_package_constants import (
19
14
  CLASS_PACKAGED,
@@ -89,12 +89,12 @@ def test_form_named_rejects_an_unknown_name() -> None:
89
89
  form_named("no-such-form")
90
90
 
91
91
 
92
- def test_every_form_is_named_in_the_rule_document() -> None:
93
- rule_text = (PACKAGE_ROOT / CONTRAST_FRAMING_RULE_DOCUMENT).read_text(
92
+ def test_every_form_is_named_in_the_rule_guide() -> None:
93
+ guide_text = (PACKAGE_ROOT / CONTRAST_FRAMING_RULE_DOCUMENT).read_text(
94
94
  encoding="utf-8"
95
95
  )
96
96
  for each_form in ALL_CONTRAST_FRAMING_FORMS:
97
- assert f"`{each_form.name}`" in rule_text
97
+ assert f"`{each_form.name}`" in guide_text
98
98
 
99
99
 
100
100
  def test_a_list_of_not_items_stays_quiet() -> None:
@@ -1,4 +1,4 @@
1
- """Each rule entry keeps the index shape, and the always-loaded entries stay inside a byte budget."""
1
+ """The rules index and each rule entry keep their shape, and the always-loaded rules stay inside a byte budget."""
2
2
 
3
3
  import re
4
4
  import sys
@@ -28,11 +28,16 @@ DOUBLED_WHEN_LEAD_IN_PATTERN = re.compile(
28
28
  r"^\*\*When(?: this applies)?:\*\* When\b", re.MULTILINE
29
29
  )
30
30
 
31
+ INDEX_LINE_PATTERN = re.compile(r"^- \*\*[^*]+\*\*(?: \(\[guide\]\((\.\./docs/rule-guides/[^)#\s]+\.md)\)\))?\. \S")
32
+ INDEX_SKILL_POINTER_PATTERN = re.compile(r"the `[a-z-]+` skill")
33
+
34
+ INDEX_FILE_NAME = "index.md"
35
+ INDEX_PATH = RULES_DIRECTORY / INDEX_FILE_NAME
31
36
  MAXIMUM_ENTRY_BYTES = 1_500
32
- MAXIMUM_ALWAYS_ON_BYTES = 12_000
33
- POINTER_ENTRY_NAMES = frozenset({"skill-pointers.md"})
37
+ MAXIMUM_INDEX_BYTES = 4_000
38
+ MAXIMUM_ALWAYS_ON_BYTES = 6_000
34
39
  CODEX_VERBATIM_ENTRY_NAMES = frozenset({"question-presentation.md"})
35
- INDEX_SHAPE_EXEMPT_ENTRY_NAMES = POINTER_ENTRY_NAMES | CODEX_VERBATIM_ENTRY_NAMES
40
+ INDEX_SHAPE_EXEMPT_ENTRY_NAMES = CODEX_VERBATIM_ENTRY_NAMES
36
41
  CODEX_MATERIALIZED_GUIDE_NAMES = frozenset(
37
42
  Path(each_path).name for each_path in codex_instruction_rule_relative_paths
38
43
  )
@@ -43,6 +48,7 @@ def _entry_paths() -> list[Path]:
43
48
  each_path
44
49
  for each_path in RULES_DIRECTORY.glob("*.md")
45
50
  if each_path.name not in instruction_alias_filenames
51
+ and each_path.name != INDEX_FILE_NAME
46
52
  )
47
53
 
48
54
 
@@ -183,11 +189,59 @@ def test_each_entry_links_a_full_text_guide_that_exists() -> None:
183
189
  assert all_problems == []
184
190
 
185
191
 
192
+ def _index_lines() -> list[str]:
193
+ return [
194
+ each_line
195
+ for each_line in INDEX_PATH.read_text(encoding="utf-8").splitlines()
196
+ if each_line.startswith("- ")
197
+ ]
198
+
199
+
200
+ def _index_guide_names() -> list[str]:
201
+ return [
202
+ Path(each_match.group(1)).name
203
+ for each_line in _index_lines()
204
+ if (each_match := INDEX_LINE_PATTERN.match(each_line)) and each_match.group(1)
205
+ ]
206
+
207
+
208
+ def test_each_index_line_names_a_rule_and_a_trigger() -> None:
209
+ malformed = [
210
+ each_line for each_line in _index_lines() if not INDEX_LINE_PATTERN.match(each_line)
211
+ ]
212
+ assert malformed == []
213
+
214
+
215
+ def test_each_index_line_links_a_guide_or_names_a_skill() -> None:
216
+ unlinked = [
217
+ each_line
218
+ for each_line in _index_lines()
219
+ if not INDEX_LINE_PATTERN.match(each_line).group(1)
220
+ and not INDEX_SKILL_POINTER_PATTERN.search(each_line)
221
+ ]
222
+ assert unlinked == []
223
+
224
+
225
+ def test_each_index_guide_exists() -> None:
226
+ missing = [
227
+ each_guide_name
228
+ for each_guide_name in _index_guide_names()
229
+ if not (RULE_GUIDES_DIRECTORY / each_guide_name).is_file()
230
+ ]
231
+ assert missing == []
232
+
233
+
234
+ def test_index_stays_inside_its_byte_budget() -> None:
235
+ assert len(INDEX_PATH.read_bytes()) <= MAXIMUM_INDEX_BYTES
236
+
237
+
186
238
  def test_each_guide_has_exactly_one_owner() -> None:
187
239
  owners_by_guide: dict[str, list[str]] = {}
188
240
  for each_path in _entry_paths():
189
241
  for each_guide_name in _linked_guide_names(_entry_text(each_path)):
190
242
  owners_by_guide.setdefault(each_guide_name, []).append(each_path.name)
243
+ for each_guide_name in _index_guide_names():
244
+ owners_by_guide.setdefault(each_guide_name, []).append(INDEX_FILE_NAME)
191
245
  unowned = sorted(
192
246
  each_guide.name
193
247
  for each_guide in RULE_GUIDES_DIRECTORY.glob("*.md")
@@ -204,7 +258,7 @@ def test_each_guide_has_exactly_one_owner() -> None:
204
258
 
205
259
 
206
260
  def test_always_loaded_entries_stay_inside_the_total_byte_budget() -> None:
207
- always_on_bytes = sum(
261
+ always_on_bytes = len(INDEX_PATH.read_bytes()) + sum(
208
262
  len(each_path.read_bytes())
209
263
  for each_path in _entry_paths()
210
264
  if _loads_in_every_session(_entry_text(each_path))
@@ -8,24 +8,9 @@ SKILLS_DIRECTORY = PACKAGE_ROOT / ".agents" / "skills"
8
8
 
9
9
  ALWAYS_ON_RULE_NAMES = frozenset(
10
10
  {
11
- "asd-ste100-language.md",
12
- "cleanup-temp-files.md",
13
11
  "correction-lens.md",
14
- "destructive-commands.md",
15
- "explore-thoroughly.md",
16
- "features-start-with-an-eval.md",
17
- "filesystem-search.md",
18
- "memory-stores-durable-facts.md",
19
- "no-contrast-framing.md",
20
- "proof-before-pull-request.md",
12
+ "index.md",
21
13
  "question-presentation.md",
22
- "research-mode.md",
23
- "request-scope.md",
24
- "session-title.md",
25
- "skill-pointers.md",
26
- "shell-invocation.md",
27
- "verify-before-asking.md",
28
- "verify-runtime-state.md",
29
14
  }
30
15
  )
31
16
 
@@ -7,7 +7,7 @@
7
7
  </role>
8
8
 
9
9
  <task_scope>
10
- Apply rules/asd-ste100-language.md for user-facing word choice, sentence style, tone, punctuation,
10
+ Apply docs/rule-guides/asd-ste100-language.md for user-facing word choice, sentence style, tone, punctuation,
11
11
  and prose form. Keep the behavior contracts below within their named scope.
12
12
  Defer to the user's judgment when they indicate a task is too ambitious or complex to pursue.
13
13
  Match the scope of every action to what the user explicitly requested: a bug fix delivers the
@@ -28,7 +28,7 @@
28
28
  <output_style>
29
29
  When a user-facing response needs beginner presentation, use ELI5: beginner framing, large
30
30
  visuals, minimal text, one stable self-contained HTML artifact, update-in-place continuity,
31
- and sharing. Apply rules/asd-ste100-language.md to sentence-level prose inside that artifact.
31
+ and sharing. Apply docs/rule-guides/asd-ste100-language.md to sentence-level prose inside that artifact.
32
32
  Lead every response with the direct answer or result. Open with the substance — the
33
33
  conclusion, the fix, the decision — before any supporting context or explanation. Expand
34
34
  depth in proportion to the complexity of the ask: a direct question earns a sentence; a
@@ -67,7 +67,7 @@
67
67
  Reserve user questions for preferences, missing context the user holds, judgment calls, and
68
68
  scope decisions.
69
69
 
70
- Source every claim as rules/research-mode.md describes.
70
+ Source every claim as docs/rule-guides/research-mode.md describes.
71
71
  </investigation>
72
72
 
73
73
  <problem_solving>
@@ -363,7 +363,7 @@
363
363
  Visible output is concise by default. The first progress update is one sentence.
364
364
  Later updates mark only important discoveries or direction changes. The final
365
365
  response begins with the outcome.
366
- Language policy: rules/asd-ste100-language.md. This section owns visible progress,
366
+ Language policy: docs/rule-guides/asd-ste100-language.md. This section owns visible progress,
367
367
  tool narration, and outcome-first final structure.
368
368
  </communication_contract>
369
369
 
@@ -1,223 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: refuse to publish an artifact page nobody has looked at.
3
-
4
- An agent that writes a page and publishes it without rendering it cannot know
5
- the page matches the ask. The hook walks the session transcript and allows the
6
- publish only when a Read opened an image file whose modification time is at
7
- least the page file's::
8
-
9
- Write page.html -> shoot shot.png -> Read shot.png -> publish -> allowed
10
- Write page.html -> publish -> refused
11
- Read shot.png -> change page.html in any tool -> publish -> refused
12
- Read an image older than the page -> publish -> refused
13
-
14
- A Read that returned an error, or whose result came before the page's last
15
- change, does not count. A non-HTML file, an asset
16
- upload, a non-publish action and an unreadable transcript all pass, so the
17
- hook never fails closed.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import json
23
- import sys
24
- from datetime import datetime
25
- from pathlib import Path
26
-
27
- hooks_root_directory = str(Path(__file__).resolve().parent.parent)
28
- if hooks_root_directory not in sys.path:
29
- sys.path.insert(0, hooks_root_directory)
30
-
31
- from hooks_constants.artifact_look_gate_constants import (
32
- ALL_IMAGE_FILE_SUFFIXES,
33
- ALL_PAGE_FILE_SUFFIXES,
34
- ARTIFACT_ACTION_INPUT_KEY,
35
- ARTIFACT_ASSET_INPUT_KEY,
36
- ARTIFACT_PUBLISH_ACTION,
37
- ARTIFACT_TOOL_NAME,
38
- ASSISTANT_ENTRY_TYPE,
39
- DENY_DECISION,
40
- ENTRY_TIMESTAMP_KEY,
41
- FILE_PATH_INPUT_KEY,
42
- IMAGE_READING_TOOL_NAME,
43
- PRE_TOOL_USE_EVENT_NAME,
44
- TOOL_INPUT_KEY,
45
- TOOL_NAME_KEY,
46
- TOOL_RESULT_BLOCK_TYPE,
47
- TOOL_USE_BLOCK_TYPE,
48
- TRANSCRIPT_DECODE_ERRORS,
49
- TRANSCRIPT_ENCODING,
50
- TRANSCRIPT_PATH_KEY,
51
- UNSEEN_PAGE_REASON_TEMPLATE,
52
- USER_ENTRY_TYPE,
53
- )
54
- from hooks_constants.hook_specific_output_keys import (
55
- HOOK_EVENT_NAME_KEY,
56
- HOOK_SPECIFIC_OUTPUT_KEY,
57
- PERMISSION_DECISION_KEY,
58
- )
59
- from hooks_constants.pr_lifecycle_skill_gate_constants import (
60
- PERMISSION_DECISION_REASON_KEY,
61
- )
62
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
63
-
64
-
65
- def _content_blocks(
66
- all_entry_fields: dict[str, object], entry_type: str
67
- ) -> list[dict[str, object]]:
68
- if all_entry_fields.get("type") != entry_type:
69
- return []
70
- message = all_entry_fields.get("message")
71
- content = message.get("content") if isinstance(message, dict) else None
72
- if not isinstance(content, list):
73
- return []
74
- return [each_block for each_block in content if isinstance(each_block, dict)]
75
-
76
-
77
- def _parsed_entry(transcript_line: str) -> dict[str, object]:
78
- try:
79
- parsed_line = json.loads(transcript_line)
80
- except json.JSONDecodeError:
81
- return {}
82
- return parsed_line if isinstance(parsed_line, dict) else {}
83
-
84
-
85
- def _entry_epoch(all_entry_fields: dict[str, object]) -> float | None:
86
- timestamp_text = all_entry_fields.get(ENTRY_TIMESTAMP_KEY)
87
- if not isinstance(timestamp_text, str):
88
- return None
89
- try:
90
- return datetime.fromisoformat(timestamp_text.replace("Z", "+00:00")).timestamp()
91
- except ValueError:
92
- return None
93
-
94
-
95
- def _successful_result_epochs(all_entries: list[dict[str, object]]) -> dict[str, float]:
96
- return {
97
- str(each_block.get("tool_use_id")): result_epoch
98
- for each_entry in all_entries
99
- if (result_epoch := _entry_epoch(each_entry)) is not None
100
- for each_block in _content_blocks(each_entry, USER_ENTRY_TYPE)
101
- if each_block.get("type") == TOOL_RESULT_BLOCK_TYPE and not each_block.get("is_error")
102
- }
103
-
104
-
105
- def _tool_calls(all_entries: list[dict[str, object]]) -> list[tuple[str, object, object]]:
106
- return [
107
- (str(each_block.get("id")), each_block.get("name"), tool_input.get(FILE_PATH_INPUT_KEY))
108
- for each_entry in all_entries
109
- for each_block in _content_blocks(each_entry, ASSISTANT_ENTRY_TYPE)
110
- if each_block.get("type") == TOOL_USE_BLOCK_TYPE
111
- and isinstance(tool_input := each_block.get("input"), dict)
112
- ]
113
-
114
-
115
- def _is_image_read(tool_name: object, file_path: object) -> bool:
116
- return (
117
- tool_name == IMAGE_READING_TOOL_NAME
118
- and isinstance(file_path, str)
119
- and file_path.lower().endswith(ALL_IMAGE_FILE_SUFFIXES)
120
- )
121
-
122
-
123
- def _modified_time(file_path: str) -> float | None:
124
- try:
125
- return Path(file_path).stat().st_mtime
126
- except OSError:
127
- return None
128
-
129
-
130
- def page_was_looked_at(all_transcript_lines: list[str], page_path: str) -> bool:
131
- """Return True when a screenshot made after the page's last change was read.
132
-
133
- The page's modification time covers every way it can change, including a
134
- Bash command or a subagent. A Read counts only when it succeeded, its
135
- result arrived after the page last changed, and its image file is at
136
- least as new as the page.
137
-
138
- Args:
139
- all_transcript_lines: The session transcript, one JSON record per line.
140
- page_path: The file the Artifact call publishes.
141
- """
142
- page_modified_time = _modified_time(page_path)
143
- if page_modified_time is None:
144
- return False
145
- all_entries = [_parsed_entry(each_line) for each_line in all_transcript_lines]
146
- result_epoch_by_id = _successful_result_epochs(all_entries)
147
- for each_id, each_tool_name, each_file_path in _tool_calls(all_entries):
148
- result_epoch = result_epoch_by_id.get(each_id)
149
- if not _is_image_read(each_tool_name, each_file_path) or result_epoch is None:
150
- continue
151
- image_modified_time = _modified_time(str(each_file_path))
152
- if (
153
- image_modified_time is not None
154
- and image_modified_time >= page_modified_time
155
- and result_epoch >= page_modified_time
156
- ):
157
- return True
158
- return False
159
-
160
-
161
- def _published_page_path(all_hook_fields: dict[str, object]) -> str | None:
162
- tool_input = all_hook_fields.get(TOOL_INPUT_KEY)
163
- if all_hook_fields.get(TOOL_NAME_KEY) != ARTIFACT_TOOL_NAME or not isinstance(tool_input, dict):
164
- return None
165
- page_path = tool_input.get(FILE_PATH_INPUT_KEY)
166
- is_page_publish = (
167
- tool_input.get(ARTIFACT_ACTION_INPUT_KEY, ARTIFACT_PUBLISH_ACTION) == ARTIFACT_PUBLISH_ACTION
168
- and tool_input.get(ARTIFACT_ASSET_INPUT_KEY) is not True
169
- and isinstance(page_path, str)
170
- and page_path.lower().endswith(ALL_PAGE_FILE_SUFFIXES)
171
- )
172
- return page_path if is_page_publish else None
173
-
174
-
175
- def unseen_page_reason(all_hook_fields: dict[str, object]) -> str | None:
176
- """Return the refusal for an unseen page publish, or None to allow it.
177
-
178
- Args:
179
- all_hook_fields: The parsed PreToolUse payload.
180
- """
181
- page_path = _published_page_path(all_hook_fields)
182
- transcript_path = all_hook_fields.get(TRANSCRIPT_PATH_KEY)
183
- if page_path is None or not isinstance(transcript_path, str):
184
- return None
185
- try:
186
- all_transcript_lines = Path(transcript_path).read_text(
187
- encoding=TRANSCRIPT_ENCODING, errors=TRANSCRIPT_DECODE_ERRORS
188
- ).splitlines()
189
- except OSError:
190
- return None
191
- if page_was_looked_at(all_transcript_lines, page_path):
192
- return None
193
- return UNSEEN_PAGE_REASON_TEMPLATE.format(page_path=page_path)
194
-
195
-
196
- def main() -> int:
197
- """Read the PreToolUse payload and print a deny decision for an unseen page.
198
-
199
- Returns:
200
- 0 in every case; the decision travels in the JSON output.
201
- """
202
- hook_payload = read_hook_input_dictionary_from_stdin()
203
- if hook_payload is None:
204
- return 0
205
- refusal_reason = unseen_page_reason(hook_payload)
206
- if refusal_reason is not None:
207
- sys.stdout.write(
208
- json.dumps(
209
- {
210
- HOOK_SPECIFIC_OUTPUT_KEY: {
211
- HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
212
- PERMISSION_DECISION_KEY: DENY_DECISION,
213
- PERMISSION_DECISION_REASON_KEY: refusal_reason,
214
- }
215
- }
216
- )
217
- )
218
- sys.stdout.flush()
219
- return 0
220
-
221
-
222
- if __name__ == "__main__":
223
- sys.exit(main())