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.
Files changed (91) hide show
  1. package/.agents/skills/pr-visual/README.md +29 -0
  2. package/.agents/skills/pr-visual/SKILL.md +62 -0
  3. package/.agents/skills/pr-visual/assets/icons.symbols +27 -0
  4. package/.agents/skills/pr-visual/assets/viewer.html +147 -0
  5. package/.agents/skills/pr-visual/fixtures/bar.scene.json +193 -0
  6. package/.agents/skills/pr-visual/fixtures/flow.scene.json +201 -0
  7. package/.agents/skills/pr-visual/fixtures/head/metrics.json +16 -0
  8. package/.agents/skills/pr-visual/fixtures/integrated.scene.json +239 -0
  9. package/.agents/skills/pr-visual/fixtures/line.scene.json +193 -0
  10. package/.agents/skills/pr-visual/fixtures/scatter.scene.json +193 -0
  11. package/.agents/skills/pr-visual/fixtures/snapshot.json +21 -0
  12. package/.agents/skills/pr-visual/references/evidence.md +9 -0
  13. package/.agents/skills/pr-visual/references/formats.md +30 -0
  14. package/.agents/skills/pr-visual/references/scene-contract.md +48 -0
  15. package/.agents/skills/pr-visual/references/visual-contract.md +23 -0
  16. package/.agents/skills/pr-visual/scripts/collect.py +111 -0
  17. package/.agents/skills/pr-visual/scripts/metrics.py +99 -0
  18. package/.agents/skills/pr-visual/scripts/render.py +133 -0
  19. package/.agents/skills/pr-visual/scripts/test_collect.py +58 -0
  20. package/.agents/skills/pr-visual/scripts/test_metrics.py +47 -0
  21. package/.agents/skills/pr-visual/scripts/test_render.py +77 -0
  22. package/.agents/skills/pr-visual/scripts/verify.py +218 -0
  23. package/.agents/skills/pr-visual/scripts/visual_config/config/constants.py +104 -0
  24. package/bin/codex-skill-load-block.mjs +0 -25
  25. package/bin/codex-skill-load-block.test.mjs +5 -17
  26. package/bin/codex-trimmed-sol.test.mjs +0 -1
  27. package/bin/cursor-native-hooks.test.mjs +3 -3
  28. package/bin/ever-shipped-skills.mjs +1 -0
  29. package/bin/install-constants.mjs +3 -0
  30. package/bin/install-deepstack-plugin.mjs +42 -25
  31. package/bin/install-deepstack-plugin.test.mjs +37 -11
  32. package/bin/install-pstack-plugin.mjs +58 -11
  33. package/bin/install-pstack-plugin.test.mjs +54 -21
  34. package/bin/install.agents-home.test.mjs +0 -1
  35. package/bin/install.codex-rules.test.mjs +0 -2
  36. package/bin/install.cursor-rules.test.mjs +96 -21
  37. package/bin/install.mjs +48 -44
  38. package/bin/install.pr-visual.test.mjs +40 -0
  39. package/bin/install.prompt-context.test.mjs +3 -4
  40. package/bin/install.prune.test.mjs +0 -1
  41. package/bin/install.question-presentation.test.mjs +0 -4
  42. package/bin/install.settings-defaults.test.mjs +0 -1
  43. package/bin/install.shared-settings.test.mjs +0 -1
  44. package/bin/install.test.mjs +14 -44
  45. package/bin/install.transaction.test.mjs +0 -8
  46. package/bin/install.uninstall-transaction.test.mjs +0 -2
  47. package/bin/install.update-keeps-hooks.test.mjs +0 -1
  48. package/docs/BDD_SCENARIO_QUALITY.md +1 -1
  49. package/docs/BDD_TEST_LAYOUT.md +1 -1
  50. package/docs/CODE_RULES.md +1 -1
  51. package/docs/rule-guides/deepstack-wording.md +3 -5
  52. package/docs/rule-guides/session-title.md +5 -4
  53. package/hooks/advisory/owned_pr_review_loop.py +365 -0
  54. package/hooks/advisory/test_owned_pr_review_loop.py +369 -0
  55. package/hooks/blocking/session_title_turn_gate.py +287 -0
  56. package/hooks/blocking/test_session_title_format_gate.py +5 -0
  57. package/hooks/blocking/test_session_title_turn_gate.py +264 -0
  58. package/hooks/features/bash-dispatchers.md +3 -0
  59. package/hooks/features/conduct-gates.md +3 -0
  60. package/hooks/features/session-context.md +0 -4
  61. package/hooks/hooks.json +15 -5
  62. package/hooks/hooks_constants/mod_handoff_constants.py +0 -2
  63. package/hooks/hooks_constants/owned_pr_review_loop_constants.py +120 -0
  64. package/hooks/hooks_constants/session_title_constants.py +41 -3
  65. package/hooks/lifecycle/nested_project_hooks.py +2 -2
  66. package/hooks/lifecycle/test_nested_project_hooks.py +2 -2
  67. package/hooks/session/compaction_user_messages.py +2 -2
  68. package/package.json +2 -2
  69. package/rules/deepstack-wording.md +3 -3
  70. package/scripts/ci/install-playtest.mjs +58 -314
  71. package/scripts/ci/install-playtest.test.mjs +24 -6
  72. package/scripts/policy_lint/adapter_configuration.py +1 -0
  73. package/scripts/policy_lint/config/constants.py +1 -0
  74. package/scripts/test_deepstack_review_criterion.py +2 -3
  75. package/scripts/test_deepstack_wording_rule.py +6 -4
  76. package/scripts/tests/test_adapter_configuration.py +8 -0
  77. package/.agents/skills/deepen/SKILL.md +0 -26
  78. package/commands/deepen.md +0 -14
  79. package/docs/decisions/deepstack-gate.md +0 -32
  80. package/hooks/hooks_constants/deepen_first_prompt_constants.py +0 -187
  81. package/hooks/session/deepen_default_rewrite.py +0 -128
  82. package/hooks/session/deepen_first_prompt.py +0 -383
  83. package/hooks/session/deepen_role_gate.py +0 -171
  84. package/hooks/session/deepen_turn_marker.py +0 -75
  85. package/hooks/session/fixtures/deepstack_replay.json +0 -15
  86. package/hooks/session/grade_deepstack_replay.py +0 -78
  87. package/hooks/session/test_deepen_default_rewrite.py +0 -90
  88. package/hooks/session/test_deepen_first_prompt.py +0 -452
  89. package/hooks/session/test_deepen_role_gate.py +0 -83
  90. package/hooks/session/test_deepen_turn_marker.py +0 -59
  91. 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),