claude-dev-env 2.35.0 → 2.36.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 (42) hide show
  1. package/.agents/skills/autoconverge/SKILL.md +19 -15
  2. package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
  3. package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
  4. package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
  5. package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
  6. package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
  7. package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
  8. package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
  9. package/bin/cde.mjs +175 -0
  10. package/bin/cde.test.mjs +222 -0
  11. package/bin/install-constants.mjs +4 -0
  12. package/bin/install.cursor-rules.test.mjs +233 -2
  13. package/bin/install.mjs +49 -3
  14. package/bin/pstack-model-preferences.codex.json +23 -0
  15. package/bin/select-pstack-models.test.mjs +378 -0
  16. package/hooks/hooks_constants/tool_names.py +20 -0
  17. package/hooks/validators/fast_save_validators.py +63 -89
  18. package/hooks/validators/run_all_validators.py +16 -8
  19. package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
  20. package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
  21. package/package.json +3 -2
  22. package/rules/pstack-models.md +119 -0
  23. package/scripts/cde_lint.py +258 -0
  24. package/scripts/policy_lint/__init__.py +41 -0
  25. package/scripts/policy_lint/adapter_configuration.py +91 -0
  26. package/scripts/policy_lint/adapter_detectors.py +328 -0
  27. package/scripts/policy_lint/adapter_pairing.py +145 -0
  28. package/scripts/policy_lint/adapter_support.py +97 -0
  29. package/scripts/policy_lint/adapters.py +352 -0
  30. package/scripts/policy_lint/config/__init__.py +1 -0
  31. package/scripts/policy_lint/config/constants.py +66 -0
  32. package/scripts/policy_lint/engine.py +224 -0
  33. package/scripts/policy_lint/model.py +387 -0
  34. package/scripts/policy_lint/registry.py +162 -0
  35. package/scripts/policy_lint/render.py +91 -0
  36. package/scripts/policy_lint/selection.py +372 -0
  37. package/scripts/policy_lint/selection_git.py +147 -0
  38. package/scripts/select_pstack_models.mjs +304 -0
  39. package/scripts/tests/test_cde_lint.py +374 -0
  40. package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
  41. package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
  42. package/scripts/tests/test_policy_lint_selection.py +373 -0
@@ -0,0 +1,52 @@
1
+ """Prove the policy-linter validator seam keeps native tools separate."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from . import run_all_validators
8
+
9
+ _CLEAN_SOURCE = (
10
+ "def add_two_numbers(first_number: int, second_number: int) -> int:\n"
11
+ " return first_number + second_number\n"
12
+ )
13
+ _OVERLAPPING_VALIDATOR_NAMES = frozenset(
14
+ {"Python Style", "Magic Values", "Type Safety", "Test Safety", "React"}
15
+ )
16
+
17
+
18
+ def _result_names(all_results: list[run_all_validators.ValidatorResult]) -> set[str]:
19
+ return {each_result.name for each_result in all_results}
20
+
21
+
22
+ def test_policy_lint_can_run_only_unique_in_process_validators(tmp_path: Path) -> None:
23
+ all_results = run_all_validators.validate_proposed_file(
24
+ str(tmp_path / "probe.py"),
25
+ _CLEAN_SOURCE,
26
+ include_ruff=False,
27
+ excluded_validator_names=_OVERLAPPING_VALIDATOR_NAMES,
28
+ )
29
+
30
+ all_result_names = _result_names(all_results)
31
+ assert all_result_names.isdisjoint(_OVERLAPPING_VALIDATOR_NAMES)
32
+ assert "Ruff" not in all_result_names
33
+ assert "Abbreviations" in all_result_names
34
+
35
+
36
+ def test_validate_proposed_file_keeps_ruff_as_an_explicit_native_step(
37
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
38
+ ) -> None:
39
+ monkeypatch.setattr(
40
+ run_all_validators,
41
+ "run_ruff_checks",
42
+ lambda _files, _config_source_path: run_all_validators.ValidatorResult(
43
+ "Ruff", "37", True, "clean"
44
+ ),
45
+ )
46
+ all_results = run_all_validators.validate_proposed_file(
47
+ str(tmp_path / "probe.py"),
48
+ _CLEAN_SOURCE,
49
+ excluded_validator_names=_OVERLAPPING_VALIDATOR_NAMES,
50
+ )
51
+
52
+ assert "Ruff" in _result_names(all_results)
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.35.0",
3
+ "version": "2.36.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-dev-env": "bin/install.mjs",
8
- "codex-compat": "bin/codex-compat.mjs"
8
+ "codex-compat": "bin/codex-compat.mjs",
9
+ "cde": "bin/cde.mjs"
9
10
  },
10
11
  "scripts": {
11
12
  "test": "node --test \"bin/*.test.mjs\" \".agents/skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
@@ -0,0 +1,119 @@
1
+ # Pstack delegation policy
2
+
3
+ Pstack uses portable role requirements. Model IDs belong to host preference
4
+ files. The active native subagent tool decides which model IDs the current host
5
+ accepts.
6
+
7
+ This policy replaces the fixed model defaults in pstack skills and setup-pstack's
8
+ legacy step that writes one universal model rule. Setup updates only the selected
9
+ host preference JSON. The installer keeps this portable policy current.
10
+
11
+ ## Setup
12
+
13
+ When setup-pstack runs, read the active native subagent tool metadata. Use the
14
+ host name from that tool or the current host context. Do not infer the host from
15
+ CODEX_HOME, CLAUDE_CONFIG_DIR, or another environment variable.
16
+
17
+ Save choices in the agents home under
18
+ `rules/pstack-model-preferences.<host>.json`. Keep each host in its own file.
19
+ The file contains this shape:
20
+
21
+ ```json
22
+ {
23
+ "host": "current-host",
24
+ "modelsByRole": {
25
+ "feature, refactoring": ["confirmed-host-model"]
26
+ }
27
+ }
28
+ ```
29
+
30
+ Offer only model IDs the active native tool accepts. Keep the user's order.
31
+ Treat `inherit-parent` and `auto` as parent inheritance, not model IDs. A
32
+ different host starts with its own confirmed choices. It never copies the Codex
33
+ preference file.
34
+
35
+ ## Selection before every delegation
36
+
37
+ Refresh the accepted model IDs from the active native subagent tool immediately
38
+ before each native delegation. Run the installed selector once for each agent.
39
+ Use `~/.agents/scripts/select_pstack_models.mjs` for the main profile. For a
40
+ named or explicit profile, use the selector under that profile's sibling agents
41
+ home.
42
+
43
+ Send one JSON object through standard input. Use the active host shell on
44
+ other operating systems:
45
+
46
+ ```powershell
47
+ $selectorInput = @'
48
+ {
49
+ "host": "current-host",
50
+ "inventoryHost": "current-host",
51
+ "role": "how critics",
52
+ "delegationIndex": 0,
53
+ "panel": {
54
+ "agentCount": 3,
55
+ "requiresDistinctModels": false
56
+ },
57
+ "availableModelIds": ["confirmed-host-model"],
58
+ "confirmedSuitableModelIds": [],
59
+ "parentModelId": "current-parent-model",
60
+ "parentFallback": {
61
+ "isAllowed": true,
62
+ "hasMaterialCapabilityLoss": false
63
+ }
64
+ }
65
+ '@
66
+ $selection = $selectorInput | node "$HOME/.agents/scripts/select_pstack_models.mjs"
67
+ ```
68
+
69
+ `availableModelIds` comes from the current native tool metadata.
70
+ `confirmedSuitableModelIds` contains available alternatives whose suitability
71
+ for this role the host metadata or user has confirmed. Do not rank or classify a
72
+ model from its name.
73
+
74
+ Use the returned `nativeSpawnArguments` in the native subagent call. An empty
75
+ object with `omitNativeModelArgument: true` means parent inheritance, so omit
76
+ the native `model` argument. Stop when `canDelegate` is false. Ask the user
77
+ when `requiresUserChoice` is true.
78
+
79
+ Run the selector again before the next agent call. Pass the same panel contract
80
+ and the new delegation index with the refreshed inventory. Report
81
+ `panel.selectedModelIds` and `panel.isCrossModelDiverse` as returned. A panel
82
+ that repeats one model is a same-model panel.
83
+
84
+ ## Portable role requirements
85
+
86
+ feature, refactoring: reliable code execution
87
+ bug-fix: careful diagnosis and code execution
88
+ perf-issue: systems reasoning and performance analysis
89
+ hillclimb: strong iterative judgment
90
+ judgment and prose: strong judgment and clear prose
91
+ hardest tasks: highest available reasoning capability
92
+ how explorer: fast codebase exploration
93
+ how explainer: clear technical synthesis
94
+ how critics: independent technical criticism
95
+ why investigators: fast evidence gathering
96
+ why synthesizer: strong evidence synthesis
97
+ reflect tooling: reliable tool and workflow analysis
98
+ reflect judgment, divergent, synthesizer: strong judgment and divergent analysis
99
+ arena runners: independent solution development
100
+ arena cross-judge pool: independent comparative judgment
101
+ swarm workers: fast bounded task execution
102
+ architect runners: independent architecture design
103
+ interrogate reviewers: independent adversarial review
104
+
105
+ ## Panel requirements
106
+
107
+ Panel size and model diversity are separate from model preferences.
108
+
109
+ - How critics use three agents by default. Cross-model diversity is optional.
110
+ - Arena runners use three agents by default and require distinct models.
111
+ - The arena cross-judge pool selects one judge. Pass `parentModelId` so the
112
+ selector chooses another available preference before the parent's model.
113
+ - Swarm uses the requested worker count. Same-model workers are valid.
114
+ - Architect uses two agents by default and requires distinct models.
115
+ - Interrogate uses three agents by default and requires distinct models.
116
+
117
+ A required-diversity panel stops when the current host lacks enough distinct
118
+ confirmed models. Parent inheritance is the last fallback. When inheritance
119
+ causes material capability loss, stop and ask the user before delegating.
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+ """Run the policy linter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from collections.abc import Callable, Sequence
9
+ from pathlib import Path
10
+ from typing import Never, TextIO
11
+
12
+ from policy_lint.config.constants import INVALID_INPUT_EXIT_CODE
13
+ from policy_lint.engine import lint
14
+ from policy_lint.model import LintReport, LintRequest, SelectionKind, TextDocument
15
+ from policy_lint.render import ReportFormat, render
16
+ from policy_lint.selection import SelectionRunFatal
17
+ from policy_lint.selection_git import GitSelectionError, git_bytes_for
18
+
19
+
20
+ class _LintUsageError(ValueError):
21
+ """Raised when command arguments cannot form a source selection."""
22
+
23
+
24
+ class _LintArgumentParser(argparse.ArgumentParser):
25
+ def error(self, message: str) -> Never:
26
+ """Raise a usage error instead of printing and exiting.
27
+
28
+ Args:
29
+ message: Parser error text.
30
+
31
+ Raises:
32
+ _LintUsageError: Always.
33
+ """
34
+ raise _LintUsageError(message)
35
+
36
+
37
+ def _discover_repository_root(starting_directory: Path) -> Path:
38
+ """Find the Git root that contains the command's current directory.
39
+
40
+ Args:
41
+ starting_directory: Directory where the command started.
42
+
43
+ Returns:
44
+ The resolved Git repository root.
45
+
46
+ Raises:
47
+ _LintUsageError: If the directory is not inside a Git repository.
48
+ """
49
+ try:
50
+ root_bytes = git_bytes_for(
51
+ starting_directory.resolve(), ("rev-parse", "--show-toplevel")
52
+ )
53
+ except (GitSelectionError, OSError) as error:
54
+ raise _LintUsageError("Current directory is not inside a Git repository") from error
55
+ root_text = root_bytes.decode("utf-8").strip()
56
+ if not root_text:
57
+ raise _LintUsageError("Git returned an empty repository root")
58
+ return Path(root_text).resolve()
59
+
60
+
61
+ def _resolve_shell_file_arguments(
62
+ all_arguments: Sequence[str], starting_directory: Path
63
+ ) -> list[str]:
64
+ all_resolved_arguments: list[str] = []
65
+ is_reading_file_paths = False
66
+ for each_argument in all_arguments:
67
+ if each_argument in ("--files", "--text-as"):
68
+ is_reading_file_paths = True
69
+ all_resolved_arguments.append(each_argument)
70
+ continue
71
+ if each_argument.startswith("--"):
72
+ is_reading_file_paths = False
73
+ all_resolved_arguments.append(each_argument)
74
+ continue
75
+ if not is_reading_file_paths:
76
+ all_resolved_arguments.append(each_argument)
77
+ continue
78
+ candidate_path = Path(each_argument)
79
+ all_resolved_arguments.append(
80
+ str(
81
+ candidate_path
82
+ if candidate_path.is_absolute()
83
+ else starting_directory / candidate_path
84
+ )
85
+ )
86
+ return all_resolved_arguments
87
+
88
+
89
+ def _add_file_sources(source_group: argparse._MutuallyExclusiveGroup) -> None:
90
+ source_group.add_argument(
91
+ "--files",
92
+ nargs="+",
93
+ dest="all_file_paths",
94
+ metavar="PATH",
95
+ default=None,
96
+ help="Lint current worktree files.",
97
+ )
98
+ source_group.add_argument(
99
+ "--staged",
100
+ action="store_const",
101
+ const=SelectionKind.STAGED,
102
+ dest="selection_kind",
103
+ help="Lint staged index content.",
104
+ )
105
+
106
+
107
+ def _add_repository_sources(
108
+ source_group: argparse._MutuallyExclusiveGroup,
109
+ ) -> None:
110
+ source_group.add_argument(
111
+ "--base",
112
+ dest="base_revision",
113
+ metavar="REVISION",
114
+ default=None,
115
+ help="Lint changes from a base revision.",
116
+ )
117
+ source_group.add_argument(
118
+ "--repository",
119
+ action="store_const",
120
+ const=SelectionKind.REPOSITORY,
121
+ dest="selection_kind",
122
+ help="Lint tracked worktree files.",
123
+ )
124
+ source_group.add_argument(
125
+ "--text-as",
126
+ dest="text_path",
127
+ metavar="PATH",
128
+ default=None,
129
+ help="Lint one editor buffer from standard input.",
130
+ )
131
+
132
+
133
+ def _build_parser() -> argparse.ArgumentParser:
134
+ parser = _LintArgumentParser(prog="cde lint")
135
+ source_group = parser.add_mutually_exclusive_group(required=True)
136
+ _add_file_sources(source_group)
137
+ _add_repository_sources(source_group)
138
+ parser.add_argument(
139
+ "--format",
140
+ dest="report_format",
141
+ choices=tuple(each_format.value for each_format in ReportFormat),
142
+ default=ReportFormat.TEXT.value,
143
+ help="Report format.",
144
+ )
145
+ parser.set_defaults(selection_kind=None)
146
+ return parser
147
+
148
+
149
+ def _request_from_namespace(
150
+ namespace: argparse.Namespace,
151
+ stdin: TextIO,
152
+ repository_root: Path,
153
+ ) -> LintRequest:
154
+ if namespace.all_file_paths is not None:
155
+ return LintRequest.files(
156
+ repository_root,
157
+ [Path(each_path) for each_path in namespace.all_file_paths],
158
+ )
159
+ if namespace.base_revision is not None:
160
+ return LintRequest.base(repository_root, namespace.base_revision)
161
+ if namespace.text_path is not None:
162
+ return LintRequest(
163
+ repository_root,
164
+ TextDocument(Path(namespace.text_path), stdin.read()),
165
+ )
166
+ if namespace.selection_kind is SelectionKind.STAGED:
167
+ return LintRequest.staged(repository_root)
168
+ if namespace.selection_kind is SelectionKind.REPOSITORY:
169
+ return LintRequest.repository(repository_root)
170
+ raise _LintUsageError("A source selection is required")
171
+
172
+
173
+ def _parse_lint_invocation(
174
+ all_arguments: Sequence[str],
175
+ stdin: TextIO,
176
+ repository_root: Path,
177
+ ) -> tuple[LintRequest, ReportFormat]:
178
+ namespace = _build_parser().parse_args(list(all_arguments))
179
+ return (
180
+ _request_from_namespace(namespace, stdin, repository_root),
181
+ ReportFormat(namespace.report_format),
182
+ )
183
+
184
+
185
+ def _run_linter(
186
+ all_arguments: Sequence[str],
187
+ stdin: TextIO,
188
+ repository_root: Path,
189
+ lint_runner: Callable[[LintRequest], LintReport],
190
+ ) -> tuple[LintReport, ReportFormat]:
191
+ request, report_format = _parse_lint_invocation(
192
+ list(all_arguments),
193
+ stdin,
194
+ repository_root,
195
+ )
196
+ return lint_runner(request), report_format
197
+
198
+
199
+ def _write_invalid_input(error: Exception, stderr: TextIO) -> int:
200
+ stderr.write(f"{error}\n")
201
+ return INVALID_INPUT_EXIT_CODE
202
+
203
+
204
+ def _render_lint(
205
+ all_arguments: Sequence[str],
206
+ repository_root: Path,
207
+ stdin: TextIO,
208
+ stdout: TextIO,
209
+ lint_runner: Callable[[LintRequest], LintReport],
210
+ ) -> int:
211
+ report, report_format = _run_linter(
212
+ all_arguments, stdin, repository_root, lint_runner
213
+ )
214
+ stdout.write(render(report, report_format))
215
+ return report.exit_code
216
+
217
+
218
+ def main(
219
+ all_arguments: Sequence[str],
220
+ repository_root: Path,
221
+ *,
222
+ stdin: TextIO = sys.stdin,
223
+ stdout: TextIO = sys.stdout,
224
+ stderr: TextIO = sys.stderr,
225
+ lint_runner: Callable[[LintRequest], LintReport] = lint,
226
+ ) -> int:
227
+ """Run the policy-lint command.
228
+
229
+ Args:
230
+ all_arguments: Command arguments.
231
+ repository_root: Repository root.
232
+ stdin: Input stream.
233
+ stdout: Output stream.
234
+ stderr: Error stream.
235
+ lint_runner: Policy-lint engine.
236
+
237
+ Returns:
238
+ The process exit code.
239
+ """
240
+ try:
241
+ return _render_lint(
242
+ all_arguments, repository_root, stdin, stdout, lint_runner
243
+ )
244
+ except _LintUsageError as usage_error:
245
+ return _write_invalid_input(usage_error, stderr)
246
+ except SelectionRunFatal as selection_error:
247
+ return _write_invalid_input(selection_error, stderr)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ try:
252
+ shell_directory = Path.cwd()
253
+ shell_repository_root = _discover_repository_root(shell_directory)
254
+ except _LintUsageError as usage_error:
255
+ sys.stderr.write(f"{usage_error}\n")
256
+ raise SystemExit(INVALID_INPUT_EXIT_CODE) from usage_error
257
+ all_shell_arguments = _resolve_shell_file_arguments(sys.argv[1:], shell_directory)
258
+ raise SystemExit(main(all_shell_arguments, shell_repository_root))
@@ -0,0 +1,41 @@
1
+ from .engine import lint
2
+ from .model import (
3
+ BaseChanges,
4
+ ChangeSetRule,
5
+ ContentOrigin,
6
+ Diagnostic,
7
+ Document,
8
+ DocumentRule,
9
+ DocumentSet,
10
+ ExplicitFiles,
11
+ LintReport,
12
+ LintRequest,
13
+ Location,
14
+ RepositoryRule,
15
+ RepositoryTree,
16
+ SelectionKind,
17
+ Severity,
18
+ StagedChanges,
19
+ TextDocument,
20
+ )
21
+
22
+ __all__ = [
23
+ "BaseChanges",
24
+ "ChangeSetRule",
25
+ "ContentOrigin",
26
+ "Diagnostic",
27
+ "Document",
28
+ "DocumentRule",
29
+ "DocumentSet",
30
+ "ExplicitFiles",
31
+ "LintReport",
32
+ "LintRequest",
33
+ "Location",
34
+ "RepositoryRule",
35
+ "RepositoryTree",
36
+ "SelectionKind",
37
+ "Severity",
38
+ "StagedChanges",
39
+ "TextDocument",
40
+ "lint",
41
+ ]
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from . import adapter_support
7
+ from .config import constants
8
+ from .model import Diagnostic, Document
9
+
10
+
11
+ def hook_configuration_diagnostics(
12
+ document: Document, repository_root: Path
13
+ ) -> tuple[Diagnostic, ...]:
14
+ """Check hook configuration for policy decision fields.
15
+
16
+ Args:
17
+ document: Current hook configuration text.
18
+ repository_root: Request repository root for document-path resolution.
19
+
20
+ Returns:
21
+ Hook configuration diagnostics.
22
+ """
23
+ adapter_support._document_path(repository_root, document)
24
+ if document.path.name != "hooks.json":
25
+ return ()
26
+ all_messages = _hook_configuration_messages(document.text)
27
+ return adapter_support._diagnostics_for_messages(
28
+ document, "hook-configuration", all_messages
29
+ )
30
+
31
+
32
+ def _hook_configuration_messages(configuration_text: str) -> tuple[str, ...]:
33
+ try:
34
+ parsed_configuration = json.loads(configuration_text)
35
+ except json.JSONDecodeError:
36
+ return ("Hook configuration is not valid JSON",)
37
+ if not isinstance(parsed_configuration, dict):
38
+ return ("Hook configuration must be an object",)
39
+ all_registered_strings = _registered_hook_strings(parsed_configuration)
40
+ if any(
41
+ _contains_action_boundary_marker(each_string)
42
+ for each_string in all_registered_strings
43
+ ):
44
+ return ("Hook configuration registers a deny, block, or ask policy path",)
45
+ return ()
46
+
47
+
48
+ def _registered_hook_strings(
49
+ all_configuration_entries: dict[object, object]
50
+ ) -> tuple[str, ...]:
51
+ all_registered_strings: list[str] = []
52
+ for each_key, each_entry in all_configuration_entries.items():
53
+ all_registered_strings.extend(
54
+ _registered_strings_for_entry(each_key, each_entry)
55
+ )
56
+ return tuple(all_registered_strings)
57
+
58
+
59
+ def _registered_strings_for_entry(
60
+ each_key: object, each_entry: object
61
+ ) -> tuple[str, ...]:
62
+ all_registered_strings: list[str] = []
63
+ if each_key in constants.ALL_HOOK_REGISTRATION_KEYS and isinstance(each_entry, str):
64
+ all_registered_strings.append(each_entry)
65
+ if each_key in constants.ALL_DECISION_REGISTRATION_KEYS and isinstance(each_entry, str):
66
+ all_registered_strings.append(each_entry)
67
+ all_registered_strings.extend(_registered_nested_strings(each_entry))
68
+ return tuple(all_registered_strings)
69
+
70
+
71
+ def _registered_nested_strings(each_entry: object) -> tuple[str, ...]:
72
+ if isinstance(each_entry, dict):
73
+ return _registered_hook_strings(each_entry)
74
+ if not isinstance(each_entry, list):
75
+ return ()
76
+ all_registered_strings: list[str] = []
77
+ for each_nested_entry in each_entry:
78
+ if isinstance(each_nested_entry, dict):
79
+ all_registered_strings.extend(_registered_hook_strings(each_nested_entry))
80
+ return tuple(all_registered_strings)
81
+
82
+
83
+ def _contains_action_boundary_marker(registered_string: str) -> bool:
84
+ normalized_string = registered_string.lower().replace("\\", "/")
85
+ normalized_string = normalized_string.replace(".", "/").replace("-", "/")
86
+ return any(
87
+ each_segment in constants.ALL_ACTION_BOUNDARY_SEGMENTS
88
+ or each_segment.startswith(constants.ALL_ACTION_BOUNDARY_PREFIXES)
89
+ for each_segment in normalized_string.split("/")
90
+ if each_segment
91
+ )