claude-dev-env 8.21.0 → 8.22.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 (40) hide show
  1. package/.agents/skills/sync-dev-env/SKILL.md +51 -0
  2. package/.agents/skills/sync-dev-env/scripts/link_plugin_skills.py +211 -0
  3. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/__init__.py +0 -0
  4. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/__init__.py +1 -0
  5. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/constants.py +26 -0
  6. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/test_constants.py +19 -0
  7. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/test___init__.py +5 -0
  8. package/.agents/skills/sync-dev-env/scripts/test_link_plugin_skills.py +142 -0
  9. package/bin/ever-shipped-skills.mjs +1 -0
  10. package/bin/merge_settings_defaults.test.mjs +43 -0
  11. package/hooks/blocking/edit_marker_gate.py +74 -0
  12. package/hooks/blocking/reply_length_gate.py +119 -0
  13. package/hooks/blocking/test_edit_marker_gate.py +110 -0
  14. package/hooks/blocking/test_reply_length_gate.py +193 -0
  15. package/hooks/hooks.json +20 -0
  16. package/hooks/hooks_constants/edit_marker_gate_constants.py +18 -0
  17. package/hooks/hooks_constants/reply_length_gate_constants.py +28 -0
  18. package/hooks/hooks_constants/test_edit_marker_gate_constants.py +9 -0
  19. package/hooks/hooks_constants/test_reply_length_gate_constants.py +15 -0
  20. package/package.json +1 -1
  21. package/rules/asd-ste100-language.md +1 -0
  22. package/rules/ci-owns-the-gate.md +28 -0
  23. package/rules/memory-stores-durable-facts.md +57 -0
  24. package/rules/review-closure-is-a-check.md +1 -0
  25. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +4 -0
  26. package/scripts/dev_env_scripts_constants/review_closure_constants.py +13 -0
  27. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +4 -0
  28. package/scripts/invoke_code_review.py +4 -1
  29. package/scripts/local_report_cli.py +50 -53
  30. package/scripts/policy_lint/adapter_configuration.py +8 -0
  31. package/scripts/policy_lint/config/constants.py +2 -0
  32. package/scripts/resolve_worker_spawn.py +4 -1
  33. package/scripts/review_closure_model.py +16 -3
  34. package/scripts/shared_tree_paths.py +27 -0
  35. package/scripts/test_resolve_worker_spawn.py +28 -0
  36. package/scripts/test_shared_tree_paths.py +27 -0
  37. package/scripts/tests/test_adapter_configuration.py +16 -0
  38. package/scripts/tests/test_local_report_publisher_cli.py +54 -0
  39. package/scripts/tests/test_review_closure.py +1 -0
  40. package/scripts/tests/test_review_closure_model.py +70 -0
@@ -137,6 +137,19 @@ USER_TYPE_KEY: str = "type"
137
137
  BOT_USER_TYPE: str = "Bot"
138
138
  """The user type GitHub reports for an app account such as a review bot."""
139
139
 
140
+ ALL_NOTICE_COMMENT_MARKERS: tuple[str, ...] = (
141
+ "No code changes since the last review",
142
+ "was updated up to the latest commit",
143
+ "<!-- graphite-review-comment -->",
144
+ "<h3>PR Summary by Qodo</h3>",
145
+ "<h3>Qodo is busy working</h3>",
146
+ "<h3>Great, no issues found!</h3>",
147
+ )
148
+ """Text a bot notice carries: a Qodo skip note, a Qodo pointer to its updated
149
+ summary, a Graphite verdict mirror whose findings arrive as review threads, a
150
+ Qodo description of the change, a Qodo placeholder posted while it reviews, and
151
+ a Qodo review that found nothing."""
152
+
140
153
  SHORT_SHA_LENGTH: int = 7
141
154
  """How much of a commit identifier the verdict line prints."""
142
155
 
@@ -12,5 +12,9 @@ PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
12
12
 
13
13
  PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
14
14
 
15
+ ADVISOR_DIRECTORY_NAME: str = "advisor"
16
+
17
+ TIER_MODEL_IDS_MODULE_FILENAME: str = "tier_model_ids.py"
18
+
15
19
  AGENTS_DIRECTORY_SUFFIX: str = ".agents"
16
20
  DEFAULT_MANAGED_ROOT_NAME: str = ".claude"
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import argparse
28
28
  import json
29
+ import os
29
30
  import subprocess
30
31
  import sys
31
32
  from collections.abc import Callable, Sequence
@@ -41,8 +42,10 @@ sys.path[:] = [
41
42
  ]
42
43
  sys.path[:0] = [_scripts_directory]
43
44
 
45
+ from shared_tree_paths import resolve_shared_advisor_scripts_directory
46
+
44
47
  _advisor_scripts_path = str(
45
- _scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
48
+ resolve_shared_advisor_scripts_directory(__file__, all_environment=os.environ)
46
49
  )
47
50
  sys.path[:] = [
48
51
  each_existing_entry
@@ -2,16 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import os
5
6
  import sys
6
7
  from collections.abc import Mapping, Sequence
7
- from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
  from typing import TextIO
10
10
 
11
11
  from local_report_core import PublicationOutcome, publish_local_report
12
12
  from local_verification.config import SUCCESS_EXIT_CODE
13
13
  from pr_verification.config.constants import GIT_BARE_SUFFIX, GITHUB_API_URL
14
- from pr_verification.github import GitHubAppAuthenticator, GitHubError
14
+ from pr_verification.github import GitHubApi, GitHubAppAuthenticator, GitHubError
15
15
  from pr_verification.model import RepositorySettings
16
16
 
17
17
 
@@ -34,8 +34,7 @@ def main(
34
34
  parser = _build_parser()
35
35
  parsed_arguments = parser.parse_args(list(all_arguments))
36
36
  try:
37
- settings = _load_settings(parsed_arguments)
38
- outcome = _publish_from_settings(settings, parsed_arguments)
37
+ outcome = _publish_from_arguments(parsed_arguments)
39
38
  except (OSError, TypeError, ValueError, GitHubError) as error:
40
39
  stderr.write(f"{error}\n")
41
40
  return 3
@@ -43,31 +42,57 @@ def main(
43
42
  return SUCCESS_EXIT_CODE
44
43
 
45
44
 
46
- def _publish_from_settings(
47
- settings: _PublisherSettings, parsed_arguments: argparse.Namespace
48
- ) -> PublicationOutcome:
49
- repository = RepositorySettings(
50
- settings.repository, settings.repository + GIT_BARE_SUFFIX
45
+ def _publish_from_arguments(parsed_arguments: argparse.Namespace) -> PublicationOutcome:
46
+ all_settings = _read_settings_file(parsed_arguments.settings)
47
+ api_url = _required_setting(
48
+ parsed_arguments.api_url, all_settings, "api_url", GITHUB_API_URL
51
49
  )
52
- authenticator = GitHubAppAuthenticator(
53
- settings.api_url,
54
- settings.app_id,
55
- settings.installation_id,
56
- settings.private_key_path,
50
+ repository_slug = _required_repository_setting(
51
+ parsed_arguments.repository, all_settings
57
52
  )
58
- github = authenticator.issue_repository_api(
59
- repository, should_write_issue_labels=True
53
+ pull_request_number = _required_integer_setting(
54
+ parsed_arguments.pull_number, all_settings, "pull_number"
60
55
  )
56
+ repository = RepositorySettings(repository_slug, repository_slug + GIT_BARE_SUFFIX)
57
+ github = _issue_repository_api(parsed_arguments, all_settings, api_url, repository)
61
58
  return publish_local_report(
62
59
  github,
63
60
  repository,
64
- settings.pull_request_number,
61
+ pull_request_number,
65
62
  parsed_arguments.local_repo,
66
63
  parsed_arguments.manifest,
67
64
  parsed_arguments.report,
68
65
  )
69
66
 
70
67
 
68
+ def _issue_repository_api(
69
+ parsed_arguments: argparse.Namespace,
70
+ all_settings: Mapping[str, object],
71
+ api_url: str,
72
+ repository: RepositorySettings,
73
+ ) -> GitHubApi:
74
+ if parsed_arguments.token_environment is not None:
75
+ return GitHubApi(api_url, _environment_token(parsed_arguments.token_environment))
76
+ authenticator = GitHubAppAuthenticator(
77
+ api_url,
78
+ _required_integer_setting(parsed_arguments.app_id, all_settings, "app_id"),
79
+ _required_integer_setting(
80
+ parsed_arguments.installation_id, all_settings, "installation_id"
81
+ ),
82
+ _required_path_setting(parsed_arguments.private_key_path, all_settings),
83
+ )
84
+ return authenticator.issue_repository_api(
85
+ repository, should_write_issue_labels=True
86
+ )
87
+
88
+
89
+ def _environment_token(variable_name: str) -> str:
90
+ token = os.environ.get(variable_name)
91
+ if not token:
92
+ raise TypeError(f"Missing publisher token in environment variable: {variable_name}")
93
+ return token
94
+
95
+
71
96
  def _outcome_mapping(outcome: PublicationOutcome) -> dict[str, object]:
72
97
  return {
73
98
  "status": outcome.status.value,
@@ -76,16 +101,6 @@ def _outcome_mapping(outcome: PublicationOutcome) -> dict[str, object]:
76
101
  }
77
102
 
78
103
 
79
- @dataclass(frozen=True)
80
- class _PublisherSettings:
81
- api_url: str
82
- app_id: int
83
- installation_id: int
84
- private_key_path: Path
85
- repository: str
86
- pull_request_number: int
87
-
88
-
89
104
  def _build_parser() -> argparse.ArgumentParser:
90
105
  parser = argparse.ArgumentParser(prog="cde publish-local-report")
91
106
  parser.add_argument("--settings", type=Path)
@@ -93,6 +108,7 @@ def _build_parser() -> argparse.ArgumentParser:
93
108
  parser.add_argument("--app-id", type=int)
94
109
  parser.add_argument("--installation-id", type=int)
95
110
  parser.add_argument("--private-key-path", type=Path)
111
+ parser.add_argument("--token-environment")
96
112
  parser.add_argument("--repository")
97
113
  parser.add_argument("--pull-number", type=int)
98
114
  parser.add_argument("--local-repo", type=Path, required=True)
@@ -101,32 +117,13 @@ def _build_parser() -> argparse.ArgumentParser:
101
117
  return parser
102
118
 
103
119
 
104
- def _load_settings(parsed_arguments: argparse.Namespace) -> _PublisherSettings:
105
- all_settings: Mapping[str, object] = {}
106
- if parsed_arguments.settings is not None:
107
- parsed_settings = json.loads(
108
- parsed_arguments.settings.read_text(encoding="utf-8")
109
- )
110
- if not isinstance(parsed_settings, Mapping):
111
- raise ValueError("Publisher settings must be an object")
112
- all_settings = parsed_settings
113
- return _PublisherSettings(
114
- _required_setting(
115
- parsed_arguments.api_url,
116
- all_settings,
117
- "api_url",
118
- GITHUB_API_URL,
119
- ),
120
- _required_integer_setting(parsed_arguments.app_id, all_settings, "app_id"),
121
- _required_integer_setting(
122
- parsed_arguments.installation_id, all_settings, "installation_id"
123
- ),
124
- _required_path_setting(parsed_arguments.private_key_path, all_settings),
125
- _required_repository_setting(parsed_arguments.repository, all_settings),
126
- _required_integer_setting(
127
- parsed_arguments.pull_number, all_settings, "pull_number"
128
- ),
129
- )
120
+ def _read_settings_file(settings_path: Path | None) -> Mapping[str, object]:
121
+ if settings_path is None:
122
+ return {}
123
+ parsed_settings = json.loads(settings_path.read_text(encoding="utf-8"))
124
+ if not isinstance(parsed_settings, Mapping):
125
+ raise ValueError("Publisher settings must be an object")
126
+ return parsed_settings
130
127
 
131
128
 
132
129
  def _required_setting(
@@ -128,6 +128,8 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
128
128
 
129
129
  hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
130
130
  hooks/blocking/step_note_gate.py -> exempt
131
+ hooks/blocking/reply_length_gate.py -> exempt
132
+ hooks/blocking/edit_marker_gate.py -> exempt
131
133
  hooks/blocking/some_new_blocker.py -> flagged
132
134
 
133
135
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -137,6 +139,12 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
137
139
  The step-note gate allows every call until the user runs ``/step-notes on``.
138
140
  It asks for a readable status line and decides no code or safety policy.
139
141
 
142
+ The reply length gate caps the sentences in a chat reply to the user. It
143
+ decides the shape of prose and no code or safety policy.
144
+
145
+ The edit marker gate keeps strikethrough and edit notes out of an edited
146
+ chat message. It decides the shape of prose and no code or safety policy.
147
+
140
148
  Args:
141
149
  registered_string: One command, path, script, or entrypoint string.
142
150
  """
@@ -64,6 +64,8 @@ ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
64
64
  ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
65
65
  "blocking/bash_pre_tool_use_dispatcher.py",
66
66
  "blocking/step_note_gate.py",
67
+ "blocking/reply_length_gate.py",
68
+ "blocking/edit_marker_gate.py",
67
69
  )
68
70
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
69
71
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -24,6 +24,7 @@ from __future__ import annotations
24
24
 
25
25
  import argparse
26
26
  import json
27
+ import os
27
28
  import subprocess
28
29
  import sys
29
30
  from collections.abc import Callable, Sequence
@@ -40,8 +41,10 @@ sys.path[:] = [
40
41
  ]
41
42
  sys.path[:0] = [_scripts_directory]
42
43
 
44
+ from shared_tree_paths import resolve_shared_advisor_scripts_directory
45
+
43
46
  _advisor_scripts_path = str(
44
- _scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
47
+ resolve_shared_advisor_scripts_directory(__file__, all_environment=os.environ)
45
48
  )
46
49
  sys.path[:] = [
47
50
  each_existing_entry
@@ -15,12 +15,16 @@ and the approvals conclusion, and name what waits.
15
15
  top-level: a bot comment, then an agent one -> closed
16
16
  top-level: the bot edits after that reply -> closed
17
17
  top-level: a person edits after that reply -> open
18
+ top-level: a bot notice with no finding -> closed
18
19
 
19
20
  A red circle marks a finding a review states as blocking, so resolution alone
20
21
  leaves it open. The reply says what changed, or why the finding stands.
21
22
  A review bot rewrites its summary comment on each pass, and any new finding it
22
23
  has arrives as a review thread or a new comment, so its edit alone reopens
23
- nothing.
24
+ nothing. A bot notice carries a skip note, a pointer to an updated summary, a
25
+ verdict mirror, a description of the change, a placeholder posted while the bot
26
+ reviews, or a review that found nothing, and never a finding, so it waits on
27
+ nobody.
24
28
  """
25
29
 
26
30
  from __future__ import annotations
@@ -33,6 +37,7 @@ from dev_env_scripts_constants.review_closure_constants import (
33
37
  ALL_BLOCKING_CONCLUSIONS,
34
38
  ALL_COMMENT_AUTHOR_KEYS,
35
39
  ALL_COMMENT_LIST_KEYS,
40
+ ALL_NOTICE_COMMENT_MARKERS,
36
41
  ALL_OUTDATED_KEYS,
37
42
  ALL_RESOLVED_KEYS,
38
43
  APPROVALS_CHECK_NAME,
@@ -93,6 +98,7 @@ class TopLevelComment:
93
98
  identifier: object
94
99
  author_login: str
95
100
  is_bot: bool
101
+ is_notice: bool
96
102
  created_at: datetime
97
103
  updated_at: datetime
98
104
  url: str
@@ -217,7 +223,7 @@ def top_level_finding(
217
223
  Returns:
218
224
  The finding this comment leaves open, or None when it is answered.
219
225
  """
220
- if comment.author_login in all_driver_logins:
226
+ if comment.author_login in all_driver_logins or comment.is_notice:
221
227
  return None
222
228
  last_counted_at = comment.created_at if comment.is_bot else comment.updated_at
223
229
  if latest_driver_time is not None and last_counted_at <= latest_driver_time:
@@ -423,6 +429,9 @@ def parse_top_level_comment(
423
429
  ) -> TopLevelComment:
424
430
  """Read one top-level comment from the REST answer.
425
431
 
432
+ A bot comment whose body carries a notice marker is a notice. A person's
433
+ comment carrying the same text still counts, since anyone can paste it.
434
+
426
435
  Args:
427
436
  all_comment_fields: The comment as the issue comments route reports it.
428
437
 
@@ -432,10 +441,14 @@ def parse_top_level_comment(
432
441
  Raises:
433
442
  ValueError: A timestamp is missing or does not parse.
434
443
  """
444
+ is_bot = _is_bot_author(all_comment_fields)
445
+ body = str(all_comment_fields.get(COMMENT_BODY_KEY) or "")
435
446
  return TopLevelComment(
436
447
  identifier=all_comment_fields.get(COMMENT_IDENTIFIER_KEY),
437
448
  author_login=_comment_author_login(all_comment_fields),
438
- is_bot=_is_bot_author(all_comment_fields),
449
+ is_bot=is_bot,
450
+ is_notice=is_bot
451
+ and any(each_marker in body for each_marker in ALL_NOTICE_COMMENT_MARKERS),
439
452
  created_at=datetime.fromisoformat(str(all_comment_fields.get(CREATED_AT_KEY))),
440
453
  updated_at=datetime.fromisoformat(str(all_comment_fields.get(UPDATED_AT_KEY))),
441
454
  url=str(all_comment_fields.get(HTML_URL_KEY) or ""),
@@ -11,6 +11,7 @@ from collections.abc import Mapping
11
11
  from pathlib import Path
12
12
 
13
13
  from dev_env_scripts_constants.shared_tree_constants import (
14
+ ADVISOR_DIRECTORY_NAME,
14
15
  AGENTS_DIRECTORY_SUFFIX,
15
16
  CLAUDE_CONFIG_DIR_ENV_VAR,
16
17
  DEFAULT_MANAGED_ROOT_NAME,
@@ -18,6 +19,7 @@ from dev_env_scripts_constants.shared_tree_constants import (
18
19
  PROCESS_TREE_KILL_MODULE_FILENAME,
19
20
  SCRIPTS_DIRECTORY_NAME,
20
21
  SHARED_PACKAGE_DIRECTORY_NAME,
22
+ TIER_MODEL_IDS_MODULE_FILENAME,
21
23
  )
22
24
 
23
25
 
@@ -131,3 +133,28 @@ def resolve_shared_process_tree_scripts_directory(
131
133
  PROCESS_TREE_KILL_MODULE_FILENAME,
132
134
  1,
133
135
  )
136
+
137
+
138
+ def resolve_shared_advisor_scripts_directory(
139
+ module_file: str | Path,
140
+ all_environment: Mapping[str, str],
141
+ ) -> Path:
142
+ """Return the advisor scripts directory that holds the tier model ids.
143
+
144
+ Names the advisor sub-package for a caller sitting one level under the
145
+ installed root, such as a dispatcher in the managed ``scripts`` directory.
146
+
147
+ Args:
148
+ module_file: Path of the importing module (``__file__``).
149
+ all_environment: Mapping that may hold ``CLAUDE_CONFIG_DIR``.
150
+
151
+ Returns:
152
+ Directory that should hold ``tier_model_ids.py``.
153
+ """
154
+ return resolve_shared_scripts_directory(
155
+ module_file,
156
+ all_environment,
157
+ ADVISOR_DIRECTORY_NAME,
158
+ TIER_MODEL_IDS_MODULE_FILENAME,
159
+ 1,
160
+ )
@@ -56,6 +56,7 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
56
56
  REASON_GROK_AUTH_FAILED,
57
57
  REASON_PROMPT_FILE_MISSING,
58
58
  REASON_TIMEOUT_OUT_OF_BOUNDS,
59
+ ROLE_TRANSLATOR,
59
60
  RESULT_KEY_ATTEMPTS,
60
61
  RESULT_KEY_OK,
61
62
  RESULT_KEY_OUTPUT,
@@ -865,6 +866,33 @@ def test_tier_three_argv_includes_agent_for_default_role(
865
866
  )
866
867
 
867
868
 
869
+ def test_translator_role_dispatches_the_poteto_agent_on_tier_three(
870
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
871
+ ) -> None:
872
+ prompt_file, working_directory, run_state_directory = _paths(tmp_path)
873
+ call_log = _install_seams(
874
+ monkeypatch,
875
+ grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
876
+ claude_outcome=_claude_served(),
877
+ host_profile=HOST_PROFILE_CLAUDE,
878
+ )
879
+
880
+ spawn_outcome = dispatcher.resolve_worker_spawn(
881
+ role=ROLE_TRANSLATOR,
882
+ prompt_file=prompt_file,
883
+ working_directory=working_directory,
884
+ timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
885
+ is_claude_tier_enabled=True,
886
+ run_state_directory=run_state_directory,
887
+ )
888
+
889
+ assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
890
+ assert call_log.claude_arguments is not None
891
+ agent_flag_index = call_log.claude_arguments.index(AGENT_FLAG)
892
+ assert call_log.claude_arguments[agent_flag_index + 1] == "poteto-agent"
893
+ assert ROLE_TRANSLATOR not in call_log.claude_arguments
894
+
895
+
868
896
  def test_large_prompt_stays_out_of_claude_argv(
869
897
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
870
898
  ) -> None:
@@ -21,6 +21,10 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
21
21
  SCRIPTS_DIRECTORY_NAME,
22
22
  SHARED_PACKAGE_DIRECTORY_NAME,
23
23
  )
24
+ from dev_env_scripts_constants.shared_tree_constants import (
25
+ ADVISOR_DIRECTORY_NAME,
26
+ TIER_MODEL_IDS_MODULE_FILENAME,
27
+ )
24
28
 
25
29
 
26
30
  def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
@@ -143,6 +147,29 @@ def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
143
147
  ).is_file()
144
148
 
145
149
 
150
+ def test_junction_scripts_dir_finds_advisor_scripts_on_unresolved_parent(
151
+ tmp_path: Path,
152
+ ) -> None:
153
+ module_file = _build_junction_scripts_layout(tmp_path)
154
+ expected_scripts_directory = (
155
+ tmp_path
156
+ / "root"
157
+ / SHARED_PACKAGE_DIRECTORY_NAME
158
+ / ADVISOR_DIRECTORY_NAME
159
+ / SCRIPTS_DIRECTORY_NAME
160
+ )
161
+ expected_scripts_directory.mkdir(parents=True)
162
+ (expected_scripts_directory / TIER_MODEL_IDS_MODULE_FILENAME).write_text(
163
+ "", encoding="utf-8"
164
+ )
165
+ resolved_scripts_directory = (
166
+ shared_tree_paths.resolve_shared_advisor_scripts_directory(
167
+ module_file, all_environment={}
168
+ )
169
+ )
170
+ assert resolved_scripts_directory == expected_scripts_directory
171
+
172
+
146
173
  def test_generalized_resolver_serves_a_deeper_anchor(tmp_path: Path) -> None:
147
174
  """A caller three levels under the root resolves its own sub-package.
148
175
 
@@ -58,6 +58,22 @@ def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
58
58
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
59
59
 
60
60
 
61
+ def test_new_reply_length_gate_registration_is_clean(tmp_path: Path) -> None:
62
+ current_document = _hook_document(
63
+ ["hooks/blocking/reply_length_gate.py"],
64
+ '{"hooks": {}}',
65
+ )
66
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
67
+
68
+
69
+ def test_new_edit_marker_gate_registration_is_clean(tmp_path: Path) -> None:
70
+ current_document = _hook_document(
71
+ ["hooks/blocking/edit_marker_gate.py"],
72
+ '{"hooks": {}}',
73
+ )
74
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
75
+
76
+
61
77
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
62
78
  current_document = _hook_document(
63
79
  ["hooks/blocking/new.py"],
@@ -6,6 +6,8 @@ import subprocess
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
+ import pytest
10
+
9
11
  SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
10
12
  if str(SCRIPTS_DIRECTORY) not in sys.path:
11
13
  sys.path.insert(0, str(SCRIPTS_DIRECTORY))
@@ -83,3 +85,55 @@ def test_publisher_script_displays_help_without_authentication() -> None:
83
85
 
84
86
  assert completed_process.returncode == 0
85
87
  assert "usage:" in completed_process.stdout
88
+
89
+
90
+ def _token_arguments(tmp_path: Path, variable_name: str) -> list[str]:
91
+ return [
92
+ "--api-url",
93
+ "http://127.0.0.1:9",
94
+ "--token-environment",
95
+ variable_name,
96
+ "--repository",
97
+ "owner/repository",
98
+ "--pull-number",
99
+ "7",
100
+ "--local-repo",
101
+ str(tmp_path),
102
+ "--manifest",
103
+ str(tmp_path / "manifest.json"),
104
+ "--report",
105
+ str(tmp_path / "report.json"),
106
+ ]
107
+
108
+
109
+ def test_cli_names_missing_token_variable_without_network_access(
110
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
111
+ ) -> None:
112
+ monkeypatch.delenv("PUBLISHER_TEST_TOKEN", raising=False)
113
+ standard_output = io.StringIO()
114
+ standard_error = io.StringIO()
115
+
116
+ exit_code = main(
117
+ _token_arguments(tmp_path, "PUBLISHER_TEST_TOKEN"),
118
+ stdout=standard_output,
119
+ stderr=standard_error,
120
+ )
121
+
122
+ assert exit_code == 3
123
+ assert standard_output.getvalue() == ""
124
+ assert "PUBLISHER_TEST_TOKEN" in standard_error.getvalue()
125
+
126
+
127
+ def test_cli_token_path_reaches_the_api_without_app_settings(
128
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
129
+ ) -> None:
130
+ monkeypatch.setenv("PUBLISHER_TEST_TOKEN", "token")
131
+ standard_error = io.StringIO()
132
+
133
+ exit_code = main(
134
+ _token_arguments(tmp_path, "PUBLISHER_TEST_TOKEN"), stderr=standard_error
135
+ )
136
+
137
+ assert exit_code == 3
138
+ assert "Missing publisher setting" not in standard_error.getvalue()
139
+ assert "refused" in standard_error.getvalue().lower()
@@ -140,6 +140,7 @@ def should_print_the_url_of_a_waiting_top_level_comment(
140
140
  identifier=1,
141
141
  author_login="qodo-merge-pro[bot]",
142
142
  is_bot=True,
143
+ is_notice=False,
143
144
  created_at=posted,
144
145
  updated_at=posted,
145
146
  url=summary_url,
@@ -4,6 +4,8 @@ import sys
4
4
  from datetime import datetime
5
5
  from pathlib import Path
6
6
 
7
+ import pytest
8
+
7
9
  SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
8
10
  if str(SCRIPTS_DIRECTORY) not in sys.path:
9
11
  sys.path.insert(0, str(SCRIPTS_DIRECTORY))
@@ -315,11 +317,13 @@ def top_level_comment(
315
317
  edited_minute: int | None = None,
316
318
  url: str = BOT_SUMMARY_URL,
317
319
  is_bot: bool = True,
320
+ is_notice: bool = False,
318
321
  ) -> model.TopLevelComment:
319
322
  return model.TopLevelComment(
320
323
  identifier=posted_minute,
321
324
  author_login=author_login,
322
325
  is_bot=is_bot,
326
+ is_notice=is_notice,
323
327
  created_at=at_minute(posted_minute),
324
328
  updated_at=at_minute(posted_minute if edited_minute is None else edited_minute),
325
329
  url=url,
@@ -445,6 +449,72 @@ def should_open_a_bot_comment_posted_after_the_driver_time() -> None:
445
449
  )
446
450
 
447
451
 
452
+ def should_close_a_bot_notice_posted_after_the_driver_time() -> None:
453
+ comment = top_level_comment(REVIEW_BOT, 5, is_notice=True)
454
+
455
+ assert model.top_level_finding(comment, DRIVING_AGENT, at_minute(4)) is None
456
+
457
+
458
+ @pytest.mark.parametrize(
459
+ "body",
460
+ [
461
+ "No code changes since the last review \u2014 review skipped",
462
+ "[Code review](https://example.test/pull/7#issuecomment-1) by qodo was "
463
+ "updated up to the latest commit https://example.test/commit/abc",
464
+ "<!-- graphite-review-comment -->\n\n### Graphite AI review",
465
+ "<h3>PR Summary by Qodo</h3>\n\nBuild the default map per instance",
466
+ "\n<h3>Qodo is busy working</h3>\n\nCheck back in a few minutes.",
467
+ "\n<h3>Code Review by Qodo</h3>\n<code>\U0001f41e Bugs (0)</code>\n\n"
468
+ "<h3>Great, no issues found!</h3>\nQodo reviewed your code",
469
+ ],
470
+ )
471
+ def should_read_a_bot_notice_as_a_notice(body: str) -> None:
472
+ comment = model.parse_top_level_comment(notice_record(body, "Bot"))
473
+
474
+ assert comment.is_notice
475
+ assert model.top_level_findings((comment,), DRIVING_AGENT) == ()
476
+
477
+
478
+ def should_read_a_person_pasting_notice_text_as_a_comment() -> None:
479
+ comment = model.parse_top_level_comment(
480
+ notice_record("No code changes since the last review", "User")
481
+ )
482
+
483
+ assert not comment.is_notice
484
+ assert len(model.top_level_findings((comment,), DRIVING_AGENT)) == 1
485
+
486
+
487
+ def should_read_a_bot_finding_as_a_comment() -> None:
488
+ comment = model.parse_top_level_comment(
489
+ notice_record("A finding: the loop never ends.", "Bot")
490
+ )
491
+
492
+ assert not comment.is_notice
493
+
494
+
495
+ def should_read_a_qodo_review_with_bugs_as_a_comment() -> None:
496
+ comment = model.parse_top_level_comment(
497
+ notice_record(
498
+ "\n<h3>Code Review by Qodo</h3>\n<code>\U0001f41e Bugs (2)</code>\n\n"
499
+ "1. The loop never ends.",
500
+ "Bot",
501
+ )
502
+ )
503
+
504
+ assert not comment.is_notice
505
+
506
+
507
+ def notice_record(body: str, user_type: str) -> dict[str, object]:
508
+ return {
509
+ "id": 1,
510
+ "user": {"login": REVIEW_BOT, "type": user_type},
511
+ "body": body,
512
+ "created_at": "2026-09-26T12:05:00+00:00",
513
+ "updated_at": "2026-09-26T12:05:00+00:00",
514
+ "html_url": BOT_SUMMARY_URL,
515
+ }
516
+
517
+
448
518
  def should_count_waiting_top_level_comments_among_the_findings() -> None:
449
519
  all_findings = model.all_open_findings(
450
520
  (), (top_level_comment(REVIEW_BOT, 1),), DRIVING_AGENT, None