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,365 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse context reminder: own the review loop on a new owned pull request.
3
+
4
+ This hook never blocks. It runs after a shell call or a GitHub MCP
5
+ ``create_pull_request`` call. When that call opened a pull request on a
6
+ repository we own, and the repository is not on the preset list of
7
+ repositories that run their own automated review, it adds one instruction
8
+ block to the agent's context::
9
+
10
+ === OWNED PR REVIEW LOOP (context reminder, never a block) ===
11
+ You opened jl-cmd-projects/claude-dev-env#42 https://github.com/...
12
+ 1. Review loop. Load the efficient-review skill ...
13
+ 2. Mark ready. ...
14
+ 3. Babysit. Follow the pstack babysit playbook ...
15
+ 4. Ship. Follow the pstack shipping playbook ...
16
+
17
+ The pull request URL in the tool response is the proof that a pull request
18
+ opened, so the hook needs no network call and reads the same on Claude Code
19
+ and Codex.
20
+
21
+ Quiet branches: another tool, a shell command that opens no pull request,
22
+ a harness-reported non-zero exit, a response that says the pull request
23
+ already exists, a response with no pull request URL, an owner outside the
24
+ owned set, and a preset repository each emit nothing.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import re
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ try:
36
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
37
+ if _hooks_root_directory not in sys.path:
38
+ sys.path.insert(0, _hooks_root_directory)
39
+ from hooks_constants.bash_post_call_dispatcher_constants import (
40
+ EXIT_CODE_ERROR_PREFIX,
41
+ )
42
+ from hooks_constants.owned_pr_review_loop_constants import (
43
+ ALL_DECLARED_URL_KEYS,
44
+ ALL_GH_API_BODY_OPTIONS,
45
+ ALL_GH_API_METHOD_OPTIONS,
46
+ ALL_GH_API_SHORT_OPTIONS,
47
+ ALL_GH_PR_CREATE_ACTIONS,
48
+ ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES,
49
+ ALL_OWNED_OWNER_DIGESTS,
50
+ ALL_PRESET_REPOSITORY_DIGESTS,
51
+ ALL_SHELL_COMMAND_INPUT_KEYS,
52
+ ALL_SHELL_TOOL_NAMES,
53
+ ALREADY_EXISTS_MARKER,
54
+ GH_API_POST_METHOD,
55
+ GH_API_PULLS_ENDPOINT_PATTERN,
56
+ GH_API_SUBCOMMAND,
57
+ GH_LONG_OPTION_PREFIX,
58
+ GH_OPTION_VALUE_SEPARATOR,
59
+ GH_PR_SUBCOMMAND,
60
+ GH_PROGRAM_NAME,
61
+ GH_SHORT_OPTION_LENGTH,
62
+ JSON_OBJECT_OPENER,
63
+ MCP_OWNER_INPUT_KEY,
64
+ MCP_REPOSITORY_INPUT_KEY,
65
+ MCP_TOOL_NAME_PREFIX,
66
+ NAME_DIGEST_TEXT_ENCODING,
67
+ PULL_REQUEST_URL_PATTERN,
68
+ REVIEW_LOOP_CONTEXT_TEMPLATE,
69
+ SHELL_ARGUMENT_SEPARATOR,
70
+ )
71
+ from hooks_constants.post_tool_use_context import (
72
+ write_post_tool_use_context_to_stdout,
73
+ )
74
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
75
+ from hooks_constants.shell_command_segments import command_tokens, split_into_segments
76
+ from hooks_constants.shell_command_wrappers import (
77
+ all_wrapped_command_texts,
78
+ segment_program_and_arguments,
79
+ )
80
+ except ImportError as import_error:
81
+ raise ImportError(
82
+ "owned_pr_review_loop: cannot import its dependencies; "
83
+ "ensure the hooks directory is importable."
84
+ ) from import_error
85
+
86
+
87
+ def _is_gh_pr_create(all_arguments: list[str]) -> bool:
88
+ return (
89
+ all_arguments[:1] == [GH_PR_SUBCOMMAND]
90
+ and len(all_arguments) > 1
91
+ and all_arguments[1] in ALL_GH_PR_CREATE_ACTIONS
92
+ )
93
+
94
+
95
+ def _option_name_and_inline_value(argument: str) -> tuple[str, str | None]:
96
+ if argument.startswith(GH_LONG_OPTION_PREFIX):
97
+ option_name, separator, inline_value = argument.partition(GH_OPTION_VALUE_SEPARATOR)
98
+ return option_name, inline_value if separator else None
99
+ short_option_name = argument[:GH_SHORT_OPTION_LENGTH]
100
+ if short_option_name in ALL_GH_API_SHORT_OPTIONS and len(argument) > GH_SHORT_OPTION_LENGTH:
101
+ return short_option_name, argument[GH_SHORT_OPTION_LENGTH:]
102
+ return argument, None
103
+
104
+
105
+ def _is_gh_api_pulls_post(all_arguments: list[str]) -> bool:
106
+ if all_arguments[:1] != [GH_API_SUBCOMMAND]:
107
+ return False
108
+ if not any(
109
+ GH_API_PULLS_ENDPOINT_PATTERN.fullmatch(each_argument) for each_argument in all_arguments
110
+ ):
111
+ return False
112
+ explicit_method: str | None = None
113
+ sends_body = False
114
+ all_following_arguments = [*all_arguments[1:], ""]
115
+ for each_argument, each_following_argument in zip(all_arguments, all_following_arguments, strict=True):
116
+ option_name, inline_value = _option_name_and_inline_value(each_argument)
117
+ if option_name in ALL_GH_API_BODY_OPTIONS:
118
+ sends_body = True
119
+ if option_name in ALL_GH_API_METHOD_OPTIONS:
120
+ explicit_method = inline_value or each_following_argument
121
+ if explicit_method:
122
+ return explicit_method.upper() == GH_API_POST_METHOD
123
+ return sends_body
124
+
125
+
126
+ def _segment_opens_pull_request(all_segment_tokens: list[str]) -> bool:
127
+ program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
128
+ return program_name == GH_PROGRAM_NAME and (
129
+ _is_gh_pr_create(all_arguments) or _is_gh_api_pulls_post(all_arguments)
130
+ )
131
+
132
+
133
+ def command_creates_pull_request(command: str) -> bool:
134
+ """Return True when any segment of the shell command opens a pull request.
135
+
136
+ Three shapes count::
137
+
138
+ gh pr create --fill
139
+ gh pr new --fill
140
+ gh api repos/acme/widgets/pulls --input pr.json
141
+
142
+ A ``gh api`` call counts only when it targets the pulls collection. An
143
+ explicit method (``-X POST``, ``-XPOST``, ``--method=POST``) decides
144
+ alone. With no method, a body (``-f``, ``-F``, ``--field``,
145
+ ``--raw-field``, ``--input``, or the ``--name=value`` and ``-ftitle=t``
146
+ forms) means
147
+ POST, as it does for ``gh``. A ``pwsh -Command "..."``
148
+ wrapper is unwrapped and its inner text scanned the same way.
149
+
150
+ Args:
151
+ command: The shell command text the agent ran.
152
+ """
153
+ return any(
154
+ _segment_opens_pull_request(each_segment)
155
+ for each_command_text in all_wrapped_command_texts(command)
156
+ for each_segment in split_into_segments(command_tokens(each_command_text))
157
+ )
158
+
159
+
160
+ def _is_mcp_create_pull_request(tool_name: str) -> bool:
161
+ return tool_name.startswith(MCP_TOOL_NAME_PREFIX) and tool_name.endswith(
162
+ ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES
163
+ )
164
+
165
+
166
+ def _shell_command(all_tool_input: dict[str, object]) -> str:
167
+ for each_key in ALL_SHELL_COMMAND_INPUT_KEYS:
168
+ command = all_tool_input.get(each_key)
169
+ if isinstance(command, str):
170
+ return command
171
+ if isinstance(command, list) and all(isinstance(each, str) for each in command):
172
+ return SHELL_ARGUMENT_SEPARATOR.join(command)
173
+ return ""
174
+
175
+
176
+ def _response_text(tool_response: object) -> str:
177
+ if isinstance(tool_response, str):
178
+ return tool_response
179
+ return json.dumps(tool_response)
180
+
181
+
182
+ def tool_call_opens_pull_request(tool_name: str, all_tool_input: dict[str, object]) -> bool:
183
+ """Return True when the tool call is a pull request creation.
184
+
185
+ Args:
186
+ tool_name: The tool name from the hook payload, such as ``Bash``,
187
+ ``exec_command``, or ``mcp__github__create_pull_request``.
188
+ all_tool_input: The tool input from the hook payload.
189
+ """
190
+ if _is_mcp_create_pull_request(tool_name):
191
+ return True
192
+ if tool_name in ALL_SHELL_TOOL_NAMES:
193
+ return command_creates_pull_request(_shell_command(all_tool_input))
194
+ return False
195
+
196
+
197
+ def _matches_for_input_repository(
198
+ all_url_matches: list[re.Match[str]], all_tool_input: dict[str, object]
199
+ ) -> list[re.Match[str]]:
200
+ input_owner = str(all_tool_input.get(MCP_OWNER_INPUT_KEY) or "").lower()
201
+ input_repository = str(all_tool_input.get(MCP_REPOSITORY_INPUT_KEY) or "").lower()
202
+ if not (input_owner and input_repository):
203
+ return all_url_matches
204
+ return [
205
+ each_match
206
+ for each_match in all_url_matches
207
+ if each_match["owner"].lower() == input_owner
208
+ and each_match["repository"].lower() == input_repository
209
+ ]
210
+
211
+
212
+ def _parsed_json_object_text(text: str) -> object:
213
+ stripped_text = text.strip()
214
+ if not stripped_text.startswith(JSON_OBJECT_OPENER):
215
+ return None
216
+ try:
217
+ return json.loads(stripped_text)
218
+ except json.JSONDecodeError:
219
+ return None
220
+
221
+
222
+ def _all_declared_url_matches(response_value: object) -> list[re.Match[str]]:
223
+ if isinstance(response_value, str):
224
+ return _all_declared_url_matches(_parsed_json_object_text(response_value))
225
+ if isinstance(response_value, list):
226
+ return [
227
+ each_match
228
+ for each_item in response_value
229
+ for each_match in _all_declared_url_matches(each_item)
230
+ ]
231
+ if not isinstance(response_value, dict):
232
+ return []
233
+ all_own_matches = [
234
+ PULL_REQUEST_URL_PATTERN.fullmatch(str(response_value.get(each_key) or ""))
235
+ for each_key in ALL_DECLARED_URL_KEYS
236
+ ]
237
+ return [each_match for each_match in all_own_matches if each_match] + [
238
+ each_match
239
+ for each_child in response_value.values()
240
+ for each_match in _all_declared_url_matches(each_child)
241
+ ]
242
+
243
+
244
+ def _scanned_url_match(
245
+ all_tool_input: dict[str, object], tool_response: object
246
+ ) -> re.Match[str] | None:
247
+ response_text = _response_text(tool_response)
248
+ if response_text.startswith(EXIT_CODE_ERROR_PREFIX):
249
+ return None
250
+ if ALREADY_EXISTS_MARKER in response_text.lower():
251
+ return None
252
+ all_url_matches = _matches_for_input_repository(
253
+ list(PULL_REQUEST_URL_PATTERN.finditer(response_text)), all_tool_input
254
+ )
255
+ return all_url_matches[-1] if all_url_matches else None
256
+
257
+
258
+ def opened_pull_request(
259
+ all_tool_input: dict[str, object], tool_response: object
260
+ ) -> dict[str, str] | None:
261
+ """Return the owner, repository, number, and url of the opened pull request.
262
+
263
+ A JSON response declares the new pull request in an ``html_url`` or
264
+ ``url`` field, as the REST API and the MCP tools do. That field wins over
265
+ any URL the body cites. With no declared field, the hook scans the text
266
+ and takes the last URL, since ``gh pr create`` prints it last; a non-zero
267
+ exit or an "already exists" notice then means no new pull request. When
268
+ the tool input names an owner and a repository, only a URL for that
269
+ repository counts.
270
+
271
+ Args:
272
+ all_tool_input: The tool input from the hook payload.
273
+ tool_response: The tool response from the hook payload.
274
+
275
+ Returns:
276
+ The pull request fields, or None when the response shows no new pull
277
+ request.
278
+ """
279
+ all_declared_matches = _matches_for_input_repository(
280
+ _all_declared_url_matches(tool_response), all_tool_input
281
+ )
282
+ url_match = next(iter(all_declared_matches), None)
283
+ url_match = url_match or _scanned_url_match(all_tool_input, tool_response)
284
+ if url_match is None:
285
+ return None
286
+ return {**url_match.groupdict(), "url": url_match.group(0)}
287
+
288
+
289
+ def name_digest(name: str) -> str:
290
+ """Return the SHA-256 digest of a name in its normalized form.
291
+
292
+ Normalizing keeps only ASCII letters and digits in lower case, so
293
+ ``"Acme-Widgets/tools"`` and ``"acmewidgetstools"`` share one digest.
294
+
295
+ Args:
296
+ name: An owner name or an ``owner/repository`` full name.
297
+ """
298
+ normalized_name = "".join(
299
+ each_character
300
+ for each_character in name.lower()
301
+ if each_character.isascii() and each_character.isalnum()
302
+ )
303
+ return hashlib.sha256(normalized_name.encode(NAME_DIGEST_TEXT_ENCODING)).hexdigest()
304
+
305
+
306
+ def needs_review_loop(
307
+ owner: str,
308
+ repository: str,
309
+ all_owned_owner_digests: frozenset[str],
310
+ all_preset_repository_digests: frozenset[str],
311
+ ) -> bool:
312
+ """Return True for an owned repository outside the preset review list.
313
+
314
+ Args:
315
+ owner: The GitHub owner of the repository.
316
+ repository: The repository name.
317
+ all_owned_owner_digests: Digests of the owners we own.
318
+ all_preset_repository_digests: Digests of the ``owner/repository``
319
+ names that run their own automated review.
320
+ """
321
+ return (
322
+ name_digest(owner) in all_owned_owner_digests
323
+ and name_digest(f"{owner}/{repository}") not in all_preset_repository_digests
324
+ )
325
+
326
+
327
+ def build_review_loop_context(all_pull_request_fields: dict[str, str]) -> str:
328
+ """Build the review-loop instruction block for one opened pull request.
329
+
330
+ Args:
331
+ all_pull_request_fields: The owner, repository, number, and url fields.
332
+ """
333
+ return REVIEW_LOOP_CONTEXT_TEMPLATE.format(**all_pull_request_fields)
334
+
335
+
336
+ def main() -> None:
337
+ """Add the review-loop instructions after a pull request opens on an owned repository.
338
+
339
+ Reads the PostToolUse payload from stdin. Every quiet branch returns with
340
+ no output, so this hook can never alter or block a tool call.
341
+ """
342
+ hook_payload = read_hook_input_dictionary_from_stdin()
343
+ if hook_payload is None:
344
+ return
345
+ tool_name = str(hook_payload.get("tool_name") or "")
346
+ tool_input = hook_payload.get("tool_input")
347
+ if not isinstance(tool_input, dict):
348
+ return
349
+ if not tool_call_opens_pull_request(tool_name, tool_input):
350
+ return
351
+ pull_request = opened_pull_request(tool_input, hook_payload.get("tool_response"))
352
+ if pull_request is None:
353
+ return
354
+ if not needs_review_loop(
355
+ pull_request["owner"],
356
+ pull_request["repository"],
357
+ ALL_OWNED_OWNER_DIGESTS,
358
+ ALL_PRESET_REPOSITORY_DIGESTS,
359
+ ):
360
+ return
361
+ write_post_tool_use_context_to_stdout(build_review_loop_context(pull_request))
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()