claude-dev-env 1.50.4 → 1.52.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 (73) hide show
  1. package/CLAUDE.md +0 -8
  2. package/_shared/pr-loop/audit-contract.md +3 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/preflight_self_heal_constants.py +28 -0
  4. package/_shared/pr-loop/scripts/preflight.py +18 -6
  5. package/_shared/pr-loop/scripts/preflight_self_heal.py +164 -0
  6. package/_shared/pr-loop/scripts/tests/test_preflight.py +39 -0
  7. package/_shared/pr-loop/scripts/tests/test_preflight_self_heal.py +273 -0
  8. package/agents/clean-coder.md +1 -1
  9. package/agents/code-quality-agent.md +7 -5
  10. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +3 -0
  11. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +3 -0
  12. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +8 -2
  13. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +3 -0
  14. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +39 -0
  15. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +40 -0
  16. package/audit-rubrics/prompts/category-a-api-contracts.md +11 -4
  17. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  18. package/audit-rubrics/prompts/category-c-resource-cleanup.md +1 -1
  19. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +1 -1
  20. package/audit-rubrics/prompts/category-e-dead-code.md +1 -1
  21. package/audit-rubrics/prompts/category-f-silent-failures.md +13 -2
  22. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +1 -1
  23. package/audit-rubrics/prompts/category-h-security-boundaries.md +1 -1
  24. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  25. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  26. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +15 -5
  27. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +1 -1
  28. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +1 -1
  29. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +10 -3
  30. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +74 -0
  31. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +75 -0
  32. package/docs/CODE_RULES.md +24 -346
  33. package/hooks/blocking/code_rules_enforcer.py +367 -42
  34. package/hooks/blocking/tdd_enforcer.py +211 -19
  35. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
  36. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
  37. package/hooks/blocking/test_tdd_enforcer.py +399 -0
  38. package/hooks/hooks.json +0 -15
  39. package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
  40. package/package.json +1 -1
  41. package/rules/ask-user-question-required.md +2 -41
  42. package/rules/confirm-implementation-forks.md +3 -44
  43. package/rules/gh-body-file.md +2 -78
  44. package/rules/gh-paginate.md +2 -78
  45. package/rules/plain-language.md +2 -41
  46. package/rules/prompt-workflow-context-controls.md +9 -38
  47. package/rules/shell-invocation-policy.md +2 -141
  48. package/rules/testing.md +10 -0
  49. package/rules/vault-context.md +3 -32
  50. package/rules/windows-filesystem-safe.md +3 -87
  51. package/scripts/sync_to_cursor/rules.py +201 -79
  52. package/scripts/tests/test_sync_to_cursor.py +122 -26
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +2 -0
  54. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +51 -4
  55. package/skills/auditing-claude-config/SKILL.md +6 -1
  56. package/skills/bugteam/CONSTRAINTS.md +1 -1
  57. package/skills/bugteam/PROMPTS.md +8 -6
  58. package/skills/bugteam/SKILL.md +5 -5
  59. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  60. package/skills/bugteam/reference/audit-contract.md +4 -4
  61. package/skills/bugteam/reference/design-rationale.md +1 -1
  62. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  63. package/skills/bugteam/reference/team-setup.md +17 -5
  64. package/skills/bugteam/scripts/bugteam_preflight.py +22 -10
  65. package/skills/bugteam/scripts/test_bugteam_preflight.py +32 -0
  66. package/skills/copilot-review/SKILL.md +5 -8
  67. package/skills/doc-gist/SKILL.md +5 -8
  68. package/skills/fixbugs/SKILL.md +1 -1
  69. package/skills/gh-paginate/SKILL.md +84 -0
  70. package/skills/pre-compact/SKILL.md +4 -9
  71. package/skills/refine/SKILL.md +8 -2
  72. package/skills/structure-prompt/SKILL.md +5 -10
  73. package/skills/update/SKILL.md +143 -0
@@ -17,7 +17,9 @@ concern focused. The separate ``tdd_enforcer.py`` hook accepts any
17
17
  """
18
18
  import json
19
19
  import sys
20
+ from collections import Counter
20
21
  from pathlib import Path
22
+ from typing import TextIO
21
23
 
22
24
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
23
25
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
@@ -120,6 +122,11 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
120
122
  ALL_CODE_EXTENSIONS,
121
123
  ALL_JAVASCRIPT_EXTENSIONS,
122
124
  ALL_PYTHON_EXTENSIONS,
125
+ PRECHECK_USAGE_EXIT_CODE,
126
+ PRECHECK_USAGE_MESSAGE,
127
+ )
128
+ from hooks_constants.setup_project_paths_constants import ( # noqa: E402
129
+ UTF8_BYTE_ORDER_MARK,
123
130
  )
124
131
 
125
132
 
@@ -310,72 +317,390 @@ def prior_and_post_edit_content(
310
317
  return existing_content, existing_content.replace(old_string, new_string, 1)
311
318
 
312
319
 
313
- def main() -> None:
314
- try:
315
- input_data = json.load(sys.stdin)
316
- except json.JSONDecodeError:
317
- sys.exit(0)
320
+ def _is_validated_target(file_path: str) -> bool:
321
+ """Return whether the path is subject to code-rules validation.
318
322
 
319
- tool_name = input_data.get("tool_name", "")
320
- tool_input = input_data.get("tool_input", {})
321
- file_path = tool_input.get("file_path", "")
323
+ Args:
324
+ file_path: The destination path of the write, edit, or pre-check target.
322
325
 
326
+ Returns:
327
+ True when the path is non-empty, outside hook infrastructure, and
328
+ carries a code extension; False for every exempt path.
329
+ """
323
330
  if not file_path:
324
- sys.exit(0)
325
-
331
+ return False
326
332
  if is_hook_infrastructure(file_path):
327
- sys.exit(0)
333
+ return False
334
+ return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
328
335
 
329
- extension = get_file_extension(file_path)
330
- if extension not in ALL_CODE_EXTENSIONS:
331
- sys.exit(0)
332
336
 
333
- old_content = ""
334
- prior_full_file_content = ""
335
- full_file_content_after_edit: str | None = None
337
+ def _without_line_prefix(violation_text: str) -> str:
338
+ """Return the violation message body with its ``Line <n>: `` locator removed.
339
+
340
+ Args:
341
+ violation_text: A violation message, optionally carrying a leading
342
+ ``Line <n>: `` locator.
343
+
344
+ Returns:
345
+ The message body shared by fragment-scoped and full-file scans of the
346
+ same violation, regardless of which line numbering produced it.
347
+ """
348
+ locator, separator, message_body = violation_text.partition(": ")
349
+ if separator and locator.startswith("Line ") and locator[len("Line "):].isdigit():
350
+ return message_body
351
+ return violation_text
352
+
353
+
354
+ def _forecast_full_file_violations(
355
+ full_file_content_after_edit: str,
356
+ file_path: str,
357
+ prior_full_file_content: str,
358
+ all_blocking_issues: list[str],
359
+ ) -> list[str]:
360
+ """Return full-file violations absent from the fragment-scoped blocking list.
361
+
362
+ Runs a complete, un-scoped scan of the whole post-edit file so fragment-scoped
363
+ checks see every line, then drops the violations already present in
364
+ ``all_blocking_issues``. Matching is line-number-agnostic with
365
+ per-occurrence accounting: each blocking entry consumes exactly one
366
+ full-file entry carrying the same message body, so a violation the fragment
367
+ itself introduces stays out of the forecast even though the two scans
368
+ number its line differently, while a second same-message violation
369
+ elsewhere in the file still surfaces. The remainder are the violations that
370
+ survive elsewhere in the file and will block a future edit.
371
+
372
+ Body matching relies on an invariant of the check suite: every check that
373
+ embeds a secondary source position in its message body (function length,
374
+ banned-noun binding spans, test isolation) scans the whole post-edit file
375
+ in both passes, so those embedded positions are identical across scans;
376
+ checks that scan only the fragment carry their position solely in the
377
+ strippable ``Line <n>: `` locator. A fragment-scoped check that embeds a
378
+ position in its body would defeat the dedup and re-list its own violation.
379
+
380
+ Args:
381
+ full_file_content_after_edit: The whole post-edit file content.
382
+ file_path: The destination path used for classification.
383
+ prior_full_file_content: The whole file content before the edit applied,
384
+ used so the comment diff reflects the real prior state.
385
+ all_blocking_issues: The fragment-scoped issues that already decide the
386
+ deny.
387
+
388
+ Returns:
389
+ The full-file violations not already in ``all_blocking_issues``.
390
+ """
391
+ all_full_file_issues = validate_content(
392
+ full_file_content_after_edit, file_path, prior_full_file_content
393
+ )
394
+ remaining_blocking_counts = Counter(
395
+ _without_line_prefix(each_issue) for each_issue in all_blocking_issues
396
+ )
397
+ forecast_issues: list[str] = []
398
+ for each_issue in all_full_file_issues:
399
+ message_body = _without_line_prefix(each_issue)
400
+ if remaining_blocking_counts[message_body] > 0:
401
+ remaining_blocking_counts[message_body] -= 1
402
+ continue
403
+ forecast_issues.append(each_issue)
404
+ return forecast_issues
405
+
406
+
407
+ def _precheck_hint() -> str:
408
+ """Return the discoverability hint pointing at the script's pre-check mode."""
409
+ script_path = str(Path(__file__).resolve())
410
+ return (
411
+ "; Pre-check a complete candidate before retrying: "
412
+ f'"{sys.executable}" "{script_path}" --check <candidate> --as <target>'
413
+ )
414
+
415
+
416
+ def _run_precheck(
417
+ candidate_path: str,
418
+ target_path: str,
419
+ violation_stream: TextIO,
420
+ error_stream: TextIO,
421
+ ) -> int:
422
+ """Validate a complete candidate file as if it lived at its destination.
423
+
424
+ Reads the candidate's full content and runs the complete verdict (no diff
425
+ scoping) using ``target_path`` for every path-based classification, so a
426
+ candidate staged in a temporary directory is judged as if written to its real
427
+ destination. Every leading byte-order mark on the candidate is stripped so
428
+ the verdict matches the one the decoded tool-payload content receives — a
429
+ BOM would otherwise fail AST parsing and silently skip every AST-based
430
+ check.
431
+
432
+ Args:
433
+ candidate_path: The path of the candidate file to validate.
434
+ target_path: The destination path used for extension dispatch and every
435
+ exemption decision.
436
+ violation_stream: The stream each violation line is written to.
437
+ error_stream: The stream the unreadable-candidate error line is written
438
+ to.
439
+
440
+ Returns:
441
+ Exit code 1 when any violation exists or the candidate cannot be read,
442
+ and 0 when the candidate is clean or the target is exempt.
443
+ """
444
+ if not _is_validated_target(target_path):
445
+ return 0
446
+ candidate_content = _read_existing_file_content(candidate_path)
447
+ if candidate_content is None:
448
+ error_stream.write(f"error: cannot read candidate file: {candidate_path}\n")
449
+ return 1
450
+ candidate_content = candidate_content.lstrip(UTF8_BYTE_ORDER_MARK)
451
+ old_content = _read_existing_file_content(target_path) or ""
452
+ all_issues = validate_content(candidate_content, target_path, old_content)
453
+ for each_issue in all_issues:
454
+ violation_stream.write(f"{each_issue}\n")
455
+ return 1 if all_issues else 0
456
+
457
+
458
+ def _precheck_arguments(all_arguments: list[str]) -> tuple[str, str] | None:
459
+ """Parse a strict pre-check argument vector into a candidate and target pair.
460
+
461
+ Accepts exactly the two documented shapes — ``--check <candidate>`` or
462
+ ``--check <candidate> --as <target>`` — with the target defaulting to the
463
+ candidate when ``--as`` is absent. Any unrecognized or extra token, a
464
+ reordered flag, or a missing path value is rejected rather than silently
465
+ ignored, so a malformed invocation can never look like a clean verdict.
466
+
467
+ Args:
468
+ all_arguments: The argument vector following the script name, expected
469
+ to lead with ``--check``.
470
+
471
+ Returns:
472
+ A ``(candidate_path, target_path)`` pair for one of the two supported
473
+ shapes; otherwise None — the vector does not lead with ``--check``,
474
+ omits a path value, places a flag-shaped token where a path belongs,
475
+ or carries an unrecognized or extra token.
476
+ """
477
+ if not all_arguments or all_arguments[0] != "--check":
478
+ return None
479
+ tokens_after_check = all_arguments[1:]
480
+ if not tokens_after_check or tokens_after_check[0].startswith("--"):
481
+ return None
482
+ candidate_path = tokens_after_check[0]
483
+ tokens_after_candidate = tokens_after_check[1:]
484
+ if not tokens_after_candidate:
485
+ return candidate_path, candidate_path
486
+ if tokens_after_candidate[0] != "--as":
487
+ return None
488
+ target_tokens = tokens_after_candidate[1:]
489
+ if len(target_tokens) != 1 or target_tokens[0].startswith("--"):
490
+ return None
491
+ return candidate_path, target_tokens[0]
492
+
493
+
494
+ def _run_precheck_command(
495
+ all_arguments: list[str],
496
+ violation_stream: TextIO,
497
+ error_stream: TextIO,
498
+ ) -> int:
499
+ """Run the pre-check CLI mode for an argument vector carrying ``--check``.
500
+
501
+ Args:
502
+ all_arguments: The argument vector following the script name.
503
+ violation_stream: The stream each violation line is written to.
504
+ error_stream: The stream usage and candidate errors are written to.
505
+
506
+ Returns:
507
+ The usage-error exit code for a malformed flag sequence, otherwise the
508
+ ``_run_precheck`` verdict for the parsed candidate and target.
509
+ """
510
+ precheck_paths = _precheck_arguments(all_arguments)
511
+ if precheck_paths is None:
512
+ error_stream.write(PRECHECK_USAGE_MESSAGE)
513
+ return PRECHECK_USAGE_EXIT_CODE
514
+ candidate_path, target_path = precheck_paths
515
+ return _run_precheck(candidate_path, target_path, violation_stream, error_stream)
516
+
517
+
518
+ def _contents_for_validation(
519
+ tool_name: str,
520
+ new_string: str,
521
+ old_string: str,
522
+ written_content: str,
523
+ file_path: str,
524
+ ) -> tuple[str, str, str | None, str] | None:
525
+ """Resolve the content views the verdict needs for the given tool payload.
526
+
527
+ Args:
528
+ tool_name: The tool named in the PreToolUse payload.
529
+ new_string: The Edit payload's replacement fragment.
530
+ old_string: The Edit payload's fragment to replace.
531
+ written_content: The Write payload's whole file body.
532
+ file_path: The destination path of the write or edit.
533
+
534
+ Returns:
535
+ A ``(content, old_content, full_file_content_after_edit,
536
+ prior_full_file_content)`` tuple, or None when no validatable view
537
+ exists — an unreadable edit target, or a write over an existing file.
538
+ """
336
539
  if tool_name == "Edit":
337
- content = tool_input.get("new_string", "")
338
- old_content = tool_input.get("old_string", "")
339
540
  prior_content, full_file_content_after_edit = prior_and_post_edit_content(
340
- file_path, old_content, content,
541
+ file_path, old_string, new_string,
341
542
  )
342
- prior_full_file_content = prior_content or ""
343
543
  if full_file_content_after_edit is None:
344
544
  full_file_content_after_edit = _read_existing_file_content(file_path)
345
545
  if full_file_content_after_edit is None:
346
- sys.exit(0)
347
- else:
348
- content = tool_input.get("content", "") or tool_input.get("new_string", "")
349
- old_content = _read_existing_file_content(file_path) or ""
546
+ return None
547
+ return new_string, old_string, full_file_content_after_edit, prior_content or ""
548
+ content = written_content or new_string
549
+ old_content = _read_existing_file_content(file_path) or ""
550
+ if old_content:
551
+ return None
552
+ return content, old_content, None, ""
350
553
 
351
- if old_content:
352
- sys.exit(0)
353
554
 
354
- if not content:
355
- sys.exit(0)
555
+ def _deny_reason_for_issues(
556
+ all_blocking_issues: list[str],
557
+ tool_name: str,
558
+ file_path: str,
559
+ full_file_content_after_edit: str | None,
560
+ prior_full_file_content: str,
561
+ ) -> str:
562
+ """Compose the deny reason: blocking list, optional forecast, pre-check hint.
356
563
 
357
- issues = validate_content(
564
+ Args:
565
+ all_blocking_issues: The blocking violations that decide the deny.
566
+ tool_name: The tool named in the PreToolUse payload.
567
+ file_path: The destination path used for forecast classification.
568
+ full_file_content_after_edit: The whole post-edit file content when the
569
+ edit reconstructs one, used to run the full-file forecast.
570
+ prior_full_file_content: The whole file content before the edit applied.
571
+ Empty when the edit's old_string is absent and no reliable prior
572
+ exists; the forecast is skipped in that case so a comment diff
573
+ against an empty prior cannot mislabel pre-existing comments as
574
+ future blockers.
575
+
576
+ Returns:
577
+ The complete ``permissionDecisionReason`` text.
578
+ """
579
+ issue_list = "; ".join(all_blocking_issues[:10])
580
+ deny_reason = (
581
+ f"BLOCKED: [CODE_RULES] {len(all_blocking_issues)} violation(s): {issue_list}"
582
+ )
583
+ has_reconstructed_prior = bool(prior_full_file_content)
584
+ if (
585
+ tool_name == "Edit"
586
+ and full_file_content_after_edit is not None
587
+ and has_reconstructed_prior
588
+ ):
589
+ forecast_issues = _forecast_full_file_violations(
590
+ full_file_content_after_edit,
591
+ file_path=file_path,
592
+ prior_full_file_content=prior_full_file_content,
593
+ all_blocking_issues=all_blocking_issues,
594
+ )
595
+ if forecast_issues:
596
+ forecast_list = "; ".join(forecast_issues[:10])
597
+ deny_reason += (
598
+ f"; FULL-FILE FORECAST — {len(forecast_issues)} additional "
599
+ "violation(s) elsewhere in this file will block future edits "
600
+ f"(full-file line numbers): {forecast_list}"
601
+ )
602
+ return deny_reason + _precheck_hint()
603
+
604
+
605
+ def _report_blocking_violations(
606
+ content: str,
607
+ tool_name: str,
608
+ file_path: str,
609
+ old_content: str,
610
+ full_file_content_after_edit: str | None,
611
+ prior_full_file_content: str,
612
+ deny_stream: TextIO,
613
+ ) -> None:
614
+ """Run the verdict and write a deny payload when blocking violations fire.
615
+
616
+ Args:
617
+ content: The fragment or whole-file body under validation.
618
+ tool_name: The tool named in the PreToolUse payload.
619
+ file_path: The destination path of the write or edit.
620
+ old_content: The fragment the edit replaces, or empty for a write.
621
+ full_file_content_after_edit: The reconstructed post-edit file body,
622
+ or None when the payload is not an Edit.
623
+ prior_full_file_content: The on-disk content before the edit.
624
+ deny_stream: The stream the JSON deny payload is written to.
625
+ """
626
+ all_blocking_issues = validate_content(
358
627
  content,
359
628
  file_path,
360
629
  old_content,
361
630
  full_file_content_after_edit,
362
631
  prior_full_file_content,
363
632
  )
364
-
365
- if issues:
366
- issue_list = "; ".join(issues[:10])
367
- deny_payload = {
368
- "hookSpecificOutput": {
369
- "hookEventName": "PreToolUse",
370
- "permissionDecision": "deny",
371
- "permissionDecisionReason": f"BLOCKED: [CODE_RULES] {len(issues)} violation(s): {issue_list}",
372
- }
633
+ if not all_blocking_issues:
634
+ return
635
+ deny_payload = {
636
+ "hookSpecificOutput": {
637
+ "hookEventName": "PreToolUse",
638
+ "permissionDecision": "deny",
639
+ "permissionDecisionReason": _deny_reason_for_issues(
640
+ all_blocking_issues,
641
+ tool_name,
642
+ file_path,
643
+ full_file_content_after_edit,
644
+ prior_full_file_content,
645
+ ),
373
646
  }
374
- print(json.dumps(deny_payload))
375
- sys.stdout.flush()
647
+ }
648
+ deny_stream.write(json.dumps(deny_payload) + "\n")
649
+ deny_stream.flush()
650
+
376
651
 
652
+ def main(all_arguments: list[str]) -> None:
653
+ """Run the enforcer for the given argument vector.
654
+
655
+ Dispatches to the pre-check CLI mode when the vector carries ``--check``;
656
+ otherwise reads a PreToolUse payload from stdin and emits a deny payload
657
+ on stdout when the content violates a blocking rule.
658
+
659
+ Args:
660
+ all_arguments: The argument vector following the script name.
661
+ """
662
+ if "--check" in all_arguments:
663
+ sys.exit(_run_precheck_command(all_arguments, sys.stdout, sys.stderr))
664
+
665
+ try:
666
+ pretooluse_payload = json.load(sys.stdin)
667
+ except json.JSONDecodeError:
668
+ sys.exit(0)
669
+
670
+ tool_name = pretooluse_payload.get("tool_name", "")
671
+ tool_input = pretooluse_payload.get("tool_input", {})
672
+ file_path = tool_input.get("file_path", "")
673
+
674
+ if not _is_validated_target(file_path):
675
+ sys.exit(0)
676
+
677
+ validation_contents = _contents_for_validation(
678
+ tool_name,
679
+ tool_input.get("new_string", ""),
680
+ tool_input.get("old_string", ""),
681
+ tool_input.get("content", ""),
682
+ file_path,
683
+ )
684
+ if validation_contents is None:
685
+ sys.exit(0)
686
+ content, old_content, full_file_content_after_edit, prior_full_file_content = (
687
+ validation_contents
688
+ )
689
+
690
+ if not content:
691
+ sys.exit(0)
692
+
693
+ _report_blocking_violations(
694
+ content,
695
+ tool_name,
696
+ file_path,
697
+ old_content,
698
+ full_file_content_after_edit,
699
+ prior_full_file_content,
700
+ sys.stdout,
701
+ )
377
702
  sys.exit(0)
378
703
 
379
704
 
380
705
  if __name__ == "__main__":
381
- main()
706
+ main(sys.argv[1:])