claude-dev-env 1.35.0 → 1.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/clean-coder.md +109 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +451 -39
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +182 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
- package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
- package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
- package/hooks/blocking/windows_rmtree_blocker.py +23 -6
- package/hooks/config/banned_identifiers_constants.py +24 -0
- package/hooks/config/hardcoded_user_path_constants.py +12 -0
- package/hooks/config/hook_log_extractor_constants.py +1 -1
- package/hooks/config/pre_tool_use_stdin.py +48 -0
- package/hooks/config/setup_project_paths_constants.py +4 -0
- package/hooks/config/stuttering_check_config.py +14 -0
- package/hooks/config/stuttering_import_binding_constants.py +11 -0
- package/hooks/config/sys_path_insert_constants.py +4 -0
- package/hooks/config/test_banned_identifiers_constants.py +48 -0
- package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
- package/hooks/config/test_hook_log_extractor_constants.py +3 -3
- package/hooks/config/test_pre_tool_use_stdin.py +80 -0
- package/hooks/config/unused_module_import_constants.py +7 -0
- package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
- package/hooks/git-hooks/config.py +3 -3
- package/hooks/git-hooks/test_gate_utils.py +10 -10
- package/hooks/mypy.ini +2 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +125 -0
- package/skills/bugteam/CONSTRAINTS.md +12 -6
- package/skills/bugteam/SKILL.md +364 -154
- package/skills/bugteam/SKILL_EVALS.md +25 -23
- package/skills/bugteam/reference/README.md +2 -0
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
- package/skills/bugteam/test_skill_additions.py +13 -4
- package/skills/bugteam/test_team_lifecycle.py +103 -0
- package/skills/findbugs/SKILL.md +3 -3
- package/skills/fixbugs/SKILL.md +4 -4
- package/skills/monitor-open-prs/SKILL.md +32 -2
- package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
- package/skills/pr-converge/SKILL.md +1206 -131
- package/skills/pr-converge/scripts/README.md +145 -0
- package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
- package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
- package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
- package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
- package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
- package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
- package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
- package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
- package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
- package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
- package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
- package/skills/pr-converge/scripts/view_pr_context.py +47 -0
- package/skills/pr-converge/test_team_lifecycle.py +56 -0
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
- package/skills/qbug/SKILL.md +4 -4
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
- package/skills/resume-review/SKILL.md +261 -0
- package/skills/bugteam/scripts/README.md +0 -58
- package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
- package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
- package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
- package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
- /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Tests for trigger_bugbot.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- gh pr comment is invoked with --body-file (per gh-body-file rule)
|
|
5
|
+
- the body file written contains the literal phrase "bugbot run\\n"
|
|
6
|
+
- the comment URL emitted by gh is returned
|
|
7
|
+
- the temp body file is cleaned up
|
|
8
|
+
- subprocess errors propagate
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
from unittest.mock import MagicMock, patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_module() -> ModuleType:
|
|
23
|
+
module_path = Path(__file__).parent / "trigger_bugbot.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("trigger_bugbot", module_path)
|
|
25
|
+
assert spec is not None
|
|
26
|
+
assert spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
trigger_bugbot_module = _load_module()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
36
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
37
|
+
process.stdout = stdout
|
|
38
|
+
process.returncode = 0
|
|
39
|
+
return process
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_invoke_gh_pr_comment_with_body_file_flag() -> None:
|
|
43
|
+
captured_body_paths: list[str] = []
|
|
44
|
+
|
|
45
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
46
|
+
invoked_argv = subprocess_args[0]
|
|
47
|
+
assert "--body-file" in invoked_argv
|
|
48
|
+
body_file_path = invoked_argv[invoked_argv.index("--body-file") + 1]
|
|
49
|
+
captured_body_paths.append(body_file_path)
|
|
50
|
+
return _completed("https://github.com/acme/widget/issues/42#issuecomment-99\n")
|
|
51
|
+
|
|
52
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents) as mock_run:
|
|
53
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
54
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
55
|
+
assert invoked_argv[0:3] == ["gh", "pr", "comment"]
|
|
56
|
+
assert "42" in invoked_argv
|
|
57
|
+
assert "--repo" in invoked_argv
|
|
58
|
+
assert "acme/widget" in invoked_argv
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_should_write_literal_bugbot_run_phrase_into_body_file() -> None:
|
|
62
|
+
captured_body_contents: list[str] = []
|
|
63
|
+
|
|
64
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
65
|
+
invoked_argv = subprocess_args[0]
|
|
66
|
+
body_file_path = Path(invoked_argv[invoked_argv.index("--body-file") + 1])
|
|
67
|
+
captured_body_contents.append(body_file_path.read_text(encoding="utf-8"))
|
|
68
|
+
return _completed("https://example.com\n")
|
|
69
|
+
|
|
70
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents):
|
|
71
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
72
|
+
assert len(captured_body_contents) == 1
|
|
73
|
+
assert captured_body_contents[0] == "bugbot run\n"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_should_return_comment_url_from_gh_stdout() -> None:
|
|
77
|
+
expected_url = "https://github.com/acme/widget/pull/42#issuecomment-12345"
|
|
78
|
+
with patch("subprocess.run") as mock_run:
|
|
79
|
+
mock_run.return_value = _completed(f"{expected_url}\n")
|
|
80
|
+
comment_url = trigger_bugbot_module.trigger_bugbot(
|
|
81
|
+
owner="acme", repo="widget", number=42
|
|
82
|
+
)
|
|
83
|
+
assert comment_url == expected_url
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_should_remove_temp_body_file_after_invocation() -> None:
|
|
87
|
+
captured_body_paths: list[Path] = []
|
|
88
|
+
|
|
89
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
90
|
+
invoked_argv = subprocess_args[0]
|
|
91
|
+
captured_body_paths.append(
|
|
92
|
+
Path(invoked_argv[invoked_argv.index("--body-file") + 1])
|
|
93
|
+
)
|
|
94
|
+
return _completed("https://example.com\n")
|
|
95
|
+
|
|
96
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents):
|
|
97
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
98
|
+
assert len(captured_body_paths) == 1
|
|
99
|
+
assert not captured_body_paths[0].exists()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
103
|
+
failure = subprocess.CalledProcessError(
|
|
104
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
105
|
+
)
|
|
106
|
+
with patch("subprocess.run", side_effect=failure):
|
|
107
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
108
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_should_write_imported_constant_directly_without_local_alias() -> None:
|
|
112
|
+
captured_body_contents: list[str] = []
|
|
113
|
+
|
|
114
|
+
def capture_body_file_contents(*subprocess_args, **_subprocess_kwargs):
|
|
115
|
+
invoked_argv = subprocess_args[0]
|
|
116
|
+
body_file_path = Path(invoked_argv[invoked_argv.index("--body-file") + 1])
|
|
117
|
+
captured_body_contents.append(body_file_path.read_text(encoding="utf-8"))
|
|
118
|
+
return _completed("https://example.com\n")
|
|
119
|
+
|
|
120
|
+
with patch("subprocess.run", side_effect=capture_body_file_contents):
|
|
121
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=99)
|
|
122
|
+
assert len(captured_body_contents) == 1
|
|
123
|
+
assert (
|
|
124
|
+
captured_body_contents[0]
|
|
125
|
+
== trigger_bugbot_module.BUGBOT_RUN_TRIGGER_PHRASE
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_render_repo_arg_via_named_template_constant() -> None:
|
|
130
|
+
with patch("subprocess.run") as mock_run:
|
|
131
|
+
mock_run.return_value = _completed("https://example.com\n")
|
|
132
|
+
trigger_bugbot_module.trigger_bugbot(owner="acme", repo="widget", number=42)
|
|
133
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
134
|
+
expected_repo_arg = trigger_bugbot_module.GH_REPO_ARG_TEMPLATE.format(
|
|
135
|
+
owner="acme", repo="widget"
|
|
136
|
+
)
|
|
137
|
+
assert expected_repo_arg == "acme/widget"
|
|
138
|
+
repo_flag_index = invoked_argv.index("--repo")
|
|
139
|
+
assert invoked_argv[repo_flag_index + 1] == expected_repo_arg
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for view_pr_context.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- gh pr view is invoked with the documented --json field list
|
|
5
|
+
- the parsed JSON object is returned
|
|
6
|
+
- subprocess errors propagate
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_module() -> ModuleType:
|
|
22
|
+
module_path = Path(__file__).parent / "view_pr_context.py"
|
|
23
|
+
spec = importlib.util.spec_from_file_location("view_pr_context", module_path)
|
|
24
|
+
assert spec is not None
|
|
25
|
+
assert spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
view_pr_context_module = _load_module()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
35
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
36
|
+
process.stdout = stdout
|
|
37
|
+
process.returncode = 0
|
|
38
|
+
return process
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_invoke_gh_pr_view_with_documented_field_list() -> None:
|
|
42
|
+
payload = json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"number": 42,
|
|
45
|
+
"url": "https://github.com/acme/widget/pull/42",
|
|
46
|
+
"headRefOid": "abc123",
|
|
47
|
+
"baseRefName": "main",
|
|
48
|
+
"headRefName": "feat/x",
|
|
49
|
+
"isDraft": True,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
with patch("subprocess.run") as mock_run:
|
|
53
|
+
mock_run.return_value = _completed(payload)
|
|
54
|
+
view_pr_context_module.view_pr_context()
|
|
55
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
56
|
+
assert invoked_argv[0:3] == ["gh", "pr", "view"]
|
|
57
|
+
assert "--json" in invoked_argv
|
|
58
|
+
fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
|
|
59
|
+
for required_field in (
|
|
60
|
+
"number",
|
|
61
|
+
"url",
|
|
62
|
+
"headRefOid",
|
|
63
|
+
"baseRefName",
|
|
64
|
+
"headRefName",
|
|
65
|
+
"isDraft",
|
|
66
|
+
):
|
|
67
|
+
assert required_field in fields_arg
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_return_parsed_json_object() -> None:
|
|
71
|
+
payload = {
|
|
72
|
+
"number": 42,
|
|
73
|
+
"url": "https://github.com/acme/widget/pull/42",
|
|
74
|
+
"headRefOid": "abc123",
|
|
75
|
+
"baseRefName": "main",
|
|
76
|
+
"headRefName": "feat/x",
|
|
77
|
+
"isDraft": True,
|
|
78
|
+
}
|
|
79
|
+
with patch("subprocess.run") as mock_run:
|
|
80
|
+
mock_run.return_value = _completed(json.dumps(payload))
|
|
81
|
+
pr_context = view_pr_context_module.view_pr_context()
|
|
82
|
+
assert pr_context == payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
86
|
+
failure = subprocess.CalledProcessError(
|
|
87
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
88
|
+
)
|
|
89
|
+
with patch("subprocess.run", side_effect=failure):
|
|
90
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
91
|
+
view_pr_context_module.view_pr_context()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_should_pass_imported_constant_directly_without_local_alias() -> None:
|
|
95
|
+
payload = json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"number": 7,
|
|
98
|
+
"url": "https://github.com/acme/widget/pull/7",
|
|
99
|
+
"headRefOid": "deadbeef",
|
|
100
|
+
"baseRefName": "main",
|
|
101
|
+
"headRefName": "feat/y",
|
|
102
|
+
"isDraft": False,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
with patch("subprocess.run") as mock_run:
|
|
106
|
+
mock_run.return_value = _completed(payload)
|
|
107
|
+
view_pr_context_module.view_pr_context()
|
|
108
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
109
|
+
fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
|
|
110
|
+
expected_fields = view_pr_context_module.PR_CONTEXT_FIELDS
|
|
111
|
+
assert fields_arg is expected_fields
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Post a `bugbot run` comment to re-trigger a Cursor Bugbot review.
|
|
2
|
+
|
|
3
|
+
Writes the literal trigger phrase to a temp file (per the gh-body-file rule —
|
|
4
|
+
`gh pr comment --body "..."` may corrupt backticks), invokes
|
|
5
|
+
`gh pr comment --body-file`, and removes the temp file on success or failure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
|
|
18
|
+
from evict_cached_config_modules import evict_cached_config_modules
|
|
19
|
+
|
|
20
|
+
evict_cached_config_modules()
|
|
21
|
+
|
|
22
|
+
from config.pr_converge_constants import (
|
|
23
|
+
BUGBOT_RUN_TEMPFILE_PREFIX,
|
|
24
|
+
BUGBOT_RUN_TEMPFILE_SUFFIX,
|
|
25
|
+
BUGBOT_RUN_TRIGGER_PHRASE,
|
|
26
|
+
GH_REPO_ARG_TEMPLATE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def trigger_bugbot(*, owner: str, repo: str, number: int) -> str:
|
|
31
|
+
"""Post the bugbot re-trigger comment, return the comment URL gh emits."""
|
|
32
|
+
file_descriptor, raw_path = tempfile.mkstemp(
|
|
33
|
+
suffix=BUGBOT_RUN_TEMPFILE_SUFFIX, prefix=BUGBOT_RUN_TEMPFILE_PREFIX
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
os.close(file_descriptor)
|
|
37
|
+
body_file_path = Path(raw_path)
|
|
38
|
+
body_file_path.write_text(BUGBOT_RUN_TRIGGER_PHRASE, encoding="utf-8")
|
|
39
|
+
repo_arg = GH_REPO_ARG_TEMPLATE.format(owner=owner, repo=repo)
|
|
40
|
+
gh_command: list[str] = [
|
|
41
|
+
"gh",
|
|
42
|
+
"pr",
|
|
43
|
+
"comment",
|
|
44
|
+
str(number),
|
|
45
|
+
"--repo",
|
|
46
|
+
repo_arg,
|
|
47
|
+
"--body-file",
|
|
48
|
+
str(body_file_path),
|
|
49
|
+
]
|
|
50
|
+
completed = subprocess.run(
|
|
51
|
+
gh_command,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
check=True,
|
|
54
|
+
text=True,
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
errors="replace",
|
|
57
|
+
)
|
|
58
|
+
return completed.stdout.strip()
|
|
59
|
+
finally:
|
|
60
|
+
Path(raw_path).unlink(missing_ok=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
65
|
+
parser.add_argument("--owner", required=True)
|
|
66
|
+
parser.add_argument("--repo", required=True)
|
|
67
|
+
parser.add_argument("--number", required=True, type=int)
|
|
68
|
+
parsed_arguments = parser.parse_args()
|
|
69
|
+
comment_url = trigger_bugbot(
|
|
70
|
+
owner=parsed_arguments.owner, repo=parsed_arguments.repo, number=parsed_arguments.number
|
|
71
|
+
)
|
|
72
|
+
sys.stdout.write(f"{comment_url}\n")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Resolve the per-tick PR context (number, url, head sha, branch names, draft state).
|
|
2
|
+
|
|
3
|
+
Wraps `gh pr view --json ...` so the skill body emits one script invocation
|
|
4
|
+
instead of repeating the field list inline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from evict_cached_config_modules import evict_cached_config_modules
|
|
17
|
+
|
|
18
|
+
evict_cached_config_modules()
|
|
19
|
+
|
|
20
|
+
from config.pr_converge_constants import PR_CONTEXT_FIELDS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def view_pr_context() -> dict[str, object]:
|
|
24
|
+
"""Return the parsed JSON object from `gh pr view --json <fields>`."""
|
|
25
|
+
gh_command: list[str] = ["gh", "pr", "view", "--json", PR_CONTEXT_FIELDS]
|
|
26
|
+
completed = subprocess.run(
|
|
27
|
+
gh_command,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
check=True,
|
|
30
|
+
text=True,
|
|
31
|
+
encoding="utf-8",
|
|
32
|
+
errors="replace",
|
|
33
|
+
)
|
|
34
|
+
return json.loads(completed.stdout)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
39
|
+
parser.parse_args()
|
|
40
|
+
pr_context = view_pr_context()
|
|
41
|
+
json.dump(pr_context, sys.stdout)
|
|
42
|
+
sys.stdout.write("\n")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Markdown assertion tests for pr-converge orchestrator team lifecycle.
|
|
2
|
+
|
|
3
|
+
Locks in the contract that pr-converge multi-PR orchestration must:
|
|
4
|
+
- own a single long-lived team for the whole sweep
|
|
5
|
+
- pass that team to every bugteam invocation via attach mode
|
|
6
|
+
- tear down only when every PR reaches `converged` or `blocked`
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pathlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _skill_text() -> str:
|
|
15
|
+
here = pathlib.Path(__file__).parent
|
|
16
|
+
return (here / "SKILL.md").read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_skill_documents_orchestrator_owned_team_in_multi_pr_mode():
|
|
20
|
+
skill_text = _skill_text()
|
|
21
|
+
assert "team_name" in skill_text
|
|
22
|
+
assert "TeamCreate" in skill_text
|
|
23
|
+
assert "orchestrator" in skill_text.lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_skill_passes_attach_mode_to_bugteam_invocations():
|
|
27
|
+
skill_text = _skill_text()
|
|
28
|
+
assert "BUGTEAM_TEAM_LIFECYCLE" in skill_text
|
|
29
|
+
assert "attach" in skill_text
|
|
30
|
+
assert "BUGTEAM_TEAM_NAME" in skill_text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_skill_tears_down_team_only_on_full_convergence():
|
|
34
|
+
skill_text = _skill_text()
|
|
35
|
+
assert "TeamDelete" in skill_text
|
|
36
|
+
convergence_phrases = [
|
|
37
|
+
"every PR",
|
|
38
|
+
"all PRs",
|
|
39
|
+
"fully converged",
|
|
40
|
+
"every prs[",
|
|
41
|
+
]
|
|
42
|
+
assert any(phrase in skill_text for phrase in convergence_phrases)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_state_schema_includes_team_name_field():
|
|
46
|
+
skill_text = _skill_text()
|
|
47
|
+
assert '"team_name"' in skill_text or "team_name:" in skill_text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_skill_md_physical_lines_fit_eighty_column_limit():
|
|
51
|
+
skill_text = _skill_text()
|
|
52
|
+
for each_line_number, each_physical_line in enumerate(skill_text.splitlines(), 1):
|
|
53
|
+
assert len(each_physical_line) <= 80, (
|
|
54
|
+
"SKILL.md line %s exceeds 80 columns (%s chars)"
|
|
55
|
+
% (each_line_number, len(each_physical_line))
|
|
56
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# AHK auto-continue loop pacing (pr-converge)
|
|
2
|
+
|
|
3
|
+
Load this document when **`ScheduleWakeup` is not available** in this session (orchestrated teams disabled, restricted tool registry, Cursor
|
|
4
|
+
without that primitive, or the user wants a visibly-running pacer). Follow it for **every** instruction below that depends on that choice.
|
|
5
|
+
Shared bugbot / bugteam / Fix protocol steps stay in the main `SKILL.md`.
|
|
6
|
+
|
|
7
|
+
## Session behavior
|
|
8
|
+
|
|
9
|
+
Keep ticks in the **same** window the auto-typer targets so each `continue` re-enters here and reads the same state line and `gh` context.
|
|
10
|
+
|
|
11
|
+
## Why this path exists
|
|
12
|
+
|
|
13
|
+
It is not a separate "mode" the user must remember — bare `/pr-converge` already implies loop-until-done; when the primary wakeup tool is
|
|
14
|
+
missing, fall through to AHK automatically. The per-tick work is unchanged; what changes is who fires the next tick. Instead of
|
|
15
|
+
`ScheduleWakeup` re-entering the skill, an external AutoHotkey utility auto-types `continue` into the active Claude Code window every 5
|
|
16
|
+
minutes, and the model treats each `continue` as the next tick trigger.
|
|
17
|
+
|
|
18
|
+
**AHK is loop pacing only:** every `phase == BUGTEAM` tick still runs **`/bugteam`** via the bugteam skill per Step 2 of the main skill —
|
|
19
|
+
nothing here replaces that audit.
|
|
20
|
+
|
|
21
|
+
**Fix protocol** commits use **`Task`** with **`subagent_type: "generalPurpose"`** and the **clean-coder preamble** from the main
|
|
22
|
+
[`SKILL.md` Fix protocol](../SKILL.md#fix-protocol) section (same as ScheduleWakeup pacing — Cursor has no `clean-coder` `subagent_type`).
|
|
23
|
+
|
|
24
|
+
Ensure `~/.claude/agents/clean-coder.md` exists (Windows: `%USERPROFILE%\.claude\agents\clean-coder.md`). Optionally also copy it to
|
|
25
|
+
`.cursor/agents/clean-coder.md` in the repo when you want the file co-located with the checkout; the spawn **prompt** must still name the
|
|
26
|
+
absolute path the subagent should **Read** first.
|
|
27
|
+
|
|
28
|
+
### One-time setup at the start of the loop
|
|
29
|
+
|
|
30
|
+
The skill bundles its driver scripts under `scripts/` and resolves them at runtime via `$HOME\.claude\skills\pr-converge\scripts\…` (the same
|
|
31
|
+
convention `/logifix` uses). The bundled `.cmd` launchers locate their siblings via `%~dp0`, so they need no path arguments — only the AHK
|
|
32
|
+
target PID.
|
|
33
|
+
|
|
34
|
+
Run these two commands in order (PowerShell-friendly Bash escaping):
|
|
35
|
+
|
|
36
|
+
1. Resolve the PID of the GUI ancestor that hosts this Claude Code session:
|
|
37
|
+
```bash
|
|
38
|
+
pwsh -NoProfile -ExecutionPolicy Bypass -File "$HOME\.claude\skills\pr-converge\scripts\caller-window-pid.ps1"
|
|
39
|
+
```
|
|
40
|
+
Capture the printed integer as `caller_pid`. Verify it points at the right window before continuing:
|
|
41
|
+
```bash
|
|
42
|
+
pwsh -NoProfile -Command "Get-Process -Id $caller_pid | Select-Object Id,ProcessName,MainWindowTitle"
|
|
43
|
+
```
|
|
44
|
+
2. Launch the auto-typer attached to that PID with auto-start enabled. The bundled launcher accepts the PID as its first arg and the
|
|
45
|
+
`--start-on` flag is forwarded to the AHK script:
|
|
46
|
+
```bash
|
|
47
|
+
"$HOME\.claude\skills\pr-converge\scripts\cursor-agents-continue.cmd" $caller_pid --start-on
|
|
48
|
+
```
|
|
49
|
+
AutoHotkey v2 must be installed at `C:\Program Files\AutoHotkey\v2\AutoHotkey64.exe`.
|
|
50
|
+
|
|
51
|
+
### Per-tick behavior under this driver
|
|
52
|
+
|
|
53
|
+
- Run Steps 1–3 of **Per-tick work** in the main `SKILL.md` exactly as written.
|
|
54
|
+
- In **Step 4**, do **not** call `ScheduleWakeup` — the auto-typer is the pacer (this is the fallback branch of Step 4 in the main skill).
|
|
55
|
+
- End every assistant response with the literal sentence `Awaiting next "continue" tick.` so the next iteration is unambiguously identifiable
|
|
56
|
+
in the transcript.
|
|
57
|
+
- When the next user message is `continue` (auto-typed by AHK) or any close paraphrase, treat it as the next tick of default-loop
|
|
58
|
+
`/pr-converge` and re-enter from Step 1 against the freshest PR state.
|
|
59
|
+
|
|
60
|
+
### Convergence cleanup
|
|
61
|
+
|
|
62
|
+
On back-to-back clean (the existing convergence rule in the main skill), run `gh pr ready`, then kill the auto-typer when this session used
|
|
63
|
+
this AHK pacing path:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pwsh -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='AutoHotkey64.exe'\" | Where-Object CommandLine -like '*cursor-agents-continue.ahk*' | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Report convergence in the same one-sentence shape as the standard flow, plus a second sentence noting the auto-typer was stopped. The skill
|
|
70
|
+
returns; no next tick fires.
|
|
71
|
+
|
|
72
|
+
### Gotchas
|
|
73
|
+
|
|
74
|
+
- **Resolver fallback semantics matter.** `caller-window-pid.ps1` walks up the parent process chain, terminates at `explorer.exe`, and falls
|
|
75
|
+
back to the foreground window when no GUI ancestor is found. Always verify `MainWindowTitle` after capture — if it isn't the Claude Code
|
|
76
|
+
session, the auto-typer will fire `continue` into the wrong window and the loop stalls silently.
|
|
77
|
+
- **Tick-duration vs. 5-minute cadence.** The auto-typer fires every 5 minutes regardless of model activity. A tick that runs longer than 5
|
|
78
|
+
minutes will receive a queued `continue` while still in flight; Claude Code processes these sequentially, so there's no corruption, but the
|
|
79
|
+
loop pace becomes irregular. Don't try to "fix" this by shortening the AHK interval — the `bugbot run` cadence already has its own pacing
|
|
80
|
+
baked into the standard flow.
|
|
81
|
+
- **AHK runs as `#SingleInstance Force`.** Re-running the launcher replaces the prior instance silently. Safe to re-issue if the loop appears
|
|
82
|
+
stalled.
|
|
83
|
+
- **`Stop-Process -Force` on `AutoHotkey64` is broad.** It kills every AHK instance, not just the one this skill started. When the user has
|
|
84
|
+
unrelated AHK utilities running, scope the kill by command-line match instead:
|
|
85
|
+
```bash
|
|
86
|
+
pwsh -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='AutoHotkey64.exe'\" | Where-Object CommandLine -like '*cursor-agents-continue.ahk*' | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
|
|
87
|
+
```
|
|
88
|
+
- **State-line responsibility is unchanged.** The state line (phase, bugbot_clean_at, inline_lag_streak, tick_count) is still emitted at the
|
|
89
|
+
end of every tick — it's how the next tick reads prior state. The auto-typer only fires `continue`; it does not preserve state for you.
|
|
90
|
+
- **No `tick_count` ceiling.** `tick_count` is observability-only (same as the main skill and `state-schema.md`). This path ends on convergence or **Stop conditions** in `SKILL.md`, not on a tick counter.
|
|
91
|
+
- **`/bugteam` is not optional for BUGTEAM ticks.** AHK only paces **when** the next tick runs; it does not replace the bugteam skill. Skipping
|
|
92
|
+
**`/bugteam`** after a clean Bugbot review breaks the back-to-back contract.
|
|
93
|
+
- **Fix protocol:** use **`Task` + `generalPurpose`** with the clean-coder **Read** preamble from the main [`SKILL.md` Fix protocol](../SKILL.md#fix-protocol)
|
|
94
|
+
(never a bare `generalPurpose` production edit). Ensure the clean-coder agent markdown exists at `~/.claude/agents/clean-coder.md` (Windows:
|
|
95
|
+
`%USERPROFILE%\.claude\agents\clean-coder.md`); copy into `.cursor/agents/` only if you want a repo-local duplicate.
|
|
96
|
+
|
|
97
|
+
## BUGBOT inline-lag (this path only)
|
|
98
|
+
|
|
99
|
+
When Step 2 BUGBOT branch c routes to API lag and you are on **this** pacing path: complete Step 4 per **Per-tick behavior under this driver**
|
|
100
|
+
above (fixed AHK cadence — there is no 60s shortcut). The inline comments should appear on the next tick.
|
|
101
|
+
|
|
102
|
+
## Convergence
|
|
103
|
+
|
|
104
|
+
On back-to-back clean: stop the auto-typer per **Convergence cleanup** above; omit `ScheduleWakeup` (not used on this path).
|
|
105
|
+
|
|
106
|
+
## Stop / safety (this path)
|
|
107
|
+
|
|
108
|
+
On hard blockers or user stop: omit loop pacing and stop the AHK auto-typer if it was started, per main skill **Stop conditions**. Use the same **scoped** `Get-CimInstance` / `Stop-Process` command as **Convergence cleanup** (command-line match on `cursor-agents-continue.ahk`) so unrelated AutoHotkey instances are not killed.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ScheduleWakeup loop pacing (pr-converge)
|
|
2
|
+
|
|
3
|
+
Load this document when **`ScheduleWakeup` is available** in the parent harness session and you use it for converge **loop pacing** (primary /
|
|
4
|
+
orchestrated-teams path). Follow it for **every** instruction below that depends on that choice. Shared bugbot / bugteam / Fix protocol steps
|
|
5
|
+
stay in the main `SKILL.md`.
|
|
6
|
+
|
|
7
|
+
## Session behavior
|
|
8
|
+
|
|
9
|
+
Call `ScheduleWakeup` from this same session so the next tick fires back into **this** transcript with the prior tick's state line and PR
|
|
10
|
+
context still addressable.
|
|
11
|
+
|
|
12
|
+
## Step 4 — `ScheduleWakeup` branch
|
|
13
|
+
|
|
14
|
+
At end of tick (unless convergence or another stop condition already omitted pacing), call `ScheduleWakeup` with:
|
|
15
|
+
|
|
16
|
+
- `delaySeconds: 270` whenever bugbot was just re-triggered (whether by Step 3 directly, by the Fix protocol's mandatory re-trigger, or by
|
|
17
|
+
BUGTEAM branch 1's same-tick re-trigger). Bugbot finishes a review in 1–4 minutes, so 270s stays under the 5-minute prompt-cache TTL while
|
|
18
|
+
giving a margin past bugbot's typical upper bound. The single exception is the BUGBOT inline-lag branch in Step 2 of the main skill, which
|
|
19
|
+
uses `delaySeconds: 60` because no re-trigger fired and the only thing being awaited is GitHub's inline-comments API catching up.
|
|
20
|
+
- `reason`: one short sentence on what is being awaited, including the current `phase` and `bugbot_clean_at` SHA when set.
|
|
21
|
+
- `prompt: "/pr-converge"` — re-enters this skill on the next firing with default loop semantics (no need for the user to type `/loop`). If
|
|
22
|
+
the parent harness requires the `/loop` wrapper for wakeups to execute, `prompt: "/loop /pr-converge"` is equivalent.
|
|
23
|
+
|
|
24
|
+
## BUGBOT inline-lag (this path only)
|
|
25
|
+
|
|
26
|
+
When Step 2 BUGBOT branch c routes to API lag and you are on **this** pacing path: complete Step 4 with `ScheduleWakeup` using `delaySeconds:
|
|
27
|
+
60` (lag is short-lived).
|
|
28
|
+
|
|
29
|
+
## Convergence
|
|
30
|
+
|
|
31
|
+
On back-to-back clean: **omit** further `ScheduleWakeup` calls. Do not start the AHK auto-typer for loop pacing when this path is the active
|
|
32
|
+
pacer.
|
|
33
|
+
|
|
34
|
+
## Stop / safety (this path)
|
|
35
|
+
|
|
36
|
+
On hard blockers or user stop: omit `ScheduleWakeup` per main skill **Stop conditions**. If the session never used AHK for pacing,
|
|
37
|
+
skip AHK shutdown commands in the companion AHK workflow.
|
package/skills/qbug/SKILL.md
CHANGED
|
@@ -19,8 +19,8 @@ description: >-
|
|
|
19
19
|
|
|
20
20
|
Shared artifacts with /bugteam are referenced below by path, using the `${CLAUDE_SKILL_DIR}` host-substitution convention (both skills land under `~/.claude/skills/` after install):
|
|
21
21
|
|
|
22
|
-
- Pre-flight script: `${CLAUDE_SKILL_DIR}
|
|
23
|
-
- Code-rules gate script: `${CLAUDE_SKILL_DIR}
|
|
22
|
+
- Pre-flight script: `${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/preflight.py`
|
|
23
|
+
- Code-rules gate script: `${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/code_rules_gate.py`
|
|
24
24
|
- Bug category rubric A–J: [`bugteam/PROMPTS.md`](../bugteam/PROMPTS.md#audit-spawn-prompt-xml-bugfind-teammate)
|
|
25
25
|
- **Audit contract** (finding schema, proof-of-absence, adversarial pass, Haiku secondary, post-fix self-audit, diagnostics JSON): [`bugteam/reference/audit-contract.md`](../bugteam/reference/audit-contract.md)
|
|
26
26
|
- PR comment lifecycle shape: [`bugteam/SKILL.md`](../bugteam/SKILL.md#step-25-pr-comments-one-review-per-loop)
|
|
@@ -48,7 +48,7 @@ Refusals — first match wins; respond with the quoted line exactly and stop:
|
|
|
48
48
|
## Step 0: Pre-flight
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
python "${CLAUDE_SKILL_DIR}
|
|
51
|
+
python "${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/preflight.py"
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
`${CLAUDE_SKILL_DIR}` is host-substituted before the shell runs. Non-zero → fix before continuing. `BUGTEAM_PREFLIGHT_SKIP=1` is emergency only. Add `--pre-commit` when `.pre-commit-config.yaml` exists.
|
|
@@ -97,7 +97,7 @@ import os
|
|
|
97
97
|
from pathlib import Path
|
|
98
98
|
|
|
99
99
|
skill_dir = Path(os.environ["CLAUDE_SKILL_DIR"])
|
|
100
|
-
gate_script_path = (skill_dir / ".." / "
|
|
100
|
+
gate_script_path = (skill_dir / ".." / ".." / "_shared" / "pr-loop" / "scripts" / "code_rules_gate.py").resolve()
|
|
101
101
|
categories_file_path = (skill_dir / ".." / "bugteam" / "PROMPTS.md").resolve()
|
|
102
102
|
```
|
|
103
103
|
|
|
@@ -22,8 +22,8 @@ def _load_skill_text() -> str:
|
|
|
22
22
|
|
|
23
23
|
def test_should_require_post_fix_gate_before_git_add() -> None:
|
|
24
24
|
skill_text = _load_skill_text()
|
|
25
|
-
assert "
|
|
26
|
-
"FIX step must run
|
|
25
|
+
assert "code_rules_gate" in skill_text, (
|
|
26
|
+
"FIX step must run code_rules_gate against modified files"
|
|
27
27
|
)
|
|
28
28
|
assert "post-fix" in skill_text.lower() or "post_fix" in skill_text.lower(), (
|
|
29
29
|
"FIX step must reference a post-fix audit phase"
|