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.
- 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-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 +90 -26
- package/bin/install.mjs +30 -36
- 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/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/conduct-gates.md +3 -0
- package/hooks/features/session-context.md +0 -4
- package/hooks/hooks.json +5 -5
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -2
- 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
|
@@ -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
|
|
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
|
|
package/docs/BDD_TEST_LAYOUT.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -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`.
|
|
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
|
|
7
|
+
A DeepStack task uses the three DeepStack labels. No hook checks them.
|
|
8
8
|
|
|
9
|
-
**When this applies:** Writing a
|
|
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
|
-
|
|
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),
|