claude-dev-env 8.23.2 → 8.25.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.
@@ -13,6 +13,8 @@ Do not add code comments. Preserve existing comments. Docstrings remain allowed.
13
13
  When a change touches code that an existing comment describes or is attached to, remove that comment in the same change and carry its meaning through clear names and structure. Leave comments tied to untouched code unchanged. Keep comment cleanup inside the requested task.
14
14
  Production and tests follow one rule. Changed directive, TODO, FIXME, HACK, XXX, and type-ignore comments are removed rather than added or justified.
15
15
 
16
+ A keep marker is the one comment that may be added and kept: a comment that opens with `STEALTH: Keep`, or with a prefix the repository lists under `comment_keep_markers` in `.claude/policy-lint.json`.
17
+
16
18
  ---
17
19
 
18
20
  ## CORE PRINCIPLES
@@ -1,8 +1,10 @@
1
1
  """Comment-presence and comment-change checks for Python and JavaScript sources."""
2
2
 
3
3
  import difflib
4
+ import functools
4
5
  import importlib
5
6
  import io
7
+ import json
6
8
  import sys
7
9
  import tokenize
8
10
  from collections import Counter
@@ -29,7 +31,11 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
29
31
  ALL_TOKEN_ANCHORED_DIRECTIVE_BOUNDARY_CHARACTERS,
30
32
  ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES,
31
33
  CHAINED_INLINE_COMMENT_PATTERN,
34
+ COMMENT_KEEP_MARKERS_KEY,
32
35
  MAX_COMMENT_ISSUES,
36
+ REPOSITORY_LINT_SETTINGS_RELATIVE_PATH,
37
+ REPOSITORY_ROOT_MARKER_NAME,
38
+ SETTINGS_TEXT_ENCODING,
33
39
  STEALTH_KEEP_COMMENT_MARKER,
34
40
  )
35
41
  _javascript_comment_scanner = importlib.import_module("javascript_comment_scanner")
@@ -153,27 +159,70 @@ def _python_comment_occurrences(
153
159
  return all_occurrences, True
154
160
 
155
161
 
156
- def _is_stealth_keep_occurrence(comment_text: str) -> bool:
157
- """Return True for a ``# STEALTH: Keep`` occurrence.
162
+ @functools.cache
163
+ def _repository_keep_markers(repository_root: Path) -> tuple[str, ...]:
164
+ settings_path = repository_root / REPOSITORY_LINT_SETTINGS_RELATIVE_PATH
165
+ try:
166
+ all_settings = json.loads(settings_path.read_text(encoding=SETTINGS_TEXT_ENCODING))
167
+ except (OSError, ValueError):
168
+ return ()
169
+ if not isinstance(all_settings, dict):
170
+ return ()
171
+ all_markers = all_settings.get(COMMENT_KEEP_MARKERS_KEY)
172
+ if not isinstance(all_markers, list):
173
+ return ()
174
+ return tuple(each for each in all_markers if isinstance(each, str) and each)
175
+
176
+
177
+ def comment_keep_markers(file_path: str) -> tuple[str, ...]:
178
+ """Return the comment prefixes the comment-change check accepts for a file.
179
+
180
+ ::
181
+
182
+ repo/.claude/policy-lint.json {"comment_keep_markers": ["VENDOR: Keep"]}
183
+ repo/app/main.py ("STEALTH: Keep", "VENDOR: Keep")
184
+ no settings file ("STEALTH: Keep",)
185
+
186
+ The repository that holds the file names its own markers. A missing or
187
+ unreadable settings file adds none.
188
+
189
+ Args:
190
+ file_path: The path of the file under check.
191
+
192
+ Returns:
193
+ The package's built-in marker, then the markers the file's repository
194
+ adds.
195
+ """
196
+ for each_directory in Path(file_path).resolve().parents:
197
+ if (each_directory / REPOSITORY_ROOT_MARKER_NAME).exists():
198
+ return (
199
+ STEALTH_KEEP_COMMENT_MARKER,
200
+ *_repository_keep_markers(each_directory),
201
+ )
202
+ return (STEALTH_KEEP_COMMENT_MARKER,)
203
+
204
+
205
+ def _is_keep_occurrence(comment_text: str, all_keep_markers: tuple[str, ...]) -> bool:
206
+ """Return True for a comment that opens with an accepted keep marker.
158
207
 
159
- ``STEALTH: Keep`` is meant to be freely added and to survive edits to
160
- the line it sits on, unlike ``noqa`` or ``TODO``, which AGENTS.md
161
- requires to be removed rather than added or justified. It is excluded
162
- here rather than through the shared exempt-marker set that
163
- ``check_comment_changes`` deliberately still blocks on add.
208
+ A keep marker is meant to be freely added and to survive edits to the
209
+ line it sits on, unlike ``noqa`` or ``TODO``, which AGENTS.md requires to
210
+ be removed rather than added or justified. It is excluded here rather
211
+ than through the shared exempt-marker set that ``check_comment_changes``
212
+ deliberately still blocks on add.
164
213
  """
165
214
  return comment_text.startswith("#") and comment_text[1:].lstrip().startswith(
166
- STEALTH_KEEP_COMMENT_MARKER
215
+ all_keep_markers
167
216
  )
168
217
 
169
218
 
170
- def _without_stealth_keep_occurrences(
171
- all_occurrences: list[tuple[str, int, bool]]
219
+ def _without_keep_occurrences(
220
+ all_occurrences: list[tuple[str, int, bool]], all_keep_markers: tuple[str, ...]
172
221
  ) -> list[tuple[str, int, bool]]:
173
222
  return [
174
223
  each_occurrence
175
224
  for each_occurrence in all_occurrences
176
- if not _is_stealth_keep_occurrence(each_occurrence[0])
225
+ if not _is_keep_occurrence(each_occurrence[0], all_keep_markers)
177
226
  ]
178
227
 
179
228
 
@@ -181,8 +230,10 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
181
230
  """Check for comment additions or removals between old and new content.
182
231
 
183
232
  Inline and standalone comments are blocking findings when added, except
184
- ``STEALTH: Keep`` occurrences, which are excluded entirely so the marker
185
- can be added and can persist across edits to its line.
233
+ keep-marker occurrences (``STEALTH: Keep`` and any marker the file's
234
+ repository names in ``.claude/policy-lint.json``), which are excluded
235
+ entirely so the marker can be added and can persist across edits to its
236
+ line.
186
237
  Existing comments can be removed when the touched code no longer needs them.
187
238
 
188
239
  When the file is Python and either *old_content* or *new_content* cannot
@@ -205,8 +256,9 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
205
256
  old_occurrences = extract_javascript_comment_occurrences(old_content, True)
206
257
  new_occurrences = extract_javascript_comment_occurrences(new_content, True)
207
258
 
208
- old_occurrences = _without_stealth_keep_occurrences(old_occurrences)
209
- new_occurrences = _without_stealth_keep_occurrences(new_occurrences)
259
+ all_keep_markers = comment_keep_markers(file_path)
260
+ old_occurrences = _without_keep_occurrences(old_occurrences, all_keep_markers)
261
+ new_occurrences = _without_keep_occurrences(new_occurrences, all_keep_markers)
210
262
 
211
263
  old_occurrence_counts = Counter(
212
264
  (each_text, is_inline)
@@ -294,8 +346,9 @@ def _retained_comment_issues(
294
346
  (old_occurrences, old_tokenize_ok), (new_occurrences, new_tokenize_ok) = (_comment_occurrences(old_content, file_path, True), _comment_occurrences(new_content, file_path, True))
295
347
  if not (old_tokenize_ok and new_tokenize_ok):
296
348
  return []
297
- old_occurrences = _without_stealth_keep_occurrences(old_occurrences)
298
- new_occurrences = _without_stealth_keep_occurrences(new_occurrences)
349
+ all_keep_markers = comment_keep_markers(file_path)
350
+ old_occurrences = _without_keep_occurrences(old_occurrences, all_keep_markers)
351
+ new_occurrences = _without_keep_occurrences(new_occurrences, all_keep_markers)
299
352
  all_changed_lines, all_deleted_lines, old_line_by_new_line = _line_diff_data(old_content, new_content)
300
353
  old_line_by_key = {
301
354
  each_key: [each_line for each_text, each_line, each_is_inline in old_occurrences if (each_text, each_is_inline) == each_key]
@@ -4,6 +4,11 @@
4
4
  The gate reads the ``text`` field of the chat tools that post to the user.
5
5
  It denies the call when the text holds more than MAXIMUM_SENTENCE_COUNT
6
6
  sentences, or a sentence longer than MAXIMUM_WORDS_PER_SENTENCE words.
7
+ It also denies a pull request number the reader cannot open::
8
+
9
+ flag: Both land in PR 5256.
10
+ flag: It merged in #4347.
11
+ ok: Both land in [PR 5256](https://github.com/owner/repo/pull/5256).
7
12
  Each non-empty line counts as its own sentence, so a list counts one
8
13
  sentence per item. URLs, markdown link targets, inline code spans, and
9
14
  fenced blocks carry no words.
@@ -34,6 +39,7 @@ from hooks_constants.reply_length_gate_constants import (
34
39
  LINE_BREAK_PATTERN,
35
40
  LINK_TARGET_PATTERN,
36
41
  LONG_SENTENCE_MESSAGE,
42
+ MARKDOWN_LINK_PATTERN,
37
43
  MAXIMUM_SENTENCE_COUNT,
38
44
  MAXIMUM_WORDS_PER_SENTENCE,
39
45
  RETRY_INSTRUCTION,
@@ -44,6 +50,8 @@ from hooks_constants.reply_length_gate_constants import (
44
50
  TOO_MANY_SENTENCES_MESSAGE,
45
51
  TOOL_INPUT_KEY,
46
52
  TOOL_NAME_KEY,
53
+ UNLINKED_PULL_REQUEST_MESSAGE,
54
+ UNLINKED_PULL_REQUEST_PATTERN,
47
55
  URL_PATTERN,
48
56
  WORD_PATTERN,
49
57
  WORD_SEPARATOR,
@@ -87,6 +95,17 @@ def length_violation(reply_text: str) -> str | None:
87
95
  return None
88
96
 
89
97
 
98
+ def unlinked_pull_request_violation(reply_text: str) -> str | None:
99
+ """Return the deny reason for a pull request number outside a link, or None."""
100
+ without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
101
+ without_code = INLINE_CODE_PATTERN.sub(" ", without_fences)
102
+ without_links = MARKDOWN_LINK_PATTERN.sub(" ", without_code)
103
+ unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(URL_PATTERN.sub(" ", without_links))
104
+ if unlinked_match is None:
105
+ return None
106
+ return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
107
+
108
+
90
109
  def main() -> int:
91
110
  hook_input = read_hook_input_dictionary_from_stdin()
92
111
  if hook_input is None:
@@ -100,7 +119,7 @@ def main() -> int:
100
119
  reply_text = tool_input.get(TEXT_KEY)
101
120
  if not isinstance(reply_text, str):
102
121
  return ALLOW_EXIT_CODE
103
- violation = length_violation(reply_text)
122
+ violation = length_violation(reply_text) or unlinked_pull_request_violation(reply_text)
104
123
  if violation is None:
105
124
  return ALLOW_EXIT_CODE
106
125
  block_reason = violation + RETRY_INSTRUCTION
@@ -13,6 +13,7 @@ a string still flag.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import json
16
17
  import sys
17
18
  import importlib
18
19
  from pathlib import Path
@@ -413,3 +414,46 @@ def test_check_comment_changes_accepts_distant_untouched_comment() -> None:
413
414
  new_content = "# TODO #999: distant note\n\ntotal = 2\n"
414
415
  issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "totals.py")
415
416
  assert not any("still on the changed lines" in each_issue for each_issue in issues)
417
+
418
+
419
+ def _repository_file(tmp_path: Path, all_keep_markers: list[str] | None) -> str:
420
+ (tmp_path / ".git").mkdir()
421
+ if all_keep_markers is not None:
422
+ (tmp_path / ".claude").mkdir()
423
+ (tmp_path / ".claude" / "policy-lint.json").write_text(
424
+ json.dumps({"comment_keep_markers": all_keep_markers}), encoding="utf-8"
425
+ )
426
+ return str(tmp_path / "app" / "main.py")
427
+
428
+
429
+ def test_check_comment_changes_accepts_a_marker_the_repository_names(
430
+ tmp_path: Path,
431
+ ) -> None:
432
+ file_path = _repository_file(tmp_path, ["VENDOR: Keep"])
433
+ issues = code_rules_enforcer.check_comment_changes(
434
+ "x = 1\n", "x = 1 # VENDOR: Keep -- reason\n", file_path
435
+ )
436
+ assert issues == []
437
+
438
+
439
+ def test_check_comment_changes_flags_that_marker_in_a_repository_without_it(
440
+ tmp_path: Path,
441
+ ) -> None:
442
+ file_path = _repository_file(tmp_path, None)
443
+ issues = code_rules_enforcer.check_comment_changes(
444
+ "x = 1\n", "x = 1 # VENDOR: Keep -- reason\n", file_path
445
+ )
446
+ assert len(issues) == 1
447
+ assert "comment added" in issues[0]
448
+
449
+
450
+ def test_check_comment_changes_accepts_a_retained_repository_marker_on_a_touched_line(
451
+ tmp_path: Path,
452
+ ) -> None:
453
+ file_path = _repository_file(tmp_path, ["VENDOR: Keep"])
454
+ issues = code_rules_enforcer.check_comment_changes(
455
+ "x = 1 # VENDOR: Keep -- reason\n",
456
+ "x = 2 # VENDOR: Keep -- reason\n",
457
+ file_path,
458
+ )
459
+ assert issues == []
@@ -191,3 +191,47 @@ def test_should_allow_a_call_without_text(
191
191
  def test_should_allow_empty_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
192
192
  monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"")))
193
193
  assert reply_length_gate.main() == 0
194
+
195
+
196
+ def test_should_deny_a_pr_number_with_no_link_and_name_it(
197
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
198
+ ) -> None:
199
+ exit_code, stderr_text = run_gate(
200
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Both land in PR 5256."}
201
+ )
202
+ assert exit_code == 2
203
+ assert "PR 5256" in stderr_text
204
+
205
+
206
+ def test_should_deny_a_bare_hash_number(
207
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
208
+ ) -> None:
209
+ exit_code, stderr_text = run_gate(
210
+ monkeypatch, capsys, POST_TOOL_NAME, {"text": "It merged in #4347."}
211
+ )
212
+ assert exit_code == 2
213
+ assert "#4347" in stderr_text
214
+
215
+
216
+ def test_should_allow_a_pr_number_inside_its_link(
217
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
218
+ ) -> None:
219
+ exit_code, stderr_text = run_gate(
220
+ monkeypatch,
221
+ capsys,
222
+ REPLY_TOOL_NAME,
223
+ {"text": "Both land in [PR 5256](https://github.com/owner/repo/pull/5256)."},
224
+ )
225
+ assert (exit_code, stderr_text) == (0, "")
226
+
227
+
228
+ def test_should_allow_a_bare_pull_request_url(
229
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
230
+ ) -> None:
231
+ exit_code, _ = run_gate(
232
+ monkeypatch,
233
+ capsys,
234
+ REPLY_TOOL_NAME,
235
+ {"text": "Pull request: https://github.com/owner/repo/pull/5256"},
236
+ )
237
+ assert exit_code == 0
@@ -136,6 +136,10 @@ ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
136
136
  )
137
137
  ALL_TOKEN_ANCHORED_DIRECTIVE_BOUNDARY_CHARACTERS: frozenset[str] = frozenset({":"})
138
138
  STEALTH_KEEP_COMMENT_MARKER: str = "STEALTH: Keep"
139
+ REPOSITORY_LINT_SETTINGS_RELATIVE_PATH: str = ".claude/policy-lint.json"
140
+ COMMENT_KEEP_MARKERS_KEY: str = "comment_keep_markers"
141
+ REPOSITORY_ROOT_MARKER_NAME: str = ".git"
142
+ SETTINGS_TEXT_ENCODING: str = "utf-8"
139
143
  ALL_FREE_FORM_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
140
144
  "type:",
141
145
  "TODO",
@@ -15,6 +15,10 @@ FENCED_BLOCK_PATTERN = re.compile(r"```.*?(?:```|\Z)", re.DOTALL)
15
15
  INLINE_CODE_PATTERN = re.compile(r"`[^`\n]*`")
16
16
  LINK_TARGET_PATTERN = re.compile(r"\]\([^)\s]*\)")
17
17
  URL_PATTERN = re.compile(r"(?:https?://|www\.)\S+")
18
+ MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]\n]*\]\([^)\s]*\)")
19
+ UNLINKED_PULL_REQUEST_PATTERN = re.compile(
20
+ r"\b(?:PRs?|pull requests?)\s*#?\d+|(?<![\w/&])#\d+", re.IGNORECASE
21
+ )
18
22
  LINE_BREAK_PATTERN = re.compile(r"\n+")
19
23
  SENTENCE_END_PATTERN = re.compile(r"(?<=[.!?])\s+")
20
24
  WORD_PATTERN = re.compile(r"[A-Za-z0-9]+(?:['.,-][A-Za-z0-9]+)*")
@@ -23,6 +27,7 @@ WORD_SEPARATOR = " "
23
27
  SENTENCE_PREVIEW_SUFFIX = "..."
24
28
  RETRY_INSTRUCTION = " Cut the text and resend the call."
25
29
  TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
30
+ UNLINKED_PULL_REQUEST_MESSAGE = 'Pull request "{reference}" has no link. Write it as [PR N](https://github.com/<owner>/<repo>/pull/N).'
26
31
  LONG_SENTENCE_MESSAGE = (
27
32
  'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
28
33
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.23.2",
3
+ "version": "8.25.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,8 @@ A red circle marks a finding a review states as blocking, so resolution in silen
33
33
 
34
34
  The driving account is the one that opened the pull request. Where the agent comments under a second login, `--driver-login <login>` names it, repeatably.
35
35
 
36
+ A repository whose review bots post notices this package does not know passes each one's marker text with `--notice-marker <text>`, repeatably. A bot comment carrying that text closes itself, like the built-in notices above.
37
+
36
38
  ## Where the check runs
37
39
 
38
40
  `.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, on a review comment, and on a top-level comment posted or edited on a pull request. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
@@ -131,6 +131,12 @@ EXIT_FALLTHROUGH: int = 1
131
131
  CLI_ROLE_FLAG: str = "--role"
132
132
  """CLI flag naming the role whose agent definition set must be present."""
133
133
 
134
+ CLI_AGENT_FLAG: str = "--agent"
135
+ """CLI flag naming the agent definition stem that serves an unregistered role."""
136
+
137
+ AGENT_DEFINITION_SUFFIX: str = ".md"
138
+ """File suffix of an agent definition under ``agents/``."""
139
+
134
140
  CLI_PING_FLAG: str = "--ping"
135
141
  """CLI flag that enables the opt-in cached live single-turn ping."""
136
142
 
@@ -203,6 +203,12 @@ DRIVER_LOGIN_ARGUMENT_HELP: str = (
203
203
  )
204
204
  """Help text for the driver login argument."""
205
205
 
206
+ NOTICE_MARKER_ARGUMENT_HELP: str = (
207
+ "Text that marks a bot comment as a notice on this repository, beside the "
208
+ "built-in markers. Repeatable."
209
+ )
210
+ """Help text for the notice marker argument."""
211
+
206
212
  CLOSED_VERDICT_LABEL: str = "CLOSED"
207
213
  """Verdict label for a pull request with every finding answered."""
208
214
 
@@ -12,7 +12,8 @@ Checks run in order and stop at the first failure:
12
12
  running workers.
13
13
  3. ``claude-dev-env`` is installed for the requested role: the install manifest
14
14
  exists in the user Claude config directory, and every agent definition file
15
- that role needs is present under ``agents/``.
15
+ that role needs is present under ``agents/``. A caller that names its own
16
+ agent needs that one definition file.
16
17
 
17
18
  Opt-in ``--ping`` runs one cached live single-turn call with a run-scoped TTL.
18
19
  The cache file lives under the caller-supplied run state directory. Auth failure
@@ -54,6 +55,7 @@ from dataclasses import dataclass
54
55
  from pathlib import Path
55
56
 
56
57
  from dev_env_scripts_constants.grok_worker_constants import (
58
+ AGENT_DEFINITION_SUFFIX,
57
59
  AGENTS_SUBDIRECTORY,
58
60
  ALL_AGENT_FILENAMES_BY_ROLE,
59
61
  ALL_AUTH_FAILURE_SIGNATURES,
@@ -277,14 +279,20 @@ def _is_grok_binary_resolvable() -> bool:
277
279
  return preflight_which(GROK_BINARY_NAME) is not None
278
280
 
279
281
 
280
- def _is_claude_dev_env_config_present(role: str) -> bool:
282
+ def _required_agent_filenames(role: str, agent: str | None) -> tuple[str, ...] | None:
283
+ if agent is not None:
284
+ return (f"{agent}{AGENT_DEFINITION_SUFFIX}",)
285
+ return ALL_AGENT_FILENAMES_BY_ROLE.get(role)
286
+
287
+
288
+ def _is_claude_dev_env_config_present(role: str, agent: str | None = None) -> bool:
281
289
  if not _install_manifest_path().is_file():
282
290
  return False
283
291
  try:
284
292
  poteto_mode_skill_path()
285
293
  except OSError:
286
294
  return False
287
- all_agent_filenames = ALL_AGENT_FILENAMES_BY_ROLE.get(role)
295
+ all_agent_filenames = _required_agent_filenames(role, agent)
288
296
  if all_agent_filenames is None:
289
297
  return False
290
298
  agents_root = _agents_directory()
@@ -390,6 +398,7 @@ def run_preflight(
390
398
  role: str,
391
399
  should_ping: bool,
392
400
  run_state_directory: Path,
401
+ agent: str | None = None,
393
402
  ) -> PreflightOutcome:
394
403
  """Evaluate whether tier 1 (grok headless) is usable.
395
404
 
@@ -398,6 +407,9 @@ def run_preflight(
398
407
  should_ping: When True, run the opt-in cached live single-turn ping.
399
408
  run_state_directory: Run-scoped directory for leader sockets and cache.
400
409
  Best-effort mkdir when missing; callers need not create it first.
410
+ agent: The agent definition stem a caller names for its own role.
411
+ When set, that one definition file must be installed in place of
412
+ the role's registered set.
401
413
 
402
414
  Returns:
403
415
  The soft-gate outcome. Callers treat a non-usable outcome as fallthrough,
@@ -412,7 +424,7 @@ def run_preflight(
412
424
  auth_outcome = _probe_grok_auth(run_state_directory)
413
425
  if not auth_outcome.is_usable:
414
426
  return auth_outcome
415
- if not _is_claude_dev_env_config_present(role):
427
+ if not _is_claude_dev_env_config_present(role, agent):
416
428
  return _fallthrough(REASON_CLAUDE_DEV_ENV_CONFIG_MISSING)
417
429
  if should_ping:
418
430
  return _probe_grok_ping(run_state_directory)
@@ -15,9 +15,9 @@ Walk order:
15
15
 
16
16
  Import ``resolve_worker_spawn`` for the outcome object, or run as a CLI::
17
17
 
18
- python resolve_worker_spawn.py --role <role> --prompt-file <path>
19
- --cwd <dir> --timeout-seconds N --run-temp-dir <dir>
20
- [--enable-claude-tier]
18
+ python resolve_worker_spawn.py --role <role> [--agent <stem>]
19
+ --prompt-file <path> --cwd <dir> --timeout-seconds N
20
+ --run-temp-dir <dir> [--enable-claude-tier]
21
21
  """
22
22
 
23
23
  from __future__ import annotations
@@ -72,6 +72,7 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
72
72
  ATTEMPT_KEY_OK,
73
73
  ATTEMPT_KEY_REASON,
74
74
  ATTEMPT_KEY_TIER,
75
+ CLI_AGENT_FLAG,
75
76
  CLI_ENABLE_CLAUDE_TIER_FLAG,
76
77
  CLI_ROLE_FLAG,
77
78
  CLI_RUN_STATE_DIR_FLAG,
@@ -284,13 +285,12 @@ def _served_claude_outcome(
284
285
 
285
286
  def _run_tier_claude_headless(
286
287
  *,
287
- role: str,
288
+ agent_name: str,
288
289
  prompt_file: Path,
289
290
  working_directory: Path,
290
291
  timeout_seconds: int,
291
292
  all_attempts: list[SpawnAttempt],
292
293
  ) -> SpawnOutcome:
293
- agent_name = _primary_agent_name_for_role(role)
294
294
  all_claude_arguments = _build_claude_arguments(agent_name=agent_name)
295
295
  try:
296
296
  prompt_stdin = prompt_file.open(encoding=UTF8_ENCODING)
@@ -310,7 +310,7 @@ def _run_tier_claude_headless(
310
310
 
311
311
  def _after_grok_fallthrough(
312
312
  *,
313
- role: str,
313
+ agent_name: str,
314
314
  prompt_file: Path,
315
315
  working_directory: Path,
316
316
  timeout_seconds: int,
@@ -325,7 +325,7 @@ def _after_grok_fallthrough(
325
325
  all_attempts, returncode=fallthrough_returncode
326
326
  )
327
327
  return _run_tier_claude_headless(
328
- role=role,
328
+ agent_name=agent_name,
329
329
  prompt_file=prompt_file,
330
330
  working_directory=working_directory,
331
331
  timeout_seconds=timeout_seconds,
@@ -335,13 +335,12 @@ def _after_grok_fallthrough(
335
335
 
336
336
  def _run_tier_grok(
337
337
  *,
338
- role: str,
338
+ agent_name: str,
339
339
  prompt_file: Path,
340
340
  working_directory: Path,
341
341
  run_state_directory: Path,
342
342
  timeout_seconds: int,
343
343
  ) -> GrokRunnerOutcome:
344
- agent_name = _primary_agent_name_for_role(role)
345
344
  return spawn_grok_runner(
346
345
  prompt_file=prompt_file,
347
346
  working_directory=working_directory,
@@ -354,7 +353,7 @@ def _run_tier_grok(
354
353
  def _record_preflight_fallthrough(
355
354
  preflight_outcome: PreflightOutcome,
356
355
  *,
357
- role: str,
356
+ agent_name: str,
358
357
  prompt_file: Path,
359
358
  working_directory: Path,
360
359
  timeout_seconds: int,
@@ -362,7 +361,7 @@ def _record_preflight_fallthrough(
362
361
  ) -> SpawnOutcome:
363
362
  all_attempts = [_attempt(TIER_GROK, is_ok=False, reason=preflight_outcome.reason)]
364
363
  return _after_grok_fallthrough(
365
- role=role,
364
+ agent_name=agent_name,
366
365
  prompt_file=prompt_file,
367
366
  working_directory=working_directory,
368
367
  timeout_seconds=timeout_seconds,
@@ -387,7 +386,7 @@ def _record_grok_success(
387
386
  def _record_grok_fallthrough(
388
387
  grok_outcome: GrokRunnerOutcome,
389
388
  *,
390
- role: str,
389
+ agent_name: str,
391
390
  prompt_file: Path,
392
391
  working_directory: Path,
393
392
  timeout_seconds: int,
@@ -401,7 +400,7 @@ def _record_grok_fallthrough(
401
400
  )
402
401
  ]
403
402
  return _after_grok_fallthrough(
404
- role=role,
403
+ agent_name=agent_name,
405
404
  prompt_file=prompt_file,
406
405
  working_directory=working_directory,
407
406
  timeout_seconds=timeout_seconds,
@@ -419,6 +418,7 @@ def resolve_worker_spawn(
419
418
  timeout_seconds: int,
420
419
  is_claude_tier_enabled: bool,
421
420
  run_state_directory: Path,
421
+ agent: str | None = None,
422
422
  ) -> SpawnOutcome:
423
423
  """Walk the worker-spawn tiers and return the structured outcome.
424
424
 
@@ -433,6 +433,9 @@ def resolve_worker_spawn(
433
433
  timeout_seconds: Timeout applied to each tier invocation.
434
434
  is_claude_tier_enabled: When True, allow tier 3 on a Claude host.
435
435
  run_state_directory: Run-scoped directory for leader sockets and cache.
436
+ agent: The agent definition stem that serves ``role``, named by a
437
+ caller whose role this package does not register. None maps
438
+ ``role`` through the package's own role table.
436
439
 
437
440
  Returns:
438
441
  The dispatcher outcome including the ordered attempts trail.
@@ -443,15 +446,17 @@ def resolve_worker_spawn(
443
446
  config-error exit code.
444
447
  """
445
448
  require_timeout_within_bounds(timeout_seconds)
449
+ agent_name = agent or _primary_agent_name_for_role(role)
446
450
  preflight_outcome: PreflightOutcome = spawn_preflight_runner(
447
451
  role=role,
448
452
  should_ping=False,
449
453
  run_state_directory=run_state_directory,
454
+ agent=agent,
450
455
  )
451
456
  if not preflight_outcome.is_usable:
452
457
  return _record_preflight_fallthrough(
453
458
  preflight_outcome,
454
- role=role,
459
+ agent_name=agent_name,
455
460
  prompt_file=prompt_file,
456
461
  working_directory=working_directory,
457
462
  timeout_seconds=timeout_seconds,
@@ -459,7 +464,7 @@ def resolve_worker_spawn(
459
464
  )
460
465
 
461
466
  grok_outcome = _run_tier_grok(
462
- role=role,
467
+ agent_name=agent_name,
463
468
  prompt_file=prompt_file,
464
469
  working_directory=working_directory,
465
470
  run_state_directory=run_state_directory,
@@ -469,7 +474,7 @@ def resolve_worker_spawn(
469
474
  return _record_grok_success(grok_outcome)
470
475
  return _record_grok_fallthrough(
471
476
  grok_outcome,
472
- role=role,
477
+ agent_name=agent_name,
473
478
  prompt_file=prompt_file,
474
479
  working_directory=working_directory,
475
480
  timeout_seconds=timeout_seconds,
@@ -512,6 +517,14 @@ def _build_argument_parser() -> argparse.ArgumentParser:
512
517
  default=DEFAULT_ROLE,
513
518
  help="Worker role name for preflight; mapped to a primary agent for grok.",
514
519
  )
520
+ parser.add_argument(
521
+ CLI_AGENT_FLAG,
522
+ default=None,
523
+ help=(
524
+ "Agent definition stem that serves the role, for a role this "
525
+ "package does not register."
526
+ ),
527
+ )
515
528
  parser.add_argument(
516
529
  PROMPT_FILE_FLAG,
517
530
  dest="prompt_file",
@@ -654,6 +667,7 @@ def main(all_command_arguments: list[str]) -> int:
654
667
  timeout_seconds=parsed_arguments.timeout_seconds,
655
668
  is_claude_tier_enabled=parsed_arguments.is_claude_tier_enabled,
656
669
  run_state_directory=run_state_directory,
670
+ agent=parsed_arguments.agent,
657
671
  )
658
672
  except WorkerTimeoutOutOfBoundsError as bounds_error:
659
673
  is_config_error = True
@@ -26,6 +26,7 @@ from dev_env_scripts_constants.review_closure_constants import (
26
26
  DRIVER_LOGIN_ARGUMENT_HELP,
27
27
  ERROR_EXIT_CODE,
28
28
  FINDING_LINE_TEMPLATE,
29
+ NOTICE_MARKER_ARGUMENT_HELP,
29
30
  NUMBER_ARGUMENT_HELP,
30
31
  OPEN_EXIT_CODE,
31
32
  SLUG_ARGUMENT_HELP,
@@ -60,7 +61,7 @@ def build_parser() -> argparse.ArgumentParser:
60
61
 
61
62
  Returns:
62
63
  A parser taking a repository slug, a pull request number, and any
63
- number of extra driver logins.
64
+ number of extra driver logins and notice markers.
64
65
  """
65
66
  parser = argparse.ArgumentParser(description=COMMAND_DESCRIPTION)
66
67
  parser.add_argument("slug", help=SLUG_ARGUMENT_HELP)
@@ -71,11 +72,21 @@ def build_parser() -> argparse.ArgumentParser:
71
72
  default=[],
72
73
  help=DRIVER_LOGIN_ARGUMENT_HELP,
73
74
  )
75
+ parser.add_argument(
76
+ "--notice-marker",
77
+ action="append",
78
+ default=[],
79
+ help=NOTICE_MARKER_ARGUMENT_HELP,
80
+ )
74
81
  return parser
75
82
 
76
83
 
77
84
  def closure_report(
78
- slug: str, number: int, all_extra_logins: Sequence[str], token: str
85
+ slug: str,
86
+ number: int,
87
+ all_extra_logins: Sequence[str],
88
+ token: str,
89
+ all_extra_notice_markers: Sequence[str] = (),
79
90
  ) -> ClosureReport:
80
91
  """Read one pull request and judge the findings on its head.
81
92
 
@@ -85,6 +96,8 @@ def closure_report(
85
96
  all_extra_logins: Logins that count as the driving agent beside the
86
97
  account that opened the pull request.
87
98
  token: The GitHub token the reads authenticate with.
99
+ all_extra_notice_markers: Text that marks a bot comment as a notice on
100
+ this repository, beside the built-in markers.
88
101
 
89
102
  Returns:
90
103
  The verdict line and each finding that waits.
@@ -95,7 +108,7 @@ def closure_report(
95
108
  all_pull_request_fields = read_pull_request(slug, number, token)
96
109
  all_findings = all_open_findings(
97
110
  read_review_threads(slug, number, token),
98
- read_top_level_comments(slug, number, token),
111
+ read_top_level_comments(slug, number, token, all_extra_notice_markers),
99
112
  driver_logins(all_pull_request_fields, all_extra_logins),
100
113
  read_approvals_conclusion(slug, head_sha(all_pull_request_fields), token),
101
114
  )
@@ -118,7 +131,11 @@ def main(all_arguments: Sequence[str]) -> int:
118
131
  parsed = build_parser().parse_args(all_arguments)
119
132
  try:
120
133
  report = closure_report(
121
- parsed.slug, parsed.number, parsed.driver_login, github_token()
134
+ parsed.slug,
135
+ parsed.number,
136
+ parsed.driver_login,
137
+ github_token(),
138
+ parsed.notice_marker,
122
139
  )
123
140
  except GitHubError as failure:
124
141
  print(failure, file=sys.stderr)
@@ -23,7 +23,7 @@ import json
23
23
  import os
24
24
  import urllib.error
25
25
  import urllib.request
26
- from collections.abc import Mapping
26
+ from collections.abc import Mapping, Sequence
27
27
 
28
28
  from dev_env_scripts_constants.review_closure_constants import (
29
29
  ALL_THREAD_NODE_KEYS,
@@ -202,7 +202,10 @@ def read_review_comments_by_id(
202
202
 
203
203
 
204
204
  def read_top_level_comments(
205
- slug: str, number: int, token: str
205
+ slug: str,
206
+ number: int,
207
+ token: str,
208
+ all_extra_notice_markers: Sequence[str] = (),
206
209
  ) -> tuple[TopLevelComment, ...]:
207
210
  """Read every top-level comment on a pull request.
208
211
 
@@ -210,6 +213,8 @@ def read_top_level_comments(
210
213
  slug: The repository as ``owner/name``.
211
214
  number: The pull request number.
212
215
  token: The GitHub token the request authenticates with.
216
+ all_extra_notice_markers: Markers the calling repository adds to the
217
+ built-in notice markers.
213
218
 
214
219
  Returns:
215
220
  Each comment posted on the pull request itself, outside any review
@@ -223,7 +228,7 @@ def read_top_level_comments(
223
228
  TOP_LEVEL_COMMENTS_ENDPOINT_TEMPLATE, slug, number, token
224
229
  )
225
230
  return tuple(
226
- _parsed_top_level_comment(each_record)
231
+ _parsed_top_level_comment(each_record, all_extra_notice_markers)
227
232
  for each_record in all_records
228
233
  if isinstance(each_record, Mapping)
229
234
  )
@@ -231,9 +236,10 @@ def read_top_level_comments(
231
236
 
232
237
  def _parsed_top_level_comment(
233
238
  all_comment_fields: Mapping[str, object],
239
+ all_extra_notice_markers: Sequence[str],
234
240
  ) -> TopLevelComment:
235
241
  try:
236
- return parse_top_level_comment(all_comment_fields)
242
+ return parse_top_level_comment(all_comment_fields, all_extra_notice_markers)
237
243
  except ValueError as failure:
238
244
  raise GitHubError(
239
245
  UNREADABLE_TOP_LEVEL_COMMENT_TEMPLATE.format(record=all_comment_fields)
@@ -426,6 +426,7 @@ def comment_records_by_id(
426
426
 
427
427
  def parse_top_level_comment(
428
428
  all_comment_fields: Mapping[str, object],
429
+ all_extra_notice_markers: Sequence[str] = (),
429
430
  ) -> TopLevelComment:
430
431
  """Read one top-level comment from the REST answer.
431
432
 
@@ -434,6 +435,8 @@ def parse_top_level_comment(
434
435
 
435
436
  Args:
436
437
  all_comment_fields: The comment as the issue comments route reports it.
438
+ all_extra_notice_markers: Markers the calling repository adds to the
439
+ built-in ones.
437
440
 
438
441
  Returns:
439
442
  The comment in the shape the closure decision reads.
@@ -448,7 +451,10 @@ def parse_top_level_comment(
448
451
  author_login=_comment_author_login(all_comment_fields),
449
452
  is_bot=is_bot,
450
453
  is_notice=is_bot
451
- and any(each_marker in body for each_marker in ALL_NOTICE_COMMENT_MARKERS),
454
+ and any(
455
+ each_marker in body
456
+ for each_marker in (*ALL_NOTICE_COMMENT_MARKERS, *all_extra_notice_markers)
457
+ ),
452
458
  created_at=datetime.fromisoformat(str(all_comment_fields.get(CREATED_AT_KEY))),
453
459
  updated_at=datetime.fromisoformat(str(all_comment_fields.get(UPDATED_AT_KEY))),
454
460
  url=str(all_comment_fields.get(HTML_URL_KEY) or ""),
@@ -1125,3 +1125,19 @@ def test_ping_incidental_credit_and_port_429_are_not_usage_exhaustion(
1125
1125
  assert outcome.is_usable is False
1126
1126
  assert outcome.reason == REASON_GROK_AUTH_FAILED
1127
1127
  assert outcome.reason != REASON_GROK_USAGE_EXHAUSTED
1128
+
1129
+
1130
+ def test_caller_named_agent_needs_its_own_definition_file(
1131
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1132
+ ) -> None:
1133
+ _write_install_layout(tmp_path)
1134
+ monkeypatch.setattr(preflight, "claude_config_home", lambda: tmp_path)
1135
+
1136
+ assert not preflight._is_claude_dev_env_config_present("vendor-role")
1137
+ assert not preflight._is_claude_dev_env_config_present(
1138
+ "vendor-role", "vendor-agent"
1139
+ )
1140
+ (tmp_path / AGENTS_SUBDIRECTORY / "vendor-agent.md").write_text(
1141
+ "# vendor-agent\n", encoding=UTF8_ENCODING
1142
+ )
1143
+ assert preflight._is_claude_dev_env_config_present("vendor-role", "vendor-agent")
@@ -32,6 +32,7 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
32
32
  )
33
33
  from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
34
34
  AGENT_FLAG,
35
+ CLI_AGENT_FLAG,
35
36
  ALL_AGENT_FILENAMES_BY_ROLE,
36
37
  ATTEMPT_KEY_OK,
37
38
  ATTEMPT_KEY_REASON,
@@ -1328,3 +1329,49 @@ def test_usage_limit_fallover_delivers_full_prompt_to_each_binary(
1328
1329
  assert prompt_text_by_command["claude-profile-c"] == FIXTURE_PROMPT_TEXT
1329
1330
  assert outcome.tier_used == TIER_CLAUDE_HEADLESS
1330
1331
  assert outcome.is_ok is True
1332
+
1333
+
1334
+ def test_caller_named_agent_serves_an_unregistered_role(
1335
+ monkeypatch: pytest.MonkeyPatch,
1336
+ tmp_path: Path,
1337
+ capsys: pytest.CaptureFixture[str],
1338
+ ) -> None:
1339
+ prompt_file, working_directory, run_state_directory = _paths(tmp_path)
1340
+ call_log = _install_seams(
1341
+ monkeypatch,
1342
+ grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
1343
+ claude_outcome=_claude_served(),
1344
+ host_profile=HOST_PROFILE_CLAUDE,
1345
+ )
1346
+ all_preflight_keyword_arguments: list[dict[str, object]] = []
1347
+
1348
+ def recording_preflight(**keyword_arguments: object) -> PreflightOutcome:
1349
+ all_preflight_keyword_arguments.append(dict(keyword_arguments))
1350
+ return _usable_preflight()
1351
+
1352
+ monkeypatch.setattr(dispatcher, "spawn_preflight_runner", recording_preflight)
1353
+
1354
+ exit_code = dispatcher.main(
1355
+ [
1356
+ CLI_ROLE_FLAG,
1357
+ "vendor-role",
1358
+ CLI_AGENT_FLAG,
1359
+ "vendor-agent",
1360
+ PROMPT_FILE_FLAG,
1361
+ str(prompt_file),
1362
+ CWD_FLAG,
1363
+ str(working_directory),
1364
+ CLI_RUN_STATE_DIR_FLAG,
1365
+ str(run_state_directory),
1366
+ CLI_ENABLE_CLAUDE_TIER_FLAG,
1367
+ ]
1368
+ )
1369
+ capsys.readouterr()
1370
+
1371
+ assert exit_code == 0
1372
+ assert all_preflight_keyword_arguments[0]["agent"] == "vendor-agent"
1373
+ assert call_log.grok_keyword_arguments is not None
1374
+ assert call_log.grok_keyword_arguments["agent_name"] == "vendor-agent"
1375
+ assert call_log.claude_arguments is not None
1376
+ agent_flag_index = call_log.claude_arguments.index(AGENT_FLAG)
1377
+ assert call_log.claude_arguments[agent_flag_index + 1] == "vendor-agent"
@@ -151,3 +151,25 @@ def should_print_the_url_of_a_waiting_top_level_comment(
151
151
 
152
152
  assert status == 1
153
153
  assert summary_url in capsys.readouterr().out
154
+
155
+
156
+ def should_pass_each_notice_marker_to_the_top_level_comment_read(
157
+ monkeypatch: pytest.MonkeyPatch,
158
+ ) -> None:
159
+ stub_reads(monkeypatch, (answered_thread(),))
160
+ all_received_markers: list[list[str]] = []
161
+
162
+ def read_comments(
163
+ slug: str, number: int, token: str, all_extra_notice_markers: list[str]
164
+ ) -> tuple[model.TopLevelComment, ...]:
165
+ all_received_markers.append(all_extra_notice_markers)
166
+ return ()
167
+
168
+ monkeypatch.setattr(command, "read_top_level_comments", read_comments)
169
+
170
+ status = command.main(
171
+ [SLUG, "7", "--notice-marker", "<!-- a -->", "--notice-marker", "<!-- b -->"]
172
+ )
173
+
174
+ assert status == 0
175
+ assert all_received_markers == [["<!-- a -->", "<!-- b -->"]]
@@ -289,3 +289,23 @@ def should_report_a_top_level_comment_without_timestamps(
289
289
 
290
290
  with pytest.raises(GitHubError):
291
291
  reader.read_top_level_comments("jl-cmd/claude-dev-env", 7, TOKEN)
292
+
293
+
294
+ def should_read_a_bot_comment_carrying_a_passed_marker_as_a_notice(
295
+ monkeypatch: pytest.MonkeyPatch,
296
+ ) -> None:
297
+ notice_record = {
298
+ **top_level_record(1),
299
+ "user": {"login": "qodo-code-review[bot]", "type": "Bot"},
300
+ "body": "<!-- vendor:trial-expiring -->\n\nYour trial ends soon.",
301
+ }
302
+ answer_with(
303
+ monkeypatch,
304
+ {"issues/7/comments?per_page=100&page=1": (200, [notice_record])},
305
+ )
306
+
307
+ all_comments = reader.read_top_level_comments(
308
+ "jl-cmd/claude-dev-env", 7, TOKEN, ("<!-- vendor:trial-expiring -->",)
309
+ )
310
+
311
+ assert [each.is_notice for each in all_comments] == [True]
@@ -549,3 +549,21 @@ def should_read_a_person_top_level_comment_record() -> None:
549
549
  )
550
550
 
551
551
  assert comment == top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False)
552
+
553
+
554
+ def should_read_a_bot_comment_carrying_a_repository_marker_as_a_notice() -> None:
555
+ body = "<!-- vendor:trial-expiring -->\n\nYour trial ends soon."
556
+
557
+ assert not model.parse_top_level_comment(notice_record(body, "Bot")).is_notice
558
+ assert model.parse_top_level_comment(
559
+ notice_record(body, "Bot"), ("<!-- vendor:trial-expiring -->",)
560
+ ).is_notice
561
+
562
+
563
+ def should_read_a_person_carrying_a_repository_marker_as_a_comment() -> None:
564
+ comment = model.parse_top_level_comment(
565
+ notice_record("<!-- vendor:trial-expiring -->", "User"),
566
+ ("<!-- vendor:trial-expiring -->",),
567
+ )
568
+
569
+ assert not comment.is_notice