claude-dev-env 8.88.7 → 8.90.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.
- package/.agents/skills/pr-visual/README.md +29 -0
- package/.agents/skills/pr-visual/SKILL.md +62 -0
- package/.agents/skills/pr-visual/assets/icons.symbols +27 -0
- package/.agents/skills/pr-visual/assets/viewer.html +147 -0
- package/.agents/skills/pr-visual/fixtures/bar.scene.json +193 -0
- package/.agents/skills/pr-visual/fixtures/flow.scene.json +201 -0
- package/.agents/skills/pr-visual/fixtures/head/metrics.json +16 -0
- package/.agents/skills/pr-visual/fixtures/integrated.scene.json +239 -0
- package/.agents/skills/pr-visual/fixtures/line.scene.json +193 -0
- package/.agents/skills/pr-visual/fixtures/scatter.scene.json +193 -0
- package/.agents/skills/pr-visual/fixtures/snapshot.json +21 -0
- package/.agents/skills/pr-visual/references/evidence.md +9 -0
- package/.agents/skills/pr-visual/references/formats.md +30 -0
- package/.agents/skills/pr-visual/references/scene-contract.md +48 -0
- package/.agents/skills/pr-visual/references/visual-contract.md +23 -0
- package/.agents/skills/pr-visual/scripts/collect.py +111 -0
- package/.agents/skills/pr-visual/scripts/metrics.py +99 -0
- package/.agents/skills/pr-visual/scripts/render.py +133 -0
- package/.agents/skills/pr-visual/scripts/test_collect.py +58 -0
- package/.agents/skills/pr-visual/scripts/test_metrics.py +47 -0
- package/.agents/skills/pr-visual/scripts/test_render.py +77 -0
- package/.agents/skills/pr-visual/scripts/verify.py +218 -0
- package/.agents/skills/pr-visual/scripts/visual_config/config/constants.py +104 -0
- package/bin/codex-skill-load-block.mjs +0 -25
- package/bin/codex-skill-load-block.test.mjs +5 -17
- package/bin/codex-trimmed-sol.test.mjs +0 -1
- package/bin/cursor-native-hooks.test.mjs +3 -3
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +3 -0
- package/bin/install-deepstack-plugin.mjs +42 -25
- package/bin/install-deepstack-plugin.test.mjs +37 -11
- package/bin/install-pstack-plugin.mjs +58 -11
- package/bin/install-pstack-plugin.test.mjs +54 -21
- package/bin/install.agents-home.test.mjs +0 -1
- package/bin/install.codex-rules.test.mjs +0 -2
- package/bin/install.cursor-rules.test.mjs +96 -21
- package/bin/install.mjs +48 -44
- package/bin/install.pr-visual.test.mjs +40 -0
- package/bin/install.prompt-context.test.mjs +3 -4
- package/bin/install.prune.test.mjs +0 -1
- package/bin/install.question-presentation.test.mjs +0 -4
- package/bin/install.settings-defaults.test.mjs +0 -1
- package/bin/install.shared-settings.test.mjs +0 -1
- package/bin/install.test.mjs +14 -44
- package/bin/install.transaction.test.mjs +0 -8
- package/bin/install.uninstall-transaction.test.mjs +0 -2
- package/bin/install.update-keeps-hooks.test.mjs +0 -1
- package/docs/BDD_SCENARIO_QUALITY.md +1 -1
- package/docs/BDD_TEST_LAYOUT.md +1 -1
- package/docs/CODE_RULES.md +1 -1
- package/docs/rule-guides/deepstack-wording.md +3 -5
- package/docs/rule-guides/session-title.md +5 -4
- package/hooks/advisory/owned_pr_review_loop.py +365 -0
- package/hooks/advisory/test_owned_pr_review_loop.py +369 -0
- package/hooks/blocking/session_title_turn_gate.py +287 -0
- package/hooks/blocking/test_session_title_format_gate.py +5 -0
- package/hooks/blocking/test_session_title_turn_gate.py +264 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/features/session-context.md +0 -4
- package/hooks/hooks.json +15 -5
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -2
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +120 -0
- package/hooks/hooks_constants/session_title_constants.py +41 -3
- package/hooks/lifecycle/nested_project_hooks.py +2 -2
- package/hooks/lifecycle/test_nested_project_hooks.py +2 -2
- package/hooks/session/compaction_user_messages.py +2 -2
- package/package.json +2 -2
- package/rules/deepstack-wording.md +3 -3
- package/scripts/ci/install-playtest.mjs +58 -314
- package/scripts/ci/install-playtest.test.mjs +24 -6
- package/scripts/policy_lint/adapter_configuration.py +1 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_deepstack_review_criterion.py +2 -3
- package/scripts/test_deepstack_wording_rule.py +6 -4
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/.agents/skills/deepen/SKILL.md +0 -26
- package/commands/deepen.md +0 -14
- package/docs/decisions/deepstack-gate.md +0 -32
- package/hooks/hooks_constants/deepen_first_prompt_constants.py +0 -187
- package/hooks/session/deepen_default_rewrite.py +0 -128
- package/hooks/session/deepen_first_prompt.py +0 -383
- package/hooks/session/deepen_role_gate.py +0 -171
- package/hooks/session/deepen_turn_marker.py +0 -75
- package/hooks/session/fixtures/deepstack_replay.json +0 -15
- package/hooks/session/grade_deepstack_replay.py +0 -78
- package/hooks/session/test_deepen_default_rewrite.py +0 -90
- package/hooks/session/test_deepen_first_prompt.py +0 -452
- package/hooks/session/test_deepen_role_gate.py +0 -83
- package/hooks/session/test_deepen_turn_marker.py +0 -59
- package/hooks/session/test_grade_deepstack_replay.py +0 -47
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Behavior tests for the owned pull request review-loop context hook.
|
|
2
|
+
|
|
3
|
+
The hook is a PostToolUse observer. It never blocks. These tests pin which
|
|
4
|
+
tool calls wake it, which repositories get the review-loop block, and that
|
|
5
|
+
every other path stays quiet. Each end-to-end test runs the script as a
|
|
6
|
+
subprocess with a Claude Code or Codex payload on stdin.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
advisory_directory = str(Path(__file__).resolve().parent)
|
|
21
|
+
if advisory_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, advisory_directory)
|
|
23
|
+
import owned_pr_review_loop
|
|
24
|
+
except ImportError as import_error:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"test_owned_pr_review_loop: cannot import its sibling modules; "
|
|
27
|
+
"ensure the advisory directory is importable."
|
|
28
|
+
) from import_error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
HOOK_SCRIPT_PATH = Path(__file__).resolve().parent / "owned_pr_review_loop.py"
|
|
32
|
+
HOOKS_JSON_PATH = Path(__file__).resolve().parent.parent / "hooks.json"
|
|
33
|
+
OWNED_PR_URL = "https://github.com/jl-cmd-projects/claude-dev-env/pull/42"
|
|
34
|
+
FOREIGN_PR_URL = "https://github.com/acme/widgets/pull/3"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_hook(payload: dict[str, object]) -> str:
|
|
38
|
+
completed = subprocess.run(
|
|
39
|
+
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
40
|
+
input=json.dumps(payload),
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
assert completed.returncode == 0, completed.stderr
|
|
46
|
+
return completed.stdout
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shell_payload(
|
|
50
|
+
command: str, stdout: str, tool_name: str = "Bash", input_key: str = "command"
|
|
51
|
+
) -> dict[str, object]:
|
|
52
|
+
return {
|
|
53
|
+
"hook_event_name": "PostToolUse",
|
|
54
|
+
"tool_name": tool_name,
|
|
55
|
+
"tool_input": {input_key: command},
|
|
56
|
+
"tool_response": {"stdout": stdout, "stderr": ""},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _mcp_payload(tool_name: str, owner: str, repository: str, url: str) -> dict[str, object]:
|
|
61
|
+
return {
|
|
62
|
+
"hook_event_name": "PostToolUse",
|
|
63
|
+
"tool_name": tool_name,
|
|
64
|
+
"tool_input": {"owner": owner, "repo": repository, "title": "Add a hook"},
|
|
65
|
+
"tool_response": [{"type": "text", "text": json.dumps({"id": "1", "url": url})}],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _context_of(stdout: str) -> str:
|
|
70
|
+
return json.loads(stdout)["hookSpecificOutput"]["additionalContext"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"command",
|
|
75
|
+
[
|
|
76
|
+
"gh pr create --title t --body b",
|
|
77
|
+
"gh pr new --fill",
|
|
78
|
+
"git push -u origin feature && gh pr create --fill",
|
|
79
|
+
'pwsh -Command "gh pr create --fill"',
|
|
80
|
+
"gh api repos/acme/widgets/pulls --input pr.json",
|
|
81
|
+
"gh api /repos/acme/widgets/pulls -f title=t -f head=x -f base=main",
|
|
82
|
+
"gh api -X POST repos/acme/widgets/pulls",
|
|
83
|
+
"gh api repos/acme/widgets/pulls --input=pr.json",
|
|
84
|
+
"gh api repos/acme/widgets/pulls --raw-field=title=t",
|
|
85
|
+
"gh api --method=POST repos/acme/widgets/pulls",
|
|
86
|
+
"gh api -XPOST repos/acme/widgets/pulls",
|
|
87
|
+
"gh api repos/acme/widgets/pulls -Ftitle=t -fhead=x",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
def test_should_detect_gh_pr_create(command: str) -> None:
|
|
91
|
+
assert owned_pr_review_loop.command_creates_pull_request(command)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"command",
|
|
96
|
+
[
|
|
97
|
+
"gh pr view 42",
|
|
98
|
+
"git push -u origin feature",
|
|
99
|
+
"gh pr",
|
|
100
|
+
"echo gh pr create",
|
|
101
|
+
"gh api repos/acme/widgets/pulls",
|
|
102
|
+
"gh api repos/acme/widgets/pulls/42 -X PATCH -f state=closed",
|
|
103
|
+
"gh api repos/acme/widgets/issues --input issue.json",
|
|
104
|
+
"gh api --method GET repos/acme/widgets/pulls -f state=open",
|
|
105
|
+
"gh api -X GET repos/acme/widgets/pulls -f state=open",
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
def test_should_not_detect_other_commands(command: str) -> None:
|
|
109
|
+
assert not owned_pr_review_loop.command_creates_pull_request(command)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize(
|
|
113
|
+
"tool_name",
|
|
114
|
+
[
|
|
115
|
+
"mcp__github__create_pull_request",
|
|
116
|
+
"mcp__codex_apps__github_create_pull_request",
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
def test_should_treat_mcp_create_tools_as_pull_request_creation(tool_name: str) -> None:
|
|
120
|
+
assert owned_pr_review_loop.tool_call_opens_pull_request(tool_name, {})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_should_read_the_codex_cmd_key_for_shell_tools() -> None:
|
|
124
|
+
assert owned_pr_review_loop.tool_call_opens_pull_request(
|
|
125
|
+
"exec_command", {"cmd": ["gh", "pr", "create", "--fill"]}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_ignore_other_mcp_tools() -> None:
|
|
130
|
+
assert not owned_pr_review_loop.tool_call_opens_pull_request(
|
|
131
|
+
"mcp__github__merge_pull_request", {}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_take_the_last_url_from_gh_output() -> None:
|
|
136
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
137
|
+
{"command": "gh pr create"},
|
|
138
|
+
{"stdout": f"Warning: see {FOREIGN_PR_URL}\n{OWNED_PR_URL}\n"},
|
|
139
|
+
)
|
|
140
|
+
assert pull_request == {
|
|
141
|
+
"owner": "jl-cmd-projects",
|
|
142
|
+
"repository": "claude-dev-env",
|
|
143
|
+
"number": "42",
|
|
144
|
+
"url": OWNED_PR_URL,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_should_take_the_url_for_the_mcp_input_repository() -> None:
|
|
149
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
150
|
+
{"owner": "JL-CMD-PROJECTS", "repo": "claude-dev-env"},
|
|
151
|
+
f"Opened {OWNED_PR_URL}; see also {FOREIGN_PR_URL}",
|
|
152
|
+
)
|
|
153
|
+
assert pull_request is not None
|
|
154
|
+
assert pull_request["number"] == "42"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_take_the_declared_url_over_urls_cited_in_the_body() -> None:
|
|
158
|
+
rest_response = json.dumps(
|
|
159
|
+
{
|
|
160
|
+
"html_url": OWNED_PR_URL,
|
|
161
|
+
"body": "Follows https://github.com/jl-cmd-projects/claude-dev-env/pull/7",
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
165
|
+
{"command": "gh api repos/jl-cmd-projects/claude-dev-env/pulls --input pr.json"},
|
|
166
|
+
{"stdout": rest_response},
|
|
167
|
+
)
|
|
168
|
+
assert pull_request is not None
|
|
169
|
+
assert pull_request["number"] == "42"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_should_take_the_declared_url_from_an_mcp_text_item() -> None:
|
|
173
|
+
mcp_response = [
|
|
174
|
+
{
|
|
175
|
+
"type": "text",
|
|
176
|
+
"text": json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"url": OWNED_PR_URL,
|
|
179
|
+
"body": "Replaces https://github.com/jl-cmd-projects/claude-dev-env/pull/7",
|
|
180
|
+
}
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
185
|
+
{"owner": "jl-cmd-projects", "repo": "claude-dev-env"}, mcp_response
|
|
186
|
+
)
|
|
187
|
+
assert pull_request is not None
|
|
188
|
+
assert pull_request["number"] == "42"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_should_keep_a_declared_pull_request_whose_body_says_already_exists() -> None:
|
|
192
|
+
rest_response = json.dumps({"html_url": OWNED_PR_URL, "body": "The helper already exists."})
|
|
193
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
194
|
+
{"command": "gh api repos/jl-cmd-projects/claude-dev-env/pulls --input pr.json"},
|
|
195
|
+
{"stdout": rest_response},
|
|
196
|
+
)
|
|
197
|
+
assert pull_request is not None
|
|
198
|
+
assert pull_request["url"] == OWNED_PR_URL
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_should_find_no_pull_request_after_a_failed_command() -> None:
|
|
202
|
+
assert (
|
|
203
|
+
owned_pr_review_loop.opened_pull_request(
|
|
204
|
+
{"command": "gh pr create"}, f"Error: Exit code 1\n{OWNED_PR_URL}"
|
|
205
|
+
)
|
|
206
|
+
is None
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_should_find_no_pull_request_when_one_already_exists() -> None:
|
|
211
|
+
assert (
|
|
212
|
+
owned_pr_review_loop.opened_pull_request(
|
|
213
|
+
{"command": "gh pr create"},
|
|
214
|
+
{"stdout": f'a pull request for branch "x" already exists:\n{OWNED_PR_URL}'},
|
|
215
|
+
)
|
|
216
|
+
is None
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_should_digest_names_in_normalized_form() -> None:
|
|
221
|
+
assert owned_pr_review_loop.name_digest("Acme-Widgets/Tools") == (
|
|
222
|
+
owned_pr_review_loop.name_digest("acmewidgetstools")
|
|
223
|
+
)
|
|
224
|
+
assert owned_pr_review_loop.name_digest("acme") != (
|
|
225
|
+
owned_pr_review_loop.name_digest("acmewidgets")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.parametrize(
|
|
230
|
+
("owner", "repository", "expected"),
|
|
231
|
+
[
|
|
232
|
+
("Acme-Widgets", "tools", True),
|
|
233
|
+
("acme-widgets", "site", True),
|
|
234
|
+
("Acme-Widgets", "reviewed", False),
|
|
235
|
+
("Other-Org", "tools", False),
|
|
236
|
+
],
|
|
237
|
+
)
|
|
238
|
+
def test_should_require_an_owned_repository_outside_the_preset_list(
|
|
239
|
+
owner: str, repository: str, expected: bool
|
|
240
|
+
) -> None:
|
|
241
|
+
all_owned_owner_digests = frozenset({owned_pr_review_loop.name_digest("acme-widgets")})
|
|
242
|
+
all_preset_repository_digests = frozenset(
|
|
243
|
+
{owned_pr_review_loop.name_digest("acme-widgets/reviewed")}
|
|
244
|
+
)
|
|
245
|
+
assert (
|
|
246
|
+
owned_pr_review_loop.needs_review_loop(
|
|
247
|
+
owner, repository, all_owned_owner_digests, all_preset_repository_digests
|
|
248
|
+
)
|
|
249
|
+
is expected
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.mark.parametrize(
|
|
254
|
+
("owner", "repository", "expected"),
|
|
255
|
+
[
|
|
256
|
+
("jl-cmd-projects", "claude-dev-env", True),
|
|
257
|
+
("jl-cmd", "claude-dev-env", True),
|
|
258
|
+
("acme", "widgets", False),
|
|
259
|
+
],
|
|
260
|
+
)
|
|
261
|
+
def test_should_apply_the_shipped_owner_list(owner: str, repository: str, expected: bool) -> None:
|
|
262
|
+
assert (
|
|
263
|
+
owned_pr_review_loop.needs_review_loop(
|
|
264
|
+
owner,
|
|
265
|
+
repository,
|
|
266
|
+
owned_pr_review_loop.ALL_OWNED_OWNER_DIGESTS,
|
|
267
|
+
owned_pr_review_loop.ALL_PRESET_REPOSITORY_DIGESTS,
|
|
268
|
+
)
|
|
269
|
+
is expected
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_should_name_every_step_and_skill_in_the_context() -> None:
|
|
274
|
+
context = owned_pr_review_loop.build_review_loop_context(
|
|
275
|
+
{
|
|
276
|
+
"owner": "jl-cmd-projects",
|
|
277
|
+
"repository": "claude-dev-env",
|
|
278
|
+
"number": "42",
|
|
279
|
+
"url": OWNED_PR_URL,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
assert "jl-cmd-projects/claude-dev-env#42" in context
|
|
283
|
+
assert "efficient-review" in context
|
|
284
|
+
assert "gh pr ready 42 --repo jl-cmd-projects/claude-dev-env" in context
|
|
285
|
+
assert "poteto-mode/playbooks/babysit.md" in context
|
|
286
|
+
assert "poteto-mode/playbooks/shipping.md" in context
|
|
287
|
+
assert "merged" in context
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_main_should_emit_the_loop_after_gh_pr_create_on_an_owned_repository() -> None:
|
|
291
|
+
stdout = _run_hook(_shell_payload("gh pr create --fill", f"{OWNED_PR_URL}\n"))
|
|
292
|
+
payload = json.loads(stdout)
|
|
293
|
+
assert payload["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
|
|
294
|
+
assert "efficient-review" in _context_of(stdout)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_main_should_emit_the_loop_after_a_gh_api_pulls_post() -> None:
|
|
298
|
+
rest_response = json.dumps(
|
|
299
|
+
{
|
|
300
|
+
"url": "https://api.github.com/repos/jl-cmd-projects/claude-dev-env/pulls/42",
|
|
301
|
+
"html_url": OWNED_PR_URL,
|
|
302
|
+
"_links": {"html": {"href": OWNED_PR_URL}},
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
stdout = _run_hook(
|
|
306
|
+
_shell_payload("gh api repos/jl-cmd-projects/claude-dev-env/pulls --input pr.json", rest_response)
|
|
307
|
+
)
|
|
308
|
+
assert "jl-cmd-projects/claude-dev-env#42" in _context_of(stdout)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_main_should_emit_the_loop_for_a_codex_shell_call() -> None:
|
|
312
|
+
stdout = _run_hook(
|
|
313
|
+
_shell_payload(
|
|
314
|
+
"gh pr create --fill", f"{OWNED_PR_URL}\n", tool_name="exec_command", input_key="cmd"
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
assert OWNED_PR_URL in _context_of(stdout)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.parametrize(
|
|
321
|
+
"tool_name",
|
|
322
|
+
["mcp__github__create_pull_request", "mcp__codex_apps__github_create_pull_request"],
|
|
323
|
+
)
|
|
324
|
+
def test_main_should_emit_the_loop_after_an_mcp_create(tool_name: str) -> None:
|
|
325
|
+
stdout = _run_hook(
|
|
326
|
+
_mcp_payload(
|
|
327
|
+
tool_name,
|
|
328
|
+
"jl-cmd-projects",
|
|
329
|
+
"DeepStack",
|
|
330
|
+
"https://github.com/jl-cmd-projects/DeepStack/pull/9",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
assert "jl-cmd-projects/DeepStack#9" in _context_of(stdout)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_main_should_stay_quiet_for_a_repository_we_do_not_own() -> None:
|
|
337
|
+
assert _run_hook(_shell_payload("gh pr create --fill", f"{FOREIGN_PR_URL}\n")) == ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_main_should_stay_quiet_for_a_command_that_opens_no_pull_request() -> None:
|
|
341
|
+
assert _run_hook(_shell_payload("gh pr view 42", f"{OWNED_PR_URL}\n")) == ""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_main_should_stay_quiet_for_a_failed_create() -> None:
|
|
345
|
+
payload = _shell_payload("gh pr create --fill", "")
|
|
346
|
+
payload["tool_response"] = "Error: Exit code 1\npull request create failed"
|
|
347
|
+
assert _run_hook(payload) == ""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_hooks_json_should_route_shell_and_mcp_create_calls_to_the_hook() -> None:
|
|
351
|
+
hooks_config = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
|
|
352
|
+
all_matchers = [
|
|
353
|
+
each_group["matcher"]
|
|
354
|
+
for each_group in hooks_config["hooks"]["PostToolUse"]
|
|
355
|
+
if any(
|
|
356
|
+
"advisory/owned_pr_review_loop.py" in each_hook.get("command", "")
|
|
357
|
+
for each_hook in each_group["hooks"]
|
|
358
|
+
)
|
|
359
|
+
]
|
|
360
|
+
assert len(all_matchers) == 1
|
|
361
|
+
for each_tool_name in (
|
|
362
|
+
"Bash",
|
|
363
|
+
"PowerShell",
|
|
364
|
+
"exec_command",
|
|
365
|
+
"shell_command",
|
|
366
|
+
"mcp__github__create_pull_request",
|
|
367
|
+
"mcp__codex_apps__github_create_pull_request",
|
|
368
|
+
):
|
|
369
|
+
assert re.fullmatch(all_matchers[0], each_tool_name), each_tool_name
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook that holds a turn until it sets the session title.
|
|
3
|
+
|
|
4
|
+
A turn starts after the latest user prompt in the transcript. Tool results,
|
|
5
|
+
meta entries, sidechain entries and compaction summaries are not prompts, and
|
|
6
|
+
a Stop hook block lands as an attachment, so a held turn stays the same turn.
|
|
7
|
+
A title call on a sidechain entry belongs to a subagent and does not count.
|
|
8
|
+
The turn may end once a tool whose name ends in __set_session_title ran in it
|
|
9
|
+
and its result came back without an error. A title the format gate denied
|
|
10
|
+
does not count. A held turn (``stop_hook_active`` true) whose title call
|
|
11
|
+
failed may end, so a title tool that keeps failing cannot hold it forever. A
|
|
12
|
+
held turn with no title call stays held, whichever Stop gate held it.
|
|
13
|
+
|
|
14
|
+
The session has the title tool when a transcript record names it::
|
|
15
|
+
|
|
16
|
+
attachment.tools[].name / attachment.entries[].name (tool listing)
|
|
17
|
+
attachment.addedNames / attachment.readdedNames (deferred tools)
|
|
18
|
+
assistant tool_use name (an earlier call)
|
|
19
|
+
|
|
20
|
+
A later ``removedNames`` entry takes the name away again. With no such record,
|
|
21
|
+
or with an unreadable transcript, the hook lets the turn end, so Codex,
|
|
22
|
+
Cursor and sessions without the tool keep working.
|
|
23
|
+
|
|
24
|
+
A block writes ``{"decision": "block", "reason": ...}`` to stdout. The reason
|
|
25
|
+
names the four status emoji and tells the model to close with one short
|
|
26
|
+
sentence, so the held turn neither ends empty nor repeats the reply.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
36
|
+
if hooks_root_directory not in sys.path:
|
|
37
|
+
sys.path.insert(0, hooks_root_directory)
|
|
38
|
+
|
|
39
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
40
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
41
|
+
from hooks_constants.session_title_constants import (
|
|
42
|
+
ALL_SKIPPED_PROMPT_FLAGS,
|
|
43
|
+
ALL_TOOL_LISTING_NAME_KEYS,
|
|
44
|
+
ALL_TOOL_LISTING_RECORD_KEYS,
|
|
45
|
+
ALLOW_EXIT_CODE,
|
|
46
|
+
ASSISTANT_ENTRY_TYPE,
|
|
47
|
+
ATTACHMENT_KEY,
|
|
48
|
+
BLOCK_DECISION,
|
|
49
|
+
BLOCK_ID_KEY,
|
|
50
|
+
BLOCK_NAME_KEY,
|
|
51
|
+
CONTENT_KEY,
|
|
52
|
+
DECISION_KEY,
|
|
53
|
+
DECODE_ERRORS_POLICY,
|
|
54
|
+
ENTRY_TYPE_KEY,
|
|
55
|
+
IS_ERROR_KEY,
|
|
56
|
+
MESSAGE_KEY,
|
|
57
|
+
REASON_KEY,
|
|
58
|
+
REMOVED_NAMES_KEY,
|
|
59
|
+
SIDECHAIN_FLAG_KEY,
|
|
60
|
+
STOP_HOOK_ACTIVE_KEY,
|
|
61
|
+
TITLE_TOOL_DISPLAY_SEPARATOR,
|
|
62
|
+
TITLE_TOOL_NAME_PATTERN,
|
|
63
|
+
TOOL_RESULT_BLOCK_TYPE,
|
|
64
|
+
TOOL_USE_BLOCK_TYPE,
|
|
65
|
+
TOOL_USE_ID_KEY,
|
|
66
|
+
TRANSCRIPT_PATH_KEY,
|
|
67
|
+
TURN_BLOCK_REASON,
|
|
68
|
+
TURN_GATE_EVENT_NAME,
|
|
69
|
+
USER_ENTRY_TYPE,
|
|
70
|
+
UTF8_ENCODING,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _content_blocks(all_entry_fields: dict[str, object]) -> list[dict[str, object]]:
|
|
75
|
+
message = all_entry_fields.get(MESSAGE_KEY)
|
|
76
|
+
content = message.get(CONTENT_KEY) if isinstance(message, dict) else None
|
|
77
|
+
if not isinstance(content, list):
|
|
78
|
+
return []
|
|
79
|
+
return [each_block for each_block in content if isinstance(each_block, dict)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
|
|
83
|
+
if all_entry_fields.get(ENTRY_TYPE_KEY) != USER_ENTRY_TYPE:
|
|
84
|
+
return False
|
|
85
|
+
if any(all_entry_fields.get(each_flag) for each_flag in ALL_SKIPPED_PROMPT_FLAGS):
|
|
86
|
+
return False
|
|
87
|
+
message = all_entry_fields.get(MESSAGE_KEY)
|
|
88
|
+
content = message.get(CONTENT_KEY) if isinstance(message, dict) else None
|
|
89
|
+
if isinstance(content, str):
|
|
90
|
+
return bool(content.strip())
|
|
91
|
+
return any(
|
|
92
|
+
each_block.get(ENTRY_TYPE_KEY) != TOOL_RESULT_BLOCK_TYPE
|
|
93
|
+
for each_block in _content_blocks(all_entry_fields)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _tool_use_blocks(all_entry_fields: dict[str, object]) -> list[dict[str, object]]:
|
|
98
|
+
if all_entry_fields.get(ENTRY_TYPE_KEY) != ASSISTANT_ENTRY_TYPE:
|
|
99
|
+
return []
|
|
100
|
+
return [
|
|
101
|
+
each_block
|
|
102
|
+
for each_block in _content_blocks(all_entry_fields)
|
|
103
|
+
if each_block.get(ENTRY_TYPE_KEY) == TOOL_USE_BLOCK_TYPE
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _title_names(all_values: object) -> list[str]:
|
|
108
|
+
if not isinstance(all_values, list):
|
|
109
|
+
return []
|
|
110
|
+
all_names = [
|
|
111
|
+
each_value.get(BLOCK_NAME_KEY) if isinstance(each_value, dict) else each_value
|
|
112
|
+
for each_value in all_values
|
|
113
|
+
]
|
|
114
|
+
return [
|
|
115
|
+
each_name
|
|
116
|
+
for each_name in all_names
|
|
117
|
+
if isinstance(each_name, str) and TITLE_TOOL_NAME_PATTERN.match(each_name)
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def available_title_tools(all_entries: list[dict[str, object]]) -> list[str]:
|
|
122
|
+
"""Return the title tool names the session holds at the end of the transcript.
|
|
123
|
+
|
|
124
|
+
::
|
|
125
|
+
|
|
126
|
+
attachment {"tools": [{"name": "mcp__x__set_session_title"}]} -> ["mcp__x__set_session_title"]
|
|
127
|
+
then attachment {"removedNames": ["mcp__x__set_session_title"]} -> []
|
|
128
|
+
no listing and no call -> []
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
all_entries: The transcript entries, oldest first.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The title tool names, in the order the transcript first named them.
|
|
135
|
+
"""
|
|
136
|
+
available_names: dict[str, None] = {}
|
|
137
|
+
for each_entry in all_entries:
|
|
138
|
+
attachment = each_entry.get(ATTACHMENT_KEY)
|
|
139
|
+
if isinstance(attachment, dict):
|
|
140
|
+
_apply_tool_listing(available_names, attachment)
|
|
141
|
+
available_names.update(dict.fromkeys(_title_names(_tool_use_blocks(each_entry))))
|
|
142
|
+
return list(available_names)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _apply_tool_listing(
|
|
146
|
+
all_available_names: dict[str, None], all_attachment_fields: dict[str, object]
|
|
147
|
+
) -> None:
|
|
148
|
+
for each_key in (*ALL_TOOL_LISTING_NAME_KEYS, *ALL_TOOL_LISTING_RECORD_KEYS):
|
|
149
|
+
all_available_names.update(
|
|
150
|
+
dict.fromkeys(_title_names(all_attachment_fields.get(each_key)))
|
|
151
|
+
)
|
|
152
|
+
for each_removed_name in _title_names(all_attachment_fields.get(REMOVED_NAMES_KEY)):
|
|
153
|
+
all_available_names.pop(each_removed_name, None)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _turn_entries(all_entries: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
157
|
+
last_prompt_index = max(
|
|
158
|
+
(
|
|
159
|
+
each_index
|
|
160
|
+
for each_index, each_entry in enumerate(all_entries)
|
|
161
|
+
if _is_user_prompt(each_entry)
|
|
162
|
+
),
|
|
163
|
+
default=-1,
|
|
164
|
+
)
|
|
165
|
+
return [
|
|
166
|
+
each_entry
|
|
167
|
+
for each_entry in all_entries[last_prompt_index + 1 :]
|
|
168
|
+
if not each_entry.get(SIDECHAIN_FLAG_KEY)
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _title_call_ids(all_turn_entries: list[dict[str, object]]) -> set[object]:
|
|
173
|
+
return {
|
|
174
|
+
each_block.get(BLOCK_ID_KEY)
|
|
175
|
+
for each_entry in all_turn_entries
|
|
176
|
+
for each_block in _tool_use_blocks(each_entry)
|
|
177
|
+
if TITLE_TOOL_NAME_PATTERN.match(str(each_block.get(BLOCK_NAME_KEY)))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def title_attempted_this_turn(all_entries: list[dict[str, object]]) -> bool:
|
|
182
|
+
"""Return True when a title call ran after the latest prompt, whatever its result.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
all_entries: The transcript entries, oldest first.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True when this turn called the title tool.
|
|
189
|
+
"""
|
|
190
|
+
return bool(_title_call_ids(_turn_entries(all_entries)))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def title_set_this_turn(all_entries: list[dict[str, object]]) -> bool:
|
|
194
|
+
"""Return True when a title call after the latest prompt came back without an error.
|
|
195
|
+
|
|
196
|
+
::
|
|
197
|
+
|
|
198
|
+
prompt -> set_session_title -> ok result -> True
|
|
199
|
+
prompt -> set_session_title -> denied result -> False
|
|
200
|
+
set_session_title -> ok result -> prompt -> False
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
all_entries: The transcript entries, oldest first.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True when this turn set the title.
|
|
207
|
+
"""
|
|
208
|
+
all_turn_entries = _turn_entries(all_entries)
|
|
209
|
+
all_title_call_ids = _title_call_ids(all_turn_entries)
|
|
210
|
+
return any(
|
|
211
|
+
each_block.get(ENTRY_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE
|
|
212
|
+
and each_block.get(TOOL_USE_ID_KEY) in all_title_call_ids
|
|
213
|
+
and not each_block.get(IS_ERROR_KEY)
|
|
214
|
+
for each_entry in all_turn_entries
|
|
215
|
+
if each_entry.get(ENTRY_TYPE_KEY) == USER_ENTRY_TYPE
|
|
216
|
+
for each_block in _content_blocks(each_entry)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def turn_block_reason(
|
|
221
|
+
all_entries: list[dict[str, object]], is_stop_hook_active: bool
|
|
222
|
+
) -> str | None:
|
|
223
|
+
"""Return the reason to hold the turn, or None when the turn may end.
|
|
224
|
+
|
|
225
|
+
::
|
|
226
|
+
|
|
227
|
+
no title tool in the transcript -> None
|
|
228
|
+
title set this turn -> None
|
|
229
|
+
held turn whose title call failed (stop_hook_active) -> None
|
|
230
|
+
no title call this turn -> reason
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
all_entries: The transcript entries, oldest first.
|
|
234
|
+
is_stop_hook_active: True when a Stop hook already held this turn.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The block reason that names the title tool, or None.
|
|
238
|
+
"""
|
|
239
|
+
all_title_tools = available_title_tools(all_entries)
|
|
240
|
+
if not all_title_tools or title_set_this_turn(all_entries):
|
|
241
|
+
return None
|
|
242
|
+
if is_stop_hook_active and title_attempted_this_turn(all_entries):
|
|
243
|
+
return None
|
|
244
|
+
return TURN_BLOCK_REASON.format(tool_name=TITLE_TOOL_DISPLAY_SEPARATOR.join(all_title_tools))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parsed_entry(line: str) -> dict[str, object] | None:
|
|
248
|
+
try:
|
|
249
|
+
parsed_entry = json.loads(line)
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
return None
|
|
252
|
+
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def read_transcript_entries(transcript_path: object) -> list[dict[str, object]]:
|
|
256
|
+
"""Return the transcript's JSON object entries, or an empty list when it cannot be read."""
|
|
257
|
+
if not isinstance(transcript_path, str) or not transcript_path:
|
|
258
|
+
return []
|
|
259
|
+
try:
|
|
260
|
+
all_lines = (
|
|
261
|
+
Path(transcript_path)
|
|
262
|
+
.read_text(encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY)
|
|
263
|
+
.splitlines()
|
|
264
|
+
)
|
|
265
|
+
except OSError:
|
|
266
|
+
return []
|
|
267
|
+
all_parsed_entries = (_parsed_entry(each_line) for each_line in all_lines)
|
|
268
|
+
return [each_entry for each_entry in all_parsed_entries if each_entry is not None]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main() -> int:
|
|
272
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
273
|
+
if hook_input is None:
|
|
274
|
+
return ALLOW_EXIT_CODE
|
|
275
|
+
block_reason = turn_block_reason(
|
|
276
|
+
read_transcript_entries(hook_input.get(TRANSCRIPT_PATH_KEY)),
|
|
277
|
+
hook_input.get(STOP_HOOK_ACTIVE_KEY) is True,
|
|
278
|
+
)
|
|
279
|
+
if block_reason is None:
|
|
280
|
+
return ALLOW_EXIT_CODE
|
|
281
|
+
log_hook_block(Path(__file__).name, TURN_GATE_EVENT_NAME, block_reason)
|
|
282
|
+
sys.stdout.write(json.dumps({DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}))
|
|
283
|
+
return ALLOW_EXIT_CODE
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
sys.exit(main())
|
|
@@ -19,6 +19,7 @@ DESKTOP_TITLE_TOOL = "mcp__ccd_session_mgmt__set_session_title"
|
|
|
19
19
|
RED_FLAG = "\U0001f6a9"
|
|
20
20
|
CHECK_MARK = "✅"
|
|
21
21
|
HOURGLASS = "⏳"
|
|
22
|
+
ROADBLOCK = "\U0001f6a7"
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def run_gate(
|
|
@@ -49,6 +50,8 @@ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
|
49
50
|
f"{CHECK_MARK} Session title gate",
|
|
50
51
|
f"{RED_FLAG}️ Runner sign-in",
|
|
51
52
|
f"{RED_FLAG} CI gate + docs",
|
|
53
|
+
f"{ROADBLOCK} Push refused",
|
|
54
|
+
f"{ROADBLOCK}️ Host unreachable",
|
|
52
55
|
],
|
|
53
56
|
)
|
|
54
57
|
def test_should_allow_a_well_formed_title_on_each_title_tool(
|
|
@@ -62,6 +65,8 @@ def test_should_allow_a_well_formed_title_on_each_title_tool(
|
|
|
62
65
|
[
|
|
63
66
|
("Broker gate + replay rule", STATUS_PREFIX_MESSAGE),
|
|
64
67
|
("\U0001f680 Broker gate", STATUS_PREFIX_MESSAGE),
|
|
68
|
+
("\u26d4 Push refused", STATUS_PREFIX_MESSAGE),
|
|
69
|
+
(f"{ROADBLOCK}{HOURGLASS} Push refused", STATUS_PREFIX_MESSAGE),
|
|
65
70
|
(f"{HOURGLASS}Broker gate", STATUS_PREFIX_MESSAGE),
|
|
66
71
|
(f"{HOURGLASS} Broker gate\nand more", SINGLE_LINE_MESSAGE),
|
|
67
72
|
(f"{HOURGLASS} Broker gate.", TRAILING_PUNCTUATION_MESSAGE),
|