claude-dev-env 8.88.7 → 8.89.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.
@@ -182,6 +182,9 @@ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
182
182
  export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
183
183
  'open_tasks_stop_gate.py',
184
184
  ]);
185
+ export const ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES = new Set([
186
+ 'owned_pr_review_loop.py',
187
+ ]);
185
188
  export const CURSOR_HOOKS_FILE_NAME = 'hooks.json';
186
189
  export const CURSOR_HOOKS_SCHEMA_VERSION = 1;
187
190
  export const CURSOR_HOOK_TIMEOUT_SECONDS = 10;
@@ -135,7 +135,12 @@ test('seeds one editable policy and one native Codex routing hook', () => {
135
135
  );
136
136
  assert.equal(firstSpawnPromptGroups.length, 1);
137
137
  assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
138
- assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'Stop']);
138
+ assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'Stop']);
139
+ const allCodexPostToolUseCommands = firstCodexHooks.hooks.PostToolUse.flatMap(
140
+ group => group.hooks.map(hook => hook.command),
141
+ );
142
+ assert.equal(allCodexPostToolUseCommands.length, 1, 'Codex gets the owned PR review loop and no other PostToolUse hook');
143
+ assert.match(allCodexPostToolUseCommands[0], /advisory\/owned_pr_review_loop\.py/);
139
144
  const allCodexStopCommands = firstCodexHooks.hooks.Stop.flatMap(group => group.hooks.map(hook => hook.command));
140
145
  assert.equal(allCodexStopCommands.length, 1, 'Codex gets the open tasks Stop gate and no other Stop hook');
141
146
  assert.match(allCodexStopCommands[0], /open_tasks_stop_gate\.py/);
@@ -310,6 +315,12 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
310
315
  eachGroup.hooks.some(eachHook => eachHook.command.includes('open_tasks_stop_gate.py')));
311
316
  assert.equal(allPackageStopGroups.length, 1);
312
317
  migratedHooks.hooks.Stop = migratedHooks.hooks.Stop.filter(eachGroup => !allPackageStopGroups.includes(eachGroup));
318
+ const allPackagePostToolUseCommands = migratedHooks.hooks.PostToolUse
319
+ .flatMap(eachGroup => eachGroup.hooks)
320
+ .map(eachHook => eachHook.command);
321
+ assert.equal(allPackagePostToolUseCommands.length, 1);
322
+ assert.match(allPackagePostToolUseCommands[0], /owned_pr_review_loop\.py/);
323
+ delete migratedHooks.hooks.PostToolUse;
313
324
  assert.deepEqual(migratedHooks, { enabled: false, metadata: 'keep-file', hooks: expectedUserHooks });
314
325
  assert.equal(readFileSync(configPath, 'utf8'), disabledConfig);
315
326
  const firstInstall = readFileSync(codexHooksPath, 'utf8');
package/bin/install.mjs CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  PYTHON_PROBE_TIMEOUT_MILLISECONDS,
53
53
  ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
54
54
  ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
55
+ ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
55
56
  } from './install-constants.mjs';
56
57
  import {
57
58
  PSTACK_PLUGIN_SPEC,
@@ -2245,15 +2246,17 @@ function mergeHooksAtPath(
2245
2246
  return groupCount;
2246
2247
  }
2247
2248
 
2248
- function isCodexNativeStopHook(hook) {
2249
- const scriptName = String(hook?.command ?? '').replace(/\\/g, '/').split('/').pop();
2250
- return ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES.has(scriptName);
2249
+ function hookScriptName(hook) {
2250
+ return String(hook?.command ?? '').replace(/\\/g, '/').split('/').pop();
2251
2251
  }
2252
2252
 
2253
- function selectCodexNativeStopGroups(stopGroups) {
2254
- if (!Array.isArray(stopGroups)) return [];
2255
- return stopGroups
2256
- .map(group => ({ ...group, hooks: (group?.hooks ?? []).filter(isCodexNativeStopHook) }))
2253
+ function selectCodexNativeGroupsByScriptName(groups, allScriptNames) {
2254
+ if (!Array.isArray(groups)) return [];
2255
+ return groups
2256
+ .map(group => ({
2257
+ ...group,
2258
+ hooks: (group?.hooks ?? []).filter(hook => allScriptNames.has(hookScriptName(hook))),
2259
+ }))
2257
2260
  .filter(group => group.hooks.length > 0);
2258
2261
  }
2259
2262
 
@@ -2267,7 +2270,14 @@ function selectCodexNativeHooksConfig(hooksConfig) {
2267
2270
  ? preToolUseGroups.filter(group => ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS.has(group?.matcher))
2268
2271
  : [],
2269
2272
  UserPromptSubmit: Array.isArray(userPromptSubmitGroups) ? userPromptSubmitGroups : [],
2270
- Stop: selectCodexNativeStopGroups(hooksConfig.hooks?.Stop),
2273
+ PostToolUse: selectCodexNativeGroupsByScriptName(
2274
+ hooksConfig.hooks?.PostToolUse,
2275
+ ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
2276
+ ),
2277
+ Stop: selectCodexNativeGroupsByScriptName(
2278
+ hooksConfig.hooks?.Stop,
2279
+ ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
2280
+ ),
2271
2281
  },
2272
2282
  };
2273
2283
  }
@@ -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()
@@ -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
@@ -11,6 +11,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
11
11
  - `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
12
12
  - `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
13
13
  - `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
14
+ - `advisory/owned_pr_review_loop.py` adds review-loop instructions after the agent opens a pull request on an owned repository outside the preset list of repositories that run their own automated review: run efficient-review until clean, mark the pull request ready, babysit it, and ship it to merge.
14
15
 
15
16
  ## When it fires
16
17
 
@@ -21,6 +22,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
21
22
  - `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
22
23
  - `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
23
24
  - `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
25
+ - `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
24
26
 
25
27
  ## Proving it
26
28
 
@@ -34,6 +36,7 @@ Preconditions:
34
36
  - **Cloud GraphQL gate.** Input is a `gh api graphql` command with `CLAUDE_CODE_REMOTE=true`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_cloud_graphql_gate.py -q`. The adjacent test observes a denial that names the REST routes, and silence outside a cloud session.
35
37
  - **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
36
38
  - **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
39
+ - **Owned pull request review loop.** Input is a pull request creation result on an owned repository. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_owned_pr_review_loop.py -q`. The adjacent test observes the review-loop block for owned repositories and silence for the preset and foreign repositories.
37
40
 
38
41
  ## Gotchas
39
42
 
package/hooks/hooks.json CHANGED
@@ -456,6 +456,16 @@
456
456
  }
457
457
  ]
458
458
  },
459
+ {
460
+ "matcher": "Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)",
461
+ "hooks": [
462
+ {
463
+ "type": "command",
464
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/advisory/owned_pr_review_loop.py",
465
+ "timeout": 10
466
+ }
467
+ ]
468
+ },
459
469
  {
460
470
  "matcher": "Bash",
461
471
  "hooks": [
@@ -0,0 +1,120 @@
1
+ """Constants for the owned pull request review-loop context hook.
2
+
3
+ The hook is a PostToolUse observer. It never blocks. After the agent opens a
4
+ pull request on a repository we own, outside the preset list of repositories
5
+ that run their own automated review, it tells the agent to own an efficient
6
+ review loop and drive the pull request through to merge.
7
+
8
+ Private owner and repository names stay out of this public tree. Each set holds the
9
+ SHA-256 digest of a name normalized to lower-case ASCII letters and digits,
10
+ the same form ``scripts/private_terms.py`` uses::
11
+
12
+ "Acme-Widgets" -> "acmewidgets"
13
+ "Acme-Widgets/tools" -> "acmewidgetstools"
14
+
15
+ To add an owner or a preset repository, append the digest of its normalized
16
+ form.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ __all__ = [
24
+ "NAME_DIGEST_TEXT_ENCODING",
25
+ "ALL_OWNED_OWNER_DIGESTS",
26
+ "ALL_PRESET_REPOSITORY_DIGESTS",
27
+ "ALL_SHELL_TOOL_NAMES",
28
+ "ALL_SHELL_COMMAND_INPUT_KEYS",
29
+ "SHELL_ARGUMENT_SEPARATOR",
30
+ "ALL_GH_PR_CREATE_ACTIONS",
31
+ "GH_PROGRAM_NAME",
32
+ "GH_PR_SUBCOMMAND",
33
+ "GH_API_SUBCOMMAND",
34
+ "GH_API_PULLS_ENDPOINT_PATTERN",
35
+ "ALL_GH_API_BODY_OPTIONS",
36
+ "ALL_GH_API_METHOD_OPTIONS",
37
+ "GH_API_POST_METHOD",
38
+ "GH_LONG_OPTION_PREFIX",
39
+ "GH_SHORT_OPTION_LENGTH",
40
+ "ALL_GH_API_SHORT_OPTIONS",
41
+ "GH_OPTION_VALUE_SEPARATOR",
42
+ "MCP_TOOL_NAME_PREFIX",
43
+ "ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES",
44
+ "MCP_OWNER_INPUT_KEY",
45
+ "MCP_REPOSITORY_INPUT_KEY",
46
+ "PULL_REQUEST_URL_PATTERN",
47
+ "ALREADY_EXISTS_MARKER",
48
+ "ALL_DECLARED_URL_KEYS",
49
+ "JSON_OBJECT_OPENER",
50
+ "REVIEW_LOOP_CONTEXT_TEMPLATE",
51
+ ]
52
+
53
+ NAME_DIGEST_TEXT_ENCODING: str = "utf-8"
54
+ ALL_OWNED_OWNER_DIGESTS: frozenset[str] = frozenset(
55
+ {
56
+ "2e8b9d88060d471690c122e6843f3bec8c95116d643af2f5501e0881b10e83c4",
57
+ "3a92fc8d256dc1f50dc8794573f80de695516f0f0e5c235118bd0c18be3d4434",
58
+ "d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
59
+ "95d4d8808440e130d0ee753e0b7530eba4534dd41a7320ad8b710a44d40441f4",
60
+ }
61
+ )
62
+ ALL_PRESET_REPOSITORY_DIGESTS: frozenset[str] = frozenset(
63
+ {"d362bee4d80f5474921b917c127f2b0680c0a5261cb352e59de35cc87b00989f"}
64
+ )
65
+
66
+ ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset(
67
+ {"Bash", "PowerShell", "exec_command", "shell_command"}
68
+ )
69
+ ALL_SHELL_COMMAND_INPUT_KEYS: tuple[str, ...] = ("command", "cmd")
70
+ SHELL_ARGUMENT_SEPARATOR: str = " "
71
+ GH_PROGRAM_NAME: str = "gh"
72
+ GH_PR_SUBCOMMAND: str = "pr"
73
+ ALL_GH_PR_CREATE_ACTIONS: frozenset[str] = frozenset({"create", "new"})
74
+ GH_API_SUBCOMMAND: str = "api"
75
+ GH_API_PULLS_ENDPOINT_PATTERN: re.Pattern[str] = re.compile(r"/?repos/[^/\s]+/[^/\s]+/pulls")
76
+ ALL_GH_API_BODY_OPTIONS: frozenset[str] = frozenset(
77
+ {"-f", "-F", "--field", "--raw-field", "--input"}
78
+ )
79
+ ALL_GH_API_METHOD_OPTIONS: frozenset[str] = frozenset({"-X", "--method"})
80
+ GH_API_POST_METHOD: str = "POST"
81
+ GH_LONG_OPTION_PREFIX: str = "--"
82
+ GH_SHORT_OPTION_LENGTH: int = 2
83
+ ALL_GH_API_SHORT_OPTIONS: frozenset[str] = frozenset({"-f", "-F", "-X"})
84
+ GH_OPTION_VALUE_SEPARATOR: str = "="
85
+
86
+ MCP_TOOL_NAME_PREFIX: str = "mcp__"
87
+ ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES: tuple[str, ...] = (
88
+ "__create_pull_request",
89
+ "__github_create_pull_request",
90
+ )
91
+ MCP_OWNER_INPUT_KEY: str = "owner"
92
+ MCP_REPOSITORY_INPUT_KEY: str = "repo"
93
+
94
+ PULL_REQUEST_URL_PATTERN: re.Pattern[str] = re.compile(
95
+ r"https://github\.com/(?P<owner>[A-Za-z0-9][A-Za-z0-9-]*)/"
96
+ r"(?P<repository>[A-Za-z0-9._-]+)/pull/(?P<number>\d+)"
97
+ )
98
+ ALREADY_EXISTS_MARKER: str = "already exists"
99
+ ALL_DECLARED_URL_KEYS: tuple[str, ...] = ("html_url", "url")
100
+ JSON_OBJECT_OPENER: str = "{"
101
+
102
+ REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
103
+ "=== OWNED PR REVIEW LOOP (context reminder, never a block) ===\n"
104
+ "You opened {owner}/{repository}#{number} {url}\n"
105
+ "This repository has no automated review, so you own the review and the merge.\n"
106
+ "1. Review loop. Load the efficient-review skill (/efficient-review). Review the\n"
107
+ " branch, check each finding against the code, fix every one that holds, push,\n"
108
+ " and review again. Repeat until a pass reports no findings.\n"
109
+ "2. Mark ready. Once the loop is clean, CI is green, and no review thread is open,\n"
110
+ " mark the PR ready for review (gh pr ready {number} --repo {owner}/{repository},\n"
111
+ " or update_pull_request with draft false).\n"
112
+ "3. Babysit. Follow the pstack babysit playbook (poteto-mode/playbooks/babysit.md,\n"
113
+ " or the standalone babysit skill outside poteto-mode). Fix failing CI, resolve\n"
114
+ " merge conflicts, and answer review threads until the PR is green and mergeable.\n"
115
+ " Any new code goes back through step 1 before the next push.\n"
116
+ "4. Ship. Follow the pstack shipping playbook (poteto-mode/playbooks/shipping.md)\n"
117
+ " to land the PR, or the pr-lifecycle skill outside poteto-mode. Read the PR\n"
118
+ " back and confirm it reads merged.\n"
119
+ "Run each step yourself. The task ends at a merged PR or a named blocker."
120
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.88.7",
3
+ "version": "8.89.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {