claude-dev-env 8.21.0 → 8.23.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 (45) hide show
  1. package/.agents/skills/eli5/SKILL.md +7 -3
  2. package/.agents/skills/eli5/reference/task-seeds.md +5 -4
  3. package/.agents/skills/sync-dev-env/SKILL.md +51 -0
  4. package/.agents/skills/sync-dev-env/scripts/link_plugin_skills.py +211 -0
  5. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/__init__.py +0 -0
  6. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/__init__.py +1 -0
  7. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/constants.py +26 -0
  8. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/test_constants.py +19 -0
  9. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/test___init__.py +5 -0
  10. package/.agents/skills/sync-dev-env/scripts/test_link_plugin_skills.py +142 -0
  11. package/bin/ever-shipped-skills.mjs +1 -0
  12. package/bin/merge_settings_defaults.test.mjs +43 -0
  13. package/hooks/blocking/edit_marker_gate.py +74 -0
  14. package/hooks/blocking/reply_length_gate.py +119 -0
  15. package/hooks/blocking/test_edit_marker_gate.py +110 -0
  16. package/hooks/blocking/test_reply_length_gate.py +193 -0
  17. package/hooks/hooks.json +25 -0
  18. package/hooks/hooks_constants/advisor_rules_prompt_constants.py +43 -0
  19. package/hooks/hooks_constants/edit_marker_gate_constants.py +18 -0
  20. package/hooks/hooks_constants/reply_length_gate_constants.py +28 -0
  21. package/hooks/hooks_constants/test_edit_marker_gate_constants.py +9 -0
  22. package/hooks/hooks_constants/test_reply_length_gate_constants.py +15 -0
  23. package/hooks/session/advisor_rules_prompt.py +76 -0
  24. package/hooks/session/test_advisor_rules_prompt.py +86 -0
  25. package/package.json +1 -1
  26. package/rules/asd-ste100-language.md +1 -0
  27. package/rules/ci-owns-the-gate.md +28 -0
  28. package/rules/memory-stores-durable-facts.md +57 -0
  29. package/rules/review-closure-is-a-check.md +1 -0
  30. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +4 -0
  31. package/scripts/dev_env_scripts_constants/review_closure_constants.py +13 -0
  32. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +4 -0
  33. package/scripts/invoke_code_review.py +4 -1
  34. package/scripts/local_report_cli.py +50 -53
  35. package/scripts/policy_lint/adapter_configuration.py +8 -0
  36. package/scripts/policy_lint/config/constants.py +2 -0
  37. package/scripts/resolve_worker_spawn.py +4 -1
  38. package/scripts/review_closure_model.py +16 -3
  39. package/scripts/shared_tree_paths.py +27 -0
  40. package/scripts/test_resolve_worker_spawn.py +28 -0
  41. package/scripts/test_shared_tree_paths.py +27 -0
  42. package/scripts/tests/test_adapter_configuration.py +16 -0
  43. package/scripts/tests/test_local_report_publisher_cli.py +54 -0
  44. package/scripts/tests/test_review_closure.py +1 -0
  45. package/scripts/tests/test_review_closure_model.py +70 -0
@@ -0,0 +1,86 @@
1
+ """Tests for advisor_rules_prompt: SessionStart hook that injects advisor consult rules."""
2
+
3
+ import json
4
+ import sys
5
+ from io import StringIO
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ _SESSION_DIR = Path(__file__).resolve().parent
12
+ _HOOKS_ROOT = _SESSION_DIR.parent
13
+ _ADVISOR_TOOL_DOC = _HOOKS_ROOT.parent / "docs" / "references" / "advisor-tool.md"
14
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
15
+ if each_sys_path_entry not in sys.path:
16
+ sys.path.insert(0, each_sys_path_entry)
17
+
18
+ import advisor_rules_prompt as starter
19
+
20
+ from hooks_constants.advisor_rules_prompt_constants import (
21
+ ADVISOR_DISABLE_ENV_VAR,
22
+ ADVISOR_RULES_PROMPT,
23
+ ALL_ADVISOR_RULE_SENTENCES,
24
+ CLAUDE_CONFIG_DIR_ENV_VAR,
25
+ )
26
+
27
+
28
+ def _run_main() -> str:
29
+ captured_stdout = StringIO()
30
+ with patch("sys.stdout", captured_stdout):
31
+ starter.main()
32
+ return captured_stdout.getvalue()
33
+
34
+
35
+ @pytest.fixture
36
+ def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
37
+ monkeypatch.setenv(CLAUDE_CONFIG_DIR_ENV_VAR, str(tmp_path))
38
+ monkeypatch.delenv(ADVISOR_DISABLE_ENV_VAR, raising=False)
39
+ return tmp_path
40
+
41
+
42
+ def _write_settings(config_directory: Path, settings: object) -> None:
43
+ (config_directory / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
44
+
45
+
46
+ class TestAdvisorRulesPrompt:
47
+ def test_should_emit_rules_when_advisor_model_is_set(self, config_directory: Path) -> None:
48
+ _write_settings(config_directory, {"advisorModel": "fable"})
49
+ hook_output = json.loads(_run_main())["hookSpecificOutput"]
50
+ assert hook_output["hookEventName"] == "SessionStart"
51
+ assert hook_output["additionalContext"] == ADVISOR_RULES_PROMPT
52
+
53
+ def test_should_emit_nothing_when_advisor_model_is_missing(
54
+ self, config_directory: Path
55
+ ) -> None:
56
+ _write_settings(config_directory, {"permissions": {}})
57
+ assert _run_main() == ""
58
+
59
+ def test_should_emit_nothing_when_advisor_model_is_blank(self, config_directory: Path) -> None:
60
+ _write_settings(config_directory, {"advisorModel": " "})
61
+ assert _run_main() == ""
62
+
63
+ def test_should_emit_nothing_when_settings_file_is_absent(self, config_directory: Path) -> None:
64
+ assert _run_main() == ""
65
+
66
+ def test_should_emit_nothing_when_settings_file_is_not_json(
67
+ self, config_directory: Path
68
+ ) -> None:
69
+ (config_directory / "settings.json").write_text("{not json", encoding="utf-8")
70
+ assert _run_main() == ""
71
+
72
+ def test_should_emit_nothing_when_environment_disables_the_advisor(
73
+ self, config_directory: Path, monkeypatch: pytest.MonkeyPatch
74
+ ) -> None:
75
+ _write_settings(config_directory, {"advisorModel": "fable"})
76
+ monkeypatch.setenv(ADVISOR_DISABLE_ENV_VAR, "1")
77
+ assert _run_main() == ""
78
+
79
+
80
+ class TestAdvisorRulesMatchTheDoc:
81
+ @pytest.mark.parametrize("each_rule_sentence", ALL_ADVISOR_RULE_SENTENCES)
82
+ def test_should_quote_each_sentence_from_advisor_tool_doc(
83
+ self, each_rule_sentence: str
84
+ ) -> None:
85
+ doc_text = " ".join(_ADVISOR_TOOL_DOC.read_text(encoding="utf-8").split())
86
+ assert each_rule_sentence in doc_text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.21.0",
3
+ "version": "8.23.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@ definitions and dictionary decisions.
26
26
  - Use inclusive, neutral language.
27
27
  - Use periods, commas, colons, and bullets to show structure.
28
28
  - Preserve exact quoted labels, identifiers, formulas, titles, and interface text.
29
+ - Send the reader only a result, a blocker, or a question. Leave out a line that says nothing is needed from them, and leave out which agent, session, or coordinator did the work.
29
30
  - Use `WARNING` for a risk of injury or death. Use `CAUTION` for a risk of equipment, tool, or machine damage. State the command or condition first, then state the result.
30
31
  - Aim for 20 words or fewer in a procedure sentence when the technical content allows.
31
32
  - Aim for 25 words or fewer in a descriptive sentence when the technical content allows.
@@ -64,6 +64,34 @@ A cache stays available on one condition. CI derives the key itself from the
64
64
  tree it is about to test, looks for a previous run under that key, and
65
65
  republishes that result. CI computes, CI verifies, CI decides.
66
66
 
67
+ ## A repository that runs no CI
68
+
69
+ An owner can rule that a repository spends no CI minutes. There the local gate
70
+ is the gate, and the agent that drives the pull request runs it on every head
71
+ it asks to merge. The repository carries the gate as a `cde verify` manifest at
72
+ `.claude/local-gate.json`: tests with collection floors, and lint.
73
+
74
+ 1. Check out the pull request head with a clean tree and fetch its base.
75
+ 2. Run the manifest against the base commit the pull request names:
76
+
77
+ ```
78
+ python <cde>/scripts/local_verification/cli.py --manifest .claude/local-gate.json --repo . --base <base sha> --output <outside the repository>/report.json
79
+ ```
80
+
81
+ 3. Publish the report:
82
+
83
+ ```
84
+ python <cde>/scripts/local_report_publisher.py --token-environment GH_TOKEN --repository <owner>/<name> --pull-number <number> --local-repo . --manifest .claude/local-gate.json --report <outside the repository>/report.json
85
+ ```
86
+
87
+ The publisher checks the report against the manifest digest, the clean tree,
88
+ and the live head and base, then posts the `local-checks` commit status. A
89
+ report that fails any of those posts a failure or an error. The branch ruleset
90
+ lists `local-checks` as a required status check, so the host refuses a merge
91
+ until a passing report covers the head, and `agent_merge_check.py` prints
92
+ `HOLD` on the same requirement. A push moves the head and leaves the status
93
+ behind, so each new head runs the gate again.
94
+
67
95
  ## Sibling rules
68
96
 
69
97
  | Rule | Role |
@@ -0,0 +1,57 @@
1
+ # Memory Stores Durable Facts
2
+
3
+ **When this applies:** Before you write or update a file in an auto-memory
4
+ directory, and before you add its line to `MEMORY.md`.
5
+
6
+ ## Rule
7
+
8
+ A memory must still help a fresh session, with no context, weeks later.
9
+
10
+ Run one test on the draft. Strip every date, pull request number, issue number,
11
+ commit, job path, session ID, and task number. When what remains is empty or
12
+ false, the draft is run state. Put it in the task tracker, the pull request, or
13
+ a handoff file beside the work, and save no memory.
14
+
15
+ ## What never becomes a memory
16
+
17
+ | Shape | Example | Where it belongs |
18
+ |---|---|---|
19
+ | Run or program state | "paused at", "resume from", "4 of 11 merged", a pointer to a handoff file | The task tracker or the handoff file |
20
+ | A pull request, issue, stack, or commit map | "#583, #605, #610 merged" | The epic issue or the pull request |
21
+ | A temporary condition | A concurrency schedule, credits out, a fleet that works this week, a fix "until X lands" | The current conversation |
22
+ | One session's topology | A session ID, a parallel session's worktree, a second-account dispatch setup | The current conversation |
23
+ | A workaround for a hook, gate, or verifier as it behaved one day | "the minter never fires, so skip the check" | A fix to the hook, per [`correction-lens.md`](correction-lens.md) |
24
+ | The story of one incident or bug fix | "the fold checkbox took five tries" | Git history and the pull request body |
25
+ | Where code lives, or a design recipe for one build phase | A module layout, a prompt recipe for one engine version | The repository and its inventories |
26
+ | A restatement of a rule file | "CI owns the gate" | The rule file already loaded |
27
+
28
+ ## What a memory holds
29
+
30
+ - A preference or standing order the user stated, with the date the user set it.
31
+ - A trap in the local setup or the platform that stays true, such as a shared
32
+ `rr-cache` that replays one-sided resolutions without a marker.
33
+ - A pointer to an external resource: a dashboard, a repository, a tracker.
34
+
35
+ Write the fact first. A date belongs only on a rule the user set on that date.
36
+
37
+ ## Keeping the folder current
38
+
39
+ When a memory goes stale, delete it and its `MEMORY.md` line in the same run.
40
+ A "superseded" note on a stale memory keeps a false fact loading into every
41
+ session.
42
+
43
+ ## Why
44
+
45
+ One project's memory folder held 104 index entries. A cleanup pass removed 57
46
+ of them, and each one failed the test above. Handoffs outlived their work,
47
+ pull request maps named merged branches, and a capacity schedule and a
48
+ dispatch topology contradicted newer standing orders. Every stale memory loads
49
+ into each session and reads as current.
50
+
51
+ ## Sibling rules
52
+
53
+ | Rule | Role |
54
+ |---|---|
55
+ | [`correction-lens.md`](correction-lens.md) | A memory records a decision, and the control holds the behavior |
56
+ | [`verify-before-asking.md`](verify-before-asking.md) | A recalled fact is a claim to re-check |
57
+ | [`cleanup-temp-files.md`](cleanup-temp-files.md) | Scratch output leaves with the task |
@@ -25,6 +25,7 @@ It prints `CLOSED` and exits 0 when every finding on the head is answered. It pr
25
25
  | A thread the driving account opened | Itself |
26
26
  | A blocking `Claude Approvals` row | A push, which moves the head the check reports on |
27
27
  | A top-level comment on the pull request | A later top-level comment from the driving account |
28
+ | A bot notice: a review-skipped note, a pointer to an updated summary, a Graphite verdict mirror, a Qodo change summary, a Qodo in-progress placeholder, or a Qodo review that found no issues | Itself |
28
29
 
29
30
  A review bot rewrites its summary comment on each pass. Its edit leaves an answered summary closed, because each new finding it has arrives as a review thread or a new comment. An edit from a person reopens the comment.
30
31
 
@@ -87,12 +87,16 @@ ROLE_BUGTEAM: str = "bugteam"
87
87
  ROLE_CLEAN_CODER: str = "clean-coder"
88
88
  """Compatibility role for FIX and standards-fix using poteto-agent."""
89
89
 
90
+ ROLE_TRANSLATOR: str = "translator"
91
+ """Role the shared_utils translation chain dispatches, served by poteto-agent."""
92
+
90
93
  DEFAULT_ROLE: str = ROLE_BUGTEAM
91
94
  """Role applied when the caller does not pass ``--role``."""
92
95
 
93
96
  ALL_AGENT_FILENAMES_BY_ROLE: dict[str, tuple[str, ...]] = {
94
97
  ROLE_BUGTEAM: ("poteto-agent.md",),
95
98
  ROLE_CLEAN_CODER: ("poteto-agent.md",),
99
+ ROLE_TRANSLATOR: ("poteto-agent.md",),
96
100
  }
97
101
  """Agent definition filenames required under ``agents/`` for each known role."""
98
102
 
@@ -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: