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.
- package/.agents/agents/test_agent_frontmatter.py +23 -23
- package/.agents/agents-archived/clean-coder.md +5 -5
- package/.agents/skills/correction/playbooks/fix.md +1 -1
- package/.agents/skills/eli5/SKILL.md +3 -3
- package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.policy-lint.test.mjs +1 -0
- package/bin/install.profile-root.test.mjs +1 -1
- package/bin/install.test.mjs +1 -0
- package/commands/fix-followups.md +1 -1
- package/commands/sr-loop.md +1 -1
- package/docs/references/prose-style-enforcement.md +1 -1
- package/docs/references/working-style.md +1 -1
- package/docs/rule-guides/asd-ste100-language.md +9 -1
- package/docs/rule-guides/cleanup-temp-files.md +8 -2
- package/docs/rule-guides/destructive-commands.md +13 -3
- package/docs/rule-guides/explore-thoroughly.md +9 -1
- package/docs/rule-guides/features-start-with-an-eval.md +8 -2
- package/docs/rule-guides/filesystem-search.md +7 -1
- package/docs/rule-guides/long-horizon-autonomy.md +1 -1
- package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
- package/docs/rule-guides/no-contrast-framing.md +10 -4
- package/docs/rule-guides/prefer-existing-tools.md +2 -2
- package/docs/rule-guides/proof-before-pull-request.md +5 -1
- package/docs/rule-guides/request-scope.md +5 -1
- package/docs/rule-guides/research-mode.md +9 -1
- package/docs/rule-guides/session-title.md +7 -1
- package/docs/rule-guides/shell-invocation.md +9 -1
- package/docs/rule-guides/verify-before-asking.md +7 -1
- package/docs/rule-guides/verify-runtime-state.md +7 -1
- package/hooks/blocking/code_rules_enforcer.py +16 -6
- package/hooks/blocking/code_rules_shared.py +23 -4
- package/hooks/blocking/reply_length_gate.py +74 -14
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
- package/hooks/blocking/test_code_rules_shared.py +33 -0
- package/hooks/blocking/test_reply_length_gate.py +184 -5
- package/hooks/blocking/test_state_description_blocker.py +1 -1
- package/hooks/features/conduct-gates.md +3 -6
- package/hooks/hooks.json +1 -6
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
- package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
- package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
- package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
- package/hooks/hooks_constants/windows_filesystem.py +51 -0
- package/hooks/session/session_env_cleanup.py +4 -30
- package/hooks/test_visual_reply_rules.py +164 -0
- package/hooks/visual_reply_rules.py +268 -0
- package/hooks/workflow/test_auto_formatter.py +4 -25
- package/package.json +1 -1
- package/rules/index.md +30 -0
- package/rules/visual-reply-rules.json +41 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/conftest.py +13 -5
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
- package/scripts/policy_lint/adapter_configuration.py +0 -5
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/test__code_review_test_support.py +40 -0
- package/scripts/test_active_capability_references.py +0 -5
- package/scripts/test_broker_state_isolation.py +3 -3
- package/scripts/test_codec_forwarding_test_support.py +28 -0
- package/scripts/test_digest_check.py +0 -5
- package/scripts/test_durable_post_lint.py +1 -5
- package/scripts/test_followup_cli.py +0 -6
- package/scripts/test_resolve_worker_spawn.py +5 -10
- package/scripts/test_setup_project_paths.py +0 -12
- package/scripts/test_shared_tree_paths.py +2 -6
- package/scripts/test_validate_instruction_pairs.py +0 -7
- package/scripts/test_verify_installable_package.py +0 -5
- package/scripts/tests/test_contrast_framing.py +3 -3
- package/scripts/tests/test_rule_index_shape.py +59 -5
- package/scripts/tests/test_rule_load_scopes.py +1 -16
- package/system-prompts/software-engineer.xml +4 -4
- package/hooks/blocking/artifact_look_gate.py +0 -223
- package/hooks/blocking/test_artifact_look_gate.py +0 -153
- package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
- package/rules/asd-ste100-language.md +0 -9
- package/rules/cleanup-temp-files.md +0 -9
- package/rules/destructive-commands.md +0 -11
- package/rules/explore-thoroughly.md +0 -9
- package/rules/features-start-with-an-eval.md +0 -9
- package/rules/filesystem-search.md +0 -9
- package/rules/memory-stores-durable-facts.md +0 -9
- package/rules/no-contrast-framing.md +0 -9
- package/rules/proof-before-pull-request.md +0 -7
- package/rules/request-scope.md +0 -7
- package/rules/research-mode.md +0 -9
- package/rules/session-title.md +0 -9
- package/rules/shell-invocation.md +0 -9
- package/rules/skill-pointers.md +0 -3
- package/rules/verify-before-asking.md +0 -9
- 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
|
|
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 (
|
|
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
|
|
71
|
-
from grok_headless_runner import (
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
93
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
33
|
-
|
|
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 =
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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())
|