claude-dev-env 8.102.1 → 8.103.1

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.
@@ -7,7 +7,8 @@ A reviewer reads the body in place of the code. The body says what the change do
7
7
  | `## What this does` | Two or three plain sentences: the behavior before, the behavior after, and who notices. Written for a reviewer who has not read the worker's conversation. |
8
8
  | `## What changed` | One bullet per area of the diff, in plain words. |
9
9
  | `## Existing work` | What already did part of the job, with a link or file and line, and what this change adds. Or the places searched. |
10
- | `## Proof in practice` | Each command in backticks, the output without the change and with it, and anything still unproven. |
10
+ | `## Proof in practice` | Each command in backticks, and the output without the change and with it. The run on the target the change exists to fix is here before the pull request opens. |
11
+ | `## Not covered` | Optional. A condition the change does not target and the runs left out, such as an older data shape. |
11
12
  | `## Eval` | Only for a `feat` title. The eval's cases and grader, the command, and its result. |
12
13
  | `## Risk` | What could break, for whom, and how anyone would notice. `Low` and one reason for a doc or test change. |
13
14
  | `## How to revert` | The exact undo step, and any data or state a plain revert leaves behind. |
@@ -35,13 +35,14 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
35
35
  mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
36
36
 
37
37
  const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
38
- assert.equal(userPromptHooks.length, 6);
38
+ assert.equal(userPromptHooks.length, 7);
39
39
  assert.equal(userPromptHooks[0].command, userCommand);
40
40
  assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
41
41
  assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
42
42
  assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
43
43
  assert.match(userPromptHooks[4].command, /session\/tracking_nudge\.py$/);
44
44
  assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
45
+ assert.match(userPromptHooks[6].command, /blocking\/turn_review_gate\.py$/);
45
46
  assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
46
47
 
47
48
  const firstMerge = JSON.stringify(settings);
@@ -30,14 +30,14 @@ For a change to Claude Code behavior, such as a hook, skill, rule, setting, prom
30
30
  - Read the broker report for the account it chose. Read the stream for the hook events and their output, `permission_denials`, the final result, and the token usage on the result event.
31
31
  - When the broker exits 3 with action "wait", no account has room. Treat that as a blocked proof and report its reason and `resets_at`. Spawn Claude only through the broker.
32
32
 
33
- Report it in the pull request body under "Proof in practice". List each command you ran, quote the output lines that show the effect, state the difference between the runs with and without the change, and name anything still unproven.
33
+ Report it in the pull request body under "Proof in practice". List each command you ran, quote the output lines that show the effect, and state the difference between the runs with and without the change. The run on the target the change exists to fix belongs in this section before the pull request opens. List a condition the change does not target, such as an older data shape, under a "Not covered" heading at the same level.
34
34
 
35
35
  When you cannot prove it, leave the pull request unopened. Tell the owner in three short sentences what you could not run and which tool would make it possible. A blocked proof is an acceptable result, because it shows where tooling is missing.
36
36
 
37
37
  ## Enforcement
38
38
 
39
- `hooks/blocking/pull_request_proof.py` reads the body of each new pull request before it opens. `pr_lifecycle_skill_gate.py` runs it on `gh pr create`, the `pull_request.py create` script, and each `mcp__*__create_pull_request` tool. The gate denies the call when the body has no "Proof in practice" heading, or when the text under that heading names no command in backticks. The gate also denies a section whose only commands run a test runner, such as `pytest`, `python -m unittest`, `uv run pytest`, `npm test`, `npx vitest`, `jest`, `go test` or `cargo test`. Quotes around a runner word, interpreter flags before `-m`, and `coverage run -m unittest` are still test runs. A command is an inline backticked span or a `$ ` line inside a fenced block. A span that starts with a path and line, such as `gate.py:12`, is not a command. A span that holds one path or file name, such as `src/x.py`, is not a command unless that span runs a test runner, such as `py.test` or `pytest.exe`. A test run piped into `tee`, `tail`, `head`, `grep` or `cat` is still a test run. The deny quotes the line "Unit tests are welcome as extra evidence. The live run is the proof." and asks for a run of the changed thing the way its user runs it, without the change and with it. A section with one command that is not a test run passes. A `gh pr create` with no `--body` or `--body-file` is denied with a request for the body. Edits, merges, and pushes pass.
39
+ `hooks/blocking/pull_request_proof.py` reads the body of each new pull request before it opens. `pr_lifecycle_skill_gate.py` runs it on `gh pr create`, the `pull_request.py create` script, and each `mcp__*__create_pull_request` tool. The gate denies the call when the body has no "Proof in practice" heading, or when the text under that heading names no command in backticks. The gate also denies a section whose only commands run a test runner, such as `pytest`, `python -m unittest`, `uv run pytest`, `npm test`, `npx vitest`, `jest`, `go test` or `cargo test`. Quotes around a runner word, interpreter flags before `-m`, and `coverage run -m unittest` are still test runs. A command is an inline backticked span or a `$ ` line inside a fenced block. A span that starts with a path and line, such as `gate.py:12`, is not a command. A span that holds one path or file name, such as `src/x.py`, is not a command unless that span runs a test runner, such as `py.test` or `pytest.exe`. A test run piped into `tee`, `tail`, `head`, `grep` or `cat` is still a test run. The deny quotes the line "Unit tests are welcome as extra evidence. The live run is the proof." and asks for a run of the changed thing the way its user runs it, without the change and with it. The gate also denies a section whose prose says part of the change is not proven, not tested or not verified, with "yet", "been" or "fully" allowed after the "not", or says unproven, untested or unverified. The deny quotes that line, and asks for the run before the pull request opens, or for the line under a "Not covered" heading when the change does not target it. Words inside backticks or a fenced block are quoted output and do not count. A section with one command that is not a test run, and no such line, passes. A `gh pr create` with no `--body` or `--body-file` is denied with a request for the body. Edits, merges, and pushes pass.
40
40
 
41
41
  When the checked-out branch changes a file people see (`.html`, `.htm`, `.css`, `.scss`, `.tsx`, `.jsx`, `.vue`, `.svelte`), the gate also denies a body whose proof section holds no picture: a markdown image, an image URL, or an Artifact page link that shows the screenshots. Render the change, read the screenshot, and put it in the section, so the reader sees what you looked at. When git in the session directory cannot list the branch's changed files, the gate asks for the picture as well.
42
42
 
43
- The gate checks that the section is there and names a command. Whether the run proves the change stays a judgment for the author and the reviewer.
43
+ The gate checks that the section is there, names a command, and admits no unproven part. Whether the run proves the change stays a judgment for the author and the reviewer.
@@ -10,6 +10,8 @@ Three templates live here. Pick the one that fits the ask before you write a lin
10
10
 
11
11
  `diagram.html` draws the content in inline SVG: boxes for things, arrows for links, and color for state. Labels are a few words. Text in bordered cards is a tracker, so it never answers a visual-only ask.
12
12
 
13
+ Show a diagram whole when the page opens, scaled to the page width. Never put a diagram in a box with a fixed or maximum height, because the reader then sees only part of it. A wide diagram may add a full-size button that lets the page scroll.
14
+
13
15
  `status-flowchart.html` reads left to right. Done steps are green boxes, each yes-or-no check is a diamond, a stuck step is an amber box under its check with a dashed recheck loop back to it, and the next step is a dashed box. A click on any shape opens its detail from the `detail-data` block below the chart. Replace the `<title>`, the `h1`, the svg `aria-label`, the shapes, the arrows and the `detail-data` entries with the work being reported. Keep each label to three words or fewer, in plain words with no abbreviations or item numbers.
14
16
 
15
17
  Render the page and look at the screenshot before you publish. Headless Chromium renders at least 500 pixels wide, so take the screenshot at 800 wide.
@@ -56,7 +56,12 @@ from hooks_constants.pull_request_proof_constants import (
56
56
  FENCED_COMMAND_PROMPT,
57
57
  INLINE_COMMAND_PATTERN,
58
58
  TEST_ONLY_PROOF_REASON,
59
+ ALL_CODE_FENCE_MARKERS,
60
+ BLOCKQUOTE_MARKERS,
61
+ INLINE_CODE_SPAN_PATTERN,
59
62
  UNITTEST_MODULE_NAME,
63
+ UNPROVEN_ADMISSION_PATTERN,
64
+ UNPROVEN_PROOF_REASON,
60
65
  ALL_PASSTHROUGH_PROGRAM_NAMES,
61
66
  PATH_WITH_LINE_PATTERN,
62
67
  SINGLE_FILE_NAME_PATTERN,
@@ -332,14 +337,31 @@ def _line_commands(line: str, is_inside_fence: bool) -> list[str]:
332
337
  return []
333
338
 
334
339
 
335
- def _proof_commands(section: str) -> list[str]:
336
- all_commands: list[str] = []
337
- is_inside_fence = False
340
+ def _fence_marker(line: str) -> str | None:
341
+ fence_text = line.strip().lstrip(BLOCKQUOTE_MARKERS).strip()
342
+ for each_marker in ALL_CODE_FENCE_MARKERS:
343
+ if fence_text.startswith(each_marker) and each_marker not in fence_text[len(each_marker) :]:
344
+ return each_marker
345
+ return None
346
+
347
+
348
+ def _lines_with_fence_state(section: str) -> Iterator[tuple[str, bool]]:
349
+ open_marker: str | None = None
338
350
  for each_line in section.splitlines():
339
- if each_line.strip().startswith(CODE_FENCE_MARKER):
340
- is_inside_fence = not is_inside_fence
351
+ line_marker = _fence_marker(each_line)
352
+ if line_marker is not None and open_marker is None:
353
+ open_marker = line_marker
341
354
  continue
342
- all_commands.extend(_line_commands(each_line, is_inside_fence))
355
+ if line_marker is not None and line_marker == open_marker:
356
+ open_marker = None
357
+ continue
358
+ yield each_line, open_marker is not None
359
+
360
+
361
+ def _proof_commands(section: str) -> list[str]:
362
+ all_commands: list[str] = []
363
+ for each_line, each_is_inside_fence in _lines_with_fence_state(section):
364
+ all_commands.extend(_line_commands(each_line, each_is_inside_fence))
343
365
  return [each_command for each_command in all_commands if _reads_as_command(each_command)]
344
366
 
345
367
 
@@ -401,6 +423,35 @@ def _proof_runs_only_tests(section: str) -> bool:
401
423
  return bool(all_commands) and all(_command_runs_tests(each_command) for each_command in all_commands)
402
424
 
403
425
 
426
+ def _prose_lines(section: str) -> Iterator[tuple[str, str]]:
427
+ for each_line, each_is_inside_fence in _lines_with_fence_state(section):
428
+ if not each_is_inside_fence:
429
+ yield each_line, re.sub(INLINE_CODE_SPAN_PATTERN, "", each_line.replace(CODE_FENCE_MARKER, "`"))
430
+
431
+
432
+ def unproven_admission(section: str) -> str | None:
433
+ """Return the first prose line of a proof section that says something is not proven, or None.
434
+
435
+ ::
436
+
437
+ "Not proven here: a fresh Convert run." -> "Not proven here: a fresh Convert run."
438
+ "The live run is untested." -> "The live run is untested."
439
+ "It has not been tested on the target." -> "It has not been tested on the target."
440
+ "`not proven` printed by the probe" -> None
441
+ "> Not proven here: the live run." -> "> Not proven here: the live run."
442
+ "Ran `python run.py`, quoted below." -> None
443
+
444
+ The markers are not proven, not tested and not verified, each with any
445
+ run of yet, been or fully after the not, plus unproven, untested and
446
+ unverified, matched without regard to case. Inline code spans and fenced
447
+ blocks are skipped, so output quoted there never counts.
448
+ """
449
+ for each_line, each_prose in _prose_lines(section):
450
+ if re.search(UNPROVEN_ADMISSION_PATTERN, each_prose, re.IGNORECASE):
451
+ return each_line.strip()
452
+ return None
453
+
454
+
404
455
  def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None:
405
456
  """Return a deny reason when a new pull request's body has no proof, else None.
406
457
 
@@ -412,6 +463,7 @@ def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None
412
463
  proof whose only command is `py.test` -> TEST_ONLY_PROOF_REASON
413
464
  proof whose only command is `python -q -m unittest` -> TEST_ONLY_PROOF_REASON
414
465
  proof with `pytest` and `python run.py` -> None
466
+ proof whose prose says "Not proven here: the run" -> UNPROVEN_PROOF_REASON
415
467
  gh pr create --fill -> MISSING_BODY_REASON
416
468
  gh pr edit, git push, any other tool -> None
417
469
 
@@ -425,6 +477,9 @@ def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None
425
477
  the section is denied. Quotes around a runner word still count. Interpreter
426
478
  flags before -m unittest still count. coverage run -m unittest counts.
427
479
  A pipe into tee, tail, head, grep or cat keeps a test run a test run.
480
+ A section whose prose says part of the change is not proven, untested or
481
+ unverified is denied, with the line quoted; unproven_admission lists the
482
+ markers.
428
483
 
429
484
  Args:
430
485
  all_payload_fields: The parsed PreToolUse input.
@@ -437,6 +492,9 @@ def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None
437
492
  return MISSING_PROOF_REASON
438
493
  if _proof_runs_only_tests(section):
439
494
  return TEST_ONLY_PROOF_REASON
495
+ admission = unproven_admission(section)
496
+ if admission is not None:
497
+ return UNPROVEN_PROOF_REASON.format(admission=admission)
440
498
  return None
441
499
 
442
500
 
@@ -18,6 +18,7 @@ from hooks_constants.pull_request_proof_constants import (
18
18
  MISSING_EXISTING_WORK_REASON,
19
19
  MISSING_PROOF_REASON,
20
20
  TEST_ONLY_PROOF_REASON,
21
+ UNPROVEN_PROOF_REASON,
21
22
  UNREADABLE_CHANGES_REASON,
22
23
  )
23
24
 
@@ -111,6 +112,92 @@ def test_proof_with_a_test_run_and_file_references_should_be_denied() -> None:
111
112
  assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
112
113
 
113
114
 
115
+ def test_proof_that_says_its_target_run_is_not_proven_should_be_denied() -> None:
116
+ body = (
117
+ "## Proof in practice\nRan `python verify.py theme`.\n> PASS\n\n"
118
+ "Not proven here: a fresh Convert run of Bear Penguin that rewrites the record.\n"
119
+ )
120
+ assert gate.missing_proof_reason(_mcp_create(body)) == UNPROVEN_PROOF_REASON.format(
121
+ admission="Not proven here: a fresh Convert run of Bear Penguin that rewrites the record."
122
+ )
123
+
124
+
125
+ @pytest.mark.parametrize(
126
+ "admission", ["unproven", "not yet tested", "Untested", "unverified", "not verified", "not been tested", "not fully verified"]
127
+ )
128
+ def test_each_unproven_marker_in_proof_prose_should_be_denied(admission: str) -> None:
129
+ admission_line = f"The live dialer path is {admission}."
130
+ body = f"## Proof in practice\nRan `python run.py`.\n{admission_line}\n"
131
+ assert gate.missing_proof_reason(_mcp_create(body)) == UNPROVEN_PROOF_REASON.format(admission=admission_line)
132
+
133
+
134
+ def test_unproven_line_moved_under_a_not_covered_heading_should_pass() -> None:
135
+ body = (
136
+ "## Proof in practice\nRan `python run.py`.\n> PASS\n\n"
137
+ "## Not covered\n\nNot proven here: a run on an older session.\n"
138
+ )
139
+ assert gate.missing_proof_reason(_mcp_create(body)) is None
140
+
141
+
142
+ def test_unproven_words_inside_quoted_output_should_pass() -> None:
143
+ body = (
144
+ "## Proof in practice\nRan `python run.py`, which printed `unverified sender denied`.\n"
145
+ "```\n$ python run.py\nstatus: not proven\n```\n"
146
+ )
147
+ assert gate.missing_proof_reason(_mcp_create(body)) is None
148
+
149
+
150
+ def test_unproven_admission_after_a_one_line_triple_backtick_span_should_be_denied() -> None:
151
+ body = (
152
+ "## Proof in practice\nRan `python run.py`.\n```python run.py``` -> PASS\n"
153
+ "Not proven here: the live run on the target.\n"
154
+ )
155
+ assert gate.missing_proof_reason(_mcp_create(body)) == UNPROVEN_PROOF_REASON.format(
156
+ admission="Not proven here: the live run on the target."
157
+ )
158
+
159
+
160
+ def test_test_run_after_a_one_line_triple_backtick_span_should_be_denied() -> None:
161
+ body = "## Proof in practice\n```pytest``` ok\nRan `pytest tests/`.\n"
162
+ assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
163
+
164
+
165
+ def test_unproven_words_in_a_fence_inside_a_blockquote_should_pass() -> None:
166
+ body = "## Proof in practice\nRan `python run.py`.\n> ```\n> status: not proven\n> ```\n"
167
+ assert gate.missing_proof_reason(_mcp_create(body)) is None
168
+
169
+
170
+ def test_unproven_words_in_a_tilde_fence_should_pass() -> None:
171
+ body = "## Proof in practice\nRan `python run.py`.\n~~~\nstatus: not proven\n~~~\n"
172
+ assert gate.missing_proof_reason(_mcp_create(body)) is None
173
+
174
+
175
+ def test_tilde_line_inside_a_backtick_fence_keeps_the_fence_open() -> None:
176
+ body = "## Proof in practice\nRan `python run.py`.\n```\n~~~\nstatus: not proven\n```\n"
177
+ assert gate.missing_proof_reason(_mcp_create(body)) is None
178
+
179
+
180
+ def test_admission_after_a_backtick_fence_holding_a_tilde_line_should_be_denied() -> None:
181
+ body = "## Proof in practice\nRan `python run.py`.\n```\n~~~\n```\nNot proven here: the live run.\n"
182
+ assert gate.missing_proof_reason(_mcp_create(body)) == UNPROVEN_PROOF_REASON.format(
183
+ admission="Not proven here: the live run."
184
+ )
185
+
186
+
187
+ def test_unproven_deny_quotes_the_line_as_written() -> None:
188
+ body = "## Proof in practice\nRan `python run.py`.\nNot proven here: the `convert.py` run on the target.\n"
189
+ assert gate.missing_proof_reason(_mcp_create(body)) == UNPROVEN_PROOF_REASON.format(
190
+ admission="Not proven here: the `convert.py` run on the target."
191
+ )
192
+
193
+
194
+ def test_unproven_admission_inside_a_blockquote_should_be_denied() -> None:
195
+ body = "## Proof in practice\nRan `python run.py`.\n> Not proven here: the live dialer run.\n"
196
+ assert gate.missing_proof_reason(_mcp_create(body)) == UNPROVEN_PROOF_REASON.format(
197
+ admission="> Not proven here: the live dialer run."
198
+ )
199
+
200
+
114
201
  def test_proof_with_a_test_run_piped_into_tee_should_be_denied() -> None:
115
202
  body = "## Proof in practice\nRan `pytest x | tee log`.\n"
116
203
  assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
@@ -0,0 +1,25 @@
1
+ from turn_review_findings import finding_lines
2
+
3
+ CHANGED_PATH = "src/service.py"
4
+
5
+
6
+ def test_should_read_a_json_finding_on_a_changed_path() -> None:
7
+ review_text = (
8
+ 'Review done.\n[{"file": "src/service.py", "line": 208, "summary": "drops the retry"},'
9
+ ' {"file": "src/other.py", "line": 3, "summary": "unchanged file"}]'
10
+ )
11
+ assert finding_lines(review_text, [CHANGED_PATH]) == ["src/service.py:208 drops the retry"]
12
+
13
+
14
+ def test_should_read_a_prose_finding_that_opens_with_a_changed_path() -> None:
15
+ review_text = (
16
+ "- `src/service.py:208` - drops the retry\nThe change at src/service.py:12 is fine."
17
+ )
18
+ assert finding_lines(review_text, [CHANGED_PATH]) == [
19
+ "- `src/service.py:208` - drops the retry"
20
+ ]
21
+
22
+
23
+ def test_should_report_nothing_for_a_review_that_opens_with_none() -> None:
24
+ review_text = "(none)\n- `src/service.py:208` - looked at the retry, it stays"
25
+ assert finding_lines(review_text, [CHANGED_PATH]) == []