claude-dev-env 2.7.1 → 2.9.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/CLAUDE.md +7 -1
- package/_shared/CLAUDE.md +1 -0
- package/_shared/advisor/advisor-protocol.md +19 -9
- package/_shared/pr-loop/audit-contract.md +4 -4
- package/_shared/pr-loop/precatch-rubric.md +2 -2
- package/_shared/process-tree/CLAUDE.md +41 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
- package/_shared/process-tree/scripts/pyproject.toml +16 -0
- package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
- package/agents/clean-coder.md +9 -19
- package/agents/code-quality-agent.md +6 -5
- package/agents/deep-research.md +7 -24
- package/agents/docs-agent.md +1 -27
- package/agents/issue-tracker.md +1 -7
- package/agents/skill-writer-agent.md +1 -2
- package/agents/test_agent_frontmatter.py +335 -12
- package/docs/CODE_RULES.md +4 -2
- package/docs/references/CLAUDE.md +2 -2
- package/docs/references/advisor-tool.md +44 -6
- package/docs/references/team-advisor-skill.md +14 -8
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/fable_spawn_gate.py +187 -0
- package/hooks/blocking/piped_pytest_blocker.py +1223 -0
- package/hooks/blocking/plain_language_blocker.py +287 -15
- package/hooks/blocking/test_fable_spawn_gate.py +374 -0
- package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
- package/hooks/blocking/test_plain_language_blocker.py +277 -2
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
- package/hooks/git-hooks/CLAUDE.md +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
- package/hooks/git-hooks/pre_push.py +343 -54
- package/hooks/git-hooks/test_pre_push.py +852 -6
- package/hooks/hooks.json +9 -19
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
- package/hooks/hooks_constants/shell_command_segments.py +1 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
- package/output-styles/CLAUDE.md +17 -0
- package/output-styles/caveman-agent.md +37 -0
- package/package.json +2 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/ask-user-question-required.md +26 -0
- package/rules/claims-as-quotes.md +65 -0
- package/rules/code-standards.md +33 -7
- package/rules/eli11-replies.md +1 -1
- package/scripts/CLAUDE.md +3 -3
- package/scripts/_code_review_test_support.py +6 -0
- package/scripts/check.ps1 +18 -5
- package/scripts/claude_chain_runner.py +203 -31
- package/scripts/codec_forwarding_test_support.py +2 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
- package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +132 -24
- package/scripts/grok_headless_runner.py +16 -83
- package/scripts/grok_patch_artifacts.py +123 -0
- package/scripts/grok_run_ledger.py +318 -0
- package/scripts/spawn_grok_batch.py +591 -10
- package/scripts/test_claude_chain_runner.py +358 -0
- package/scripts/test_grok_headless_runner.py +18 -63
- package/scripts/test_grok_patch_artifacts.py +82 -0
- package/scripts/test_grok_run_ledger.py +116 -0
- package/scripts/test_invoke_code_review.py +298 -0
- package/scripts/test_resolve_worker_spawn.py +6 -0
- package/scripts/test_spawn_grok_batch.py +396 -0
- package/scripts/tests/CLAUDE.md +1 -0
- package/scripts/tests/test_grok_worker_constants.py +59 -0
- package/skills/CLAUDE.md +4 -2
- package/skills/_shared/CLAUDE.md +37 -4
- package/skills/_shared/advisor/CLAUDE.md +9 -0
- package/skills/_shared/advisor/advisor-protocol.md +5 -0
- package/skills/_shared/advisor/scripts/README.md +9 -0
- package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -1
- package/skills/_shared/pr-loop/audit-contract.md +5 -0
- package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
- package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
- package/skills/_shared/pr-loop/fix-protocol.md +5 -0
- package/skills/_shared/pr-loop/gh-payloads.md +5 -0
- package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
- package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
- package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
- package/skills/_shared/pr-loop/state-schema.md +5 -0
- package/skills/_shared/pr-loop/worker-spawn.md +5 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
- package/skills/autoconverge/workflow/converge.mjs +66 -33
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
- package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
- package/skills/codex-review/scripts/run_codex_review.py +16 -64
- package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
- package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
- package/skills/e-code-review/SKILL.md +15 -9
- package/skills/e-code-review/reference/fix.md +29 -7
- package/skills/e-code-review/reference/loop.md +230 -14
- package/skills/e-code-review/reference/low.md +33 -15
- package/skills/e-code-review/reference/medium.md +55 -21
- package/skills/e-code-review/reference/runner-selection.md +40 -0
- package/skills/e-code-review/reference/xhigh.md +30 -12
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
- package/skills/e-code-review/scripts/grok_code_review.py +221 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
- package/skills/fresh-branch/CLAUDE.md +5 -5
- package/skills/fresh-branch/SKILL.md +14 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
- package/skills/grok-spawn/SKILL.md +10 -0
- package/skills/orchestrator/SKILL.md +9 -1
- package/skills/orchestrator-refresh/SKILL.md +5 -1
- package/skills/task-build/reference/tool-routing.md +3 -0
- package/skills/team-advisor/SKILL.md +23 -41
- package/system-prompts/software-engineer.xml +6 -3
- package/skills/test_markdown_link_integrity.py +0 -107
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Covers the shared prose scanner (fenced code, inline code, blockquotes, URLs,
|
|
4
4
|
file paths), the word-boundary guard, multi-word phrase matching, case
|
|
5
|
-
insensitivity, the term -> replacement block message,
|
|
6
|
-
PreToolUse surfaces (AskUserQuestion and Write|Edit on .md targets)
|
|
5
|
+
insensitivity, the term -> replacement block message, both registered
|
|
6
|
+
PreToolUse surfaces (AskUserQuestion and Write|Edit on .md targets), and the
|
|
7
|
+
lean-question-block check that keeps chat detail out of an AskUserQuestion call.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import importlib.util
|
|
@@ -39,6 +40,9 @@ hook_module = _load_hook_module()
|
|
|
39
40
|
find_banned_terms = hook_module.find_banned_terms
|
|
40
41
|
strip_non_prose_regions = hook_module.strip_non_prose_regions
|
|
41
42
|
build_block_reason = hook_module.build_block_reason
|
|
43
|
+
find_question_block_violations = hook_module.find_question_block_violations
|
|
44
|
+
build_lean_block_reason = hook_module.build_lean_block_reason
|
|
45
|
+
evaluate = hook_module.evaluate
|
|
42
46
|
|
|
43
47
|
from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
|
|
44
48
|
|
|
@@ -296,3 +300,274 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
|
296
300
|
logged_record = json.loads(all_records[0])
|
|
297
301
|
assert logged_record["hook"] == "plain_language_blocker.py"
|
|
298
302
|
assert logged_record["event"] == "PreToolUse"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
LEAN_QUESTION = "Which gate should run first?"
|
|
306
|
+
LEAN_DESCRIPTION = "Runs on every write."
|
|
307
|
+
QUESTION_AT_THE_WORD_CAP = (
|
|
308
|
+
"Should we keep the gate on the write path where it reads the whole file "
|
|
309
|
+
"each time, or move it to the commit path where it reads only the lines "
|
|
310
|
+
"that a change has already staged for the current review?"
|
|
311
|
+
)
|
|
312
|
+
QUESTION_OVER_THE_WORD_CAP = (
|
|
313
|
+
"Should we keep the gate on the write path where it reads the whole file "
|
|
314
|
+
"each time it runs, or move the gate to the commit path where it reads "
|
|
315
|
+
"only the staged lines and skips the files that no one on the team ever "
|
|
316
|
+
"touched?"
|
|
317
|
+
)
|
|
318
|
+
DESCRIPTION_AT_THE_WORD_CAP = (
|
|
319
|
+
"Runs the gate at commit time so it reads only the lines a change staged."
|
|
320
|
+
)
|
|
321
|
+
DESCRIPTION_OVER_THE_WORD_CAP = (
|
|
322
|
+
"Runs the gate at commit time so it reads only the lines the change "
|
|
323
|
+
"actually staged for review."
|
|
324
|
+
)
|
|
325
|
+
QUESTION_WITH_INLINE_CODE_AT_THE_WORD_CAP = (
|
|
326
|
+
"Should we keep the gate on the write path where it reads the whole file "
|
|
327
|
+
"each time, or move it to the commit path where it reads only what "
|
|
328
|
+
"`git diff --cached --name-only HEAD` lists for the current review before "
|
|
329
|
+
"the team sees it?"
|
|
330
|
+
)
|
|
331
|
+
QUESTION_WITH_INLINE_CODE_OVER_THE_WORD_CAP = (
|
|
332
|
+
"Should we keep the gate on the write path where it reads the whole file "
|
|
333
|
+
"each time, or move it to the commit path where it reads only what "
|
|
334
|
+
"`git diff --cached --name-only HEAD` lists for the current review before "
|
|
335
|
+
"the whole team sees it?"
|
|
336
|
+
)
|
|
337
|
+
DESCRIPTION_WITH_INLINE_CODE_AT_THE_WORD_CAP = (
|
|
338
|
+
"Runs the gate at commit time so it reads only what "
|
|
339
|
+
"`git diff --cached --name-only HEAD` lists for review."
|
|
340
|
+
)
|
|
341
|
+
DESCRIPTION_WITH_INLINE_CODE_OVER_THE_WORD_CAP = (
|
|
342
|
+
"Runs the gate at commit time so it reads only what "
|
|
343
|
+
"`git diff --cached --name-only HEAD` lists for the review."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _ask_payload(question_text: str, all_descriptions: list[str]) -> dict[str, object]:
|
|
348
|
+
return {
|
|
349
|
+
"tool_name": "AskUserQuestion",
|
|
350
|
+
"tool_input": {
|
|
351
|
+
"questions": [
|
|
352
|
+
{
|
|
353
|
+
"question": question_text,
|
|
354
|
+
"header": "Gate",
|
|
355
|
+
"options": [
|
|
356
|
+
{"label": f"Gate {each_index}", "description": each_description}
|
|
357
|
+
for each_index, each_description in enumerate(all_descriptions)
|
|
358
|
+
],
|
|
359
|
+
}
|
|
360
|
+
]
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_question_carrying_a_list_marker_plan_is_denied() -> None:
|
|
366
|
+
plan_question = f"{LEAN_QUESTION}\n- Split the file\n- Wire the gate\n- Run the suite"
|
|
367
|
+
|
|
368
|
+
deny_reason = evaluate(_ask_payload(plan_question, [LEAN_DESCRIPTION]))
|
|
369
|
+
|
|
370
|
+
assert deny_reason is not None
|
|
371
|
+
assert "a bullet or numbered list marker" in deny_reason
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_question_carrying_a_fenced_block_is_denied() -> None:
|
|
375
|
+
fenced_question = f"{LEAN_QUESTION}\n```\nrun_gate()\n```"
|
|
376
|
+
|
|
377
|
+
deny_reason = evaluate(_ask_payload(fenced_question, [LEAN_DESCRIPTION]))
|
|
378
|
+
|
|
379
|
+
assert deny_reason is not None
|
|
380
|
+
assert "a fenced code block" in deny_reason
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_question_carrying_a_second_paragraph_is_denied() -> None:
|
|
384
|
+
two_paragraph_question = f"{LEAN_QUESTION}\n\nThe write gate reads every file."
|
|
385
|
+
|
|
386
|
+
deny_reason = evaluate(_ask_payload(two_paragraph_question, [LEAN_DESCRIPTION]))
|
|
387
|
+
|
|
388
|
+
assert deny_reason is not None
|
|
389
|
+
assert "more than one paragraph" in deny_reason
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_question_carrying_a_one_line_fence_is_denied() -> None:
|
|
393
|
+
one_line_fence_question = f"{LEAN_QUESTION}\n```print(1)```"
|
|
394
|
+
|
|
395
|
+
deny_reason = evaluate(_ask_payload(one_line_fence_question, [LEAN_DESCRIPTION]))
|
|
396
|
+
|
|
397
|
+
assert deny_reason is not None
|
|
398
|
+
assert "a fenced code block" in deny_reason
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_question_carrying_a_carriage_return_paragraph_break_is_denied() -> None:
|
|
402
|
+
crlf_question = f"{LEAN_QUESTION}\r\n\r\nThe write gate reads every file."
|
|
403
|
+
|
|
404
|
+
deny_reason = evaluate(_ask_payload(crlf_question, [LEAN_DESCRIPTION]))
|
|
405
|
+
|
|
406
|
+
assert deny_reason is not None
|
|
407
|
+
assert "more than one paragraph" in deny_reason
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_question_carrying_a_heading_is_denied() -> None:
|
|
411
|
+
headed_question = f"{LEAN_QUESTION}\n## The write gate\nIt reads every file."
|
|
412
|
+
|
|
413
|
+
deny_reason = evaluate(_ask_payload(headed_question, [LEAN_DESCRIPTION]))
|
|
414
|
+
|
|
415
|
+
assert deny_reason is not None
|
|
416
|
+
assert "a heading" in deny_reason
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_option_description_carrying_a_table_row_is_denied() -> None:
|
|
420
|
+
deny_reason = evaluate(_ask_payload(LEAN_QUESTION, ["| gate | 12 ms |"]))
|
|
421
|
+
|
|
422
|
+
assert deny_reason is not None
|
|
423
|
+
assert "a table row" in deny_reason
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_question_over_the_word_cap_is_denied() -> None:
|
|
427
|
+
deny_reason = evaluate(_ask_payload(QUESTION_OVER_THE_WORD_CAP, [LEAN_DESCRIPTION]))
|
|
428
|
+
|
|
429
|
+
assert deny_reason is not None
|
|
430
|
+
assert "46 words, over the 40-word cap" in deny_reason
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_question_over_the_sentence_cap_is_denied() -> None:
|
|
434
|
+
three_sentence_question = (
|
|
435
|
+
"Which gate runs first? The write gate reads every file. "
|
|
436
|
+
"The commit gate reads staged lines only."
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
deny_reason = evaluate(_ask_payload(three_sentence_question, [LEAN_DESCRIPTION]))
|
|
440
|
+
|
|
441
|
+
assert deny_reason is not None
|
|
442
|
+
assert "3 sentences, over the 2-sentence cap" in deny_reason
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_option_description_over_the_word_cap_is_denied() -> None:
|
|
446
|
+
deny_reason = evaluate(_ask_payload(LEAN_QUESTION, [DESCRIPTION_OVER_THE_WORD_CAP]))
|
|
447
|
+
|
|
448
|
+
assert deny_reason is not None
|
|
449
|
+
assert "18 words, over the 15-word cap" in deny_reason
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_option_description_over_the_sentence_cap_is_denied() -> None:
|
|
453
|
+
deny_reason = evaluate(_ask_payload(LEAN_QUESTION, ["Runs on write. Reads every file."]))
|
|
454
|
+
|
|
455
|
+
assert deny_reason is not None
|
|
456
|
+
assert "2 sentences, over the 1-sentence cap" in deny_reason
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def test_lean_question_with_three_short_options_is_allowed() -> None:
|
|
460
|
+
all_descriptions = [
|
|
461
|
+
LEAN_DESCRIPTION,
|
|
462
|
+
"Runs at commit time.",
|
|
463
|
+
"Runs in both places.",
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
assert evaluate(_ask_payload(LEAN_QUESTION, all_descriptions)) is None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_two_sentence_question_at_the_sentence_cap_is_allowed() -> None:
|
|
470
|
+
two_sentence_question = f"{LEAN_QUESTION} Both gates read the same lines."
|
|
471
|
+
|
|
472
|
+
assert evaluate(_ask_payload(two_sentence_question, [LEAN_DESCRIPTION])) is None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_question_at_the_word_cap_is_allowed() -> None:
|
|
476
|
+
assert evaluate(_ask_payload(QUESTION_AT_THE_WORD_CAP, [LEAN_DESCRIPTION])) is None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_option_description_at_the_word_cap_is_allowed() -> None:
|
|
480
|
+
assert evaluate(_ask_payload(LEAN_QUESTION, [DESCRIPTION_AT_THE_WORD_CAP])) is None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_question_with_an_inline_code_span_is_allowed() -> None:
|
|
484
|
+
question_text = QUESTION_WITH_INLINE_CODE_AT_THE_WORD_CAP
|
|
485
|
+
|
|
486
|
+
assert len(question_text.split()) > 40
|
|
487
|
+
assert evaluate(_ask_payload(question_text, [LEAN_DESCRIPTION])) is None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def test_inline_code_span_counts_as_one_word() -> None:
|
|
491
|
+
deny_reason = evaluate(
|
|
492
|
+
_ask_payload(QUESTION_WITH_INLINE_CODE_OVER_THE_WORD_CAP, [LEAN_DESCRIPTION])
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
assert deny_reason is not None
|
|
496
|
+
assert "41 words, over the 40-word cap" in deny_reason
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_option_description_with_an_inline_code_span_is_allowed() -> None:
|
|
500
|
+
option_description = DESCRIPTION_WITH_INLINE_CODE_AT_THE_WORD_CAP
|
|
501
|
+
|
|
502
|
+
assert len(option_description.split()) > 15
|
|
503
|
+
assert evaluate(_ask_payload(LEAN_QUESTION, [option_description])) is None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_inline_code_span_counts_as_one_word_in_an_option_description() -> None:
|
|
507
|
+
deny_reason = evaluate(
|
|
508
|
+
_ask_payload(LEAN_QUESTION, [DESCRIPTION_WITH_INLINE_CODE_OVER_THE_WORD_CAP])
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
assert deny_reason is not None
|
|
512
|
+
assert "16 words, over the 15-word cap" in deny_reason
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_markdown_write_keeps_its_bullets_and_tables(tmp_path: Path) -> None:
|
|
516
|
+
bulleted_prose = "- Split the file\n- Wire the gate\n\n| gate | 12 ms |\n"
|
|
517
|
+
target_path = str(tmp_path / "notes.md")
|
|
518
|
+
heavy_payload = {
|
|
519
|
+
"tool_name": "Write",
|
|
520
|
+
"tool_input": {
|
|
521
|
+
"file_path": target_path,
|
|
522
|
+
"content": f"{bulleted_prose}Then utilize the cache.",
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
clean_payload = {
|
|
526
|
+
"tool_name": "Write",
|
|
527
|
+
"tool_input": {"file_path": target_path, "content": bulleted_prose},
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
assert evaluate(heavy_payload) is not None
|
|
531
|
+
assert evaluate(clean_payload) is None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def test_find_question_block_violations_reports_one_line_per_repeated_fault() -> None:
|
|
535
|
+
tool_input = {
|
|
536
|
+
"questions": [
|
|
537
|
+
{
|
|
538
|
+
"question": LEAN_QUESTION,
|
|
539
|
+
"options": [
|
|
540
|
+
{"label": "Write", "description": DESCRIPTION_OVER_THE_WORD_CAP},
|
|
541
|
+
{"label": "Commit", "description": DESCRIPTION_OVER_THE_WORD_CAP},
|
|
542
|
+
],
|
|
543
|
+
}
|
|
544
|
+
]
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
all_violations = find_question_block_violations(tool_input)
|
|
548
|
+
|
|
549
|
+
assert all_violations == [
|
|
550
|
+
"an option description runs 18 words, over the 15-word cap"
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def test_lean_block_reason_names_the_fault_and_the_fix() -> None:
|
|
555
|
+
reason = build_lean_block_reason(["the question carries a table row"])
|
|
556
|
+
|
|
557
|
+
assert "the question carries a table row" in reason
|
|
558
|
+
assert "in chat text" in reason
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def test_each_denial_carries_its_own_user_notice() -> None:
|
|
562
|
+
plan_question = f"{LEAN_QUESTION}\n- Split the file\n- Wire the gate"
|
|
563
|
+
lean_denial = _run_hook_with_payload(_ask_payload(plan_question, [LEAN_DESCRIPTION]))
|
|
564
|
+
heavy_denial = _run_hook_with_payload(
|
|
565
|
+
_ask_payload("Should we utilize the cache?", [LEAN_DESCRIPTION])
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
lean_notice = json.loads(lean_denial.stdout)["systemMessage"]
|
|
569
|
+
heavy_notice = json.loads(heavy_denial.stdout)["systemMessage"]
|
|
570
|
+
|
|
571
|
+
assert "question" in lean_notice.lower()
|
|
572
|
+
assert "word" in heavy_notice.lower()
|
|
573
|
+
assert lean_notice != heavy_notice
|
|
@@ -656,8 +656,11 @@ def test_dispatcher_edit_applies_both_groups() -> None:
|
|
|
656
656
|
assert "blocking/stale_comment_reference_blocker.py" in all_edit_script_paths, (
|
|
657
657
|
"stale_comment_reference_blocker belongs in the Edit applicable set"
|
|
658
658
|
)
|
|
659
|
-
assert
|
|
660
|
-
|
|
659
|
+
assert "advisory/refactor_guard.py" in all_edit_script_paths, (
|
|
660
|
+
"refactor_guard is Edit-scoped and hosted, so it belongs in the Edit applicable set"
|
|
661
|
+
)
|
|
662
|
+
assert len(all_edit_entries) == 24, (
|
|
663
|
+
f"expected 24 Edit entries, got {len(all_edit_entries)}"
|
|
661
664
|
)
|
|
662
665
|
|
|
663
666
|
|
|
@@ -844,6 +847,35 @@ def test_runpy_hosted_hook_sees_its_own_argv_not_the_dispatchers(tmp_path: Path)
|
|
|
844
847
|
)
|
|
845
848
|
|
|
846
849
|
|
|
850
|
+
def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
|
|
851
|
+
"""A hook the dispatcher hosts must not also run as its own process.
|
|
852
|
+
|
|
853
|
+
Each folded hook spawns an interpreter of its own when hooks.json still
|
|
854
|
+
registers it, so the fold only pays off once the standalone entry is gone.
|
|
855
|
+
"""
|
|
856
|
+
hooks_json_text = (_HOOKS_ROOT / "hooks.json").read_text(encoding="utf-8")
|
|
857
|
+
hooks_configuration = json.loads(hooks_json_text)
|
|
858
|
+
all_pre_tool_use_commands = [
|
|
859
|
+
each_hook["command"]
|
|
860
|
+
for each_group in hooks_configuration["hooks"]["PreToolUse"]
|
|
861
|
+
for each_hook in each_group["hooks"]
|
|
862
|
+
]
|
|
863
|
+
folded_script_relative_paths = (
|
|
864
|
+
"advisory/refactor_guard.py",
|
|
865
|
+
"advisory/migration_safety_advisor.py",
|
|
866
|
+
)
|
|
867
|
+
for each_script_path in folded_script_relative_paths:
|
|
868
|
+
matching_commands = [
|
|
869
|
+
each_command
|
|
870
|
+
for each_command in all_pre_tool_use_commands
|
|
871
|
+
if each_script_path in each_command
|
|
872
|
+
]
|
|
873
|
+
assert not matching_commands, (
|
|
874
|
+
f"{each_script_path} is hosted by the dispatcher, so its standalone "
|
|
875
|
+
f"hooks.json entry must be gone. Found: {matching_commands!r}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
847
879
|
def test_hosted_hook_set_covers_all_write_edit_blocking_hooks() -> None:
|
|
848
880
|
"""The hosted hook set covers all previously-registered Write/Edit blocking hooks.
|
|
849
881
|
|
|
@@ -7,8 +7,8 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
|
|
|
7
7
|
| File | Git hook | What it does |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `pre_commit.py` | `pre-commit` | Runs the CODE_RULES gate (`precommit_code_rules_gate.py`) over staged changes; exits 1 when any staged file has a blocking violation |
|
|
10
|
-
| `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate
|
|
11
|
-
| `pre_push_base_reference.py` | — | Resolves
|
|
10
|
+
| `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate. An existing branch's gate base is the merge base with the remote default branch; the gate process still diffs that base against checkout HEAD, so the surface matches the pushed tip only when HEAD is that tip. |
|
|
11
|
+
| `pre_push_base_reference.py` | — | Resolves a usable gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then turns a symbolic default-branch head into a reference that git can resolve |
|
|
12
12
|
| `post_commit.py` | `post-commit` | Runs after a commit lands; performs any post-commit bookkeeping |
|
|
13
13
|
| `gate_utils.py` | — | Shared helpers: resolves the gate script path, checks that the path is a safe regular file |
|
|
14
14
|
| `test_config.py` | — | Test configuration helpers |
|
|
@@ -47,7 +47,35 @@ NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
|
|
|
47
47
|
"claude-dev-env pre-push: no parseable stdin lines; aborting"
|
|
48
48
|
)
|
|
49
49
|
NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
|
|
50
|
+
UNRESOLVABLE_MERGE_BASE_SENTINEL: str = "__unresolvable_merge_base__"
|
|
51
|
+
UNRESOLVABLE_MERGE_BASE_MESSAGE: str = (
|
|
52
|
+
"claude-dev-env pre-push: git found no merge base between the pushed object "
|
|
53
|
+
"and the default branch, so the gate scope is unknown and "
|
|
54
|
+
"enforcement is skipped. That happens when the pushed branch and the "
|
|
55
|
+
"default branch hold unrelated histories, or when a default-branch ref "
|
|
56
|
+
"resolved and merge-base still could not name a shared commit "
|
|
57
|
+
"(for example a shallow clone missing the connecting history)."
|
|
58
|
+
)
|
|
50
59
|
LOCAL_BRANCH_REFERENCE_PREFIX: str = "refs/heads/"
|
|
60
|
+
ORIGIN_HEAD_SYMBOLIC_REFERENCE: str = "refs/remotes/origin/HEAD"
|
|
61
|
+
ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX: str = "refs/remotes/origin/"
|
|
62
|
+
ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES: tuple[str, ...] = (
|
|
63
|
+
"refs/remotes/origin/main",
|
|
64
|
+
"refs/remotes/origin/master",
|
|
65
|
+
)
|
|
66
|
+
ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX: tuple[str, ...] = (
|
|
67
|
+
"git",
|
|
68
|
+
"symbolic-ref",
|
|
69
|
+
"--quiet",
|
|
70
|
+
)
|
|
71
|
+
ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX: tuple[str, ...] = (
|
|
72
|
+
"git",
|
|
73
|
+
"rev-parse",
|
|
74
|
+
"--verify",
|
|
75
|
+
"--quiet",
|
|
76
|
+
)
|
|
77
|
+
ALL_GIT_MERGE_BASE_COMMAND_PREFIX: tuple[str, ...] = ("git", "merge-base")
|
|
78
|
+
GIT_REFERENCE_QUERY_TIMEOUT_SECONDS: int = 15
|
|
51
79
|
CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_REVIEW_PUSH_GATE_PATH"
|
|
52
80
|
CODE_REVIEW_PUSH_GATE_MODULE_NAME: str = "code_review_push_gate"
|
|
53
81
|
CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME: str = "code_review_push_gate.py"
|