claude-dev-env 8.89.0 → 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 (86) 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-deepstack-plugin.mjs +42 -25
  30. package/bin/install-deepstack-plugin.test.mjs +37 -11
  31. package/bin/install-pstack-plugin.mjs +58 -11
  32. package/bin/install-pstack-plugin.test.mjs +54 -21
  33. package/bin/install.agents-home.test.mjs +0 -1
  34. package/bin/install.codex-rules.test.mjs +0 -2
  35. package/bin/install.cursor-rules.test.mjs +90 -26
  36. package/bin/install.mjs +30 -36
  37. package/bin/install.pr-visual.test.mjs +40 -0
  38. package/bin/install.prompt-context.test.mjs +3 -4
  39. package/bin/install.prune.test.mjs +0 -1
  40. package/bin/install.question-presentation.test.mjs +0 -4
  41. package/bin/install.settings-defaults.test.mjs +0 -1
  42. package/bin/install.shared-settings.test.mjs +0 -1
  43. package/bin/install.test.mjs +14 -44
  44. package/bin/install.transaction.test.mjs +0 -8
  45. package/bin/install.uninstall-transaction.test.mjs +0 -2
  46. package/bin/install.update-keeps-hooks.test.mjs +0 -1
  47. package/docs/BDD_SCENARIO_QUALITY.md +1 -1
  48. package/docs/BDD_TEST_LAYOUT.md +1 -1
  49. package/docs/CODE_RULES.md +1 -1
  50. package/docs/rule-guides/deepstack-wording.md +3 -5
  51. package/docs/rule-guides/session-title.md +5 -4
  52. package/hooks/blocking/session_title_turn_gate.py +287 -0
  53. package/hooks/blocking/test_session_title_format_gate.py +5 -0
  54. package/hooks/blocking/test_session_title_turn_gate.py +264 -0
  55. package/hooks/features/conduct-gates.md +3 -0
  56. package/hooks/features/session-context.md +0 -4
  57. package/hooks/hooks.json +5 -5
  58. package/hooks/hooks_constants/mod_handoff_constants.py +0 -2
  59. package/hooks/hooks_constants/session_title_constants.py +41 -3
  60. package/hooks/lifecycle/nested_project_hooks.py +2 -2
  61. package/hooks/lifecycle/test_nested_project_hooks.py +2 -2
  62. package/hooks/session/compaction_user_messages.py +2 -2
  63. package/package.json +2 -2
  64. package/rules/deepstack-wording.md +3 -3
  65. package/scripts/ci/install-playtest.mjs +58 -314
  66. package/scripts/ci/install-playtest.test.mjs +24 -6
  67. package/scripts/policy_lint/adapter_configuration.py +1 -0
  68. package/scripts/policy_lint/config/constants.py +1 -0
  69. package/scripts/test_deepstack_review_criterion.py +2 -3
  70. package/scripts/test_deepstack_wording_rule.py +6 -4
  71. package/scripts/tests/test_adapter_configuration.py +8 -0
  72. package/.agents/skills/deepen/SKILL.md +0 -26
  73. package/commands/deepen.md +0 -14
  74. package/docs/decisions/deepstack-gate.md +0 -32
  75. package/hooks/hooks_constants/deepen_first_prompt_constants.py +0 -187
  76. package/hooks/session/deepen_default_rewrite.py +0 -128
  77. package/hooks/session/deepen_first_prompt.py +0 -383
  78. package/hooks/session/deepen_role_gate.py +0 -171
  79. package/hooks/session/deepen_turn_marker.py +0 -75
  80. package/hooks/session/fixtures/deepstack_replay.json +0 -15
  81. package/hooks/session/grade_deepstack_replay.py +0 -78
  82. package/hooks/session/test_deepen_default_rewrite.py +0 -90
  83. package/hooks/session/test_deepen_first_prompt.py +0 -452
  84. package/hooks/session/test_deepen_role_gate.py +0 -83
  85. package/hooks/session/test_deepen_turn_marker.py +0 -59
  86. package/hooks/session/test_grade_deepstack_replay.py +0 -47
@@ -91,7 +91,6 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
91
91
  CDE_INSTALL_DEEPSTACK: '0',
92
92
  CDE_INSTALL_USAGE_WRAPUP: '0',
93
93
  CDE_INSTALL_SUBAGENT_MODELS: '0',
94
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
95
94
  HOME: box.root,
96
95
  USERPROFILE: box.root,
97
96
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -158,7 +157,6 @@ test('capture and restore recover an additional host settings file', () => {
158
157
  CDE_INSTALL_DEEPSTACK: '0',
159
158
  CDE_INSTALL_USAGE_WRAPUP: '0',
160
159
  CDE_INSTALL_SUBAGENT_MODELS: '0',
161
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
162
160
  HOME: box.root,
163
161
  USERPROFILE: box.root,
164
162
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -198,7 +196,6 @@ test('runWithInstallTransaction restores prior state on injected fault', () => {
198
196
  CDE_INSTALL_DEEPSTACK: '0',
199
197
  CDE_INSTALL_USAGE_WRAPUP: '0',
200
198
  CDE_INSTALL_SUBAGENT_MODELS: '0',
201
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
202
199
  HOME: box.root,
203
200
  USERPROFILE: box.root,
204
201
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -257,7 +254,6 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
257
254
  CDE_INSTALL_DEEPSTACK: '0',
258
255
  CDE_INSTALL_USAGE_WRAPUP: '0',
259
256
  CDE_INSTALL_SUBAGENT_MODELS: '0',
260
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
261
257
  HOME: box.root,
262
258
  USERPROFILE: box.root,
263
259
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -305,7 +301,6 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
305
301
  CDE_INSTALL_DEEPSTACK: '0',
306
302
  CDE_INSTALL_USAGE_WRAPUP: '0',
307
303
  CDE_INSTALL_SUBAGENT_MODELS: '0',
308
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
309
304
  HOME: homeDirectory,
310
305
  USERPROFILE: homeDirectory,
311
306
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -343,7 +338,6 @@ test('a later install fault removes newly seeded Codex pstack files', () => {
343
338
  CDE_INSTALL_DEEPSTACK: '0',
344
339
  CDE_INSTALL_USAGE_WRAPUP: '0',
345
340
  CDE_INSTALL_SUBAGENT_MODELS: '0',
346
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
347
341
  CDE_CODEX_EXECUTABLE: codexCommandPath,
348
342
  },
349
343
  });
@@ -491,7 +485,6 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
491
485
  CDE_INSTALL_DEEPSTACK: '0',
492
486
  CDE_INSTALL_USAGE_WRAPUP: '0',
493
487
  CDE_INSTALL_SUBAGENT_MODELS: '0',
494
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
495
488
  HOME: homeDirectory,
496
489
  USERPROFILE: homeDirectory,
497
490
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -513,7 +506,6 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
513
506
  CDE_INSTALL_DEEPSTACK: '0',
514
507
  CDE_INSTALL_USAGE_WRAPUP: '0',
515
508
  CDE_INSTALL_SUBAGENT_MODELS: '0',
516
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
517
509
  HOME: homeDirectory,
518
510
  USERPROFILE: homeDirectory,
519
511
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -77,7 +77,6 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
77
77
  CDE_INSTALL_DEEPSTACK: '0',
78
78
  CDE_INSTALL_USAGE_WRAPUP: '0',
79
79
  CDE_INSTALL_SUBAGENT_MODELS: '0',
80
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
81
80
  HOME: homeDirectory,
82
81
  USERPROFILE: homeDirectory,
83
82
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -316,7 +315,6 @@ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
316
315
  CDE_INSTALL_DEEPSTACK: '0',
317
316
  CDE_INSTALL_USAGE_WRAPUP: '0',
318
317
  CDE_INSTALL_SUBAGENT_MODELS: '0',
319
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
320
318
  HOME: homeDirectory,
321
319
  USERPROFILE: homeDirectory,
322
320
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -28,7 +28,6 @@ function installerEnvironment(homeDirectory) {
28
28
  CDE_INSTALL_DEEPSTACK: '0',
29
29
  CDE_INSTALL_USAGE_WRAPUP: '0',
30
30
  CDE_INSTALL_SUBAGENT_MODELS: '0',
31
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
32
31
  HOME: homeDirectory,
33
32
  USERPROFILE: homeDirectory,
34
33
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -40,7 +40,7 @@ Then I should be presented with the following hotels:
40
40
 
41
41
  ## Meaningful Actors
42
42
 
43
- Personas ground scenarios in realistic goals and context. Use light soap-opera personas when you need depth before full UX research: introduce names and roles as needed and deepen them across scenarios.
43
+ Personas ground scenarios in realistic goals and context. Use light soap-opera personas when you need depth before full UX research: introduce names and roles as needed and develop them across scenarios.
44
44
 
45
45
  > Smart & Molak §7.6.5 describe personas as rich, realistic descriptions: each persona captures goals, abilities, and background information that ground the test scenario in a user context.
46
46
 
@@ -18,7 +18,7 @@ Dan North (2006): naming tests as sentences keeps focus on the **next behaviour*
18
18
 
19
19
  ## Soap-opera personas
20
20
 
21
- When you lack full personas, introduce **short-lived characters** with a name and role and deepen them as scenarios grow—like a serial drama adding cast over episodes (Smart & Molak §7.6.5). Embed **\[Name], the \[role]** in scenario titles or setup where the actor changes behavior.
21
+ When you lack full personas, introduce **short-lived characters** with a name and role and develop them as scenarios grow—like a serial drama adding cast over episodes (Smart & Molak §7.6.5). Embed **\[Name], the \[role]** in scenario titles or setup where the actor changes behavior.
22
22
 
23
23
  ## Example (JavaScript-style)
24
24
 
@@ -132,4 +132,4 @@ Open [details](code-rules/enforcement-surfaces.md) when adding a check.
132
132
 
133
133
  ## 11.7 A NEW INSTRUCTION NAMES THE TRIGGER THAT COVERS IT
134
134
 
135
- A new rule, hook, or skill names the trigger that covers it and records that trigger in `rules-archived/ARCHIVE-MANIFEST.md`. The first-task gate is `hooks/session/deepen_first_prompt.py`. DeepStack cites that gate. This criterion cites the gate. It does not restate the gate.
135
+ A new rule, hook, or skill names the trigger that covers it and records that trigger in `rules-archived/ARCHIVE-MANIFEST.md`.
@@ -4,9 +4,9 @@ Back to the [rules index](../../rules/index.md).
4
4
 
5
5
  ## In brief
6
6
 
7
- A deepened task uses the three DeepStack labels and puts `CDE_DEEPEN:v1` on line 1. The gate in `hooks/session/deepen_first_prompt.py` is the check. This guide cites that gate.
7
+ A DeepStack task uses the three DeepStack labels. No hook checks them.
8
8
 
9
- **When this applies:** Writing a deepened task, a worker prompt, or a sentence that names the three DeepStack lines.
9
+ **When this applies:** Writing a worker prompt or a sentence that names the three DeepStack lines.
10
10
 
11
11
  ## Labels
12
12
 
@@ -18,6 +18,4 @@ Say "the user" in public text.
18
18
 
19
19
  ## Where it is enforced
20
20
 
21
- | Surface | What runs |
22
- |---|---|
23
- | A first-task rewrite | `hooks/session/deepen_first_prompt.py` |
21
+ Nowhere. The agent applies it.
@@ -4,9 +4,9 @@ Back to the [rules index](../../rules/index.md).
4
4
 
5
5
  ## In brief
6
6
 
7
- Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
7
+ Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the construction sign when an outside dependency blocks the work, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
8
8
 
9
- **Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title.
9
+ **Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` holds a turn that set no title.
10
10
 
11
11
  **When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
12
12
 
@@ -14,13 +14,14 @@ Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when
14
14
 
15
15
  Before each turn ends, set the title to `<emoji> <name>`. Get the session id from `get_session` with no `session_id` when the tool asks for one.
16
16
 
17
- Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it.
17
+ Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it. When the Stop gate holds the turn after the answer went out, call the title tool and close with one short sentence. Leave the answer as it stands.
18
18
 
19
19
  ## Status emoji
20
20
 
21
21
  | Emoji | Use it when |
22
22
  |---|---|
23
23
  | Red flag | The user must act: a question, an approval, or a step only the user can do. |
24
+ | Construction sign | An outside dependency blocks the work and the user cannot clear it right now, such as a refused push or an unreachable host. |
24
25
  | Check mark | The work is done: merged, or nothing left. |
25
26
  | Hourglass | Work, CI, or a merge queue still runs and nothing waits on the user. |
26
27
 
@@ -30,4 +31,4 @@ The name has 25 characters or fewer and names the main change or outcome in conc
30
31
 
31
32
  ## Enforcement
32
33
 
33
- `hooks/blocking/session_title_format_gate.py` denies a malformed title.
34
+ `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` is a Stop hook. It holds the end of a turn until a title call in that turn comes back without an error. It applies when the transcript shows the title tool. A held turn whose title call failed may end, so a failing title tool cannot hold it forever.
@@ -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),