diff --git a/marketplace.json b/marketplace.json index b988fe5..414bb02 100644 --- a/marketplace.json +++ b/marketplace.json @@ -740,6 +740,31 @@ "visualization" ], "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/obsidian/icon.png" + }, + { + "name": "regress-guard", + "source": "./plugins/regress-guard", + "description": "AI-coding governance gate for ZCode: pre-commit test enforcement, manifest-boundary guard, six-layer trust surface, assumption ledger, and FP-calibrated telemetry.", + "description_i18n": { + "en": "AI-coding governance gate for ZCode: pre-commit test enforcement, manifest-boundary guard, six-layer trust surface, assumption ledger, and FP-calibrated telemetry.", + "zh-CN": "AI 辅助开发治理门禁:提交前测试强制(不信 AI 自报的 done)、清单边界事前拦截、六层信任面、假设账本与误报校准遥测。" + }, + "version": "1.88.0", + "author": { + "name": "1339190177", + "url": "https://github.com/1339190177" + }, + "category": "developer-tools", + "keywords": [ + "governance", + "hooks", + "pre-commit", + "testing", + "regression", + "guardrails", + "spec-driven", + "telemetry" + ] } ] } diff --git a/plugins/regress-guard/.pytest_cache/.gitignore b/plugins/regress-guard/.pytest_cache/.gitignore new file mode 100644 index 0000000..bc1a1f6 --- /dev/null +++ b/plugins/regress-guard/.pytest_cache/.gitignore @@ -0,0 +1,2 @@ +# Created by pytest automatically. +* diff --git a/plugins/regress-guard/.pytest_cache/CACHEDIR.TAG b/plugins/regress-guard/.pytest_cache/CACHEDIR.TAG new file mode 100644 index 0000000..fce15ad --- /dev/null +++ b/plugins/regress-guard/.pytest_cache/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by pytest. +# For information about cache directory tags, see: +# https://bford.info/cachedir/spec.html diff --git a/plugins/regress-guard/.pytest_cache/README.md b/plugins/regress-guard/.pytest_cache/README.md new file mode 100644 index 0000000..b89018c --- /dev/null +++ b/plugins/regress-guard/.pytest_cache/README.md @@ -0,0 +1,8 @@ +# pytest cache directory # + +This directory contains data from the pytest's cache plugin, +which provides the `--lf` and `--ff` options, as well as the `cache` fixture. + +**Do not** commit this to version control. + +See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/plugins/regress-guard/.pytest_cache/v/cache/lastfailed b/plugins/regress-guard/.pytest_cache/v/cache/lastfailed new file mode 100644 index 0000000..29d3a46 --- /dev/null +++ b/plugins/regress-guard/.pytest_cache/v/cache/lastfailed @@ -0,0 +1,3 @@ +{ + "tests/test_facts.py::test_record_creates_card_with_frontmatter": true +} \ No newline at end of file diff --git a/plugins/regress-guard/.pytest_cache/v/cache/nodeids b/plugins/regress-guard/.pytest_cache/v/cache/nodeids new file mode 100644 index 0000000..84a8fcd --- /dev/null +++ b/plugins/regress-guard/.pytest_cache/v/cache/nodeids @@ -0,0 +1,680 @@ +[ + "tests/test_auto_watch.py::test_auto_consult_cooldown", + "tests/test_auto_watch.py::test_auto_consult_dead_port_falls_back", + "tests/test_auto_watch.py::test_auto_consult_kill_switch", + "tests/test_auto_watch.py::test_auto_consult_ladder_all_empty_gives_up", + "tests/test_auto_watch.py::test_auto_consult_ladder_all_length_annotates", + "tests/test_auto_watch.py::test_auto_consult_ladder_recovers_from_length", + "tests/test_auto_watch.py::test_auto_consult_single_empty_gives_up", + "tests/test_auto_watch.py::test_auto_consult_single_truncated_annotates", + "tests/test_auto_watch.py::test_auto_consult_success", + "tests/test_auto_watch.py::test_benign_commands_not_risky", + "tests/test_auto_watch.py::test_blocked_observation_episode_dedup", + "tests/test_auto_watch.py::test_compact_notice_emits_warning", + "tests/test_auto_watch.py::test_decision_reminder_cooldown", + "tests/test_auto_watch.py::test_decision_reminder_on_consult", + "tests/test_auto_watch.py::test_decision_reminder_on_correction", + "tests/test_auto_watch.py::test_decision_reminder_stale_correction_skipped", + "tests/test_auto_watch.py::test_drift_detected_for_unplanned_change", + "tests/test_auto_watch.py::test_intent_restate_cooldown", + "tests/test_auto_watch.py::test_intent_restate_fires_on_new_manifest", + "tests/test_auto_watch.py::test_intent_restate_no_manifest_no_prompt", + "tests/test_auto_watch.py::test_intent_restate_off_switch", + "tests/test_auto_watch.py::test_intent_restate_refires_after_interval", + "tests/test_auto_watch.py::test_manifest_approved_via_edit_journals_once", + "tests/test_auto_watch.py::test_manifest_cancelled_journals_once", + "tests/test_auto_watch.py::test_manifest_edit_journals_plan_refined", + "tests/test_auto_watch.py::test_manifest_write_journals_plan_created", + "tests/test_auto_watch.py::test_new_episode_after_unblock_journaled", + "tests/test_auto_watch.py::test_non_manifest_edits_not_journaled", + "tests/test_auto_watch.py::test_planned_change_no_drift", + "tests/test_auto_watch.py::test_provisional_observation_dedup", + "tests/test_auto_watch.py::test_risk_watch_records_via_stdin", + "tests/test_auto_watch.py::test_risky_env_file_write", + "tests/test_auto_watch.py::test_risky_force_push_and_drop", + "tests/test_auto_watch.py::test_risky_rm_rf_detected", + "tests/test_auto_watch.py::test_risky_with_recent_consult_no_inject", + "tests/test_auto_watch.py::test_risky_without_consult_injects", + "tests/test_auto_watch.py::test_storm_downgrades_to_chronic_journal", + "tests/test_auto_watch.py::test_storm_still_fires_for_acute_new_failures", + "tests/test_auto_watch.py::test_storm_suppressed_when_success_interleaved", + "tests/test_auto_watch.py::test_usage_only_counts_bash", + "tests/test_auto_watch.py::test_verifying_status_counts_active", + "tests/test_benchmark_manifest.py::test_attack_summary_length_cap", + "tests/test_benchmark_manifest.py::test_ids_unique_and_stable_shape", + "tests/test_benchmark_manifest.py::test_scale_floor_five_families_twenty_cases", + "tests/test_benchmark_manifest.py::test_schema_exact_fields_and_types", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ACC-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ACC-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ACC-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ACC-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ACC-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[APIBASE-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-007]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[BOUND-008]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CACHE-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CACHE-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CACHE-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CACHE-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CACHE-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CACHE-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[CRED-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ENV-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ENV-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ENV-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ENV-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[ENV-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[FPIN-007]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-007]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-008]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-009]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[GATE-010]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[HOME-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[PIN-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[PIN-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-007]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[RECALL-008]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[REFLOW-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[REFLOW-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[REFLOW-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[REFLOW-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[REFLOW-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[REFLOW-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-007]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-008]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SCAN-009]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SECRET-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SECRET-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SECRET-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[SECRET-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[TRUST-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[TRUST-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-001]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-002]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-003]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-004]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-005]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-006]", + "tests/test_benchmark_manifest.py::test_selector_hits_real_tests[VALVE-007]", + "tests/test_benchmark_manifest.py::test_top_level_is_nonempty_list", + "tests/test_boundary_guard.py::test_approval_flips_to_allow", + "tests/test_boundary_guard.py::test_approved_sibling_allows_while_other_planning", + "tests/test_boundary_guard.py::test_approved_then_blocked_still_blocks", + "tests/test_boundary_guard.py::test_bash_absolute_target_unaffected", + "tests/test_boundary_guard.py::test_bash_cd_chain_relative_target_resolved", + "tests/test_boundary_guard.py::test_bash_cd_variable_fail_open", + "tests/test_boundary_guard.py::test_bash_harmless_and_devnull_pass", + "tests/test_boundary_guard.py::test_bash_in_progress_boundary_write_allowed", + "tests/test_boundary_guard.py::test_bash_mv_sed_dd_blocked", + "tests/test_boundary_guard.py::test_bash_outside_boundary_blocked_when_active", + "tests/test_boundary_guard.py::test_bash_redirection_write_blocked", + "tests/test_boundary_guard.py::test_bash_rm_inside_planning_boundary_blocked", + "tests/test_boundary_guard.py::test_blocked_blocks_inside_boundary_with_reason", + "tests/test_boundary_guard.py::test_blocked_sibling_still_blocks_but_inprogress_allows", + "tests/test_boundary_guard.py::test_bypass_also_pardons_task_boundary", + "tests/test_boundary_guard.py::test_cancelled_manifest_not_active", + "tests/test_boundary_guard.py::test_config_disable", + "tests/test_boundary_guard.py::test_config_standing_include_whitelists_machine_paths", + "tests/test_boundary_guard.py::test_cross_session_edit_blocked", + "tests/test_boundary_guard.py::test_dequoted_payload_command_word_not_target", + "tests/test_boundary_guard.py::test_done_manifest_body_status_quote_passes", + "tests/test_boundary_guard.py::test_exact_set_mode_without_explicit_boundary", + "tests/test_boundary_guard.py::test_exempt_regress_and_agents", + "tests/test_boundary_guard.py::test_expired_bypass_does_not_escape", + "tests/test_boundary_guard.py::test_forbidden_escape_via_bypass", + "tests/test_boundary_guard.py::test_forbidden_wins_over_include", + "tests/test_boundary_guard.py::test_forbidden_wrong_shape_warns_not_bricks", + "tests/test_boundary_guard.py::test_forbidden_zone_blocks_regardless_of_task", + "tests/test_boundary_guard.py::test_heredoc_body_not_targets_but_redir_is", + "tests/test_boundary_guard.py::test_inside_boundary_new_file_in_scope_dir", + "tests/test_boundary_guard.py::test_inside_boundary_planned_file", + "tests/test_boundary_guard.py::test_inside_boundary_tests_glob", + "tests/test_boundary_guard.py::test_long_body_manifest_still_detected", + "tests/test_boundary_guard.py::test_no_active_manifest_passes", + "tests/test_boundary_guard.py::test_no_regress_dir_passes", + "tests/test_boundary_guard.py::test_non_edit_tools_pass", + "tests/test_boundary_guard.py::test_outside_boundary_blocked", + "tests/test_boundary_guard.py::test_outside_project_blocked", + "tests/test_boundary_guard.py::test_outside_project_without_include_still_blocks", + "tests/test_boundary_guard.py::test_own_session_edit_unaffected", + "tests/test_boundary_guard.py::test_planning_blocks_even_inside_boundary", + "tests/test_boundary_guard.py::test_planning_read_tools_not_affected", + "tests/test_boundary_guard.py::test_planning_with_approved_at_allows", + "tests/test_boundary_guard.py::test_planning_with_empty_approved_still_blocks", + "tests/test_boundary_guard.py::test_quoted_redirection_target_still_caught", + "tests/test_boundary_guard.py::test_redirect_dequoted_comparison_not_target", + "tests/test_boundary_guard.py::test_redirect_herestring_and_real_writes", + "tests/test_boundary_guard.py::test_track_write_extends_boundary", + "tests/test_boundary_guard.py::test_unblocked_allows_again", + "tests/test_boundary_guard.py::test_union_of_active_manifests", + "tests/test_boundary_guard.py::test_unstamped_manifest_stays_shared", + "tests/test_execution_valve.py::test_benign_commands_pass", + "tests/test_execution_valve.py::test_catastrophic_patterns_block", + "tests/test_execution_valve.py::test_compound_command_caught", + "tests/test_execution_valve.py::test_empty_and_nonbash_pass", + "tests/test_execution_valve.py::test_flag_variants_rm", + "tests/test_execution_valve.py::test_main_allows_with_token", + "tests/test_execution_valve.py::test_main_blocks_without_token", + "tests/test_execution_valve.py::test_main_config_kill_switch", + "tests/test_execution_valve.py::test_main_env_kill_switch", + "tests/test_execution_valve.py::test_main_ignores_non_bash", + "tests/test_execution_valve.py::test_nondict_payload_blocks", + "tests/test_execution_valve.py::test_rm_rf_outside_project_blocks", + "tests/test_execution_valve.py::test_token_unlocks", + "tests/test_facts.py::test_cli_health_smoke", + "tests/test_facts.py::test_different_domain_or_title_appends", + "tests/test_facts.py::test_health_reports_oldest_and_stale", + "tests/test_facts.py::test_health_retire_threshold", + "tests/test_facts.py::test_index_orders_by_date_desc_and_covers_domains", + "tests/test_facts.py::test_record_creates_card_with_frontmatter", + "tests/test_facts.py::test_record_creates_thin_card_with_index", + "tests/test_facts.py::test_rehit_refreshes_date_not_duplicating", + "tests/test_filelock.py::test_lock_basic", + "tests/test_filelock.py::test_lock_concurrent_writes", + "tests/test_filelock.py::test_lock_creates_lockfile", + "tests/test_filelock.py::test_lock_nested_reentrant", + "tests/test_fragile_gate.py::test_flagged_allows_with_notice", + "tests/test_fragile_gate.py::test_locked_silent", + "tests/test_fragile_gate.py::test_locked_verify_fails_blocks", + "tests/test_fragile_gate.py::test_locked_verify_fails_warns_when_not_strict", + "tests/test_fragile_gate.py::test_locked_without_verify_warns_evidence_gap", + "tests/test_fragile_gate.py::test_no_fragile_section_passes", + "tests/test_fragile_gate.py::test_open_fragile_point_blocks_commit", + "tests/test_fragile_gate.py::test_open_fragile_point_warns_when_not_strict", + "tests/test_fragile_gate.py::test_parser_extracts_fragile_points", + "tests/test_fragile_gate.py::test_sensory_fossil_wrong_vid_still_blocks", + "tests/test_fragile_gate.py::test_sensory_locked_with_fossil_passes", + "tests/test_fragile_gate.py::test_sensory_locked_without_fossil_blocks", + "tests/test_fragile_gate.py::test_staged_regress_journal_exempt_from_f3", + "tests/test_gen_reference.py::test_check_passes_on_fresh_generation", + "tests/test_gen_reference.py::test_tampered_region_fails_check", + "tests/test_git_diff_analyzer.py::test_filter_empty_input", + "tests/test_git_diff_analyzer.py::test_filter_ignores_agents_md", + "tests/test_git_diff_analyzer.py::test_filter_ignores_lockfiles", + "tests/test_git_diff_analyzer.py::test_filter_ignores_markdown", + "tests/test_git_diff_analyzer.py::test_filter_ignores_regress_dir", + "tests/test_git_diff_analyzer.py::test_filter_keeps_test_files", + "tests/test_git_diff_analyzer.py::test_filter_preserves_order", + "tests/test_git_diff_analyzer.py::test_find_untracked_all_in_manifest", + "tests/test_git_diff_analyzer.py::test_find_untracked_basic", + "tests/test_git_diff_analyzer.py::test_find_untracked_empty_changed", + "tests/test_git_diff_analyzer.py::test_find_untracked_empty_manifest", + "tests/test_git_diff_analyzer.py::test_is_commit_basic", + "tests/test_git_diff_analyzer.py::test_is_commit_ci_alias", + "tests/test_git_diff_analyzer.py::test_is_commit_with_path", + "tests/test_git_diff_analyzer.py::test_is_not_commit_add", + "tests/test_git_diff_analyzer.py::test_is_not_commit_empty", + "tests/test_git_diff_analyzer.py::test_is_not_commit_none", + "tests/test_git_diff_analyzer.py::test_is_not_commit_push", + "tests/test_history.py::test_archive", + "tests/test_history.py::test_avg_coverage", + "tests/test_history.py::test_block_heatmap_empty", + "tests/test_history.py::test_block_heatmap_ranking", + "tests/test_history.py::test_block_reasons", + "tests/test_history.py::test_build_trace", + "tests/test_history.py::test_cache_stats_all_miss", + "tests/test_history.py::test_cache_stats_empty", + "tests/test_history.py::test_cache_stats_mixed", + "tests/test_history.py::test_commit_observed_counted", + "tests/test_history.py::test_empty_summary", + "tests/test_history.py::test_f3_rate", + "tests/test_history.py::test_feature_fire_firing", + "tests/test_history.py::test_feature_fire_unmeasurable", + "tests/test_history.py::test_feature_fire_zero_fire", + "tests/test_history.py::test_heatmap_newkey_mark", + "tests/test_history.py::test_noise_filter", + "tests/test_history.py::test_nudge_ineffective_cross_session", + "tests/test_history.py::test_nudge_keys_isolated", + "tests/test_history.py::test_nudge_repeat_same_session", + "tests/test_history.py::test_nudge_single_block_unflagged", + "tests/test_history.py::test_observed_source_distinction", + "tests/test_history.py::test_quality_score", + "tests/test_history.py::test_recall_clean_shadow_pending", + "tests/test_history.py::test_recall_cross_manifest_events_ignored", + "tests/test_history.py::test_record_and_load", + "tests/test_history.py::test_session_id_recorded", + "tests/test_history.py::test_tech_debt", + "tests/test_history.py::test_telemetry_both_files", + "tests/test_history.py::test_telemetry_missing_journal", + "tests/test_hooks_contract.py::test_all_guards_registered", + "tests/test_hooks_contract.py::test_command_synced_in_selfheal_and_uninstall", + "tests/test_hooks_contract.py::test_install_copies_every_hook_script", + "tests/test_hooks_contract.py::test_install_registers_every_hooks_json_script", + "tests/test_hooks_contract.py::test_no_empty_matcher", + "tests/test_hooks_contract.py::test_stop_entry_no_matcher", + "tests/test_hooks_contract.py::test_uninstall_covers_all_commands", + "tests/test_hooks_contract.py::test_uninstall_filters_all_guard_scripts", + "tests/test_journal.py::test_advisor_adoption_empty", + "tests/test_journal.py::test_advisor_adoption_rate", + "tests/test_journal.py::test_append_and_load", + "tests/test_journal.py::test_cli_add_buries_assumption_fossil", + "tests/test_journal.py::test_cli_add_rejects_bad_json", + "tests/test_journal.py::test_descriptive_error_not_journaled", + "tests/test_journal.py::test_digest_cross_session_filter", + "tests/test_journal.py::test_fail_watch_journals", + "tests/test_journal.py::test_field_cap", + "tests/test_journal.py::test_journal_stats_fields", + "tests/test_journal.py::test_kill_switch", + "tests/test_journal.py::test_no_regress_dir_skips", + "tests/test_journal.py::test_prompt_correction_journals", + "tests/test_journal.py::test_risk_watch_journals_risk_not_usage", + "tests/test_launcher_env.py::test_case_insensitive_keys", + "tests/test_launcher_env.py::test_case_normalized_comparison_keeps_original_key", + "tests/test_launcher_env.py::test_contract_and_base_vars_kept", + "tests/test_launcher_env.py::test_mixed_env_exact_output", + "tests/test_launcher_env.py::test_path_and_PATH_coexist", + "tests/test_launcher_env.py::test_poison_env_dropped", + "tests/test_launcher_env.py::test_poison_env_fully_stripped", + "tests/test_launcher_env.py::test_require_does_not_spawn", + "tests/test_launcher_env.py::test_whitelist_exact_set", + "tests/test_launcher_env.py::test_whitelist_var_kept[ALL_PROXY]", + "tests/test_launcher_env.py::test_whitelist_var_kept[CLAUDE_PROJECT_DIR]", + "tests/test_launcher_env.py::test_whitelist_var_kept[CLAUDE_SESSION_ID]", + "tests/test_launcher_env.py::test_whitelist_var_kept[HOME]", + "tests/test_launcher_env.py::test_whitelist_var_kept[HTTPS_PROXY]", + "tests/test_launcher_env.py::test_whitelist_var_kept[HTTP_PROXY]", + "tests/test_launcher_env.py::test_whitelist_var_kept[LANG]", + "tests/test_launcher_env.py::test_whitelist_var_kept[LC_ALL]", + "tests/test_launcher_env.py::test_whitelist_var_kept[LC_CTYPE]", + "tests/test_launcher_env.py::test_whitelist_var_kept[NO_PROXY]", + "tests/test_launcher_env.py::test_whitelist_var_kept[PATH]", + "tests/test_launcher_env.py::test_whitelist_var_kept[SYSTEMROOT]", + "tests/test_launcher_env.py::test_whitelist_var_kept[TERM]", + "tests/test_launcher_env.py::test_whitelist_var_kept[TMPDIR]", + "tests/test_launcher_env.py::test_whitelist_var_kept[TZ]", + "tests/test_launcher_env.py::test_whitelist_var_kept[ZCODE_HOME]", + "tests/test_launcher_env.py::test_whitelist_var_kept[ZCODE_PLUGIN_ROOT]", + "tests/test_launcher_env.py::test_whitelist_var_kept[ZCODE_PROJECT_DIR]", + "tests/test_launcher_env.py::test_whitelist_var_kept[ZCODE_SESSION_ID]", + "tests/test_manifest_fields.py::test_block_value_and_filled", + "tests/test_manifest_fields.py::test_editable_matrix", + "tests/test_manifest_fields.py::test_open_fp_regex_tolerates_trailing_comment", + "tests/test_manifest_fields.py::test_open_fragile_count_variants", + "tests/test_manifest_fields.py::test_parse_core_full", + "tests/test_manifest_fields.py::test_parse_core_non_manifest", + "tests/test_manifest_parser.py::test_empty_actual_changes", + "tests/test_manifest_parser.py::test_find_active_invented_status", + "tests/test_manifest_parser.py::test_find_active_manifest", + "tests/test_manifest_parser.py::test_find_active_manifest_all_done", + "tests/test_manifest_parser.py::test_get_all_changed_files", + "tests/test_manifest_parser.py::test_get_status", + "tests/test_manifest_parser.py::test_missing_closer", + "tests/test_manifest_parser.py::test_no_frontmatter", + "tests/test_manifest_parser.py::test_nonexistent_file", + "tests/test_manifest_parser.py::test_parse_actual_changes", + "tests/test_manifest_parser.py::test_parse_basic_fields", + "tests/test_manifest_parser.py::test_parse_characterization_needed", + "tests/test_manifest_parser.py::test_parse_planned_changes", + "tests/test_manifest_parser.py::test_parse_tests_required", + "tests/test_manifest_parser.py::test_read_frontmatter_stops_at_closing_fence", + "tests/test_manifest_parser.py::test_read_frontmatter_unfenced_fallback_capped", + "tests/test_manifest_parser.py::test_template_body_has_acceptance_and_tradeoff", + "tests/test_manifest_parser.py::test_template_body_has_amnesia_sections", + "tests/test_manifest_parser.py::test_template_body_has_hypothesis_ledger", + "tests/test_manifest_parser.py::test_template_frontmatter_parses_with_new_fields", + "tests/test_manifest_parser.py::test_template_kind_vocab_includes_oracle_sensory", + "tests/test_manifest_parser.py::test_template_product_context_sections", + "tests/test_manifest_parser.py::test_update_adds_field", + "tests/test_manifest_parser.py::test_update_preserves_existing", + "tests/test_marketplace_ready.py::test_hooks_json_all_plugin_relative", + "tests/test_marketplace_ready.py::test_hooks_json_events_supported", + "tests/test_marketplace_ready.py::test_marketplace_manifest_shape", + "tests/test_marketplace_ready.py::test_plugin_manifest_name_pattern_and_root", + "tests/test_notify.py::test_api_base_pinned", + "tests/test_notify.py::test_channel_runs_with_quoted_placeholders", + "tests/test_notify.py::test_chat_fold_corrupt_state", + "tests/test_notify.py::test_chat_fold_off_switch", + "tests/test_notify.py::test_chat_fold_same_title", + "tests/test_notify.py::test_cli_smoke", + "tests/test_notify.py::test_done_event_default_on_and_toggleable", + "tests/test_notify.py::test_event_toggle_and_master_switch", + "tests/test_notify.py::test_failing_channel_never_raises", + "tests/test_notify.py::test_machine_conf_path_env_seam_still_works", + "tests/test_notify.py::test_machine_conf_path_home_injection_neutralized", + "tests/test_notify.py::test_machine_fallback_and_keywise_merge", + "tests/test_notify.py::test_pin_bootstrap_first_run", + "tests/test_notify.py::test_pin_human_retrust_repin", + "tests/test_notify.py::test_pin_nonsensitive_repin", + "tests/test_notify.py::test_pin_swap_sensitive_denied", + "tests/test_notify.py::test_progress_event_default_on_toggleable", + "tests/test_notify.py::test_project_name_prefix_and_time_suffix", + "tests/test_notify.py::test_stats_cache_line_empty_history_no_crash", + "tests/test_notify.py::test_stats_cache_line_hit", + "tests/test_notify.py::test_test_event_bypasses_toggles", + "tests/test_notify.py::test_trust_cli", + "tests/test_notify.py::test_trust_cli_readonly_never_writes", + "tests/test_notify.py::test_trust_cli_table_unchanged", + "tests/test_notify.py::test_trust_default_denied", + "tests/test_notify.py::test_trust_seam_env_passthrough", + "tests/test_notify.py::test_trusted_executes", + "tests/test_notify.py::test_wecom_cred_fields_gated", + "tests/test_notify.py::test_wecom_cred_fields_trusted", + "tests/test_notify.py::test_wecom_proxy_roundtrip", + "tests/test_notify.py::test_wecom_push_roundtrip", + "tests/test_notify.py::test_wecom_subprocess_receives_merged_conf", + "tests/test_pending.py::test_add_returns_incrementing_ids", + "tests/test_pending.py::test_bad_channel_template_isolated", + "tests/test_pending.py::test_bad_lines_skipped", + "tests/test_pending.py::test_blocked_coalesces_same_key_within_window", + "tests/test_pending.py::test_blocked_different_cause_or_ref_pushes", + "tests/test_pending.py::test_blocked_repush_after_resolve_or_expiry", + "tests/test_pending.py::test_decision_push_creates_pending_with_id_in_body", + "tests/test_pending.py::test_info_push_creates_no_pending", + "tests/test_pending.py::test_list_shows_outcomes_and_caps_resolved", + "tests/test_pending.py::test_newest_open_matches_triple_key", + "tests/test_pending.py::test_notify_source_id_flows_into_ref", + "tests/test_pending.py::test_resolve_by_ref_exact_and_legacy_fallback", + "tests/test_pending.py::test_resolve_by_ref_no_ambig_no_hit", + "tests/test_pending.py::test_resolve_filters_pending_and_feeds_fp_rate", + "tests/test_pending.py::test_resolve_rejects_unknown_outcome", + "tests/test_pending.py::test_resolved_outcome_single_bucket", + "tests/test_pending.py::test_stats_dashboard_parses_ledger", + "tests/test_plan_approve.py::test_approve_auto_resolves_pending", + "tests/test_plan_approve.py::test_approve_flips_status_and_stamps", + "tests/test_plan_approve.py::test_approve_journals_dirty_snapshot", + "tests/test_plan_approve.py::test_approve_journals_event", + "tests/test_plan_approve.py::test_approve_non_planning_rejected", + "tests/test_plan_approve.py::test_approved_task_not_cancellable", + "tests/test_plan_approve.py::test_bad_escape_note_keeps_manifest", + "tests/test_plan_approve.py::test_block_planning_rejected", + "tests/test_plan_approve.py::test_block_requires_reason", + "tests/test_plan_approve.py::test_block_unblock_block_cycle_keeps_all_fossils", + "tests/test_plan_approve.py::test_block_writes_four_questions_and_journals", + "tests/test_plan_approve.py::test_cancel_after_provisional_vetoes", + "tests/test_plan_approve.py::test_cancel_sets_cancelled_and_journals", + "tests/test_plan_approve.py::test_double_approve_no_duplicate_journal", + "tests/test_plan_approve.py::test_drift_repo_field_selects_repo", + "tests/test_plan_approve.py::test_drift_warning_when_head_moved", + "tests/test_plan_approve.py::test_no_drift_when_base_head_matches", + "tests/test_plan_approve.py::test_preserves_human_direct_edit_timestamp", + "tests/test_plan_approve.py::test_provisional_flips_and_journals", + "tests/test_plan_approve.py::test_provisional_non_planning_rejected", + "tests/test_plan_approve.py::test_provisional_requires_advisor", + "tests/test_plan_approve.py::test_provisional_without_review_fossil_rejected", + "tests/test_plan_approve.py::test_template_placeholder_base_head_treated_as_empty", + "tests/test_plan_approve.py::test_unblock_not_blocked_rejected", + "tests/test_plan_approve.py::test_unblock_restores_in_progress_with_resolved", + "tests/test_plan_bridge.py::test_allocate_concurrent_unique_ids", + "tests/test_plan_bridge.py::test_allocate_sequence_same_as_before", + "tests/test_plan_bridge.py::test_bridge_receipt_default_on", + "tests/test_plan_bridge.py::test_bridge_receipt_opt_in", + "tests/test_plan_bridge.py::test_extract_paths_filters_url_and_bare_names", + "tests/test_plan_bridge.py::test_fail_mode_zero_residue_with_fossil", + "tests/test_plan_bridge.py::test_kill_switch", + "tests/test_plan_bridge.py::test_next_id_preserves_project_format", + "tests/test_plan_bridge.py::test_no_regress_project_untouched", + "tests/test_plan_bridge.py::test_post_creates_manifest_verbatim", + "tests/test_plan_bridge.py::test_post_idempotent_same_plan_hash", + "tests/test_plan_bridge.py::test_post_revision_updates_in_place", + "tests/test_plan_bridge.py::test_post_stamps_existing_planning_manifest", + "tests/test_plan_bridge.py::test_post_with_rejection_response_treated_as_rejected", + "tests/test_post_install_check.py::test_good_tree_passes", + "tests/test_post_install_check.py::test_missing_script_fails", + "tests/test_post_install_check.py::test_plugin_form_version_mismatch", + "tests/test_post_install_check.py::test_plugin_form_version_pass", + "tests/test_post_install_check.py::test_syntax_error_fails", + "tests/test_post_install_check.py::test_version_mismatch_fails", + "tests/test_pre_commit_guard.py::test_acceptance_all_checked_stamps_done", + "tests/test_pre_commit_guard.py::test_acceptance_exempt_s_tier", + "tests/test_pre_commit_guard.py::test_acceptance_mark_anywhere_counts", + "tests/test_pre_commit_guard.py::test_acceptance_mark_without_verify_still_blocks", + "tests/test_pre_commit_guard.py::test_acceptance_missing_blocks", + "tests/test_pre_commit_guard.py::test_acceptance_open_blocks", + "tests/test_pre_commit_guard.py::test_acceptance_placeholder_blocks", + "tests/test_pre_commit_guard.py::test_acceptance_two_line_bullet_joined", + "tests/test_pre_commit_guard.py::test_active_manifest_blocks_without_runner", + "tests/test_pre_commit_guard.py::test_attribution_fallback_prefers_provisional", + "tests/test_pre_commit_guard.py::test_attribution_nested_repo_staging", + "tests/test_pre_commit_guard.py::test_attribution_picks_intersecting_manifest", + "tests/test_pre_commit_guard.py::test_auto_reflow_pending_on_gate_pass", + "tests/test_pre_commit_guard.py::test_block_recall_corrupt_ledger_degrades", + "tests/test_pre_commit_guard.py::test_block_recall_not_wired_reason", + "tests/test_pre_commit_guard.py::test_block_recall_off_switch", + "tests/test_pre_commit_guard.py::test_block_recall_shows_history_rules", + "tests/test_pre_commit_guard.py::test_blocked_pushes_via_configured_channel", + "tests/test_pre_commit_guard.py::test_cache_env_off_bypasses", + "tests/test_pre_commit_guard.py::test_cache_hit_on_same_tree", + "tests/test_pre_commit_guard.py::test_cache_miss_after_tree_change", + "tests/test_pre_commit_guard.py::test_card_sync_false_exempts", + "tests/test_pre_commit_guard.py::test_commit_am_flag_blocks", + "tests/test_pre_commit_guard.py::test_commit_amend_exempt", + "tests/test_pre_commit_guard.py::test_commit_event_has_anchors", + "tests/test_pre_commit_guard.py::test_commit_via_npm_version_detected", + "tests/test_pre_commit_guard.py::test_completed_is_terminal", + "tests/test_pre_commit_guard.py::test_compound_stage_commit_blocks", + "tests/test_pre_commit_guard.py::test_corrupt_config_blocks", + "tests/test_pre_commit_guard.py::test_corrupt_manifest_blocks", + "tests/test_pre_commit_guard.py::test_debug_pattern_in_tests_not_triggered", + "tests/test_pre_commit_guard.py::test_deps_infra_fail_warns_and_passes_rule", + "tests/test_pre_commit_guard.py::test_deps_vulnerable_blocks_via_stub", + "tests/test_pre_commit_guard.py::test_foreign_clash_with_declared_file_blocks", + "tests/test_pre_commit_guard.py::test_foreign_manifest_does_not_block_my_commit", + "tests/test_pre_commit_guard.py::test_foreign_manifest_undeclared_files_no_clash", + "tests/test_pre_commit_guard.py::test_garbage_yaml_manifest_blocks", + "tests/test_pre_commit_guard.py::test_heredoc_payload_not_false_positive", + "tests/test_pre_commit_guard.py::test_invented_status_not_active", + "tests/test_pre_commit_guard.py::test_message_angle_bracket_blocks", + "tests/test_pre_commit_guard.py::test_message_checks_skip_no_manifest_lane", + "tests/test_pre_commit_guard.py::test_message_count_match_passes", + "tests/test_pre_commit_guard.py::test_message_count_mismatch_blocks_077_replay", + "tests/test_pre_commit_guard.py::test_message_no_ref_m_blocks", + "tests/test_pre_commit_guard.py::test_message_ref_with_note_m_passes", + "tests/test_pre_commit_guard.py::test_message_short_ref_passes", + "tests/test_pre_commit_guard.py::test_message_unmarked_count_warns_only", + "tests/test_pre_commit_guard.py::test_metadata_adds_do_not_trigger_rule_b", + "tests/test_pre_commit_guard.py::test_ml_missing_scan_blocks", + "tests/test_pre_commit_guard.py::test_ml_with_scan_passes_rule_a", + "tests/test_pre_commit_guard.py::test_monorepo_finds_parent_regress", + "tests/test_pre_commit_guard.py::test_no_card_project_structural_warns", + "tests/test_pre_commit_guard.py::test_no_regress_dir_passes", + "tests/test_pre_commit_guard.py::test_no_session_env_shares_all", + "tests/test_pre_commit_guard.py::test_no_trigger_keys_absent_legal", + "tests/test_pre_commit_guard.py::test_non_commit_command_passes", + "tests/test_pre_commit_guard.py::test_non_utf8_manifest_blocks", + "tests/test_pre_commit_guard.py::test_own_manifest_governs_despite_foreign", + "tests/test_pre_commit_guard.py::test_planning_manifest_not_stamped", + "tests/test_pre_commit_guard.py::test_pure_commit_not_caught_by_compound_rule", + "tests/test_pre_commit_guard.py::test_quoted_payload_not_false_positive", + "tests/test_pre_commit_guard.py::test_rollback_default_invalid_on_escape_surface", + "tests/test_pre_commit_guard.py::test_rollback_missing_blocks_all_tiers", + "tests/test_pre_commit_guard.py::test_rollback_specific_answer_passes_escape", + "tests/test_pre_commit_guard.py::test_s_tier_exempt_from_rule_a", + "tests/test_pre_commit_guard.py::test_scan_placeholder_rejected", + "tests/test_pre_commit_guard.py::test_secret_clean_passes_rule", + "tests/test_pre_commit_guard.py::test_secret_leak_blocks", + "tests/test_pre_commit_guard.py::test_self_review_debug_residue_key_required", + "tests/test_pre_commit_guard.py::test_self_review_planned_outside_key_required", + "tests/test_pre_commit_guard.py::test_split_calls_end_to_end_passes", + "tests/test_pre_commit_guard.py::test_structural_change_with_card_staged_passes_rule_b", + "tests/test_pre_commit_guard.py::test_structural_change_without_card_sync_blocks", + "tests/test_pre_commit_guard.py::test_supply_chain_secrets_disabled", + "tests/test_pre_commit_guard.py::test_terminal_status_passes_without_runner", + "tests/test_pre_commit_guard.py::test_untracked_file_blocks_and_records", + "tests/test_prepare_marketplace.py::test_dist_excludes_dev_and_keeps_manifest", + "tests/test_prepare_marketplace.py::test_marketplace_entry_shape", + "tests/test_prompt_hooks.py::test_approval_delegation_not_counted_as_prod", + "tests/test_prompt_hooks.py::test_correction_direction_wrong", + "tests/test_prompt_hooks.py::test_correction_english", + "tests/test_prompt_hooks.py::test_correction_resets_prod_counter", + "tests/test_prompt_hooks.py::test_correction_understanding_wrong", + "tests/test_prompt_hooks.py::test_error_report_is_not_correction", + "tests/test_prompt_hooks.py::test_ferry_below_threshold_no_trigger", + "tests/test_prompt_hooks.py::test_ferry_in_non_git_dir", + "tests/test_prompt_hooks.py::test_ferry_no_regress_not_connected", + "tests/test_prompt_hooks.py::test_ferry_package_assembled", + "tests/test_prompt_hooks.py::test_governance_active_manifest_line", + "tests/test_prompt_hooks.py::test_governance_no_manifest_silent", + "tests/test_prompt_hooks.py::test_governance_off_escape", + "tests/test_prompt_hooks.py::test_governance_rule_recall_line", + "tests/test_prompt_hooks.py::test_normal_request_no_correction", + "tests/test_prompt_hooks.py::test_prod_second_time_triggers_scout", + "tests/test_publish.py::test_check_message_accepts_compliant", + "tests/test_publish.py::test_check_message_accepts_ref_with_note", + "tests/test_publish.py::test_check_message_rejects_angle_bracket", + "tests/test_publish.py::test_check_message_rejects_empty", + "tests/test_publish.py::test_check_message_rejects_english_only", + "tests/test_publish.py::test_check_message_rejects_missing_ref", + "tests/test_publish.py::test_cli_dry_run_no_network", + "tests/test_publish.py::test_cli_rejects_noncompliant_message", + "tests/test_publish.py::test_cli_rejects_unknown_flag", + "tests/test_publish.py::test_mirror_default_takes_state", + "tests/test_publish.py::test_mirror_since_joins_subjects", + "tests/test_publish.py::test_no_state_no_rev_teaches", + "tests/test_publish.py::test_state_roundtrip", + "tests/test_read_before_edit.py::test_blind_edit_blocked", + "tests/test_read_before_edit.py::test_edit_already_read_file_exempt", + "tests/test_read_before_edit.py::test_edit_unread_file_blocked_when_ratio_exceeded", + "tests/test_read_before_edit.py::test_fingerprint_blocks_external_change", + "tests/test_read_before_edit.py::test_fingerprint_only_checked_for_real_files", + "tests/test_read_before_edit.py::test_fingerprint_reread_restores", + "tests/test_read_before_edit.py::test_fingerprint_unchanged_allows", + "tests/test_read_before_edit.py::test_new_file_exempt", + "tests/test_read_before_edit.py::test_partial_read_blocked", + "tests/test_read_before_edit.py::test_ratio_zero_disables", + "tests/test_read_before_edit.py::test_read_then_edit_allowed", + "tests/test_read_before_edit.py::test_regress_files_exempt", + "tests/test_read_before_edit.py::test_second_edit_requires_more_reads", + "tests/test_read_before_edit.py::test_self_edit_suspends_fingerprint", + "tests/test_rules_ledger.py::test_corrupt_ledger_tolerated", + "tests/test_rules_ledger.py::test_health_decay_and_promote", + "tests/test_rules_ledger.py::test_match_cli_json", + "tests/test_rules_ledger.py::test_match_empty_ledger_and_no_overlap", + "tests/test_rules_ledger.py::test_match_from_manifest_cli", + "tests/test_rules_ledger.py::test_match_min_shared_threshold", + "tests/test_rules_ledger.py::test_match_ranks_relevant_first", + "tests/test_rules_ledger.py::test_match_stopbigram_filters_boilerplate", + "tests/test_rules_ledger.py::test_match_superseded_not_recalled", + "tests/test_rules_ledger.py::test_not_initialized_project", + "tests/test_rules_ledger.py::test_query_from_manifest_bad_path", + "tests/test_rules_ledger.py::test_query_from_manifest_terms", + "tests/test_rules_ledger.py::test_record_distinct_no_hint", + "tests/test_rules_ledger.py::test_record_first_time_then_hit", + "tests/test_rules_ledger.py::test_record_near_dup_hint", + "tests/test_rules_ledger.py::test_record_same_sig_rehit_no_hint", + "tests/test_rules_ledger.py::test_superseded_rule_not_decay_candidate", + "tests/test_rules_ledger.py::test_unlinked_rule_decay_unchanged", + "tests/test_scan_check.py::test_absent_when_no_card", + "tests/test_scan_check.py::test_fresh_right_after_card", + "tests/test_scan_check.py::test_json_cli", + "tests/test_scan_check.py::test_not_stale_on_metadata_only", + "tests/test_scan_check.py::test_stale_on_commit_volume", + "tests/test_scan_check.py::test_stale_on_structural_add", + "tests/test_secret_scan.py::test_builtin_allowlist_doc_example", + "tests/test_secret_scan.py::test_deleted_lines_not_scanned", + "tests/test_secret_scan.py::test_generic_exempt_in_tests_and_md", + "tests/test_secret_scan.py::test_generic_quoted_required", + "tests/test_secret_scan.py::test_high_precision_hit_and_lineno", + "tests/test_secret_scan.py::test_high_precision_not_exempt_in_tests", + "tests/test_secret_scan.py::test_private_key_block", + "tests/test_self_heal.py::test_backfill_idempotent", + "tests/test_self_heal.py::test_backfill_lock_gitignore_idempotent", + "tests/test_self_heal.py::test_backfill_missing_readme", + "tests/test_self_heal.py::test_backfill_never_overwrites_custom", + "tests/test_self_heal.py::test_no_marker_never_touched_even_if_stale_content", + "tests/test_self_heal.py::test_refresh_stale_machine_readme", + "tests/test_self_heal.py::test_required_commands_include_finish", + "tests/test_self_heal.py::test_required_commands_include_new_ones", + "tests/test_self_heal.py::test_same_version_no_refresh", + "tests/test_self_heal.py::test_sentinel_blocked_shows_need", + "tests/test_self_heal.py::test_sentinel_done_returns_none", + "tests/test_self_heal.py::test_sentinel_fresh_planning_no_stale_hint", + "tests/test_self_heal.py::test_sentinel_in_progress", + "tests/test_self_heal.py::test_sentinel_planning", + "tests/test_self_heal.py::test_sentinel_provisional", + "tests/test_self_heal.py::test_sentinel_stale_mtime_fallback", + "tests/test_self_heal.py::test_sentinel_stale_planning_hint", + "tests/test_self_heal.py::test_version_drift_warns", + "tests/test_self_heal.py::test_version_no_drift_silent", + "tests/test_sentinel.py::test_sentinel_empty_is_clean", + "tests/test_sentinel.py::test_sentinel_lists_active_with_session_and_clash", + "tests/test_sentinel.py::test_sentinel_marks_blocked_waiting_human", + "tests/test_session_scope.py::test_relay_atomic_no_litter", + "tests/test_session_scope.py::test_relay_isolated_per_project", + "tests/test_session_scope.py::test_relay_roundtrip_last_writer_wins", + "tests/test_session_scope.py::test_sid_env_priority", + "tests/test_session_scope.py::test_stamp_session_env_fallback", + "tests/test_session_scope.py::test_stamp_session_no_identity_unchanged", + "tests/test_session_scope.py::test_stamp_session_relay_beats_env", + "tests/test_stop_notify.py::test_autonomy_word_pushes", + "tests/test_stop_notify.py::test_chat_event_togglable_without_touching_done", + "tests/test_stop_notify.py::test_cooldown_prevents_double_buzz", + "tests/test_stop_notify.py::test_drift_equal_or_none_silent", + "tests/test_stop_notify.py::test_drift_new_pair_warns_once", + "tests/test_stop_notify.py::test_empty_prompt_turn_still_pushes", + "tests/test_stop_notify.py::test_normal_chat_also_pushes", + "tests/test_stop_notify.py::test_normal_chat_stays_silent", + "tests/test_stop_notify.py::test_round_push_uses_chat_event_not_done", + "tests/test_test_cache.py::test_config_off_disables", + "tests/test_test_cache.py::test_default_on", + "tests/test_test_cache.py::test_env_off_disables", + "tests/test_test_cache.py::test_key_ignores_regress_runtime", + "tests/test_test_cache.py::test_key_ignores_tracked_regress", + "tests/test_test_cache.py::test_key_none_outside_git", + "tests/test_test_cache.py::test_key_sensitive_tracked_delete", + "tests/test_test_cache.py::test_key_sensitive_tracked_edit", + "tests/test_test_cache.py::test_key_sensitive_untracked_add", + "tests/test_test_cache.py::test_key_stable_same_tree", + "tests/test_test_cache.py::test_key_staged_equals_unstaged", + "tests/test_test_cache.py::test_key_untracked_staged_equals", + "tests/test_test_cache.py::test_lookup_ttl_expiry", + "tests/test_test_cache.py::test_record_lookup_roundtrip", + "tests/test_test_cache.py::test_record_rejects_fail", + "tests/test_test_cache.py::test_record_trims", + "tests/test_test_runner.py::test_detect_broken_package_json", + "tests/test_test_runner.py::test_detect_go", + "tests/test_test_runner.py::test_detect_jest", + "tests/test_test_runner.py::test_detect_maven", + "tests/test_test_runner.py::test_detect_nested_subrepo", + "tests/test_test_runner.py::test_detect_none", + "tests/test_test_runner.py::test_detect_pytest", + "tests/test_test_runner.py::test_detect_pytest_conftest", + "tests/test_test_runner.py::test_detect_skips_dependency_dirs", + "tests/test_test_runner.py::test_detect_vitest", + "tests/test_test_runner.py::test_hermetic_env_strips_host_identity", + "tests/test_test_runner.py::test_parse_jest_coverage_missing", + "tests/test_test_runner.py::test_parse_jest_empty", + "tests/test_test_runner.py::test_parse_jest_empty_fail", + "tests/test_test_runner.py::test_parse_jest_fail", + "tests/test_test_runner.py::test_parse_jest_pass", + "tests/test_test_runner.py::test_parse_jest_with_coverage", + "tests/test_test_runner.py::test_parse_pytest_errors", + "tests/test_test_runner.py::test_parse_pytest_fail", + "tests/test_test_runner.py::test_parse_pytest_pass", + "tests/test_test_runner.py::test_timeout_config_knob", + "tests/test_validate_guard.py::test_architecture_guard_detects_planted_violation", + "tests/test_validate_guard.py::test_command_byte_budget_warns_on_bloat", + "tests/test_validate_guard.py::test_deploy_contract_drift_guard", + "tests/test_validate_guard.py::test_doc_coverage_matrix_both_paths", + "tests/test_validate_guard.py::test_gate_liveness_watchdog_both_paths", + "tests/test_validate_guard.py::test_lib_single_source_has_the_patterns" +] \ No newline at end of file diff --git a/plugins/regress-guard/.regress_check_ignore b/plugins/regress-guard/.regress_check_ignore new file mode 100644 index 0000000..e69de29 diff --git a/plugins/regress-guard/.zcode-plugin/plugin.json b/plugins/regress-guard/.zcode-plugin/plugin.json new file mode 100644 index 0000000..25d15ea --- /dev/null +++ b/plugins/regress-guard/.zcode-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "regress-guard", + "version": "1.88.0", + "description": "AI-coding governance gate for ZCode: pre-commit test enforcement, manifest-boundary guard, six-layer trust surface, assumption ledger, and FP-calibrated telemetry.", + "author": { + "name": "1339190177", + "url": "https://github.com/1339190177" + }, + "license": "MIT", + "commands": "commands", + "hooks": "hooks", + "description_i18n": { + "en": "AI-coding governance gate for ZCode: pre-commit test enforcement, manifest-boundary guard, six-layer trust surface, assumption ledger, and FP-calibrated telemetry.", + "zh-CN": "AI 辅助开发治理门禁:提交前测试强制(不信 AI 自报的 done)、清单边界事前拦截、六层信任面、假设账本与误报校准遥测。" + }, + "keywords": [ + "governance", + "hooks", + "pre-commit", + "testing", + "regression", + "guardrails", + "spec-driven", + "telemetry" + ], + "category": "developer-tools" +} diff --git a/plugins/regress-guard/LICENSE b/plugins/regress-guard/LICENSE new file mode 100644 index 0000000..112f86e --- /dev/null +++ b/plugins/regress-guard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 1339190177 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/regress-guard/README.md b/plugins/regress-guard/README.md new file mode 100644 index 0000000..a375160 --- /dev/null +++ b/plugins/regress-guard/README.md @@ -0,0 +1,112 @@ +# regress-guard + +> AI-coding governance gate for ZCode: **don't trust the agent's "done"** — the gate +> re-runs your tests itself, blocks edits outside the declared change-scope, and +> keeps human-only-calibrated telemetry on every interception. +> +> Design philosophy first: [docs/PHILOSOPHY.md](docs/PHILOSOPHY.md) (sixteen +> principles and the real incidents behind them). Features age, principles don't. +> Full workflow: [docs/WORKFLOW.md](docs/WORKFLOW.md) · 中文文档:[README_CN.md](README_CN.md) + +## Why + +Agents report "done" confidently and cheaply. This plugin makes "done" a +machine-verified claim, at the only surface that matters in a direct-to-main +workflow: **the commit**. + +- **Completion re-verification (the core)**: a PreToolUse gate intercepts commit + commands and runs the project's own test suite itself — agent self-reports are + never trusted. EARS-style acceptance rows must be checked off with evidence + before `done` is stamped. Same-tree retries hit a content-keyed result cache + (0-second gates). +- **Boundary guard**: edits/writes to files not declared in the active change + manifest are blocked before they happen — the plan's file list *is* the boundary. +- **Assumption ledger**: when reality falsifies a plan's assumption, the + was→reality→evidence record is mandatory. +- **Six-layer trust surface**: project-level notification channels require a + human-granted trust table + content pinning; HOME/env-injection redirection is + neutralized at the launcher allowlist. Cloned-repo injection surfaces are closed. +- **FP-calibrated telemetry**: every interception is adjudicated later by the + human (useful / false_positive / ignored) — the agent gets no vote on its own + accuracy. Public benchmark: 82 cases / 13 families, see [benchmarks/](benchmarks/). +- **Self-healing**: session start compares the installed copy against the source + repo; version drift is announced, not silently tolerated. + +## Install + +**Marketplace (recommended)**: install `regress-guard` from the ZCode plugin +marketplace — hooks activate via the host runtime, and the plugin can be +toggled/upgraded natively. + +**Personal-source track**: add marketplace source +`github:1339190177/regress-guard` in ZCode settings, then install. + +**Dev track**: clone this repo, then `bash install.sh` (or tell the AI +`/regress:install`). Use one track at a time. + +Requirements: ZCode client + Python 3.10+. + +## Five-minute start + +1. In any project, tell the AI: `/regress:init` +2. Daily flow: state a requirement → the AI produces a plan card → you + **approve / edit / cancel** → it works (with boundary enforcement) → on + commit, the gate re-runs tests and blocks on failure or unchecked + acceptance rows +3. Project state and archaeology live in `.regress/` + +## Side-effects declaration (transparency contract) + +- **Execution**: all hooks are local Python 3.10+ processes (no prebuilt + binaries, no obfuscated source) +- **File writes**: `.regress/` inside projects (manifests/history/ledgers) and + `~/.zcode/regress-guard-hooks` (installed copy) +- **Command execution**: exactly two classes — the gate invoking the project's + own test runner (pytest/jest/maven/gradle/go, auto-detected), and notification + channel commands (**only for human-granted projects**; untrusted configs fall + back to machine level, closing the cloned-repo injection surface) +- **Network**: zero egress by default. Optional interception notifications via + WeCom self-built apps — credentials require human trust-grant and the API base + is pinned to the official domain. No MCP, no telemetry upload, no third-party + analytics +- **Hook surface**: PreToolUse (commit gate / boundary guard / execution valve), + PostToolUse (manifest bridge), UserPromptSubmit (governance context), + SessionStart (self-heal), Stop (end-of-turn notices) +- **Escapes**: `/regress:bypass N` gives a time-boxed amnesty (audited); each + feature has an independent env switch + +## Health & verification + +`bash validate.sh` (expect 5/5) · `python3 -m pytest tests/ -q` (expect green) +· post-install smoke check runs automatically with `install.sh` + +## License + +MIT. Third-party: none at runtime (stdlib only). + + + +**实况(由 gen_reference.py 生成,勿手改本区)** + +- 命令:16 个 · hook 注册:11 个事件条目 · 测试函数:565 个 + +| 命令 | 说明 | +|---|---| +| `/regress:bypass` | 紧急绕过回归卡点(限时 + 审计日志),用于 hotfix 等紧急场景 | +| `/regress:characterize` | 特征测试生成:改无测试老代码前先钉住现状行为(golden master——探针真跑取真值,不猜输出) | +| `/regress:evolve` | 遇到难点时查社区经验→适配项目→沉淀为知识库(不再重复查) | +| `/regress:finish` | 收尾流水线:track 回写 → verify 全证据 → 代谢沉淀 → 状态推进 → 汇总报告(一条命令走完收尾,人只出现在决策点) | +| `/regress:init` | 初始化项目的 .regress/ 数据目录(代码已在全局级安装,这里只建数据) | +| `/regress:install` | 一键安装 regress-guard 到用户级(零配置,装一次全局生效) | +| `/regress:learn` | 分析历史+检测框架规则,输出项目洞察,写入 AGENTS.md | +| `/regress:plan` | 需求→解析→消歧→改动清单。AI 先补全上下文再动手(不问能推断的,只问关键分歧) | +| `/regress:quick` | 快速模式——机器判据达标的小改动专用(合并 plan+track 一步到位) | +| `/regress:resume` | 从 .regress/ 产物层单侧重建工作现场(断点续作,不依赖对话历史) | +| `/regress:stats` | 健康报表:门禁拦截/债务/规律命中/僵尸清单/钩子活性一屏读(观测期仪表盘,只读不改) | +| `/regress:trace` | 查看交付链:需求→会话→事件→提交 的可追溯视图(文本版 Inspector) | +| `/regress:track` | 对比 git diff 发现 F3 并直接回写(AI 完成修改后自动执行,不需用户手动触发) | +| `/regress:uninstall` | 卸载 regress-guard(清理用户级配置 + hook + skill + 命令) | +| `/regress:update` | 检查并更新 regress-guard 到最新版(手动触发;SessionStart 也会自动检测) | +| `/regress:verify` | 跑测试预览结果(提交时 hook 也会自动跑;测试失败 AI 应自行修复重跑) | + + diff --git a/plugins/regress-guard/README_CN.md b/plugins/regress-guard/README_CN.md new file mode 100644 index 0000000..703f25c --- /dev/null +++ b/plugins/regress-guard/README_CN.md @@ -0,0 +1,184 @@ +# regress-guard + +> AI 辅助开发的测试回归卡点 + 反脆弱控制体(四公理架构,版本见 plugin.json) +> +> **先读思想**:[docs/PHILOSOPHY.md](docs/PHILOSOPHY.md)——十六条设计哲学与它们背后的真实教训。功能会过时,思想不会。 + +## 安装(三轨) + +**官方市场轨(推荐)**:ZCode 插件市场搜索 regress-guard 一键安装 +(官方市场收录后可用;市场安装即含全部钩子,无需 install.sh)。 + +## 安装(另两轨) + +**插件轨(推荐,实验路径)**:本仓已是 marketplace 就绪形态(根目录 +`marketplace.json` + `hooks/hooks.json` 全 `${ZCODE_PLUGIN_ROOT}` 相对化)。 +在 ZCode 设置 → 插件市场添加个人来源 `github:1339190177/regress-guard`, +安装 regress-guard——钩子经宿主运行时合并自动生效,可随插件开关启停与升级 +(git pin 自带完整性校验)。自检脚本已适配双轨(版本面:.source 戳或已装 plugin.json 任一形态)。 + +**开发轨(现行)**:克隆本仓后 `bash install.sh`(或对 AI 说 `/regress:install`) +——脚本拷贝钩子到 `~/.zcode/regress-guard-hooks` 并写用户级注册,附带三查自检。 +两轨只取其一,同装会双份钩子。 + +## 五分钟上手 + +1. **环境**:ZCode 客户端(hooks/commands 机制)+ Python 3.10+ +2. **安装**:`bash install.sh`(或对 AI 说 `/regress:install`) +3. **接入项目**:任意项目里对 AI 说 `/regress:init` +4. **日常**:说需求 → AI 出计划卡片 → 你「批准/修改/取消」→ 开工; + 提交时 hook 自动跑测试拦问题;卡住看项目里的 `.regress/README.md` + +- 人类使用手册:[docs/小白指南.md](docs/小白指南.md)——你只需要会说三类话 +- 完整工作流:[docs/WORKFLOW.md](docs/WORKFLOW.md) +- 健康检查:`bash validate.sh`(应 5/5)· 单元测试:`python3 -m pytest tests/ -q`(应全绿) + +## 与 ZCode 原生能力的关系(分层,不重复) + +本插件遵循第一原则"不做 ZCode 已有的,只做增强层"——原生能力是战友不是对手: + +| ZCode 原生 | 本插件的关系 | 分层逻辑 | +|---|---|---| +| 计划模式(EnterPlanMode) | **互补** | 宿主计划模式管**会话权限**(只读探索,批准后放开工具);本插件 planning 状态管**产物生命周期**(清单未批准,边界守卫拦编辑、批准落 approved.at、临行进否决窗)。一个绑会话、一个绑清单,可叠加:计划阶段建议在宿主计划模式下探索 | +| 智能体(Agent 工具) | **复用** | /regress:plan 步骤 3 的 A/B/C 并行分析(需求理解/改动点/影响面)就是直接委派宿主智能体 | +| TodoWrite | **分层** | 会话内待办(易失)vs 清单 manifest(跨会话、门禁绑定) | +| hooks 事件系统 | **复用** | 全部强制力(11 个注册)骑在宿主 PreToolUse/PostToolUse/Stop/SessionStart 事件上——"外挂宪法"的字面含义 | +| skills / MCP | **复用** | second-opinion 技能、advisor MCP;verify 报告可接 document-skills,GUI 回归可接 browser-use | + +## 可选:第二意见顾问生态 + +部分注入文本会建议调用 `mcp__advisor__consult`(外部第二意见工具)——这是**可选生态**: + +- **未安装顾问**:AI 收到降级提示(自行判断并标注「未获第二意见」),全部核心门禁不受影响 +- **一票关闭**:`REGRESS_AUTO_CONSULT=off` +- 设计哲学:顾问有一票否决权、无一票批准权(见 `commands/regress:plan.md` 步骤 5a)—— + 意见只注入文本、永不执行 + +## 误报口径与公开基准 + +误报口径 **human-only**:拦截是否有用由**人事后裁决**(useful / false_positive / +ignored),AI 无投票权——门禁数字不自己给自己打分,比厂商自报口径严。 +本仓测试内容本就公开,[benchmarks/](benchmarks/) 基准集是对既有攻击回放用例的 +策展,**非新披露**。方法论、实测数字与引用格式见 +[benchmarks/README.md](benchmarks/README.md)。 + +## 四公理架构(v1.8:基于脆弱性前置的分布式认知控制论) + +| 公理 | 机制 | 强制力 | +|------|------|--------| +| **一·脆弱性前置** | 清单 `fragile_points` 挂牌(open/locked/flagged);verify 命令拿证据;环境指纹 env.lock.json 漂移检测 | open 状态 **hook 阻断提交** | +| **二·认知物质化** | 决策链写 `.regress/decisions.md`;文件指纹(mtime+size)——读后被外部改过禁止盲改 | 指纹不匹配 **hook 阻断修改** | +| **三·考古持久化** | 失败/风险动作/用户纠正自动埋入 `.regress/journal/`(append-only,随 git 入库);/regress:learn 挖跨会话重复失败 | 自动、无感、不可关闭(REGRESS_JOURNAL=off 除外) | +| **四·建议执行分离** | 执行阀:mkfs/dd if=/force push/DROP/TRUNCATE/chmod -R 777/项目外 rm -rf 需显式令牌;顾问意见只注入文本、永不执行 | 无令牌 **hook 阻断**(令牌 `REGRESS_CONFIRM=YES`) | + +一句话:不要指望变聪明,要指望变死板——灰尘对得上(指纹)、失败刻在石头上(地层)、拆弹要输密码(令牌)、未挂牌的脆弱点不许出门(V 门禁)。 + +## 核心能力 + +| 能力 | 实现方式 | 什么时候触发 | +|------|---------|------------| +| **commit 门禁** | hook 自跑测试,exit 2 阻断 | git commit 时 | +| **F3 追踪** | git diff 对比清单 | /regress:track | +| **先读后改** | hook 阻断(ratio=2)+ 文件指纹复核 | Edit/Write 时 | +| **pre-mortem** | Stop hook 注入提醒 | 改≥3 文件时 | +| **自愈+升级** | SessionStart hook | ZCode 启动时 | +| **需求入口检查** | UserPromptSubmit hook | 用户输入时 | +| **提交观测** | git post-commit 钩子(静默/自禁用/monorepo 感知) | 任何来源的提交,含 IDE/终端 | +| **覆盖率信号** | jest --coverage 自动采集 | 每次门禁测试 | +| **经验注入** | 启动时摘要注入(防重复门) | 项目规律变化时 | +| **执行阀** | PreToolUse(Bash) 令牌制 | 不可逆命令时 | +| **开发边界守卫** | PreToolUse(Edit/Write) 事前拦截越界编辑;track 回写即扩界 | 活跃清单存在时 | +| **计划审批落产物** | planning 清单边界内拦编辑;plan_approve.py 转写 approved.{at,note} + base_head 漂移检查;人类可直接填 approved.at(产物直通) | /regress:plan 步骤 5 | +| **现场重建** | /regress:resume 从 .regress/ 产物层单侧重建工作现场(不依赖对话) | 断点续作时 | +| **受阻一等状态** | plan_approve.py --block 四问落产物(阻塞/已试/为何不能绕/需要人类什么);受阻期间边界守卫拦编辑;--unblock 解阻 | 实施卡住时 | +| **假设失效化石** | 推测被实测证伪→追加 was→reality→evidence + journal add assumption_broken | verify 证伪假设时 | +| **假设账本(软引导)** | 排障任务先检验后动手:假设/检验命令/结果/裁决四列落清单正文;第一嫌疑人=自己最近的 diff;失败即证伪不叠层;宣称跟着证据走(不说"根治"只说"缓解待验证")。病例:REGRESS-005 三轮防御 6+ 轮部署,真凶是自己上一轮的参数 | /regress:plan 步骤 2e · 排障时 | +| **项目禁改区** | config boundary.forbidden 冻结区通配——与任务无关任何状态都拦;逃生口统一 /regress:bypass(赦后记债) | 编辑冻结区时 | +| **赦免权普适化** | bypass_until 与 commit 门禁同源,边界/提交类流程闸统一认;**不可赦名单两条**:执行阀(物理不可逆,逐次令牌)+ 先读后改(防盲改,读文件成本≈10s,赦它省时间只会引雷) | bypass 有效期内 | +| **解析单一来源** | lib/manifest_fields.py 统一 frontmatter 读取与可编辑性判定;validate 架构守卫保证块正则不出 lib | 维护时 | +| **感官证据(AVS)** | sensory 脆弱点问人只收布尔 → human_check 化石;门禁验化石存在性不复跑感官;拓扑新增 oracle 类(测试替身保真度) | 终验/提交时 | +| **顾问预审·临行** | 计划卡片送顾问预审(一票否决权/无一票批准权);预授权+无异议 → --provisional 临行进否决窗,哨兵标 🚀 | /regress:plan 步骤 5a | +| **活跃清单哨兵** | SessionStart 注入进行中任务(id/status/open 数/受阻 need)→ 指路 /regress:resume | 每次会话启动 | +| **老项目自动迁移** | SessionStart self_heal 检测 .regress/ 缺零号 README → 从模板幂等补写(永不覆盖定制版) | 每次会话启动 | +| **失忆读者层** | 清单正文写给断上下文的读者:实施顺序一步一响(可观察里程碑)+ 环境准备 + 报错自救(rescue 字段) | /regress:plan 步骤 4.7 | +| **考古地层** | 探测器自动 append 到 journal/ | 每次失败/风险/纠正 | +| **代谢链(规律账本)** | finish 收尾必过沉淀位(有料沉淀无料跳过);规律记账(再检出=命中);🍂 半年零命中列降级候选提示人工修剪(永不自动删);🦴 命中≥3 建议经人批准用 skill-creator 固化为宿主 skill——地层是脂肪,规律是肌肉,skill 是骨骼 | /regress:finish · /regress:learn | +| **需求判据层(软引导)** | 验收标准节(判据/证据/状态——"根治/更好"类词必须挂判据,非功能底线需求阶段定型)+ 设计备选卡(没有备选=没想过);未门禁化——误用病例出现再议 | /regress:plan 4.8 · verify | +| **产品适定性层(软引导)** | 产品上下文卡(用户/价值观人类填,行业惯例顾问 scout 起草,永不删人类段)+ 草图先行(用户可见功能卡片必备「所见」,否决在草图不在实现后)+ design_rejected 学费化石+ 预审第四问必带搜索(scout 不可用则标注,不以无搜索充数) | /regress:plan 2a·5a · learn | + +## 命令 + +| 命令 | 作用 | +|------|------| +| `/regress:install` / `uninstall` / `update` | 安装/卸载/升级 | +| `/regress:init` | 初始化项目 .regress/ | +| `/regress:plan` | 需求解析 + 改动清单 | +| `/regress:track` | 发现 F3 + 回写 | +| `/regress:verify` | 跑测试 | +| `/regress:quick` | 快速模式 | +| `/regress:bypass` | 紧急绕过 | +| `/regress:learn` | 分析历史 + 框架规则 → 写入 AGENTS.md | +| `/regress:trace` | 交付链视图(意图→过程→产出) | +| `/regress:resume` | 断点续作:产物层单侧重建现场 | +| `/regress:finish` | 收尾流水线:track→verify→状态推进→报告 | +| `/regress:evolve` | 查社区 → 适配 → 沉淀到知识库 | + +## 三种工作密度 + +| 模式 | 步骤 | 适用 | +|------|------|------| +| full | 说需求→批准卡片→实施→finish→commit(门禁发 done) | 团队/大需求 | +| fast | 改代码→quick→commit | 个人/小改动 | +| bypass | bypass→commit | 紧急 hotfix | + +## 配置 + +`.regress/config.json`: +```json +{"strict": true, "read_before_edit_ratio": 2} +``` + +## 安装架构 + +代码全局装一次(`~/.zcode/`),数据各项目独立(`项目/.regress/`)。 + +详见 [小白指南](docs/小白指南.md) · [WORKFLOW](docs/WORKFLOW.md) + + + +**实况(由 gen_reference.py 生成,勿手改本区)** + +- 命令:16 个 · hook 注册:11 个事件条目 · 测试函数:563 个 + +| 命令 | 说明 | +|---|---| +| `/regress:bypass` | 紧急绕过回归卡点(限时 + 审计日志),用于 hotfix 等紧急场景 | +| `/regress:characterize` | 特征测试生成:改无测试老代码前先钉住现状行为(golden master——探针真跑取真值,不猜输出) | +| `/regress:evolve` | 遇到难点时查社区经验→适配项目→沉淀为知识库(不再重复查) | +| `/regress:finish` | 收尾流水线:track 回写 → verify 全证据 → 代谢沉淀 → 状态推进 → 汇总报告(一条命令走完收尾,人只出现在决策点) | +| `/regress:init` | 初始化项目的 .regress/ 数据目录(代码已在全局级安装,这里只建数据) | +| `/regress:install` | 一键安装 regress-guard 到用户级(零配置,装一次全局生效) | +| `/regress:learn` | 分析历史+检测框架规则,输出项目洞察,写入 AGENTS.md | +| `/regress:plan` | 需求→解析→消歧→改动清单。AI 先补全上下文再动手(不问能推断的,只问关键分歧) | +| `/regress:quick` | 快速模式——机器判据达标的小改动专用(合并 plan+track 一步到位) | +| `/regress:resume` | 从 .regress/ 产物层单侧重建工作现场(断点续作,不依赖对话历史) | +| `/regress:stats` | 健康报表:门禁拦截/债务/规律命中/僵尸清单/钩子活性一屏读(观测期仪表盘,只读不改) | +| `/regress:trace` | 查看交付链:需求→会话→事件→提交 的可追溯视图(文本版 Inspector) | +| `/regress:track` | 对比 git diff 发现 F3 并直接回写(AI 完成修改后自动执行,不需用户手动触发) | +| `/regress:uninstall` | 卸载 regress-guard(清理用户级配置 + hook + skill + 命令) | +| `/regress:update` | 检查并更新 regress-guard 到最新版(手动触发;SessionStart 也会自动检测) | +| `/regress:verify` | 跑测试预览结果(提交时 hook 也会自动跑;测试失败 AI 应自行修复重跑) | + + + +## 副作用声明(透明度契约) + +- **执行**:全部钩子为本机 Python 3.10+ 进程(无预编译二进制、无混淆源码) +- **写文件**:项目内 `.regress/`(清单/历史/台账/考古地层)与 `~/.zcode/regress-guard-hooks`(安装副本) +- **命令执行**:仅两类——门禁自跑项目测试运行器(pytest/jest/maven/gradle/go 自动探测); + 通知通道命令(**仅人工授信项目可配**,未受信一律回退机器级,克隆仓库注入面封堵) +- **网络**:默认零外发。可选拦截通知(企业微信自建应用)——凭据须人工授信且 API 域 + 钉官方域;无 MCP、无遥测上传、无第三方分析 +- **Hook 面**:PreToolUse(提交门禁/边界守卫/执行阀)、PostToolUse(清单回写桥)、 + UserPromptSubmit(治理上下文)、SessionStart(自愈)、Stop(轮末警示) +- **逃生**:`/regress:bypass N` 分钟级限时赦免(留债审计);各特性独立 env 开关 diff --git a/plugins/regress-guard/benchmarks/README.md b/plugins/regress-guard/benchmarks/README.md new file mode 100644 index 0000000..d637dcd --- /dev/null +++ b/plugins/regress-guard/benchmarks/README.md @@ -0,0 +1,100 @@ +# 公开攻击回放基准(benchmarks/cases.json) + +对 regress-guard 既有攻击回放测试的**策展索引**:把散在 `tests/` 里的供应链注入、 +信任旁路、缓存伪造、边界旁路、密钥外发、执行阀、先读后改指纹、提交门禁、 +全貌新鲜度、验收入环、规律召回、待决回流等真实翻车标本,整理为可独立引用的 +基准用例集(当前 82 例 / 13 族)。 + +## 非新披露声明 + +**本集是策展,不是新披露。** 所有被引用的测试内容本来就公开于本仓 `tests/` +目录(攻击回放自 v1.29 起即以测试形态入库);cases.json 只增加一层脱敏摘要 +与稳定 id,不引入任何新的攻击载荷、命令原文或密钥样例形态。摘要粒度停在 +"环境变量注入重定向信任面"这一层,不复述具体变量名、命令与值。 + +## 方法论:human-only 误报口径(为什么比厂商自报严) + +安全工具厂商的基准数字通常由厂商自报——自己拦截、自己判定拦截是否有价值, +既当运动员又当裁判。本插件的口径不同: + +- **拦截是否有用,由人**事后**裁决**:门禁每次拦截经人复核,归入三类—— + `useful`(真拦住了问题)/ `false_positive`(误报)/ `ignored`(人主动忽略) +- **AI 无投票权**:AI(包括本插件的自动化层)不能把自己的拦截记为 useful, + 也不能销掉 false_positive——数字不经被测系统的手 +- 对照面同理:放行路径上的对照用例(如授权后放行、暂存态不误拒)红掉时 + 同样计入问题——误放与误拦都算翻车 + +厂商自报口径下"0 误报"是营销数字;human-only 口径下它是审计结果。 + +## 当前实测数字(截至 2026-09-21) + +| 指标 | 值 | +|---|---| +| 拦截累计裁决 | 33 useful / 0 false_positive / 1 ignored | +| 门禁测试缓存命中 | 4 次(键随树变化,命中即同树全绿) | + +数字来源:真实使用中的人工裁决累计,非基准集跑分。基准集本身是 +"这些拦截能力有测试钉着"的证据链,不是分数发生器。 + +## 如何跑 + +每条用例的 `pytest_selector` 字段是可直接使用的 `-k` 表达式: + +```bash +# 单条(以 TRUST-001 为例) +python3 -m pytest tests/ -k "test_trust_default_denied" + +# 一族(launcher-env 白名单钉族) +python3 -m pytest tests/ -k "test_poison_env_fully_stripped or test_mixed_env_exact_output or test_whitelist_exact_set" + +# 全族回放:按 cases.json 的 pytest_selector 逐条跑,或直接跑源测试文件 +python3 -m pytest tests/test_notify.py tests/test_test_cache.py tests/test_boundary_guard.py \ + tests/test_secret_scan.py tests/test_launcher_env.py tests/test_execution_valve.py \ + tests/test_read_before_edit.py tests/test_pre_commit_guard.py tests/test_scan_check.py \ + tests/test_history.py tests/test_pending.py -q +``` + +清单自身的健康校验(schema / selector 真实可命中 / 摘要长度上限): + +```bash +python3 -m pytest tests/test_benchmark_manifest.py -q +``` + +## 族一览 + +| family | 覆盖面 | 例数 | +|---|---|---| +| notify-trust | 通知通道供应链信任面(env 注入重定向 / 凭据字段门 / API 域钉住) | 5 | +| notify-pin | 同路径换内容分级防线(敏感面调包 / 人工重授信对照) | 2 | +| launcher-env | 守卫子进程环境白名单(全量剥离 / 全集双向钉 / 大小写归一) | 5 | +| test-cache | 门禁测试缓存完整性(键敏感 / 伪造拒绝 / 生存期 / 防死循环对照) | 6 | +| boundary-guard | 开发边界与 shell 写目标提取(越界 / 工具旁路 / 跨会话 / 误报防御) | 8 | +| secret-scan | 提交密钥扫描(高精度命中 / 私钥块 / 目录豁免边界 / 删除行对照) | 4 | +| execution-valve | 不可逆命令执行阀(毁灭类模式 / 复合命令夹带 / 旗标变形 / 令牌授权对照) | 7 | +| edit-fingerprint | 先读后改与文件指纹守卫(盲改 / 读数阈值 / 外部改动失配 / 重读恢复对照) | 7 | +| commit-gate | 提交门禁本体(间接提交识别 / 清单解析 fail-closed / 验收拦截 / 自审键) | 10 | +| scan-freshness | 全貌新鲜度三态与规则A(缺扫描拦 / 占位防绕 / 三态判定 / 轻量豁免对照) | 9 | +| acceptance-gate | 验收标准入环 EARS 机器化(未勾拦 / 占位拦 / 两行判据 / 宽松计勾对照) | 5 | +| rule-recall | 拦截现场规律召回与有效性度量(TOP-3 召回 / 开关与坏账降级 / 三态归类 / 热力图) | 8 | +| pending-reflow | 待决决策自动回流(过门禁闭环 / 精确锚定 / 多义防护 / 误报口径隔离) | 6 | + +schema 冻结为五字段(增删须同步 `tests/test_benchmark_manifest.py` 与本文档): + +```json +{"id": "TRUST-001", "family": "notify-trust", "attack_summary": "一句话脱敏描述", + "expected": "门禁应然行为(拒/拦/剥离/放行/钉回)", "pytest_selector": "-k 表达式"} +``` + +约束:`attack_summary` <= 80 字符(防载荷复述回潮);`id` 全局唯一且形状稳定 +(大写词-三位序号)。 + +## 引用格式 + +按 id 引用,不引用测试函数名(函数会改名,id 不会): + +> regress-guard 公开基准 TRUST-001(环境变量注入重定向通知信任面): +> 通知通道默认拒 + 生产路径零环境变量影响。 + +批量引用时可引族名:`regress-guard benchmarks: launcher-env 族(5 例)`。 +校验引用是否仍有效:`python3 -m pytest tests/test_benchmark_manifest.py -q` +——任何一条 selector 失配(测试被删/改名)都会红。 diff --git a/plugins/regress-guard/benchmarks/cases.json b/plugins/regress-guard/benchmarks/cases.json new file mode 100644 index 0000000..0cbf4b4 --- /dev/null +++ b/plugins/regress-guard/benchmarks/cases.json @@ -0,0 +1,576 @@ +[ + { + "id": "TRUST-001", + "family": "notify-trust", + "attack_summary": "环境变量三重注入重定向通知信任面,诱导未受信项目通道执行", + "expected": "拒:生产路径零环境变量影响,恶意通道不执行且给出人工信任出口", + "pytest_selector": "test_trust_default_denied" + }, + { + "id": "TRUST-002", + "family": "notify-trust", + "attack_summary": "诱导信任管理子命令代写信任表,绕过人工授信", + "expected": "拒:信任视图只读,任何形态不写表文件", + "pytest_selector": "test_trust_cli_readonly_never_writes" + }, + { + "id": "CRED-001", + "family": "notify-trust", + "attack_summary": "克隆仓库配置覆盖通知凭据字段,劫持凭据外发", + "expected": "剥离:未受信项目凭据字段覆盖回退机器级并告警", + "pytest_selector": "test_wecom_cred_fields_gated" + }, + { + "id": "APIBASE-001", + "family": "notify-trust", + "attack_summary": "环境变量劫持通知推送 API 基域指向恶域", + "expected": "钉回:非白名单域被重置为官方域并告警", + "pytest_selector": "test_api_base_pinned" + }, + { + "id": "HOME-001", + "family": "notify-trust", + "attack_summary": "注入家目录变量重定向机器级通知配置读取路径", + "expected": "剥离:默认路径仍按系统账户数据库派生,注入路径无效", + "pytest_selector": "test_machine_conf_path_home_injection_neutralized" + }, + { + "id": "PIN-001", + "family": "notify-pin", + "attack_summary": "同路径换内容替换通知通道敏感配置(同址调包)", + "expected": "拒:敏感面变更被拦,回退已钉配置并提示人工重授信", + "pytest_selector": "test_pin_swap_sensitive_denied" + }, + { + "id": "PIN-002", + "family": "notify-pin", + "attack_summary": "同址调包后人工已重新授信的对照路径", + "expected": "放行:人工重授信后重钉新配置(授权路径不误拦)", + "pytest_selector": "test_pin_human_retrust_repin" + }, + { + "id": "ENV-001", + "family": "launcher-env", + "attack_summary": "全污染环境(覆盖类/凭据类/自定义变量)传入守卫子进程", + "expected": "剥离:白名单外变量全部不出现在子进程环境", + "pytest_selector": "test_poison_env_fully_stripped" + }, + { + "id": "ENV-002", + "family": "launcher-env", + "attack_summary": "混合环境中夹带覆盖类与凭据类变量,试探精确输出", + "expected": "剥离:输出恰等于白名单子集,多留一个即红", + "pytest_selector": "test_mixed_env_exact_output" + }, + { + "id": "ENV-003", + "family": "launcher-env", + "attack_summary": "白名单自身被放宽(增条目漂移)的试探", + "expected": "钉死:白名单全集双向等值,增删条目即红", + "pytest_selector": "test_whitelist_exact_set" + }, + { + "id": "ENV-004", + "family": "launcher-env", + "attack_summary": "以小写形态夹带覆盖类与凭据类变量绕过比较", + "expected": "剥离:归一比较命中白名单外小写形态仍被剥离", + "pytest_selector": "test_case_normalized_comparison_keeps_original_key" + }, + { + "id": "ENV-005", + "family": "launcher-env", + "attack_summary": "对照:白名单变量夹带在污染环境中传递", + "expected": "保留:白名单变量原键原值到达,同环境毒变量被剥离", + "pytest_selector": "test_whitelist_var_kept" + }, + { + "id": "CACHE-001", + "family": "test-cache", + "attack_summary": "已跟踪文件内容被篡改后重放旧缓存冒充门禁通过", + "expected": "拒:缓存键随内容变化,旧记录失效", + "pytest_selector": "test_key_sensitive_tracked_edit" + }, + { + "id": "CACHE-002", + "family": "test-cache", + "attack_summary": "新增未跟踪文件后重放旧缓存冒充门禁通过", + "expected": "拒:未跟踪新增同样入键,旧记录失效", + "pytest_selector": "test_key_sensitive_untracked_add" + }, + { + "id": "CACHE-003", + "family": "test-cache", + "attack_summary": "删除已跟踪文件后重放旧缓存冒充门禁通过", + "expected": "拒:删除使键变化,旧记录失效", + "pytest_selector": "test_key_sensitive_tracked_delete" + }, + { + "id": "CACHE-004", + "family": "test-cache", + "attack_summary": "伪造失败结果写入缓存冒充通过", + "expected": "拒:仅通过结果可落账,失败不写入", + "pytest_selector": "test_record_rejects_fail" + }, + { + "id": "CACHE-005", + "family": "test-cache", + "attack_summary": "篡改缓存时间戳使陈旧记录永生", + "expected": "拒:超生存期记录查询失效", + "pytest_selector": "test_lookup_ttl_expiry" + }, + { + "id": "CACHE-006", + "family": "test-cache", + "attack_summary": "对照:门禁自身运行时目录写入不得改键(防死循环)", + "expected": "放行:运行时目录不入键,同树缓存命中", + "pytest_selector": "test_key_ignores_regress_runtime" + }, + { + "id": "BOUND-001", + "family": "boundary-guard", + "attack_summary": "编辑活跃清单边界外文件,绕过任务声明面", + "expected": "拦:越界编辑被阻断并指路回写命令", + "pytest_selector": "test_outside_boundary_blocked" + }, + { + "id": "BOUND-002", + "family": "boundary-guard", + "attack_summary": "待批准计划期内经 shell 删除边界内文件(工具旁路)", + "expected": "拦:计划未批准时 shell 写路径与编辑同等拦截", + "pytest_selector": "test_bash_rm_inside_planning_boundary_blocked" + }, + { + "id": "BOUND-003", + "family": "boundary-guard", + "attack_summary": "经 shell 重定向写边界内文件(工具旁路)", + "expected": "拦:重定向目标被识别为写目标并阻断", + "pytest_selector": "test_bash_redirection_write_blocked" + }, + { + "id": "BOUND-004", + "family": "boundary-guard", + "attack_summary": "经移动/原地编辑/低级块拷贝类命令写边界内文件", + "expected": "拦:多形态写命令均被识别为写目标", + "pytest_selector": "test_bash_mv_sed_dd_blocked" + }, + { + "id": "BOUND-005", + "family": "boundary-guard", + "attack_summary": "他会话活跃清单边界内的文件被本会话编辑(竞态)", + "expected": "拦:跨会话编辑被阻断并给出对焦提示", + "pytest_selector": "test_cross_session_edit_blocked" + }, + { + "id": "BOUND-006", + "family": "boundary-guard", + "attack_summary": "任务受阻后利用既往批准戳短路拦截", + "expected": "拦:受阻拦截不被既往批准戳短路", + "pytest_selector": "test_approved_then_blocked_still_blocks" + }, + { + "id": "BOUND-007", + "family": "boundary-guard", + "attack_summary": "载荷文本中嵌入命令词样字符串,试探写目标误判(防误报)", + "expected": "放行:引号载荷内容不判为写目标,无误拦", + "pytest_selector": "test_dequoted_payload_command_word_not_target" + }, + { + "id": "BOUND-008", + "family": "boundary-guard", + "attack_summary": "文档体内嵌入删除与重定向样文本,试探写目标误判", + "expected": "半拦:文档体不判写目标,真实重定向目标仍收", + "pytest_selector": "test_heredoc_body_not_targets_but_redir_is" + }, + { + "id": "SECRET-001", + "family": "secret-scan", + "attack_summary": "新增行夹带高精度形态云凭据", + "expected": "拦:命中并给出文件与行号", + "pytest_selector": "test_high_precision_hit_and_lineno" + }, + { + "id": "SECRET-002", + "family": "secret-scan", + "attack_summary": "新增行夹带私钥块头", + "expected": "拦:私钥块命中", + "pytest_selector": "test_private_key_block" + }, + { + "id": "SECRET-003", + "family": "secret-scan", + "attack_summary": "把真凭据藏进测试目录试探豁免", + "expected": "拦:高精度模式不豁免测试目录", + "pytest_selector": "test_high_precision_not_exempt_in_tests" + }, + { + "id": "SECRET-004", + "family": "secret-scan", + "attack_summary": "对照:删除行中的历史凭据样文本不产生新增告警", + "expected": "放行:只扫新增行,删除行不误报", + "pytest_selector": "test_deleted_lines_not_scanned" + }, + { + "id": "VALVE-001", + "family": "execution-valve", + "attack_summary": "直呼不可逆模式串试探执行阀盲区(毁盘/强推/删库/递归改权类)", + "expected": "拦:毁灭类模式串命中即阻断并提示令牌出口", + "pytest_selector": "test_catastrophic_patterns_block" + }, + { + "id": "VALVE-002", + "family": "execution-valve", + "attack_summary": "复合命令前段良性后段夹带毁灭步骤,试探只查整串首词", + "expected": "拦:串内每段独立过阀,夹带段照拦", + "pytest_selector": "test_compound_command_caught" + }, + { + "id": "VALVE-003", + "family": "execution-valve", + "attack_summary": "删除旗标变形与多旗标聚合,试探字面匹配绕过", + "expected": "拦:旗标归一解析后变形形态同样命中", + "pytest_selector": "test_flag_variants_rm" + }, + { + "id": "VALVE-004", + "family": "execution-valve", + "attack_summary": "递归强删目标逃出项目根目录,试探作用域放行", + "expected": "拦:项目外路径命中阻断(项目内常规目录类不拦)", + "pytest_selector": "test_rm_rf_outside_project_blocks" + }, + { + "id": "VALVE-005", + "family": "execution-valve", + "attack_summary": "对照:良性命令含项目内递归删与常规推送,试探误拦面", + "expected": "放行:模式不中即放,无令牌也不误拦", + "pytest_selector": "test_benign_commands_pass" + }, + { + "id": "VALVE-006", + "family": "execution-valve", + "attack_summary": "对照:显式令牌前缀携不可逆命令走授权出口", + "expected": "放行:令牌在场即人工确认语义,阀门打开", + "pytest_selector": "test_token_unlocks" + }, + { + "id": "VALVE-007", + "family": "execution-valve", + "attack_summary": "顶层非字典畸形载荷试探解析崩溃反向放行", + "expected": "拦:解析异常走阻断出口,fail-closed 不反向", + "pytest_selector": "test_nondict_payload_blocks" + }, + { + "id": "FPIN-001", + "family": "edit-fingerprint", + "attack_summary": "零读直改试探跳过理解环节(盲改)", + "expected": "拦:读数不足即阻断并提示先读后改", + "pytest_selector": "test_blind_edit_blocked" + }, + { + "id": "FPIN-002", + "family": "edit-fingerprint", + "attack_summary": "读数恰差一次(阈值下一格)试探宽松比较", + "expected": "拦:差一次也算不足,阈值无弹性", + "pytest_selector": "test_partial_read_blocked" + }, + { + "id": "FPIN-003", + "family": "edit-fingerprint", + "attack_summary": "对照:读满阈值后再改(守规矩路径)", + "expected": "放行:读数达标即放(授权路径不误拦)", + "pytest_selector": "test_read_then_edit_allowed" + }, + { + "id": "FPIN-004", + "family": "edit-fingerprint", + "attack_summary": "首轮读数耗尽后改未读过的文件(轮次阶梯爬升)", + "expected": "拦:新一轮重新计读,未读文件被拦并点名", + "pytest_selector": "test_edit_unread_file_blocked_when_ratio_exceeded" + }, + { + "id": "FPIN-005", + "family": "edit-fingerprint", + "attack_summary": "读后被外部修改(他会话/工具格式化)再改,指纹失配", + "expected": "拦:指纹不匹配阻断,防基于过期读取态盲改", + "pytest_selector": "test_fingerprint_blocks_external_change" + }, + { + "id": "FPIN-006", + "family": "edit-fingerprint", + "attack_summary": "对照:被拦后重读刷新指纹再改(恢复路径)", + "expected": "放行:重读后指纹对齐即放", + "pytest_selector": "test_fingerprint_reread_restores" + }, + { + "id": "FPIN-007", + "family": "edit-fingerprint", + "attack_summary": "对照:自己改完未重读继续迭代(防误拦)", + "expected": "放行:自改挂起指纹校验,不拦迭代", + "pytest_selector": "test_self_edit_suspends_fingerprint" + }, + { + "id": "GATE-001", + "family": "commit-gate", + "attack_summary": "经版本工具隐式自动提交,试探命令识别只认显式提交", + "expected": "拦:间接提交形态同样被识别进门禁", + "pytest_selector": "test_commit_via_npm_version_detected" + }, + { + "id": "GATE-002", + "family": "commit-gate", + "attack_summary": "清单夹非法编码字节试探解析异常穿透为放行", + "expected": "拦:解码异常不反向,fail-closed 阻断", + "pytest_selector": "test_non_utf8_manifest_blocks" + }, + { + "id": "GATE-003", + "family": "commit-gate", + "attack_summary": "乱写清单头试探手写兜底拿空字段冒充无清单", + "expected": "拦:字段全失即解析失败阻断,不静默当无清单", + "pytest_selector": "test_garbage_yaml_manifest_blocks" + }, + { + "id": "GATE-004", + "family": "commit-gate", + "attack_summary": "自造清单状态词不在活跃枚举内(语义反转回归锁定)", + "expected": "放行:未登记状态不算活跃,不误卡提交并留痕", + "pytest_selector": "test_invented_status_not_active" + }, + { + "id": "GATE-005", + "family": "commit-gate", + "attack_summary": "测试全绿但清单缺验收标准节,试探以绿测代替验收", + "expected": "拦:中档清单缺验收节不许盖章", + "pytest_selector": "test_acceptance_missing_blocks" + }, + { + "id": "GATE-006", + "family": "commit-gate", + "attack_summary": "判据行勾选了但缺验证命令(假勾)", + "expected": "拦:勾与验命令双全才算完成判据", + "pytest_selector": "test_acceptance_mark_without_verify_still_blocks" + }, + { + "id": "GATE-007", + "family": "commit-gate", + "attack_summary": "对照:判据全勾验命令齐全且测试绿(合规盖章)", + "expected": "放行:过验收即盖章终态并落通过账", + "pytest_selector": "test_acceptance_all_checked_stamps_done" + }, + { + "id": "GATE-008", + "family": "commit-gate", + "attack_summary": "实际变更夹带清单外文件而计划外自审键缺失", + "expected": "半拦:键缺先拦,补键声明后转放行", + "pytest_selector": "test_self_review_planned_outside_key_required" + }, + { + "id": "GATE-009", + "family": "commit-gate", + "attack_summary": "变更夹带调试残留而调试残留自审键缺失", + "expected": "半拦:键缺先拦,逐处确认声明后转放行", + "pytest_selector": "test_self_review_debug_residue_key_required" + }, + { + "id": "GATE-010", + "family": "commit-gate", + "attack_summary": "对照:测试目录内的调试模式是测试常态(防误触)", + "expected": "放行:测试路径豁免不触发自审键", + "pytest_selector": "test_debug_pattern_in_tests_not_triggered" + }, + { + "id": "SCAN-001", + "family": "scan-freshness", + "attack_summary": "无产品架构卡片的仓库试探新鲜度判定崩溃或误判", + "expected": "判 absent:提示先建卡或声明纯库,不误判新鲜", + "pytest_selector": "test_absent_when_no_card" + }, + { + "id": "SCAN-002", + "family": "scan-freshness", + "attack_summary": "对照:卡片随代码同步提交后的即时新鲜态", + "expected": "判 fresh:同步即新鲜,不误报重扫", + "pytest_selector": "test_fresh_right_after_card" + }, + { + "id": "SCAN-003", + "family": "scan-freshness", + "attack_summary": "结构性新增模块文件后拿旧卡片冒充全貌仍新鲜", + "expected": "判 stale:结构性增删点名文件并要求重扫", + "pytest_selector": "test_stale_on_structural_add" + }, + { + "id": "SCAN-004", + "family": "scan-freshness", + "attack_summary": "无结构变更但代码提交堆量,试探活动量代理失明", + "expected": "判 stale:代码提交数过阈即老化触发重扫", + "pytest_selector": "test_stale_on_commit_volume" + }, + { + "id": "SCAN-005", + "family": "scan-freshness", + "attack_summary": "对照:仅新增测试与文档类元数据文件", + "expected": "判 fresh:元数据不算结构变更,不误判 stale", + "pytest_selector": "test_not_stale_on_metadata_only" + }, + { + "id": "SCAN-006", + "family": "scan-freshness", + "attack_summary": "中档清单缺全貌扫描三行,以绿测试探跳过理解产物", + "expected": "拦:规则A 命中即阻断并点名全貌产物要求", + "pytest_selector": "test_ml_missing_scan_blocks" + }, + { + "id": "SCAN-007", + "family": "scan-freshness", + "attack_summary": "全貌扫描行填模板占位值冒充已填,试探防绕校验", + "expected": "拦:占位值不算填过,规则A 照拦", + "pytest_selector": "test_scan_placeholder_rejected" + }, + { + "id": "SCAN-008", + "family": "scan-freshness", + "attack_summary": "对照:全貌三行齐备的中档清单正常提交", + "expected": "放行:规则A 齐备即不因全貌拦(合规路径不误拦)", + "pytest_selector": "test_ml_with_scan_passes_rule_a" + }, + { + "id": "SCAN-009", + "family": "scan-freshness", + "attack_summary": "对照:轻量档清单不带全貌仪式直接提交", + "expected": "放行:规则A 仅约束中重档,轻量档豁免", + "pytest_selector": "test_s_tier_exempt_from_rule_a" + }, + { + "id": "ACC-001", + "family": "acceptance-gate", + "attack_summary": "验收判据行无勾选标记,试探以未验判据盖章", + "expected": "拦:验收未勾不许盖章并点名未勾行数", + "pytest_selector": "test_acceptance_open_blocks" + }, + { + "id": "ACC-002", + "family": "acceptance-gate", + "attack_summary": "验收判据填模板占位值冒充已写", + "expected": "拦:占位行视同未写,验收照拦", + "pytest_selector": "test_acceptance_placeholder_blocks" + }, + { + "id": "ACC-003", + "family": "acceptance-gate", + "attack_summary": "判据拆两行书写,试探行级解析漏看续行勾选", + "expected": "拦:续行合并成逻辑行再判,只报真实未勾行", + "pytest_selector": "test_acceptance_two_line_bullet_joined" + }, + { + "id": "ACC-004", + "family": "acceptance-gate", + "attack_summary": "对照:勾选标记后拖验证输出文字(非行尾形态)", + "expected": "放行:任意位置计勾,真实验过不误拦", + "pytest_selector": "test_acceptance_mark_anywhere_counts" + }, + { + "id": "ACC-005", + "family": "acceptance-gate", + "attack_summary": "对照:轻量档清单无验收节直接提交", + "expected": "放行:验收环仅约束中重档,轻量档豁免", + "pytest_selector": "test_acceptance_exempt_s_tier" + }, + { + "id": "RECALL-001", + "family": "rule-recall", + "attack_summary": "重犯同类拦截时试探现场不给历史规律提示", + "expected": "召回:拦截输出附规律段封顶三条,召回事件落账", + "pytest_selector": "test_block_recall_shows_history_rules" + }, + { + "id": "RECALL-002", + "family": "rule-recall", + "attack_summary": "关闭召回开关试探门禁依赖增强链路而失拦", + "expected": "降级:召回可一键关,门禁本体照拦不受拖累", + "pytest_selector": "test_block_recall_off_switch" + }, + { + "id": "RECALL-003", + "family": "rule-recall", + "attack_summary": "未接线的拦截原因试探强挂召回段制造噪声", + "expected": "静默:选择性接线,未挂载拦截点不召回", + "pytest_selector": "test_block_recall_not_wired_reason" + }, + { + "id": "RECALL-004", + "family": "rule-recall", + "attack_summary": "规律账本被写坏试探召回链路异常拖垮门禁", + "expected": "降级:坏账本静默跳过,拦截照常不反向", + "pytest_selector": "test_block_recall_corrupt_ledger_degrades" + }, + { + "id": "RECALL-005", + "family": "rule-recall", + "attack_summary": "召回后事件序列混杂,试探有效性三态误判", + "expected": "分态:干净/影子/未决按事件序列精确归类", + "pytest_selector": "test_recall_clean_shadow_pending" + }, + { + "id": "RECALL-006", + "family": "rule-recall", + "attack_summary": "他会话他清单拦截事件混入试探召回误判影子态", + "expected": "隔离:跨清单事件不污染本清单三态判定", + "pytest_selector": "test_recall_cross_manifest_events_ignored" + }, + { + "id": "RECALL-007", + "family": "rule-recall", + "attack_summary": "拦截历史杂乱堆积,试探热力图排序聚合失真", + "expected": "聚合:按原因频次降序,含清单数与最近时间", + "pytest_selector": "test_block_heatmap_ranking" + }, + { + "id": "RECALL-008", + "family": "rule-recall", + "attack_summary": "新拦截原因混入老原因,试探新增趋势失明", + "expected": "标注:近期首现原因标记为新键", + "pytest_selector": "test_heatmap_newkey_mark" + }, + { + "id": "REFLOW-001", + "family": "pending-reflow", + "attack_summary": "同清单过门禁后旧拦截待决滞留,试探闭环失灵", + "expected": "回流:同锚点未决拦截自动闭环,异锚点不动", + "pytest_selector": "test_auto_reflow_pending_on_gate_pass" + }, + { + "id": "REFLOW-002", + "family": "pending-reflow", + "attack_summary": "旧格式待决按标题兜底,试探相近编号误回流", + "expected": "精确:全字命中才回流,词边界防短编号吃长编号", + "pytest_selector": "test_resolve_by_ref_exact_and_legacy_fallback" + }, + { + "id": "REFLOW-003", + "family": "pending-reflow", + "attack_summary": "多条待决标题同含目标编号,试探盲回流了结", + "expected": "不动:多义不兜底,宁挂起不误决", + "pytest_selector": "test_resolve_by_ref_no_ambig_no_hit" + }, + { + "id": "REFLOW-004", + "family": "pending-reflow", + "attack_summary": "决策推送缺结构化锚点,试探回流链路失联", + "expected": "携带:推送落锚点字段,回流有数据前提", + "pytest_selector": "test_notify_source_id_flows_into_ref" + }, + { + "id": "REFLOW-005", + "family": "pending-reflow", + "attack_summary": "已裁决拦截再发或锚点超窗,试探刷屏或该推不推", + "expected": "对称:裁决重置窗口可重推,超窗锚点照样重推", + "pytest_selector": "test_blocked_repush_after_resolve_or_expiry" + }, + { + "id": "REFLOW-006", + "family": "pending-reflow", + "attack_summary": "自动闭环量混入人工裁决,试探误报率口径污染", + "expected": "单列:自动回流单独计数,不入误报率分母", + "pytest_selector": "test_resolved_outcome_single_bucket" + } +] diff --git a/plugins/regress-guard/commands/regress:bypass.md b/plugins/regress-guard/commands/regress:bypass.md new file mode 100644 index 0000000..e606145 --- /dev/null +++ b/plugins/regress-guard/commands/regress:bypass.md @@ -0,0 +1,64 @@ +--- +description: 紧急绕过回归卡点(限时 + 审计日志),用于 hotfix 等紧急场景 +argument-hint: +allowed-tools: Read, Write, Edit, Bash +--- + +# /regress:bypass — 紧急绕过 + +**仅用于紧急 hotfix 场景。** 限时开启 bypass 模式,期间 commit 不被 hook 阻断,但所有绕过行为记入审计日志。 + +## 使用场景 + +- 生产环境紧急 hotfix +- 测试环境快速验证(非正式提交) +- 演示/原型阶段 + +## 使用方式 + +参数 `$1` 是绕过时长(分钟),默认 10 分钟: + +``` +/regress:bypass 10 # 绕过 10 分钟 +/regress:bypass 30 # 绕过 30 分钟 +``` + +## 执行 + +在 `.regress/config.json` 中写入 `bypass_until` 字段: + +```bash +# 计算到期时间 +EXPIRES=$(python3 -c " +from datetime import datetime, timedelta +mins = ${1:-10} +print((datetime.now() + timedelta(minutes=mins)).isoformat()) +") +``` + +更新 `.regress/config.json`: +```json +{ "strict": true, "bypass_until": "" } +``` + +## 审计 + +每次 bypass 期间的 commit,hook 会写入 `.regress/bypass.log`: +``` +2026-08-11T16:30:00+08:00 | bypass commit by user | +``` + +## 到期自动恢复 + +bypass 到期后,hook 自动清除 `bypass_until`,恢复严格模式。无需手动关闭。 + +## 安全提示 + +``` +⚠️ bypass 已开启( 到期) + +此期间的 commit 不会做回归校验,但会记入审计日志。 +到期后自动恢复严格模式。 + +请确保事后补跑 /regress:verify 验证这些改动。 +``` diff --git a/plugins/regress-guard/commands/regress:characterize.md b/plugins/regress-guard/commands/regress:characterize.md new file mode 100644 index 0000000..63cdccf --- /dev/null +++ b/plugins/regress-guard/commands/regress:characterize.md @@ -0,0 +1,62 @@ +--- +description: 特征测试生成:改无测试老代码前先钉住现状行为(golden master——探针真跑取真值,不猜输出) +argument-hint: <文件或目录> +allowed-tools: Read, Write, Edit, Bash, Grep +--- + +# /regress:characterize — 特征测试生成(v1.45) + +治的病:无测试的老代码不敢改——改了不知道坏了什么。特征测试 +(characterization test)**不判断对错,只钉住现状**:把当前行为变成可执行 +断言;之后的修改若改变被钉行为,测试立即红——修改的意外副作用当场现形。 + +## 判据(何时该跑) + +- 活跃清单的 F 项命中无测试覆盖的老文件(plan 脆弱点拓扑的产出) +- 或人类明说"要改这块老代码,先钉住" + +## 流程(五步 · 证据律:跑真代码取真值,不猜) + +### 1. 枚举入口 + +Read 目标文件,列出**公开**函数/类方法(含签名与明显副作用)。跳过并记入 +报告"不可钉":私有(`_` 前缀)、需外部服务/网络/数据库的、明显非确定的 +(随机/时间依赖——除非可注入种子)。 + +### 2. 探针真跑 + +写临时探针脚本(/tmp 下,不进项目),对每个入口用**三类输入**各调一次: + +- 典型值(从调用方/文档/常识推断) +- 空或零值(`""`/`0`/`None`/`[]`,按签名选) +- 边界极端值(超长/负数/嵌套深,按语义选) + +**必须真跑**(Bash 执行探针),把实际返回值/异常落 JSON:浮点记 `repr()`, +异常只记类型不记消息(消息 wording 会漂),副作用可观察的记观察结果。 + +### 3. 生成测试 + +按探针 JSON 生成 `tests/test_characterized_.py`: + +- 每入口至少 3 断言(对应三类输入) +- **断言的是探针捕获的真值,不是 AI 以为的值**——这是 golden master 的命 +- 浮点用 `pytest.approx`;异常用 `pytest.raises(类型)` +- 文件头注释两行:特征测试=钉现状非判对错;行为**有意**改变时更新断言 + 并注明清单 id(区分意图性变更与回归) + +### 4. 跑绿 + +pytest 新文件全绿 = 行为已钉。红 = 探针与生成不一致(自己修到绿;通常是 +非确定依赖——修不了就标"不可钉"并落脆弱点,**诚实优于假钉**)。 + +### 5. 回写 + +- 有活跃清单 → `actual_changes` 记 F3(tests 文件),脆弱点拓扑加一条: + `kind: machine`「特征钉住 <入口数> 入口」verify=`pytest <新文件> -q` +- 无清单 → 提示下次走 /regress:quick(tests 文件也是改动,要留痕收编) + +## 边界 + +- 不 mock 外部服务(mock 出的行为不是现状,钉了也是假钉) +- 不追求覆盖率数字,追求**"要改的路径被钉"**——目标文件的目标入口,不是全仓 +- 与 adapter 的关系:这是项目内单测,走项目自己的 runner(门禁会复验) diff --git a/plugins/regress-guard/commands/regress:evolve.md b/plugins/regress-guard/commands/regress:evolve.md new file mode 100644 index 0000000..27e164c --- /dev/null +++ b/plugins/regress-guard/commands/regress:evolve.md @@ -0,0 +1,49 @@ +--- +description: 遇到难点时查社区经验→适配项目→沉淀为知识库(不再重复查) +argument-hint: <遇到的问题描述> +allowed-tools: Read, Write, Edit, Bash, WebSearch, WebFetch, Grep +--- + +# /regress:evolve — 查攻略·适配·沉淀 + +遇到不熟悉的难题,查社区最佳实践,**适配当前项目**(不盲目照搬),沉淀到知识库。下次同样的问题直接读知识库,不再查。 + +参数 `$ARGUMENTS` 是遇到的问题描述。 + +## 流程 + +### 1. 先查知识库 +读 `.regress/knowledge-base.json`。如果已有匹配的 → 直接用,不查社区, +**并给该条记一笔**(v1.24 代谢链:`hits` +1、`last_hit` 今天——命中是留存的证据, +半年零命中的条下次提示复验,知识库也要新陈代谢)。 + +### 2. 查社区(WebSearch) +搜索关键词,读 2-3 个高赞结果,提取社区公认的解法 + 坑 + 适用条件。 + +### 3. 适配当前项目 +**社区方案再好也要适合当前情况。** 对每个方案问: +- 本项目技术栈匹配? +- 改动量可接受? +- 和已有代码冲突? +不完全适配 → 怎么调整? + +### 4. 沉淀 +写入 `.regress/knowledge-base.json`: +```json +{ + "question": "遇到的问题", + "answer": "社区的解法", + "adaptation": "针对本项目的调整", + "verified": true/false, + "captured_at": "首次沉淀日期", + "hits": 0, + "last_hit": "" +} +``` +同时把适配后的经验写入项目 AGENTS.md。 + +## 自主决策 +- 能从代码推断解决 → 不查社区 +- 知识库里已有 → 直接用 +- 查到后 → 必须适配,不照搬 +- 同一问题已沉淀 → 不再查 diff --git a/plugins/regress-guard/commands/regress:finish.md b/plugins/regress-guard/commands/regress:finish.md new file mode 100644 index 0000000..875d0a1 --- /dev/null +++ b/plugins/regress-guard/commands/regress:finish.md @@ -0,0 +1,152 @@ +--- +description: 收尾流水线:track 回写 → verify 全证据 → 代谢沉淀 → 状态推进 → 汇总报告(一条命令走完收尾,人只出现在决策点) +argument-hint: [manifest-id] +allowed-tools: Read, Write, Edit, Bash, Grep +--- + +# /regress:finish — 收尾流水线 + +实施完成后的一条命令收尾:**track → verify → 代谢沉淀 → 提交就绪**。 +流程段之间没有人肉粘合剂——中间不需要人,人只出现在两个决策点 +(残留 open/flagged 的处置、提交本身)。 + +找最新 `status != done` 的清单(或用 `$ARGUMENTS` 指定 id),依次执行: + +## 步骤 1:track(F3 回写 = 扩界留痕) + +按 /regress:track 的完整规则执行:`git diff` + 未跟踪文件对比清单, +F3 直接回写 `actual_changes`(这是开发边界的扩界出口,留痕)。 + +## 步骤 1.5:自审 diff(v1.41:触发表激活,防空转) + +**通读 `git diff`(staged+unstaged),三类各扫一遍**——改错自己写的 diff +只有自己通读才能发现。键按触发表激活(不适用=键不出现,「无」=查过没有): + +- **计划外**(触发:actual_changes 非空):F3 回写的每个计划外文件——是有意 + 扩界还是顺手带上?值=逐文件一句结论或「无」 +- **调试残留**(触发:diff 命中 print/console.log/debugger/pdb 且非 tests/): + 是断言所需的输出还是忘删的探针?值=条目或「无」 +- **顺手重构**(无机器触发,纪律位):计划内文件里夹的函数重命名/移动—— + 发现了记进报告(拆批或明示),不静默混入 + +落产物:清单 frontmatter `self_review:` 下对应键。**提交门禁会拦** +(reason=finish_missing):触发的键缺失过不了 commit——防"三类全填无"的 +形式化由触发表承担(不触发不用填,触发了必须看过)。 + +## 步骤 2:verify(全证据) + +按 /regress:verify 的完整规则执行: + +- 跑项目测试——失败自己修再跑;**修复 3 次仍败或需要输入 → 先分流再受阻**: + 知识型阻塞(用法/取舍/报错不懂)先带四问去 consult,顾问能解则继续(标注采纳情况); + 环境/权限型或顾问不能解 → `plan_approve.py <清单> --block --reason --need` + (tried 里写明顾问意见),把 need 转达人类,流程到此暂停 + (受阻是合法停止,不是 finish 失败) +- 逐条脆弱点:带 `rescue:` 的失败**先自救再重试**;通过 → `locked`; + sensory 类 → 问人一个布尔问题,`human_check` 化石落档后转 locked(verify 写 + `human_check:`,门禁验化石存在性不复跑感官); + **验收标准表逐条拿证据(v1.25)——"根治/完成"必须能指回判据,指不回去不许说**; + 实测证伪假设 → 「假设失效记录」+ 地层化石;显式不处理 → `flagged`(写明知悉原因) +- env.lock.json 漂移检测(漂移了要在报告里标红) + +## 步骤 2.5:DoD 自检 + 弹性打磨(v1.35:跑通≠完成) + +先自检后打磨——**有缺口才打磨,没缺口不强求一轮仪式**(防僵硬,顾问修正): + +1. **DoD 自检**:对照清单「DoD 状态完备性」表(UI 功能)逐状态问"真渲染看过吗"; + 对照「广度矩阵」伴侣清单逐项问"声明与实现一致吗"。全部有证据 → 直进步骤 3 +2. **弹性打磨**(自检见缺口才启动):对缺的状态/粗糙点补一轮——文案、对齐、 + 加载反馈、空态引导、交互一致性(frontend-design / ui-ux-pro-max Skill + 在此适用);补完的证据回填 DoD 表 +3. **缺口不清零的出口**:补不了的转 `flagged`(写明为什么本期不做), + 并把缺口写进模块卡片「已知缺口」栏——缺口要么修、要么挂牌、要么进卡片 + 待办,三选一,不许蒸发 + +## 步骤 3:状态推进(机器判据,不由感觉) + +- **全部脆弱点 locked/flagged + 测试绿** → 无需手动改 status:直接提交。 + commit 门禁自跑测试复验,通过时自动写 `status: done` + `test_verified_by: hook` + ——done 是门禁发的,不是自称的 +- **有残留 open** → 这是人的决策点,两条路摆给用户: + ① 列出每条 open,说明锁死还差什么证据;② 显式 flagged(写明知悉原因) + 用户不表态不继续——摆完即推送:`notify.py . finish_open --title "🏁 收尾待决 " --body ""` + +## 步骤 4:代谢沉淀位(v1.24:任务结束必过,有料沉淀无料跳过) + +任务收尾即代谢入口——规律不靠人想起来才沉淀(病例:v1.23 排障纪律靠人类推动才固化): + +```bash +python3 "<插件路径>/hooks/scripts/lib/journal.py" . digest +``` + +- **digest 非空 / 本次有 rescue 回填 / top_f3 有新共变** → 执行 /regress:learn 的沉淀 + 流程(规律入 AGENTS.md 标记块 + `rules_ledger.py . record` 记账),汇报新增规律条数 +- **机器级分支(v1.31)**:沉淀里若有机器味事实(服务器拓扑/通道/环境坑/跨项目工具坑)→ + `python3 "<插件路径>/hooks/scripts/lib/facts.py" record "<标题>" "<一句话+验证命令>" [域]` + (同键重录自动刷新日期;落 ~/.zcode/skills/machine-facts/,跨项目可借,人类无感) +- **模块卡片回写(v1.35 起;v1.40 升机器位)**:本任务动了用户可见能力/模块结构 → 更新 + `.regress/product-arch.md` 对应卡(完成度提档/能力增行/缺口增删/依赖变化; + 顺手校对卡上的验证路径还走得通吗);重大选型若发生在实施中 → 补 ADR。 + 纯内部零结构变化 → 报告一行「卡片无变化」。 + **提交门禁会拦**(v1.40 规则B):staged 有结构性增删/改名而卡片未随同 staged → + block(病例:028 新增桥脚本卡片 8 天未回写无人拦);纯脚手架确不需进卡 → + 清单 scan 加 `card_sync: false` 并写明理由 +- **验收行勾选(v1.55 升机器位)**:M/L 清单「## 验收标准」每行验证过后行尾加 + `✅`——done 盖章时门禁校验全勾(缺节/未勾/占位/判据缺(验:命令)都拦); + 纸面验收不是反馈,勾过的才算数 +- **顾问采纳落账(v1.49)**:本批评审带顾问意见 → 落一条 advisor_adoption + 事件(journal add advisor_adoption,adoption ∈ adopted/partial/rejected)—— + 与回复中的标注义务同源(回复里写「已咨询第二意见:采纳」,台账里也要有同条), + 采纳率在 /regress:stats 第 10 项可查 +- 全空 → 报告一行「无可沉淀,代谢跳过」——不硬凑 +- 顺带跑 `rules_ledger.py . health`:命中 ≥3 的稳定规律输出「建议固化 skill」卡片 + (🦴 经人批准后用 skill-creator 固化——**自动固化的错误经验会以技能的形式高速复发**) + +## 步骤 5:产物干净检查(v1.27:学费随分支走) + +```bash +git status --porcelain .regress 2>/dev/null +``` + +- 非空 → 汇报「.regress 有 项未提交——清单/地层/决策是学费,随分支走才不丢 + (worktree 场景:remove 前未合并 = 蒸发)」 +- 命令失败(非 git 项目)→ 报告一行「产物层无版本保护(非 git),地层只活在 + 单份工作区」,后续不重复提醒 +- 双仓库项目(.regress 与代码分属两仓,如 dogfood 工作区)→ 在 .regress 所属仓库执行 + +## 步骤 6:汇总报告 + +``` +🏁 收尾完成 · REGRESS- + 改动:F1/F2(计划内)+ F3 × (已回写留痕) + 脆弱点: locked / flagged / 0 open + 测试:/ 通过<+覆盖率> + 债务: 笔 bypass 未补回归(>0 要提示还债) + 环境:<与 env.lock 一致 或 ⚠️已漂移:项> + +可以提交(门禁复验后自动标 done)。 +``` + +**离场召回(v1.31.1)**:汇总输出后推送 done 事件——离场人类靠它知道"回来收货": + +```bash +python3 "<插件路径>/hooks/scripts/lib/notify.py" . done --title "🏁 完成 :<需求一句话>" --body "|测试 /|债务 |<残留或干净>" +``` + +(一条/任务,频率天然低;干净收尾与 finish_open 二选一触发,不双响。 +标题只写任务名——【项目名】前缀与 🕐 时间由 notify 层自动注入,v1.31.2; +正文首位放任务内容简介(requirement 原文,不另行改写——离场人类回看时知道 +"这任务是干啥的",不用回来问),v1.31.2 补。) + +## 自主决策 + +长任务心跳(P1#13 接线):授权自主推进的任务,每完成一个阶段边界(单个 F 落地/ +半小时过半)推送一次——人不在屏前也看得见进度,治"两小时黑箱": + +```bash +python3 "<插件路径>/hooks/scripts/lib/notify.py" . progress --title "⏳ 清单id·阶段n" --body "本阶段一句:动了什么/下一阶段" +``` + +- 清单已是 done/completed → 报告"已收尾",只输出债务/漂移检查 +- 测试全绿但用户不在场 → 报告就绪状态并停,不代答 open 的处置 +- 小改动收不了尾(无清单)→ 提示下次走 /regress:quick 或先 /regress:plan diff --git a/plugins/regress-guard/commands/regress:init.md b/plugins/regress-guard/commands/regress:init.md new file mode 100644 index 0000000..bdc12e9 --- /dev/null +++ b/plugins/regress-guard/commands/regress:init.md @@ -0,0 +1,187 @@ +--- +description: 初始化项目的 .regress/ 数据目录(代码已在全局级安装,这里只建数据) +argument-hint: [--force] +allowed-tools: Read, Write, Edit, Bash +--- + +# /regress:init — 初始化项目 + +在项目根目录创建 `.regress/`(**只是数据目录,不是代码**)。代码已在全局级 `~/.zcode/` 安装。 + +## 步骤 1:冲突检测 + +先检查项目里是否有重复的 regress-guard 代码(会导致和全局级冲突): + +```bash +# 检查项目级是否有重复 skill/command +ls .zcode/skills/regression-planning 2>/dev/null +ls .zcode/skills/characterization-testing 2>/dev/null +ls .zcode/skills/change-impact-analysis 2>/dev/null +ls .agents/skills/regression-planning 2>/dev/null +ls regress-guard/hooks/ 2>/dev/null +``` + +如果发现重复 → **警告并询问是否删除项目级副本**: +``` +⚠️ 检测到项目级有 regress-guard 的 skill/command 副本: + .zcode/skills/regression-planning/ + +全局级已安装(~/.zcode/skills/),项目级副本会导致: + - 用到旧版本 + - 更新不同步 + - ZCode 优先级混乱 + +建议删除项目级副本(数据不受影响)。 +``` + +## 步骤 2:检查已有 + +```bash +ls .regress/config.json 2>/dev/null +``` +若已存在且无 `--force` → 停止,提示已初始化。 + +## 步骤 3:创建数据目录 + +```bash +mkdir -p .regress/manifests .regress/journal +``` + +写入 `.regress/config.json`: +```json +{ "strict": true, "read_before_edit_ratio": 2 } +``` + +创建 `.regress/README.md`(**零号入口**——写给失忆的读者:打开仓库第一眼 +就知道从哪读起、"做完"由什么定义。内容与 `templates/regress-dir-readme.md` 同源, +复制后可按项目微调;self_heal 会在老项目里自动补这个文件,永不覆盖已定制版本): + +```bash +cp "<插件路径>/templates/regress-dir-readme.md" .regress/README.md +``` + +创建 `.regress/product-context.md`(v1.26 产品上下文卡——「资深·懂行业·贴近用户」 +那部分知识的住所;用户/价值观段人类填,行业惯例段可由顾问带搜索起草。 +缺失才补、永不覆盖): + +```bash +cp "<插件路径>/templates/product-context.md" .regress/product-context.md +``` + +创建 `.regress/product-arch.md`(v1.35 模块卡片——产品存量与架构连接点同卡: +每模块一卡 = 完成度|能力|依赖方向|契约|缺口|验证路径。AI 扫代码起草初版 +(每个可辨认的模块一张卡),人类校对;缺失才补、永不覆盖): + +```bash +cp "<插件路径>/templates/product-arch.md" .regress/product-arch.md +``` + +创建 `.regress/adr/` 目录(v1.35 架构决策记录——重大选型必落 +`adr/NNNN-短标题.md`,模板 `templates/adr.md`;decisions.md 记"决定", +ADR 记"为什么这么定"的完整备选与红线): + +```bash +mkdir -p .regress/adr +``` + +创建决策日志 `.regress/decisions.md`(公理二:认知物质化——决策链刻在文件里, +新会话/新人不靠记忆,读文件): + +```markdown +# 决策日志(append-only,最新在最上) + + +``` + +采集环境指纹 `.regress/env.lock.json`(公理一:谁帮我设的 CUDA?锁住当下环境, +漂移可检测): + +```bash +python3 - <<'EOF' +import json, subprocess, sys, platform +def cap(cmd): + try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10).stdout.strip() + except Exception: return "unavailable" +lock = { + "captured_at": __import__("datetime").datetime.now().isoformat(), + "python": sys.version.split()[0], + "node": cap("node --version"), + "git": cap("git --version").split()[-1], + "os": f"{platform.system()} {platform.release()}", +} +open(".regress/env.lock.json", "w").write(json.dumps(lock, indent=2, ensure_ascii=False)) +print(json.dumps(lock, ensure_ascii=False)) +EOF +``` + +## 步骤 4:安装 git 提交观测钩子(补全证据链) + +ZCode 门卫只能看到 ZCode 发起的提交;IDE/终端直接 commit 时不可见。 +装一个原生 git `post-commit` 钩子记录**所有来源**的提交(静默/自禁用/不阻断/monorepo 向上找 .regress)。 + +**已知边界(v1.41 注记)**:钩子装在 init 所在仓库的 `.git/hooks/`——嵌套仓 +拓扑(.regress 在外仓、代码在内仓)时代码提交走内仓,钩子观测不到 +(评审病例:commit_observed 0 条+.expect-commit 残留)。单仓项目机制完整。 + +项目是 git 仓库且 `.git/hooks/post-commit` 不存在时才装(husky 等已有钩子则跳过并提示): + +```bash +if git rev-parse --git-dir >/dev/null 2>&1 && [ ! -f .git/hooks/post-commit ]; then +cat > .git/hooks/post-commit << 'RGHOOK' +#!/bin/sh +D="$(git rev-parse --show-toplevel 2>/dev/null)" +RG="" +while [ -n "$D" ] && [ "$D" != "/" ]; do + [ -d "$D/.regress" ] && RG="$D/.regress" && break + D="$(dirname "$D")" +done +[ -n "$RG" ] || exit 0 +SRC="git-hook" +EXP="$RG/.expect-commit" +if [ -f "$EXP" ]; then + if [ -n "$(find "$EXP" -mmin -5 2>/dev/null)" ]; then + SRC="zcode-$(sed -n 's/^kind=//p' "$EXP" | head -1)" + fi + rm -f "$EXP" 2>/dev/null +fi +SHA="$(git rev-parse --short HEAD 2>/dev/null)" +SUBJ="$(git log -1 --format=%s 2>/dev/null | head -c 80 | sed 's/\\/\\\\/g; s/"/\\"/g')" +printf '{"timestamp":"%s","event":"commit_observed","manifest_id":"","commit_sha":"%s","subject":"%s","source":"%s"}\n' \ + "$(date -Iseconds)" "$SHA" "$SUBJ" "$SRC" >> "$RG/history.jsonl" 2>/dev/null +exit 0 +RGHOOK +chmod +x .git/hooks/post-commit +fi +``` + +## 步骤 5:输出 + +``` +✅ regress-guard 已为本项目初始化 + .regress/config.json — 项目配置 + .regress/manifests/ — 回归清单目录 + .regress/journal/ — 考古地层(失败/风险/纠正化石,append-only) + .regress/decisions.md — 决策日志(决策链物质化) + .regress/env.lock.json — 环境指纹(漂移检测基线) + .git/hooks/post-commit — 提交观测(记录所有来源的提交) + + 代码在全局级(~/.zcode/),所有项目共享。 + 这个项目只存自己的数据(清单/历史/配置)。 + +建议把 .regress/(含 journal/)纳入 git——历史厚度决定系统生存韧性。 + +下一步:/regress:plan <需求描述> +(老项目首次大提交若被 F3 拦截,直接跑 /regress:track 一次性回填全部文件) +``` + +## 架构说明 + +``` +全局级 ~/.zcode/ → 代码(skill/command/hook),装一次 +项目级 项目/.regress/ → 数据(清单/历史/配置),每个项目独立 +``` + +不要在项目里放 regress-guard 代码——会导致和全局级冲突。 diff --git a/plugins/regress-guard/commands/regress:install.md b/plugins/regress-guard/commands/regress:install.md new file mode 100644 index 0000000..0d7eb1b --- /dev/null +++ b/plugins/regress-guard/commands/regress:install.md @@ -0,0 +1,60 @@ +--- +description: 一键安装 regress-guard 到用户级(零配置,装一次全局生效) +allowed-tools: Bash, Read, Write +--- + +# /regress:install — 一键安装 + +用户说"安装 regress-guard"或"/regress:install"时触发。自动把框架安装到用户级(`~/.zcode/`),装一次全局生效,所有项目都能用。 + +## 执行 + +找到插件源目录(通常在当前工作区或已知路径),运行安装脚本: + +```bash +# 尝试找到 install.sh +PLUGIN_DIR="" + +# 常见位置 +for candidate in \ + "${CLAUDE_PROJECT_DIR:-$(pwd)}/regress-guard" \ + "${HOME}/.zcode/workspace/default/regress-guard" \ + "$(pwd)/regress-guard"; do + if [ -f "${candidate}/install.sh" ]; then + PLUGIN_DIR="${candidate}" + break + fi +done + +if [ -z "${PLUGIN_DIR}" ]; then + echo "找不到 regress-guard 安装脚本。请指定插件目录。" + exit 1 +fi + +bash "${PLUGIN_DIR}/install.sh" +``` + +## 安装后 + +安装脚本会输出成功信息。然后告诉用户: + +``` +✅ regress-guard 已安装! + +现在可以在任意项目里用了。对 AI 说: + /regress:init ← 在项目中初始化回归工作流 + +或者直接开始用——AI 会自动在需要时加载相关 skill。 +``` + +## 如果已安装 + +install.sh 是幂等的——重复运行只会更新,不会重复添加。所以直接跑就行。 + +## 卸载 + +用户想卸载时,运行: +```bash +bash "${PLUGIN_DIR}/uninstall.sh" +``` +或对 AI 说 `/regress:uninstall`。 diff --git a/plugins/regress-guard/commands/regress:learn.md b/plugins/regress-guard/commands/regress:learn.md new file mode 100644 index 0000000..eaf32fd --- /dev/null +++ b/plugins/regress-guard/commands/regress:learn.md @@ -0,0 +1,101 @@ +--- +description: 分析历史+检测框架规则,输出项目洞察,写入 AGENTS.md +allowed-tools: Read, Write, Edit, Bash +--- + +# /regress:learn — 学习项目规律 + +分析历史 + 框架 co-change 规则,输出洞察,**写入 AGENTS.md**。 + +## 执行 + +```bash +# 历史分析 +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress summary + +# 框架 co-change 规则 +python3 "<插件路径>/hooks/scripts/lib/cochange_rules.py" . + +# 考古地层挖掘(公理三:跨会话重复失败 = 稳定经验) +python3 "<插件路径>/hooks/scripts/lib/journal.py" . digest +``` + +## 考古地层(.regress/journal/events.jsonl) + +工具失败、风险动作、用户纠正三类化石自动埋入地层(随 git 入库,重启不丢)。 +`journal.py digest` 输出**跨会话出现 ≥2 次**的重复失败签名(单会话高频是重试噪声, +跨会话重复才是稳定经验)。 + +digest 不为空时,对每条重复签名: +1. `journal.py . raw` 找到该签名的历次现场(时间/会话/上下文) +2. **三问归因(v1.46,GEPA 借思想)**——把"模型错了"翻译成"规则该改": + - **① 错误定位**:哪个推理步骤出的错(读哪份文件/哪个假设/哪次调用)? + - **② 规则归因**:AGENTS 契约或命令文档的**哪一条**导致或未能阻止这个错误? + (归因不到具体条款=这不是规则问题,是知识缺口——走 facts/machine-facts 而非改契约) + - **③ 改进建议**:那一条该怎么改(定向改写,不是"更小心"式泛泛建议)? +3. 写入下方项目规律块,格式:`- 「<签名>」反复失败 ×N(N 个会话):定位 <步骤>,归因条款 <契约第几条/命令某节>,改法 <定向建议>` +4. **同步记账(v1.24 代谢链)**: + ```bash + python3 "<插件路径>/hooks/scripts/lib/rules_ledger.py" . record --sig "<签名>" --occurrences + ``` + 首次沉淀 hits=1;同签名再检出(下次 learn 又见到它)= 命中一次—— + 命中是固化的证据,没记过账的规律块只会膨胀(实测:真实项目 3 周 18 节)。 + 新沉淀若输出「⚠️ 近重复候选」(v1.57 源头去重)→ 是同一规律的改写就改用 + `supersede --old <旧> --new <新>` 链接版本,别让账本双胞胎膨胀 +5. **匹配型证据只用于排序(GEPA 6.3 防线)**:规律"恰好命中"(任务顺利只是因为 + 没踩到坑)只作排序参考,不单独构成沉淀/固化依据——否则"改得越少→匹配越多 + →分越高"会把规律带向保守;沉淀要的是失败现场的归因,不是顺利时的巧合 + +**产品否决同样可挖(v1.26)**:digest 之外查 `design_rejected` 类化石—— +跨任务重复的产品否决(行业惯例/用户习惯/该简化)升格为产品规律,写入 +`.regress/product-context.md` 的「设计否决记录」段,稳定后晋级「行业惯例」段。 + +## 如果 total_commits == 0 +告诉用户:"暂无历史数据。用了几次后再来。" + +## 如果 tech_debt > 0 +``` +💳 技术债务:{debt} 笔 bypass 未补回归 +``` + +## 如果 outside_gate_commits > 0 +``` +📤 {n} 次提交未走门禁(IDE/终端直提或历史回填)——门禁采用率的诚实镜子 +``` + +## 如果 avg_coverage_pct 非 null +``` +📊 平均测试覆盖率 {n}%(jest 项目自动采集) +``` + +## 如果 top_f3_files 不为空 → 写入 AGENTS.md +```markdown + +## 项目规律(/regress:learn 自动发现) +- 改 src/auth/ 时,src/utils/validator.js 通常需要同步改(出现 5 次) + +``` + +## 报错自救合流(v1.15) + +清单里预先写的 rescue(报错→自救命令)与地层学出的失败根因是同一回路的两端: + +- digest 里的签名,若在活跃/历史清单的 `rescue:` 或「报错自救」表里已有对策 → + 把「报错关键词 → 自救命令」写进上面的项目规律块(跨任务的 FAQ) +- 只有失败没有对策 → 标注"待补自救",下次踩同坑时先回填清单再修 + +## 规律健康(v1.24:记账的另一半是衰变) + +```bash +python3 "<插件路径>/hooks/scripts/lib/rules_ledger.py" . health +``` + +- 🦴 **固化候选**(hits ≥3):稳定规律建议固化为宿主 skill——输出卡片**等人批准**, + 批准后用 skill-creator 造(地层是脂肪,规律是肌肉,skill 是骨骼) +- 🍂 **降级候选**(>180 天零命中):提示人工修剪——**本工具永不自动删 AGENTS.md** + (人类文件红线);只进不出的"知识库"是熵增库 +- 空 digest + 空账本 → 告诉用户"暂无可沉淀规律",不硬凑 + +## 自主决策 +- 发现规律 → 直接写入项目 AGENTS.md(不问用户) +- 数据太少(<3 次提交)→ 告诉用户多用几次再来 diff --git a/plugins/regress-guard/commands/regress:plan.md b/plugins/regress-guard/commands/regress:plan.md new file mode 100644 index 0000000..0358c7e --- /dev/null +++ b/plugins/regress-guard/commands/regress:plan.md @@ -0,0 +1,312 @@ +--- +description: 需求→解析→消歧→改动清单。AI 先补全上下文再动手(不问能推断的,只问关键分歧) +argument-hint: <需求描述> +allowed-tools: Read, Write, Edit, Bash, Grep +--- + +# /regress:plan — 需求解析 + 改动分析 + +参数 `$ARGUMENTS` 是需求描述。为空则提示用法。 + +> **与宿主计划模式叠加**:可在 EnterPlanMode 下进行——原生管会话权限(只读容器), +> 本命令管产物生命周期(清单=批准后契约,值班到 done)。批准双落:宿主手势退出 +> + plan_approve 落 approved.at;v1.39 起宿主 ExitPlanMode 批准由 plan_bridge +> 钩子自动转录/盖章(钩子未生效时 AI 补转录,幂等)。小改动走 quick(判据见 0.5)。 + +## 步骤 0.5:规模判定(v1.37:full 的重仪式按档触发,不再一刀全量) + +对着需求与初判改动面打档(判据与 /regress:quick 同源,越级可审计): + +- **S 档**(≤3 文件·纯内部·无环境变更)→ 本该走 quick;用户执意 full 时 + 只强制:F 列表+边界+**至少 1 条脆弱点**+验收一行——4.7/4.9/4.10 留骨架跳过 +- **M 档**(用户可见功能)→ 全流程必走 4.9 广度矩阵 + DoD 状态完备性 +- **L 档**(跨模块/结构/新依赖)→ M 全量 + 4.10 架构影响 + ADR 判定 + +塌方曲线的教训(评审 2026-023:011-020 十份连续 0 脆弱点 0 假设): +重仪式在速度压力下被绕过而非被遵守——分档让"轻"合法留痕,而不是伪装"重"。 + +## 步骤 0:自动初始化(如果无 .regress/) + +检测到无 `.regress/config.json` → 直接创建(不问)。 + +## 步骤 1:幂等检查 + +已有活跃清单匹配同一需求 → 跳过重复分析,直接输出已有清单。 + +## 步骤 2:需求解析 + +**先理解需求要做什么,再谈改哪些文件。** + +### 2a:上下文补全(AI 自己做,不问人)+ 全貌新鲜度(v1.40) + +**用户可见的产品需求,先读 `.regress/product-context.md`**(产品上下文卡 v1.26); +卡缺 → 步骤 5 卡片一次问全并建卡。 + +**再读模块卡片并验新鲜度**(全貌层 v1.40:看不全就对不准——歧义检测扫的是 +需求文本的二义性,扫不出需求与代码现状的错配): + +```bash +python3 "<插件路径>/hooks/scripts/lib/scan_check.py" . +``` + +- `fresh` → 继续 +- `stale` → **重扫受影响模块卡再继续**(AI 扫:卡片补上期间的新能力/缺口; + 治的是"计划建立在过期认知上") +- `absent` → M/L 档先建卡(init 产品层)或明确声明纯库项目无产品面; + 结构性提交时门禁会给无卡警示 + +从代码/配置/文档中**推断**缺失的上下文: + +例:"保存设置+下发全部成功才是200" → AI 从代码读出:接口 PUT …/settings +(Controller)、下发走设备控制平台(service)、现行为保存即 200、协议 MQTT。 +能推断→直接用;推断不了→记"未知"。 + +**前召回(v1.61,2a 收尾时顺手一条)**:清单落盘后跑 +`python3 "<插件路径>/hooks/scripts/lib/rules_ledger.py" . match --from-manifest <清单>` +——从清单派生查询(文件+脆弱点+卡名)召回历史规律。开工前看老规律, +比拦截时才看见早一整段(召回是提示不是行动,采纳前对照本次现场)。 + +### 2b:歧义检测(识别二义性) + +检查多种合理理解。例:"下发全部成功才是200"——A 所有设备成功才 200; +B 下发动作执行完即 200;C 200=请求被接受(现状)。 + +### 2c:消歧决策(自决 vs 确认) + +对每个歧义点判断: + +能从代码推断→自己定并标注依据;不能→只问这一个点;改变交付性质 +(如"失败回滚吗")→必须问。 + +**只问"无法自决且影响交付物"的歧义。其他自己定。** + +### 2d:输出解析结果 + +格式:📋 需求解析——理解(一段)/依据(从代码推断,逐条)/我做的假设 +(编号)/需要你确认的(只 1 个问题)。 + +### 2e:排障分流(需求是修 bug/排障时必走,v1.23) + +**先检验后动手——瓶颈不是权限,是顺序。** +(病例 REGRESS-005:真凶是自己上一轮的参数;全文 PHILOSOPHY §4) +三条纪律(先审自己的 diff / 假设落账·先跑检验后写修复 / 旧解释不复用)的完整表述 +见清单模板「假设账本」节(单一来源)——本步骤的职责是**分流**:修 bug 类需求必走, +账本裁决通过才进改动分析(步骤 3),修的是被证据指认的根因,不是防御层。 + +## 步骤 3:并行分析(使用子智能体) + +对复杂需求(涉及 >3 个文件),用 **Agent 工具委派子智能体并行分析**。 + +> ⚠️ **子代理只委派只读分析类任务**——子代理不过 PreToolUse 拦截链(实测见 +> WORKFLOW「智能体通道定界」);写文件/改代码/跑不可逆命令必须主智能体执行。 + +### 子智能体 A:需求理解 +Agent(prompt="分析需求的理解与歧义:代码推断→检测歧义→能推自决不能则列。输出:理解+假设+待确认") + +### 子智能体 B:改动点识别 +Agent(prompt="分析改动点:Grep 源码→判断类型→查现有测试(无则标 characterization_needed)。输出:改动清单") + +### 子智能体 C:影响范围评估 +Agent(prompt="评估影响范围:Grep 引用方+model 广影响。输出:影响矩阵(文件→被谁引用→是否需回归)") + +**简单需求(≤3 文件)不委派子智能体**:自己读 3 个文件分析;类型词表 +method-logic / signature / model / new-file / config。结果合并后进入步骤 4。 + +## 步骤 4:生成清单(含理解字段 + 脆弱点拓扑) + +```yaml +--- +id: REGRESS-<序号> +requirement: "<原始需求>" +understood_intent: "" +assumptions: # AI 自己做的假设(2d 产出) + - "下发失败不回滚" +confirmed: [] # 人类确认过的(初始空) +ambiguities_resolved: # point / ai_choice / basis 三键 + - point: "下发模式" + ai_choice: "同步等待所有设备" + basis: "DeviceControlService.dispatch() 同步调用" +status: planning # 待人类批准(拦编辑)→ plan_approve.py 转写后 in-progress +base_head: "" # 创建时基线——批准时校验漂移 +approved: # 批准落产物:转写器填,或人类直接填 at(产物直通) + at: "" + note: "" +planned_changes: [...] # 全量字段见 templates/regress-manifest.md +fragile_points: # 公理一:穷举关键脆弱点(字段见模板) + - id: V1 + kind: env + description: "CUDA 由 module 加载,重启后丢失" + verify: "python3 -c 'import torch;print(torch.cuda.is_available())'" + status: open +--- +``` + +### 步骤 4.5:脆弱点拓扑(公理一,生成清单时必做) + +**"正确"是瞬态,"锁死脆弱点"才是产出。** 对着改动清单逐类问: + +| 类别 | 问自己 | 典型例子 | +|------|--------|----------| +| env | 谁帮我设的?换台机器还在吗? | CUDA_HOME、NODE_OPTIONS、locale | +| dependency | 哪个库会报错/静默变行为? | flash_attn 编译、语义化版本漂移 | +| resource | 会被抢吗?重启后还在吗? | PID 复用、端口占用、GPU 显存 | +| data | 丢了能恢复吗? | 无迁移的 schema 变更、删跑数据 | +| concurrency | 两个进程同时写会怎样? | 状态文件竞争、缓存击穿 | +| oracle | **测试替身漏掉了什么断言?** | 模拟器不校验帧 length、mock 不查 Redis key 前缀——单测假阴性之源(实测:17/17 绿照样漏 bug 都死在这) | +| sensory | 终验靠人的感官吗? | 对讲声音清晰、画面卡顿——问人只收通过/不通过,verify 写 `human_check:`(门禁验化石存在性,不复跑感官) | +| product | **用户可见的状态都看过吗?**(v1.35 DoD) | 空态/错误态/加载态——真渲染逐个看(browser-use 截图),代码审查发现不了空态尴尬;UI 功能必列 | +| arch | **依赖方向被破坏了吗?**(v1.35) | A 不得 import B、数据流只进不出——ADR 红线写成 grep 类可执行命令当 verify(结构稳定模块适用;结构剧变期人工评审挂牌) | + +- **热点共变(v1.20)**:AGENTS.md 规律/历史 top_f3 有共变对→自动带草案 + (标来源);**不许塞 `echo ok` 式空点**(挂牌制靠稀缺性保值) +- verify 必须是可执行命令(exit code 是证据,"已确认"四个字不是);能当场跑→ + locked;跑不了→open;明知不处理→flagged(写明知悉原因) +- **open 挡提交**——这是设计不是故障 + +### 步骤 4.6:开发边界(计划→物理拦截) + +清单 frontmatter 加 `boundary.include`(目录通配)——**这是省 token 和提质量的关键机制**: +无边界时 AI 漫游改无关文件(实证:一天 6B 消耗、半个月完不成任务、复检几十轮); +边界把行动空间收窄到任务本身(<1B/天、多任务/天、自动复检即达标)。 + +- include = 改动目录 + 配套测试目录;**跨层之问**:跨三层的大杂烩清单是 + 边界失守前兆,该拆就拆 +- hook 在 Edit/Write **发生前**拦截边界外文件;扩界唯一出口=/regress:track + (回写留痕);项目级关闭:config 设 "boundary_enforced": false + +### 步骤 4.7:失忆读者层(v1.15:写给断上下文的读者) + +清单 body 三段(骨架在模板):①**环境准备**——新环境/依赖/特殊启动必写, +给全命令("别点 IDE 的 Run"要写明);纯内部改动留骨架。②**实施顺序**—— +planned_changes 排成编号步骤,每步挂可观察里程碑("单测绿"/"控制台打印 X"), +先基石后骨架后联动,**禁止一口气写完再调**——里程碑是断点续作的"从哪继续"。 +③**报错自救**——已知脆弱点配 rescue(失败先敲什么);踩坑后回填。 + +### 步骤 4.8:设计备选(v1.25:没有备选的设计不是设计) + +方案唯一往往不是想清楚了,是没想过(病例:v1.23 框定偏差被用户纠正——卡片有备选, +人类 30 秒可纠偏)。落产物:清单「需求理解」记一行 +**设计取舍**:`<被否方案>——否决因:<一句理由>`。 + +### 步骤 4.9:广度矩阵(v1.35:治"做完才发现少一半") + +用户可见功能必走,落清单「广度矩阵」节(骨架在模板)。三问: + +1. **角色 × 任务**:谁(哪些角色)拿它做什么(jobs-to-be-done)? + **每行配验证路径**——用户故事反推一条可操作走查语句(顾问补强: + 没有路径的矩阵是条款清单,不是产品思考) +2. **伴侣功能清单**(UI 类 10 项逐个过:设置/错误态/空态/加载态/撤销与二次 + 确认/权限/移动端/数据导出/i18n/无障碍)——**写"不做"也算答,留空=没想过**。 + AI 的默认行为是只做字面需求:没人逼它枚举伴侣,它就永远漏 +3. **竞品对照**(有竞品时):人家有什么我们没有?差距动作 = 补(进本期/下期) + 或不补(写一句为什么不补) + +已装机的 brainstorming(superpowers)/ office-hours Skill 适用于本步骤的 +探索期——知识层在场,流程层以本步骤为准。 + +### 步骤 4.10:架构影响与 ADR(v1.35:选型理由不随对话蒸发) + +先读 `.regress/product-arch.md` 模块卡片(卡不存在 → /regress:init 补骨架), +跨模块/结构性改动必答清单「架构影响」四问(动哪些模块/依赖方向变化/ +新增耦合/触发 ADR 否);单文件内部改动一句"无结构性影响"了事。 + +**何时必须落 ADR**(`.regress/adr/NNNN-标题.md`,模板在 templates/adr.md): +引入新依赖 / 新服务 / 改数据流 / 破模块边界 / 推翻旧 ADR。 +ADR 的「红线」节写成可执行 verify 命令 → 进清单 fragile_points kind=arch +(机器强制);清单 frontmatter `adr:` 填引用。小改动免仪式(防僵硬)。 + +## 步骤 5:计划审批(人类的一寸——方向错误的最后低价纠偏点) + +### 5a:顾问预审(先于卡片输出) + +输出卡片前送顾问独立预审(真实 consult,audit 可查不得代笔)。四问:方向性 +风险/遗漏脆弱点/边界合理性/产品适定。**产品适定必带 mode=scout**(无来源的 +行业断言只是另一个自信的模型;问题通用化不含内部数据;scout 不可用则标注 +「未获带来源的行业预审」)。不对称原则:它说反惯例可信(否决权), +说没问题不可信——它不知道你们的用户。 + +判据层追加三问(EARS 一致性,M/L 档清单预审时照单检查): + +1. **判据行三件齐吗**:When 条件/则 结果/验 命令——缺一即不完整判据 +2. **When 条件可观察吗**:外部可观察的事件/状态,不是内心态或"正确地" +3. **验命令当场可复跑吗**:不需要人肉准备环境 + +动机:Kiro SMT 形式化分析的轻量替代路线——不追重基建,用既有顾问管道做 +判据层形式审查(行业对比 2026-09-21 行动项)。 + +埋预审化石: + +```bash +python3 "<插件路径>/hooks/scripts/lib/journal.py" . add plan_advisor_review \ + '{"manifest_id":"","verdict":"no_objection|objection|unavailable","summary":"<要点>"}' +``` + +**不对称原则:顾问有一票否决权,没有一票批准权。** + +- 有方向性异议 → 卡片附「⚠️ 顾问异议:<要点>」,保持 planning 等人——**即使任务已预授权** +- 无异议 + 已预授权 → 卡片标注后临行(伪全自动): + ```bash + python3 "<插件路径>/hooks/scripts/lib/plan_approve.py" <清单> --provisional \ + --advisor "<一句话预审结论>" + ``` + 临行进入否决窗(直到 done):人类随时 `--cancel` 否决,或给修改意见完善计划重新预审 +- 无异议 + 未预授权 → 卡片附「✓ 顾问预审无方向性异议」,仍等人类批准词 +- 顾问不可用 → 卡片标注「未获预审」,按原流程走(预审是增强不是依赖) + +清单以 `status: planning` 创建后,**停下输出计划卡片**(30 秒可扫完),不实施: + +(选号纪律,v1.56:原生桥路径的编号分配已挂文件锁防并发撞号;**AI 手写清单不经锁**—— +落盘前先 `ls .regress/manifests/` 取当前最大号再 +1,别凭记忆写号) + +``` +📋 计划(等待批准 · REGRESS-<序号>) + 理解:<一句话,含边界(做什么/不做什么)> + 改动点:F1 <文件·类型> / F2 … + 边界: + 脆弱点:V1 <描述·验证命令·状态> … + 备选:<被否方案 + 一句否决因>(4.8) + 所见:<界面示意/三步交互流/API 示例,用户可见功能必备> + 广度:<角色×任务几行+伴侣清单做/不做统计,UI 类必填;纯内部改动写"不适用">(4.9) + 架构:<动哪些模块/新增耦合/ADR 触发否——跨模块必填,单文件写"无结构性影响">(4.10) + 测试:<要求> / 假设:<我做的假设> + 验收: + 行业: + 歧义(如有):<只列无法自决且影响交付的> +``` + +通知:`notify.py . plan_approval --title "📋 待批准 " --body "<一句>" --ref <清单id>` +(--ref 进待决台账,批准时自动回流)。然后明确请求批准,三选一: + +- **「批准 / 开始 / ok」** → 运行权威转写器(一次完成全部落产物动作): + ```bash + python3 "<插件路径>/hooks/scripts/lib/plan_approve.py" .regress/manifests/<清单>.md --note "对话批准" + ``` + 它会:status→in-progress、填 `approved.{at,note}`、**漂移检查**(base_head ≠ + 当前 HEAD → 警示旧基线,差异报人类确认后继续)、`plan_approved` 入地层 +- **修改意见** → 对话完善计划、更新清单(保持 `planning`,每次细化自动入地层 + `plan_refined`),再次输出卡片请求批准。**产品理由的否决**(行业/习惯/该简化) + → 埋学费化石(跨任务重复经 learn 升格为产品规律): + ```bash + python3 "<插件路径>/hooks/scripts/lib/journal.py" . add design_rejected \ + '{"reason_class":"行业惯例|用户习惯|该简化|其他","note":"<一句>"}' + ``` +- **「取消」** → `plan_approve.py <清单> --cancel`(status: cancelled 归档不实施, + `plan_cancelled` 入地层) + +规则(浓缩):批准词必须来自人类(AI 不得代填 approved.at,地层让一切批准 +可审计);人类直填 approved.at=产物直通,AI 补跑转写对齐(不覆盖时间戳); +原始需求已预批准("直接做")→ 展示卡片标注后直接实施;S 档小改动走 quick; +待批准期间边界内编辑被拦(planning 机器强制),断点续作 /regress:resume。 + +## 步骤 6:实施 + +批准后进入开发。**长任务心跳(P1#13)**:每完成一个阶段边界推 progress +(notify.py . progress,标题「⏳ 清单id·阶段n」正文一句)—— +授权自主推进期间人不看屏也知道走到哪。 + +批准后进入开发。实施期间边界守卫/文件指纹/漂移检测/意图复述照常工作; +完成走 /regress:track → commit 门禁(自跑测试+脆弱点复验)。 + +实施中卡住(环境/权限/修复 3 次仍败/需人类决策)→ 别硬磨: +`plan_approve.py <清单> --block --reason "..." --need "..."`——四问落产物, +受阻期间拦编辑(合法停止),need 转达人类,解阻 --unblock。 diff --git a/plugins/regress-guard/commands/regress:quick.md b/plugins/regress-guard/commands/regress:quick.md new file mode 100644 index 0000000..689d3a5 --- /dev/null +++ b/plugins/regress-guard/commands/regress:quick.md @@ -0,0 +1,79 @@ +--- +description: 快速模式——机器判据达标的小改动专用(合并 plan+track 一步到位) +argument-hint: <需求描述> +allowed-tools: Read, Write, Edit, Bash, Grep +--- + +# /regress:quick — 快速模式 + +合并 `/regress:plan` 和 `/regress:track` 为一步:**基于 git diff 直接生成清单** +(不分 planned/actual,全部算实际改动)。清单标 `mode: quick`。 + +## 与 full 模式的分界(v1.37:机器判据,不再靠"个人/小改动"感觉) + +**四条全过才许 quick**(任一不过 → 走 /regress:plan full): + +1. 改动 ≤3 个文件(git diff 统计,含新增) +2. 无 new-file 以外的结构类型(model 签名变更/跨模块调用链 → full) +3. 纯内部改动(不新增用户可见行为——新功能/UI/接口语义变化 → full, + 用户可见必过广度矩阵与 DoD,那是 full 的领土) +4. 无环境/依赖变更(requirements/package/build/配置文件 → full) + +| | full 模式 | quick 模式 | +|---|---------|----------| +| 流程 | plan→开发→track→verify | 开发→quick→commit | +| F3 追踪 | ✅ 区分计划内/外 | ❌ 不区分(全是 actual) | +| 脆弱点 | 拓扑穷举 | **至少 1 条**(0 条=没想过,不是没有风险) | +| runner 缺失 | 阻断 | **豁免**(warn 放行留痕——门禁认 mode: quick) | +| 广度/架构/ADR | 按触发条件 | 永不(quick 定义即不触发) | + +## 流程 + +参数 `$ARGUMENTS` 是需求描述。 + +### 步骤 1:判据检查(四条) + +```bash +git diff --name-only HEAD; git ls-files --others --exclude-standard +``` +统计文件数、看类型、找用户可见面、找环境文件。**判据不过就在此说 +"这单走 full",转 /regress:plan**——塌方曲线的病根是重仪式被绕过, +不是仪式被正确豁免;机器判据让豁免留痕、越级可审计。 + +### 步骤 2:用户先改好代码 + +quick 模式假设代码**已经改完了**。如果还没改,提示用户先改代码。 + +### 步骤 3:生成清单(全部记为 actual_changes) + +```yaml +--- +id: REGRESS-<序号> +requirement: "<需求>" +status: in-progress +mode: quick +planned_changes: [] +actual_changes: + - id: F1 + file: "<文件>" + type: <类型> + reason: "quick 模式:基于 git diff 生成" + tests_required: [unit] +fragile_points: # 至少 1 条(哪怕就是"无测试覆盖,人工验证过 X") + - id: V1 + kind: oracle + description: "<这条改动最怕什么>" + verify: "<可执行命令>" + status: locked +--- +``` + +### 步骤 4:提示用户 + +``` +⚡ 快速清单已生成(mode: quick·判据四条全过) + F1: src/auth/login.js (method-logic) + V1: <脆弱点一行> + rollback: git revert 即回滚(v1.41 全档必填——触及迁移/外部状态时写真路径) +现在可以直接 git commit。hook 自动跑测试验证;无 runner 时 warn 放行(quick 豁免)。 +``` diff --git a/plugins/regress-guard/commands/regress:resume.md b/plugins/regress-guard/commands/regress:resume.md new file mode 100644 index 0000000..7abbe75 --- /dev/null +++ b/plugins/regress-guard/commands/regress:resume.md @@ -0,0 +1,100 @@ +--- +description: 从 .regress/ 产物层单侧重建工作现场(断点续作,不依赖对话历史) +allowed-tools: Read, Bash, Grep +--- + +# /regress:resume — 现场重建(产物自足性) + +对话会压缩、会丢上下文;产物层(.regress/)不会。本命令**只从产物重建现场**, +不依赖任何对话历史——这是「以产物为中心」的验收标准: +清单 + 地层 + 决策三样在,工作现场就在。 + +## 执行 + +### 1. git 现状 + +```bash +git status --short && git log --oneline -5 +``` + +非 git 项目跳过。有未提交改动 → 列出文件(对照清单判断是半成品还是漂移)。 + +### 2. 活跃清单逐份读取 + +`.regress/manifests/*.md` 中 status ∈ planning / in-progress / verifying / **blocked** 的,逐份提取: + +- id / requirement / status / created_at +- **blocked 的清单置顶**:读 `blocked` 四问(reason/tried/unsafe_why/need)—— + 这是需要人类处理的事,恢复现场第一件事就是把它亮出来 +- 脆弱点:open / locked / flagged 各几个(**open = 禁提交**,优先处理) +- **实施顺序对齐**:清单正文有「实施顺序(一步一响)」表时,对照 git diff 与 + 已 locked 的脆弱点推断当前走到第几步——下一步从**第一个未达里程碑的步骤**继续 +- **老格式回填**:清单正文缺「环境准备/实施顺序/报错自救」段(v1.15 前创建)→ + 提示按新模板回填(写给失忆的读者标准),回填本身就是恢复现场的一部分; + `.regress/` 里没有 README.md → 按模板同步补上零号入口 +- 边界 boundary.include +- approved.at(空 = 还没批,提醒人类批准) +- actual_changes 与 planned_changes 的差(F3 回写了几条) + +### 3. 决策考古(方向记忆) + +- 读 `.regress/decisions.md` **尾部 20 行**:最近的方向纠正、否决过的方案 + (否决过的方案不重新提出——那是已经交过学费的岔路) +- 跨会话慢性失败: + +```bash +python3 "<插件路径>/hooks/scripts/lib/journal.py" . digest +``` + +digest 非空 → 每条签名查 `journal.py . raw` 找历次现场,归纳根因模式。 + +- 近期关键化石(假设失效 / 受阻 / 解阻——"为什么"层的叙事): + +```bash +python3 "<插件路径>/hooks/scripts/lib/journal.py" . raw | grep -E 'assumption_broken|task_blocked|task_unblocked' | tail -5 +``` + +### 4. 历史指标 + +```bash +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress summary +``` + +- tech_debt > 0 → 有 bypass 未补回归,提醒还债 +- outside_gate_commits > 0 → 门禁采用率的诚实镜子 + +### 5. 知识库 + +读 `.regress/knowledge-base.json`:条目数 + 最近 3 条的问题关键词 +(上次查过的攻略直接用,不再查)。 + +## 输出格式(现场重建简报) + +``` +🔁 现场重建 · + +📋 REGRESS-):<需求一句话> + 脆弱点: open / locked / flagged + 边界: + 批准: + 改动: 计划 / 已回写 + +📜 最近决策: +⛏️ 慢性失败: +💳 债务: 笔 bypass 未补回归 + +下一步(按 status): + blocked → 🛑 最优先:把 blocked.need 转达给人类(受阻等待人类输入) + planning → 重新输出计划卡片请人类批准(lib/plan_approve.py 转写) + in-progress→ 对齐实施顺序表,从第一个未达里程碑的步骤继续;脆弱点未锁死的先锁 + verifying → 跑 /regress:verify 收尾 +``` + +多份活跃清单 → 逐份输出卡片(按 created_at 排序),下一步只列最旧的一份。 + +## 自主决策 + +- 无活跃清单 → 读 `.regress/history.jsonl` 最后几条事件,报告"上个任务已收尾", + 并列出 decisions.md 要点供开新任务参考 +- 无 `.regress/` → "本项目未接入 regress-guard,/regress:init 初始化" +- 信息缺失(字段空/文件不存在)→ 跳过该项并在简报中注明,不猜 diff --git a/plugins/regress-guard/commands/regress:stats.md b/plugins/regress-guard/commands/regress:stats.md new file mode 100644 index 0000000..3ef7057 --- /dev/null +++ b/plugins/regress-guard/commands/regress:stats.md @@ -0,0 +1,77 @@ +--- +description: 健康报表:门禁拦截/债务/规律命中/僵尸清单/钩子活性一屏读(观测期仪表盘,只读不改) +allowed-tools: Read, Bash +--- + +# /regress:stats — 健康报表 + +一屏读完治理实况。**只读**——本命令不改任何状态,全部数据来自既有产物。 +观测期仪表盘(病例:v1.26.1 审计时手工统计部署差异,本命令是那次需求现场的工具化)。 + +## 执行(六个既有 CLI,零新采集) + +```bash +# 1. 提交观测:通过/绕门禁/覆盖率/技术债 +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress summary + +# 2. 考古地层:跨会话重复失败(稳定经验候选) +python3 "<插件路径>/hooks/scripts/lib/journal.py" . digest + +# 3. 规律账本:固化候选 🦴 / 降级候选 🍂 +python3 "<插件路径>/hooks/scripts/lib/rules_ledger.py" . health + +# 4. 钩子活性(链外看门狗;P1#14 双路径:用户级装机在 regress-guard-docs,插件模式在插件根) +{ python3 ~/.zcode/regress-guard-docs/check_docs.py 2>/dev/null || python3 "<插件路径>/scripts/check_docs.py"; } 2>&1 | grep -E "config.file.invalid|空 matcher" || echo "钩子链健康" + +# 5. 机器事实卡:条数/最老/陈旧(机制存活判据:长期零新增=死重可退场) +python3 "<插件路径>/hooks/scripts/lib/facts.py" health + +# 6. 僵尸清单:planning/verifying 搁置 >30 天(哨兵口径) +grep -l "status: planning\|status: verifying" .regress/manifests/*.md 2>/dev/null | while read f; do + age=$(( ( $(date +%s) - $(stat -c %Y "$f") ) / 86400 )) + [ $age -gt 30 ] && echo "⏰ $(basename $f) 已搁置 ${age} 天" +done; true + +# 7. 推送闭环(v1.34):送达率/待决未决/误报率——告警从广播到可度量可校准 +python3 "<插件路径>/hooks/scripts/lib/notify.py" stats + +# 8. 待决裁决:人类说「有用/误报/忽略」后由 agent 记账(对号,见推送正文〔待决#N〕) +python3 "<插件路径>/hooks/scripts/lib/pending.py" list --pending + +# 9. 块消息有效性(v1.47 影子采集,GEPA 评分环):拦截消息即提示词—— +# 同清单同原因反复被拦=消息没教会 agent;🚨 无效候选(≥3 且跨会话/跨 7 天, +# 分母恒带)值得改写消息文案;⚠️ repeat 是告警级观察 +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress nudge + +# 10. 顾问采纳率(v1.49,给裁判装评分器):adopted+partial 计入采纳; +# 低采纳率=顾问意见不合用,校准咨询姿势(finish 代谢位落 advisor_adoption 事件) +python3 "<插件路径>/hooks/scripts/lib/journal.py" . adoption + +# 11. 召回有效性(v1.58,事件序代理·弱证据只排序):rule_recall 后同清单 +# 直接放行=干净解决;间有拦截=带干预解决(单列不加总);未决。扩召回接线看这里 +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress recall + +# 12. 拦截热力图(v1.62):commit_blocked 按 reason 聚合频次——高频原因 +# 就是下一个该接召回的拦截点(v1.54 只挂3点,扩点看这里的数据) +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress heatmap +``` + +## 输出格式 + +``` +📊 regress-guard 健康报表 · <项目名> + 门禁: 过 / 绕行(IDE 直提)/ 债 笔 + 地层: 事件 / 跨会话重复失败 个签名 + 规律: 条(🦴 固化候选 / 🍂 降级候选 ) + 事实卡: 条机器事实 / 最老 天 / 🍂 条未刷新 + 钩子:健康 | ⚠️ <看门狗告警> + 僵尸:<无 | ⏰ 清单×n> +``` + +## 解读要点 + +- **绕行率持续高** → 门禁采用率问题(IDE/终端直提),不是测试问题——先解决提交习惯 +- **债不还** → 赦免权闭环失效的前兆(PHILOSOPHY §11) +- **规律只涨不落** → 该跑 learn 并修剪降级候选(熵增警戒) +- **事实卡长期零新增** → 机制死重,摘掉 finish 的机器级分支即退场(机制也要代谢) +- 全部为零 → 项目刚开始或钩子长期静默——用第 4 项确认链活性 diff --git a/plugins/regress-guard/commands/regress:trace.md b/plugins/regress-guard/commands/regress:trace.md new file mode 100644 index 0000000..7147685 --- /dev/null +++ b/plugins/regress-guard/commands/regress:trace.md @@ -0,0 +1,61 @@ +--- +description: 查看交付链:需求→会话→事件→提交 的可追溯视图(文本版 Inspector) +argument-hint: [manifest-id] +allowed-tools: Read, Bash +--- + +# /regress:trace — 交付链追溯 + +把 `.regress/history.jsonl` 按 **意图→过程→产出** 组织成可读的交付链(借鉴 Harness Inspector 的证据链模型,文本版实现)。 + +## 执行 + +```bash +python3 "<插件路径>/hooks/scripts/lib/history.py" .regress trace +``` + +## 输出示例 + +``` +📌 REGRESS-001 (2026-08-12T17:20) + └─ 会话 sess_3744109b + 🚫 17:20 commit_blocked untracked_files → src/utils/validator.js + 🔍 17:21 f3_discovered + ✅ 17:22 commit_passed (jest) @a3f8c2d1 + 📦 产出: 1 次放行提交 + +📌 REGRESS-002 (2026-08-13T11:32) + └─ 会话 sess_7f9299c9 + ⚠️ 11:32 error + ✅ 11:32 commit_passed (none) @b7e1f0a9 + 📦 产出: 1 次放行提交 +``` + +## 怎么读 + +| 元素 | 含义 | +|------|------| +| 📌 manifest | 意图锚点——哪个需求 | +| └─ 会话 | 过程锚点——哪次 ZCode 会话 | +| ✅/🚫/⚡/❌ | 事件:放行/阻断/bypass/测试失败 | +| @sha | 产出锚点——基于哪个提交 | + +## 用途 + +- **检查交付**:一个需求经历了什么(多少阻断、是否 bypass、最后有没有产出) +- **发现异常**:一个需求挂了很多会话但 0 产出 → 卡住了 +- **审计 bypass**:⚡ 事件有到期时间,可对照是否补了回归 + +## 哨兵视图(v1.34 多会话) + +trace 看历史,哨兵看**现在**——多会话并行时任何单个会话都看不见别人的 +活跃清单(冲突只在集成点现形)。先跑: + +```bash +python3 "<插件路径>/hooks/scripts/lib/sentinel.py" . +``` + +一屏看清:活跃清单 × 会话归属(本会话/他会话/共享无戳)× 悬停时长 +(in-progress >1 天 ⚠️、blocked 🛑 在等人类),以及**多会话声明了同一 +文件**的重叠清单——提交前先对焦,比提交后回滚便宜。门禁的跨会话拦截 +消息会指路到这里。 diff --git a/plugins/regress-guard/commands/regress:track.md b/plugins/regress-guard/commands/regress:track.md new file mode 100644 index 0000000..7c9d9a1 --- /dev/null +++ b/plugins/regress-guard/commands/regress:track.md @@ -0,0 +1,47 @@ +--- +description: 对比 git diff 发现 F3 并直接回写(AI 完成修改后自动执行,不需用户手动触发) +argument-hint: [manifest-id] +allowed-tools: Read, Write, Edit, Bash, Grep +--- + +# /regress:track — 发现 F3 并回写 + +**AI 完成代码修改后应自动执行此命令,不等用户手动触发。** + +对比 `git diff` 与清单,找出 F3(计划外改动),**直接回写**,汇报结果。 + +## 执行(不等确认) + +```bash +git diff --name-only HEAD 2>/dev/null +git ls-files --others --exclude-standard 2>/dev/null +``` + +1. 找最新 `status != done` 的清单 +2. 对比 `planned_changes`,找出不在清单中的文件 = F3 +3. **直接回写**到 `actual_changes`——**这也是开发边界的扩界出口**:被 boundary_guard + 拦下的文件,回写 actual_changes 后边界随之扩展(扩界必须经过这一步 = 留痕) +4. 为每个 F3 分析影响范围(`grep -rl` 找引用方) +5. 输出汇报 + +## 自主决策(不问用户) + +- F3 只改注释/格式 → 忽略 +- F3 改了逻辑 → 回写 + 提示补测试 +- F3 是新文件 → 回写 + 提示补测试 + +## 过滤规则(自动忽略) + +`.regress/`、`AGENTS.md`、锁文件、纯 `.md`、`*.test.*` + +## 输出 + +``` +🔍 改动审计完成 + ✅ F1, F2(计划内) + ⚠️ F3: src/utils/validator.ts → 已回写 + +继续提交。 +``` + +无 F3 时:`✅ 无计划外改动,可以提交。` diff --git a/plugins/regress-guard/commands/regress:uninstall.md b/plugins/regress-guard/commands/regress:uninstall.md new file mode 100644 index 0000000..275f212 --- /dev/null +++ b/plugins/regress-guard/commands/regress:uninstall.md @@ -0,0 +1,35 @@ +--- +description: 卸载 regress-guard(清理用户级配置 + hook + skill + 命令) +allowed-tools: Bash +--- + +# /regress:uninstall — 卸载 + +清理 regress-guard 的所有用户级配置。项目的 `.regress/` 目录保留(历史数据不删)。 + +## 执行 + +找到卸载脚本并运行: + +```bash +# 尝试找到 uninstall.sh +for candidate in \ + "${HOME}/.zcode/workspace/default/regress-guard" \ + "${CLAUDE_PROJECT_DIR:-$(pwd)}/regress-guard"; do + if [ -f "${candidate}/uninstall.sh" ]; then + bash "${candidate}/uninstall.sh" + exit 0 + fi +done +echo "找不到 uninstall.sh" +``` + +## 清理内容 + +- 删除 `~/.zcode/skills/` 下的 3 个 skill +- 删除 `~/.zcode/commands/` 下的 8 个命令 +- 删除 `~/.zcode/regress-guard-hooks/` 目录 +- 从 `config.json` 移除 PreToolUse + SessionStart hook +- 从 `AGENTS.md` 移除回归契约块 + +**安全**:不会删除用户的其他 skill/命令/配置。项目的 `.regress/` 保留。 diff --git a/plugins/regress-guard/commands/regress:update.md b/plugins/regress-guard/commands/regress:update.md new file mode 100644 index 0000000..e41b058 --- /dev/null +++ b/plugins/regress-guard/commands/regress:update.md @@ -0,0 +1,45 @@ +--- +description: 检查并更新 regress-guard 到最新版(手动触发;SessionStart 也会自动检测) +allowed-tools: Bash +--- + +# /regress:update — 更新 + +手动检查源目录是否有新版本,有则升级。 + +**通常不需要手动跑**——每次 ZCode 启动时 self_heal 会自动检测版本差异,源目录更新了会静默升级。本命令用于立即触发更新(不等下次启动)。 + +## 执行 + +```bash +python3 "<安装路径>/regress-guard-hooks/lib/self_heal.py" +``` + +如果有更新: +``` +✅ 自动升级 v0.5.0 → v0.6.0(15 个文件已更新) +``` + +如果已是最新: +``` +✅ 已是最新版 v0.6.0 +``` + +## 如何更新源目录 + +如果你是开发者,修改了 `~/.zcode/workspace/default/regress-guard/` 下的代码: +1. 改完后 bump `plugin.json` 的 version(如 0.5.0 → 0.6.0) +2. 所有已安装的 ZCode 下次启动时自动升级(self_heal 检测到版本差异) +3. 或在其他项目里跑 `/regress:update` 立即生效 + +## 升级机制原理 + +``` +源目录 plugin.json: version=0.6.0 +安装目录 .source: source_version=0.5.0 + ↓ self_heal 检测到 + 0.6.0 > 0.5.0 + ↓ 自动全量覆盖 + skills/commands/hooks/lib 全部更新 + .source 的 source_version 更新为 0.6.0 +``` diff --git a/plugins/regress-guard/commands/regress:verify.md b/plugins/regress-guard/commands/regress:verify.md new file mode 100644 index 0000000..b66fc96 --- /dev/null +++ b/plugins/regress-guard/commands/regress:verify.md @@ -0,0 +1,150 @@ +--- +description: 跑测试预览结果(提交时 hook 也会自动跑;测试失败 AI 应自行修复重跑) +argument-hint: [manifest-id] +allowed-tools: Read, Write, Edit, Bash +--- + +# /regress:verify — 测试验证 + +**提交时 hook 会自动跑测试。本命令用于提交前预览。** + +如果测试失败,**AI 应自行分析失败原因、修复代码、重跑测试**——不要把错误甩给用户。 + +## 自主决策流程 + +``` +跑测试 + ├─ 全通过 → 汇报"测试通过,可以提交" + ├─ 有失败 → 自己分析失败原因 + │ ├─ 是自己的代码 bug → 自己修,重跑 + │ ├─ 是测试本身写错 → 自己修测试,重跑 + │ └─ 是环境/依赖问题 → 修复环境,重跑 + │ └─ 修了 3 次还失败 → 才找用户,附上完整错误 + └─ 无测试运行器 → 告知用户需要装什么,不问"要不要装" +``` + +## 执行 + +直接用 Bash 调项目测试运行器: +- **Node.js**: `npx jest --json --silent` +- **Python**: `python3 -m pytest -q --tb=line` +- **Java**: `mvn test -q` +- **Go**: `go test ./...` + +## 验收标准(v1.25:需求侧的 done 定义) + +清单正文「验收标准」表逐条拿证据(判据 → 证据列的 verify 命令 / `human_check:`), +通过在表内标 `locked`。**汇报里的"根治/完成/更好"必须能指回这里某一条判据**, +指不回去的词不许说(病例:REGRESS-005"根治"×3,每次证据等级零)。 +非功能底线(延迟/安全/兼容)若发现缺席——报告里标红"验收标准缺非功能判据", +补上再继续(缺席的底线会在设计后期反噬,jitter buffer 病例)。 + +## DoD 状态完备性(v1.35:跑通≠完成) + +用户可见功能(清单「DoD 状态完备性」节非空)逐状态**真渲染看过**才算过: + +- 正常态/空态/错误态/加载态逐个用 browser-use(web-gui-tester)打开看一遍, + 截图为证;看不了渲染的(CLI/API)用等价证据(空输入请求、错误注入响应) +- **代码审查发现不了空态尴尬**——"我读了代码,没有空态问题"不是证据, + 截图/human_check 才是 +- 状态证据落脆弱点 kind=product(open 挡提交的机器强制与脆弱点同轨); + 需要人眼裁决美感的走 sensory(`human_check:`,只收通过/不通过) +- 伴侣功能清单里写了"不做"的项不复检——决策已留痕,不翻旧账 + +## 脆弱点证据(公理一:证据律) + +活跃清单 frontmatter 里有 `fragile_points` 时,测试之外还要逐条拿证据: + +1. 逐条执行 `verify` 命令(必须非交互、有 exit code) +2. 通过 → 回写该条 `status: locked`(证据 = 命令 + exit 0,不是"我觉得没问题") +3. 失败 → **先查该条的 `rescue:` 字段和正文「报错自救」表**(为这个错准备的自救命令, + 跑完再重试 verify);仍失败保持 `open`,失败输出贴进报告;自己能修就修完重跑, + 修好后把自救命令**回填** rescue(下次不再踩同一个坑) +4. 显式不处理 → `status: flagged`,description 追加知悉原因 +5. **结果与清单推测不符(假设被证伪)** → 别只改 description 了事: + 清单正文「假设失效记录」表追加 was→reality→evidence,并埋地层化石—— + ```bash + python3 "<插件路径>/hooks/scripts/lib/journal.py" . add assumption_broken \ + '{"manifest_id":"","vid":"V?","was":"<原假设>","reality":"<实际>","evidence":"<命令>"}' + ``` + 失效过程(为什么错、错在哪)比修正后的结论更有价值——它是下次推测的校准器 +6. **sensory 类脆弱点(人的感官是唯一判据,v1.19)**:终验时问人**一个布尔问题** + (通过/不通过,禁止开放描述)→ 结果落化石 → 转 locked(verify 写 `human_check:`): + ```bash + python3 "<插件路径>/hooks/scripts/lib/journal.py" . add human_check \ + '{"manifest_id":"","vid":"V8","question":"对讲声音清晰无断续?","result":"pass"}' + ``` + 门禁不复跑感官——机器验证"人确认过"这个**事实的存在**(AVS 公理三:人只是传感器, + 传感器读数入档即证据);提问前先推送通知:`notify.py . sensory --title "🎤 感官终验 " --body "<问题>"` + +**open 状态的清单提交时会被 hook 阻断**——别绕过它,去拿证据或挂牌。 + +## 排障纪律(v1.23:失败即证伪,不叠层) + +修复部署后 oracle(测试/真机/用户)仍报同样的问题 → **假设被证伪,不是防御不够**: + +- 回清单「假设账本」新增一行(假设/检验命令/结果/裁决=证伪),重新取证—— + 下一层防御必须由**新证据**指认,禁止"再叠一层说不定就好了" + (病例:REGRESS-005 三层防御烧 6+ 轮部署循环,真凶是自己上一轮的参数) +- **宣称跟着证据走**:根因被证据指认、oracle 复验通过之前,只说 + 「缓解了 X,待验证」,**不说「根治」**(病例:同会话"根治"宣言发了三次, + 每次证据等级为零) + +## 真机/联调阶段纪律(用户令:"你自己弄!成功后再叫人类!") + +发起、连接、抓包、日志核对**全部自主完成**;仅感官终验(声音/画面/手感)时叫人, +成功后通知。禁止让人类"帮忙看下日志"、"试下输个特殊字符"——人是传感器不是备用断言机。 + +## 启动预检(v1.19:环境自检 fail-fast) + +环境类脆弱点优先落为 preflight 脚本/健康检查(探测 + 断言 + 不符即**拒绝启动**), +而不是 README 人读指令("请确保 Redis 已启动"是被禁止的形态)。预检标准不止"端口活着"—— +**跨服务契约往返断言**:ticket 写→读一致、流端口握手可完成(多租户前缀这类静默篡改 +只有往返能抓到,实测教训:4001 票据 bug 绿灯存活)。verify 时环境脆弱点 = 跑预检脚本拿 exit code。 + +## 受阻出口(v1.14;v1.18 加知识型分流) + +修复 3 次仍失败、或需要输入才能继续 → **先分流再受阻**: + +1. **知识型阻塞**(API 用法/方案取舍/报错不理解)→ 先带四问上下文去 consult: + 顾问能解 → 实施其建议继续(意见标注采纳/部分采纳/不采纳);仍不能解 → 下一步 +2. **环境/权限型阻塞**(端口不通/凭据缺失/共享环境)→ 顾问无能为力,直接受阻: + +```bash +python3 "<插件路径>/hooks/scripts/lib/plan_approve.py" <清单> --block \ + --reason "卡在哪" --tried "已试什么(含顾问意见)" --unsafe "为何不能绕" --need "需要人类什么" +``` + +受阻期间边界守卫拦编辑(合法停止);把 need 报告给人类;解阻后 `--unblock` 恢复。 + +## 环境指纹漂移检测(公理一:谁帮我设的 CUDA?) + +`.regress/env.lock.json` 存在时,重新采集当前环境并与锁文件对比: + +```bash +python3 -c "import sys;print('python', sys.version.split()[0])" +node --version 2>/dev/null; git --version | cut -d' ' -f3; uname -r +``` + +python / node / git / 内核任一与锁文件不一致 → 在报告里标红"环境已漂移", +并列出漂移项——上次验证过的结论,换了环境不作数。 + +## 输出 + +``` +✅ 测试通过 9/9,可以提交。 +``` + +或(自行修复后): +``` +⚠️ 测试初次失败,已修复 1 个问题,重跑通过 9/9。 + 修复:src/math.js divide() 除零未处理 +``` + +或(修不了才找用户): +``` +❌ 测试失败,我尝试修复 3 次仍未通过。 + 失败用例:"should connect to database" + 原因:需要真实的数据库连接,我无法自行修复。 + 需要你确认:是 mock 掉还是用测试数据库? +``` diff --git a/plugins/regress-guard/docs/PHILOSOPHY.md b/plugins/regress-guard/docs/PHILOSOPHY.md new file mode 100644 index 0000000..ca95e04 --- /dev/null +++ b/plugins/regress-guard/docs/PHILOSOPHY.md @@ -0,0 +1,228 @@ +# regress-guard 的设计思想 + +> 这不是一个测试工具,是一套关于**如何与 AI 协作而不被它烧掉**的治理哲学。 +> 功能会过时,思想不会。本文按依赖顺序展开十三条,每一条都来自真实的教训, +> 每一条都在代码里有机器强制的对应物——**没有 hook 兜底的思想只是愿望**。 + +--- + +## 0. 起源:信任链靠证据,不靠声明 + +AI 改代码会偷偷改坏,而"改坏了"往往没有失败信号——测试碰巧绿、日志碰巧静默。 +本项目的全部机制回应同一个问题:**当执行者自己就是不确定性来源时,信任从哪来?** + +答案是一句话,也是本项目的第一性原理: + +> **不要指望它变聪明,要指望它变死板。** +> 灰尘对得上(文件指纹)、失败刻在石头上(考古地层)、 +> 拆弹要输密码(执行阀)、未挂牌的脆弱点不许出门(V 门禁)。 + +## 1. 四公理:基于脆弱性前置的分布式认知控制论 + +**公理一·脆弱性前置**——"正确"是瞬态,"锁死脆弱点"才是产出。每个任务开工前穷举致败 +脆弱点(环境/依赖/资源/数据/并发/**测试判据**/**感官终验**),逐个挂牌: +`open`(禁提交)→ `locked`(verify 命令实测通过)或 `flagged`(带病挂牌,写明知悉原因)。 +**未列出的脆弱点才是真正的未知风险。** + +**公理二·认知物质化**——人脑和会话上下文都是易失存储。决策写 `decisions.md` +(含否决过的方案),文件改动前比对指纹,一切"知道"都刻进文件。 + +**公理三·考古持久化**——失败/风险/用户纠正自动 append 进地层(journal,随 git 入库)。 +单会话高频失败是重试噪声,**跨会话重复失败才是稳定经验**——地层让未来会话像考古学家 +一样挖掘规律,而不是重新踩一遍坑。 + +**公理四·建议执行分离**——不可逆动作(mkfs/dd/force push/DROP)需要显式令牌; +顾问意见只注入文本、**永不执行**。建议的权力上限就是被听见。 + +## 2. 检测 ≠ 拦截 + +事后提醒是"追认":token 已经烧了,熵已经生了。边界必须在动作发生**前**物理拦截。 + +实测数据:不加边界的 agent 一天烧 6B token、半个月完不成一个任务、人工复检几十轮; +加上"边界内才许编辑"的事前拦截后,降到每天 1B 以内、多任务/天、自动复检即达标。 +**约束不是拖慢,是提速**——漫游才是最贵的。 + +## 3. 证据律:done 由门禁发,不由人自称 + +`locked` 的意思是"verify 命令**此刻**能过",不是"曾经能过"——提交门禁会机器复跑 +每条 locked 的验证命令,自封不算数。测试全过时由门禁写入 `done`,而不是开发者自己宣布。 + +> **没有证据的状态,被视为不存在。** + +## 4. 取证先于结论:诊断的证据律 + +§3 管 done 的嘴,这一条管诊断的脑:**代码要测试兜底,结论要证据兜底—— +同一条律,两个域。** + +病例(REGRESS-005,对讲音频排障):同一个会话里,所有突破都来自两三条命令的取证 +(Send-Q=0 定位幽灵会话、日志实测 50fps 推翻"前端 81fps 超发"的定罪),所有事故都 +来自未经取证的动手(读错 ss 列序→守卫误杀活链致设备死锁;假设"同帧连发"→去重层 +实测只拦到 1 帧)。最大的一次误诊已写出整套规格要推给前端,被用户两个字扭转—— +**"看日志"**。而日志全程在 AI 手边:瓶颈不是权限、不是知识,是顺序。 + +四条纪律: + +1. **先审自己的 diff**——症状出现在某次修复之后,第一嫌疑人就是那次修复; + 向外搜索(前端/固件/网络)之前,先看自己最近改了什么(该病例真凶是 + AI 自己上一轮写的缓冲参数) +2. **假设先检验后动手**——每个假设配一条当场能跑的检验命令,跑通才写修复; + 写不出检验命令的不是假设,是猜测 +3. **失败即证伪**——修复后问题还在,说明假设错了,不是防御不够; + 正确反应是重新取证,不是叠下一层防御(叠层须出示新证据) +4. **宣称跟着证据走**——根因被证据指认、复验通过之前,只说"缓解,待验证", + 不说"根治"(该病例"根治"宣言发了三次,每次证据等级为零) + +还有一条隐含的:**曾经为真的解释,换症状后必须重测**——上次灵过的解释 +复用起来最顺口,也最容易掩盖新病因。 + +产物对应物是清单里的「假设账本」(假设/检验命令/结果/裁决四列):清单审计 +"改了什么",管住手;账本审计"信了什么",管住脑。 + +## 5. 以产物为中心:写给失忆的自己 + +对话会压缩、会丢失;产物层(清单/地层/决策)不会。写作标准一句话钉死: +**写给失忆的自己**——断上下文的新会话、新同事、初级程序员,是同一种读者。 +文档不能只有 What 和 Why,必须有 **How-to-step-by-step**(一步一响,每步一个可观察 +里程碑)和 **What-if-broken**(报错→自救命令)。断点续作只凭产物层重建现场, +不依赖任何人的记忆。 + +## 6. 受阻是一等状态 + +卡住时最危险的不是停下来,是**硬磨或绕过**。受阻是合法的、被机器尊重的状态: +四问落产物(阻塞在哪/已试什么/为何不能绕/需要人类什么),受阻期间边界守卫拦下 +一切编辑,直到解阻。求助不是失败,**无声地绕过才是**。 + +## 7. 人类的一寸 + +人只出现在三个时刻,且每次介入都被压缩为最便宜的形态: + +- **意图授权**:30 秒扫计划卡片,批准/否决/修改——方向错误的最后低价纠偏点 +- **物理传感器**:给权限、给凭据、贴一条实测输出 +- **感官终验**:声音清晰吗?只收通过/不通过,读数入档即证据 + +流程段之间**没有人肉粘合剂**。机器无法读心,只能把理解亮出来供人扫视。 + +## 8. 不对称原则:顾问有一票否决权,没有一票批准权 + +外部第二意见(AI 顾问)的价值边界:它说"有问题"较可信(假阳性代价=多等一次人), +它说"没问题"不可信(顾问也会自信地错)。所以预审异议必然挡下任务, +而预审通过只允许"临行"(provisional)——且临行的执行授权来自人类的**事前预授权**, +永不来自顾问。人类从"每个任务批一次"升级为"定授权规则 + 否决窗"。 +**顾问是安全网,不是签字人。** + +## 9. 验证主权与 oracle 保真度 + +自动化验证的最大暗坑不是覆盖率,是**判据(oracle)的保真度**: +模拟器不校验帧长度、mock 不查 key 前缀——单测 17/17 全绿照样漏掉真 bug。 +所以脆弱点拓扑里"测试替身漏掉了什么断言"单列一类(oracle),与"终验靠人的感官吗" +(sensory)并列。人的感官读数(通过/不通过)落化石入证据链—— +**人只是传感器,传感器读数入档即证据**;机器验证"人确认过"这个事实的存在, +不复跑感官。 + +## 10. 工程宪法 + +约束要分权,否则执法者变成第二个立法者(形式指标强制之日就是指标失真之时): + +> **法归人类**(意图授权 + 预审否决),**果归观测**(技术果入地层、业务果归 APM、 +> 感官归人耳),**行归门禁**(复验执法),**赦归应急**(流程闸统一限时 bypass, +> 赦后记债),**阀归令牌**(不可逆动作物理不可赦),**容归规划**(容量边界 +> 不是赦免出来的,是规划出来的)。 + +一句话版本:**形归 lint,果归地层,行归门禁**——做执法者和实测仪, +不做第二个立法者。每个新门禁出生三问:拦什么、证据是什么、逃生口在哪。 + +## 11. 债与失效的哲学 + +- **赦后还债**:紧急 bypass 记账,测试通过的提交才还上一笔——赦免权不闭环就会 + 淤积成永久性架构腐化。 +- **假设失效过程比结论值钱**:推测被实测推翻时,不悄悄改写文档,追加 + was→reality→evidence 的失效记录——失效过程是下次推测的校准器。 +- **否决过的方案别重走**:decisions.md 里的否决项是已经交过学费的岔路。 + +## 12. 简约与定界 + +- **复用不重建**:lint/CI/观测各有成熟领土,本项目只做它们不做的事(过程证据)。 +- **契约长度冻结**:每加一条规则先问删哪一条——太长的规则会被摘要, + 被摘要的规则等于没写。 +- **拒绝清单也是资产**:每个"不做什么"的决定连同理由记档,防止未来重新发明 + 已被否决的复杂度。 +- **"全部/所有"是审计词,不是修辞词**——写"全部"之前必须盘点全集。 + +--- + +## 13. 维护准则:文档的证据律(v1.22) + +文档腐烂的根因:文档是"对系统的声明",而声明长期没有验证通道。本项目的对策三条: + +- **写决定,不写状态**:带版本号的史实句("v1.12 起计划须批准")永远为真; + 裸现在时的描述("计划须批准")随时会假——写史实是零成本抗腐烂。 +- **叙事手写,事实派生**:reference 型事实(命令表/计数/清单)由 + `gen_reference.py` 生成并校验,手写只允许在生成区之外——没有手写数字, + 就没有可绕过的检查(Goodhart 面消失)。 +- **规则由病例驱动**:每条文档断言必须注释它抓过的真实病例,无病例不立规则; + 半年零命中的规则标废弃——检查器自己也要有衰变管理。 + +机检的诚实边界:doctest 只能保证已写的一致,**漏写永远是人的问题**—— +省略型腐烂(示例漏新行为、状态机缺态)的最后一道证据律,永远是照着文档走的真人。 + +## 14. 代谢与衰变:带证据律的自改进 + +行业证明了"用得越多越强"的方向(任务后自写 skill 的 agent),也暴露了它的病: +学错没人拦,错得越快。本项目的代谢链做同一件事,但放进证据律: + +> **地层是脂肪(原始病例),规律是肌肉(跨会话≥2 的沉淀),skill 是骨骼 +> (反复命中后经人批准固化)。** + +- **沉淀有闸门**:单会话高频是重试噪声,跨会话重复才是经验(公理三的老规则)。 +- **命中要记账**:规律带 captured_at / hits,再检出即命中一次——没有记账的 + 规律块只会膨胀(实测:真实项目 3 周 18 节)。 +- **衰变是义务**:半年零命中的规律列降级候选,提示人工修剪——检查器有死词表, + 规律也要有新陈代谢;只进不出的"知识库"是熵增库。 +- **成骨要人批**:命中≥3 的稳定规律建议固化为宿主 skill,批准权在人—— + 自动固化的错误经验会以技能的形式高速复发。 + +一句话:**学得快不如学得对;学得对还要忘得掉。** + +## 15. 需求的尽头:判据与备选 + +执行层的尽头是门禁,需求层的尽头是**可检验的判据**。 + +- **验收判据前置**:需求阶段就写下"做到什么算完"(布尔判据/可观察行为), + 非功能底线(延迟/安全/兼容)在这里定型——它们在需求阶段缺席,设计就会朝 + 反方向跑(病例:jitter buffer 回滚——"延迟不可牺牲"这条产品价值观在两轮 + 过度设计后才抵达)。"根治/更好/优化"这类词必须挂在某条判据上,指不回去 + 就不许说(同会话"根治"宣称三次,每次证据等级零)。 +- **没有备选的设计不是设计**:动手前至少否决一条备选路径——方案唯一往往 + 不是想清楚了,是没想过。备选和否决因上卡片:人类 30 秒扫的不是"你选了 + 什么",而是"你是否知道还有别的路"。 + +与 §3 的关系:§3 是代码的证据律(done 由门禁发),这一条是需求的证据律—— +**done 的定义先于 done 的实现**。与脆弱点的分层:验收是需求侧(功能对不对), +脆弱点是风险侧(周边会不会坏),两个都 locked 才是真 done。 + +## 16. 产品适定性:知识可外包,价值观不可 + +AI 的设计常常产品幼稚——不是不会写,是不懂行业、不懂你的用户。这个缺口 +三分,两分在人类,一分可外包: + +- **行业惯例可外包给带搜索的顾问**(scout)——无来源的行业断言只是另一个 + 自信的模型;实测 scout 与裸模型的差距是质不是量(来源/诚实边界/判断清单)。 + 但不对称依旧:它说"反惯例"可信(否决权),说"没问题"不可信——它不知道 + 你们的用户是谁。 +- **用户语境与产品价值观不可外包**——只有人类知道"延迟不可牺牲""误报远好于 + 漏报"。住所是产品上下文卡:人类填价值观,顾问起草行业段,AI 永不删人类写的段。 +- **判断在草图**——产品适定性没有机器 oracle,唯一判据是人;多次设计的根因 + 是反馈点在实现之后。草图先行把否决从"实现后"挪到"草图后",一轮返工从 + 天级降到分钟级。学费要记账:产品理由的设计否决埋 design_rejected 化石, + 跨任务重复的升格为产品规律。 + +一句话:**行业知识借外脑,用户价值观问本人,判断在草图,学费记在账。** + +## 收束 + +本项目的全部机制,都是这一句话的注脚: + +> **把"相信"换成"验证",把"记住"换成"刻录",把"下次小心"换成"下次拦截"。** + +AI 不会因为我们的信任而变可靠,但流程可以因为死板而变可靠—— +而死板,恰恰是机器最擅长的事。 diff --git a/plugins/regress-guard/docs/WORKFLOW.md b/plugins/regress-guard/docs/WORKFLOW.md new file mode 100644 index 0000000..67f9d17 --- /dev/null +++ b/plugins/regress-guard/docs/WORKFLOW.md @@ -0,0 +1,343 @@ +# 工作流 + +> 自动触发为主,人类只在决策点。命令无需手动召唤——说需求即可(v1.12 起计划 +> 待人类批准,v1.17 起收尾有流水线)。 + +## Full 模式(团队/大需求) + +``` +说需求(自然语言,无需敲命令) + ↓ UserPromptSubmit 钩子提示 AI 走 plan 流程(含 /regress:init 自动兜底) +/regress:plan 逻辑 → 清单(F1/F2 + 脆弱点拓扑 + 边界 + 实施顺序 + 验收标准/设计取舍/用户所见草图;产品需求读产品上下文卡;修 bug 类先过假设账本),status: planning + ↓ (顾问预审=方向四问(方向/脆弱点/边界/产品适定——产品适定必带搜索)+判据三问(判据三件齐/When 可观察/验命令可复跑);有方向性异议必等人) +📋 计划卡片 → 人类 30 秒:批准 / 修改 / 取消 ← 人类决策点 ① + ↓ plan_approve.py:status→in-progress + approved.at 落产物 + ↓ (预授权任务无异议可 --provisional 临行,进否决窗) +[AI 开发] ← 边界守卫事前拦截越界编辑;受阻则四问落产物 + ↓ (人类决策点 ②:给 need 所需的输入/权限) +/regress:finish → track 回写 F3 → verify 全证据(rescue 自救/感官问人收布尔)→ 代谢沉淀 → 产物干净检查(产品否决已埋 design_rejected 化石) + ↓ (人类决策点 ③:感官终验 human_check) +git commit ← 门禁自跑测试 + locked 复验,通过自动写 done +``` + +**长任务进度心跳(v1.33 企业级)**:授权自决策任务预计 >30 分钟时,每个阶段边界 +(F 项完成/测试转绿/进入下一段)推一条 `progress`——离场人类看到的是"⏳ 进度: +F1 完成,进入 F2",而不是两小时黑箱后一声终响: + +```bash +python3 "<插件路径>/hooks/scripts/lib/notify.py" . progress --title "⏳ 进度 :<阶段一句话>" --body "<下一步是什么>" +``` + +**企业级推送覆盖(v1.33)**:决策类=plan_approval/blocked/sensory/finish_open/🦴固化候选; +进程类=done(收尾)/progress(心跳)/💬每轮回复;元层=链外看门狗 +(systemd timer 每小时体检治理链,死亡时独立报警腿:deploy/watchdog.sh); +通道故障=全通道失败时本机声音兜底 + 发送台账(~/.zcode/wecom-send.log)可审计。 + +## 智能体通道定界(v1.28.1,实测) + +ZCode 智能体(Agent 工具)的子代理**不受 PreToolUse 治理**(边界/先读后改/执行阀 +均不覆盖——2026-09-03 对照实验实锤;业界同构:Claude Code #34692 早期同样形态, +后以"钩子在子代理内触发+载荷带 agent 身份"收口——我们的钩子是 config 级, +宿主跟进后覆盖自动延伸)。纪律:**子代理只做只读分析,写操作归主智能体**。 + +## 旁路矩阵(v1.29:状态改变通道 × 治理覆盖) + +| 通道 | 覆盖 | 依据 | +|---|---|---| +| 主智能体 Edit/Write/ApplyPatch | ✅ 拦截(活体实证) | planning 阻断活体首证 | +| 主智能体 Bash 写目标(rm/mv/cp/tee/truncate/touch/sed -i/dd of= 及 >、>> 重定向) | ✅ v1.29 收口(高置信形态;2> 与 /dev/null 豁免防误拦) | 病例:rm 删边界内文件零拦截;37 项单测 | +| 主智能体 Bash 不可逆命令 | ✅ 执行阀(活体实证) | dd 字样拦截首证 | +| 子代理工具调用 | ⚠️ 定界(宿主领土,见上) | 对照实验实锤 | +| MCP 工具写文件 / 浏览器改外部状态 | ⚠️ 未测定界 | 待病例 | +| 无活跃清单时的任意编辑 | ✅ fail-open(设计语义) | 无边界信息不锁人 | + +## git worktree:独立任务舱(v1.27,实测于临时仓) + +`git worktree add` 出的每个工作目录是一套独立治理(2026-09-03 四项实测): +无 `.regress` 的 worktree 守卫放行(fail-open);自建 `.regress` 后哨兵/边界/ +清单自成一体,主仓清单互不可见——base_head 漂移也不会被其他舱的提交污染。 +产物层随分支走,**merge 即知识回流**;注意三点: + +- worktree 清理前先确认 `.regress` 变更已随分支提交(finish 步骤 5 会检查—— + 未合并就 remove = 学费蒸发) +- journal 同位置追加在两个舱都写过时 merge 必冲突:两边都保留即可 +- 边界交叉(两个舱都含同一文件)留给 git merge——冲突检测是 git 的领土 + +## 多会话同仓(v1.34 会话作用域) + +worktree 是**物理隔离**;不开 worktree、多会话直接并行同一仓库时 +(2026-09-07 五标本:门禁被他人清单挡/git add -A 扫走他人暂存/清单号 +竞争/版本碰撞/活体编辑竞态),治理按**会话归属**切分: + +- **身份**:钩子进程带会话 env(Bash 工具进程不带)。UserPromptSubmit + 每轮把它写进会话中继(last-writer-wins,原子写);plan_approve 批准/ + 临行/受阻时给清单盖 `session:` 戳——中继优先,env 兜底 +- **门禁**:mine=无戳或戳==本会话;env 缺失时全部视为 mine(老行为)。 + 他人清单只在本提交 staged 撞其清单文件时拦(跨会话冲突=集成态检查), + 否则放行+警示——他人清单不再挡你的提交 +- **边界**:编辑撞他会话活跃清单边界 → 拦+对焦提示(冲突只在集成点 + 现形,编辑时对焦比提交后回滚便宜);无戳清单保持共享语义 +- **哨兵视图**:`python3 hooks/scripts/lib/sentinel.py`——活跃清单 × + 会话归属 × 悬停时长 × 多会话文件重叠,提交前扫一眼 +- **已知边界**:中继错归属一拍时,错向是"自己的清单被当他人的" + (拦截可见可修),不是静默放行;清单号竞争/他人 git add -A 扫暂存 + 仍靠 git 自身与事后审计(F3 检查以我的清单为基准,他人文件天然不在 + 我清单内→拦,方向正确) + +并行子代理开发的移交界面见 [subagent-playbook.md](subagent-playbook.md) + +## 原生计划模式桥(v1.39:批准点对齐) + +ZCode 原生计划模式的批准由 plan_bridge 钩子自动转录成治理清单——两条批准 +通道,产物层只有一份: + +``` +ExitPlanMode(计划原文) ──批准──→ PostToolUse → 转录+盖章原子完成 + ──拒绝──→ PostToolUseFailure → 零残留 + design_rejected 化石 +``` + +- **幂等键** = session + plan_hash:重复事件 no-op;计划修订整档重写(落 plan_refined) +- **双轨合一**:同 session 已有 /regress:plan 的 planning 清单 → 直接盖章它 +- **顾问预审豁免有据**:原生批准时人刚逐字读完计划=意图裁决(顾问否决权 + 为保护不在场者,人在环即最高裁决) +- 边界从计划文本尽力提取(cap 12),提取不到留空——F3/track 扩界留痕兜底 +- 双重防御:成功事件内含拒绝语义(tool_response 特征)也按拒绝处理(拒绝 + 载荷语义未证,failure 事件与 response 特征双保险) +- 注册漂移:脚本在而 config.json 未注册时 self_heal 警示(不自动改用户配置), + 修复出口=重跑 install.sh;钩子未生效时 AI 按契约条款 1 补转录(幂等无害) +- **回执(v1.52 试验位 → v1.79 转正默认开)**:转录成功向对话注入一行 + additionalContext(agent 和人都看得见桥在工作)——PostToolUse 契约已经 + ZCode 3.14.0 开源源码证实(hookEventName 严格匹配/唯一 stdout JSON); + `RG_PLAN_BRIDGE_RECEIPT=off/0/false` 逃生 + +## 推送闭环(v1.34:广播 → 可度量可校准) + +决策型推送(plan_approval/blocked/sensory/finish_open)送出即落 +`~/.zcode/regress-pending.jsonl` 待决台账(正文带〔待决#N〕);人类回 +「有用/误报/忽略」,agent 记 `pending.py resolve `—— +误报率是推送策略的校准指标(防 alert fatigue:噪音推送腐蚀信任, +与「该推没推」是对称的病)。观察仪表盘: + +```bash +python3 hooks/scripts/lib/notify.py stats # 送达率 + event 分布 + 待决/误报率 +``` + +发送台账(~/.zcode/wecom-send.log)v1.34 起带 event= 维度;轮末提醒 v1.38 +起是独立 `chat` 事件(曾冒充 done——台账 done×375 几乎全是轮末提醒,真 done +仅 3 次)。blocked 推送按「项目+清单 ref+拦截原因指纹」合并(v1.38):同键 +未决 30 分钟窗口内折叠不重发、不重记账,只追加旁路行进 stats(治了多少轰炸 +要看得见)——病例:另一项目同清单 6 分钟 6 连推,主动制造 alert fatigue。 + +## Fast 模式(个人/小改动) + +``` +[先改好代码] + ↓ +/regress:quick <需求> → 基于 diff 生成清单(全 actual) + ↓ +git commit ← 门禁跑测试,通过放行 +``` + +## 测试结果缓存(v1.85:同树重试不重跑全量) + +门禁跑全量前先查 `.regress/test-cache.jsonl`:键=测试目录所在 git 仓的 +组合哈希(HEAD+status porcelain+diff 补丁+未跟踪内容,`.regress/` 排除)。 +TTL 4h 内同键有通过记录 → 跳过全量(stderr ♻️ 复用 + `commit_passed` 事件 +带 `cached:true`/`cache_key`);真跑通过才落账(只缓存通过,上限 50 条)。 + +立场(顾问裁):**优化位,非安全边界**——命中大声可审计;伪造缓存者本有 +更廉价的 `/regress:bypass`。关闭:环境变量 `RG_TEST_CACHE=off` 或 +`.regress/config.json` 的 `test_cache.enabled: false`(ttl_minutes/max_entries +同节可调)。验收环(EARS 勾验)不受缓存影响——它读清单,不读树。 + +## 特征测试(v1.45:改无测试老代码前先钉住现状) + +`/regress:characterize <文件>` 五步:枚举公开入口(私有/外部服务依赖标"不可钉") +→ **探针真跑**三类输入(典型/空值/边界)落 JSON → 生成断言**真值**的 golden +master pytest(浮点 approx/异常只记类型)→ 跑绿=行为已钉 → 回写 F3+脆弱点 +`kind: machine`。证据律沿用:断言探针捕获的真值,不猜 AI 以为的值—— +不 mock 外部服务(mock 出的行为不是现状,钉了也是假钉)。 + +## Bypass 模式(紧急 hotfix) + +``` +/regress:bypass 10 → 10 分钟赦免窗口(边界/门禁统一放行,赦后记债) + ↓ +git commit ← 放行(审计日志 + 技术债记账,测试通过的提交才还债) + ↓ +(到期自动恢复严格模式;逾期未还会被哨兵点名) +``` + +## 信任链 + +hook 在 commit 时**自己跑测试**(自动探测 jest/pytest/maven/go test): +- **测试通过** → 写入 `test_verified_by: hook` + `status: done` → 放行 +- **测试失败** → 阻断,列出失败用例 +- **无测试运行器** → 回退到检查清单 status(降级信任) +- **locked 脆弱点** → 门禁复验 verify 命令(证据律:locked = 此刻能过); + sensory 类验 human_check 化石存在性,不复跑感官 + +## 状态机(v1.20) + +``` +planning ──批准/临行──→ in-progress ──→ verifying ──门禁测试过──→ done + │ │ ↑ + │取消 受阻 └─解阻 + ↓ ↓ + cancelled ←──────────────(临行任务否决窗内可取消;正式批准的任务走受阻/完成) +``` + +`done` 由门禁写入(测试通过时),不由 AI 手动标记。blocked 期间边界守卫拦编辑。 + +## 责任矩阵(谁做什么) + +| 角色 | 职责 | 触发频率 | +|---|---|---| +| **人类(ZCode 外)** | 物理动作(权限/凭据/真机)、感官感知(听/看)、意图理解(批卡前的 30 秒) | 按需 | +| **人类(ZCode 内,必做)** | ①说需求 ②批准/修改/取消卡片 ③答 flagged 疑问 ④转达受阻输入 ⑤感官终验一个词 | 每任务 4-5 句 | +| **人类(ZCode 内,罕见)** | `/regress:install`(新机一次)、`/regress:bypass`(紧急)、否决临行("停") | 一次性/紧急 | +| **AI(可被对话引导)** | 需求解析/脆弱点拓扑/清单与卡片/写码修测/越界找路/知识型先问顾问/finish 收尾/提交 | 全程 | +| **hook(铁律层)** | 边界拦截/执行阀/门禁自跑测试/证据律复验/哨兵/地层采集/自愈升级/老项目迁移 | 全程无感 | + +人类**不需要**做的:说"提交"(AI 自提)、init/plan/track/verify/finish/resume(自动或哨兵指路后 AI 自跑)。 + +三原则:物理世界的动作只能人做;意图的裁决只能人做;其余默认自动且人可随时介入。 + +## 自迭代协议(v1.43:两次实战后固化——批2/10 自迭代 run) + +自迭代≠自批准(同模型审自己=相关错误整队穿透);它的形态是**临行机制的成批运用**: + +- **授权句式**:`自迭代,N 批`(有界预授权)或 `自迭代,按照你的思想来`(默认 1 批+ + 检点回报——首跑校准用)。可加目标与停止条件。 +- **否决窗**:整个 run 一个大否决窗——人类随时说"停";任一批被顾问拦或受阻即停。 +- **批内全治理环不豁免**:清单→顾问预审(队列级 consult 一次覆盖+批级引用化石, + 新决策点才追加咨询)→临行→实现→门禁→发布→done 推送→落账。 +- **检点节奏**:首跑 1 批即检点;多批 run 在中点做方向检点(复利目标漂移——顾问 + 每次只看一份计划,没人查"N 批加起来还在服务原目标吗"——是人检点的唯一职责)。 +- **任务来源**:业界对比优先级清单 / decisions 排队项 / 观察项,AI 自选自报。 +- **停止条件**:N 批完成 / 顾问拦 / 受阻 / 队列价值耗尽(宁缺毋滥——恒零键删除 + 原则同样适用于批)。 +- 每批一条 done 推送(离场人类的进度信号);run 结束出综合检点报告。 +- **run 制已验证三形态**(1 批校准制 / 10 批有界制 / 3 批密集制 / 10 批混合制 + 含研究批):研究批(结论落 decisions + 后续批号)是合法产物形态——宁缺毋滥 + 的意思不是硬凑代码,是"没代码结论也是交付"。run3(v1.56→1.64)另证两条 + 操作纪律:FP verify 须选 15s 内的快子集(全量门禁套件超 5.6 窗口);被门禁 + 拦的是整条工具调用——同命令行里的 git add 不会执行,提交前必须 git status + 核验暂存完整性。 + +### 自迭代轮收尾检查 + +自迭代 run 每批收尾(含研究批)过一遍三行: + +1. **卡片对齐**:product-arch.md 的模块卡片与本次结构性变更对齐了吗 + (新增/删除/改名/契约变化,验证路径仍走得住)? +2. **焦点重写**:frontier-focus.md 的焦点还指向真前沿吗(被本批改变的 + 要重写)? +3. **记忆索引**:MEMORY.md 相关条目刷新了吗(新决策/新踩坑入条)? + +动机(2026-09-21 标本):自迭代模式绕过 finish 代谢位 + 双仓拓扑规则 B +对嵌套仓提交失明 → 卡片停更 v1.83 被对齐审计逮住——自迭代的收尾纪律 +不能只靠"批内全治理环"自觉,run 级另设此明文检查位。 + +## 无感层(自动,零操作) + +(v1.81)分发双轨:marketplace 就绪(marketplace.json+hooks 全插件根相对)—— +用户可经个人市场源一键安装(插件开关/升级走宿主原生);开发轨 install.sh 不变。 +两轨只取其一(同装双钩子)。 + +(v1.80)UserPromptSubmit 治理上下文前置:每轮对话注入活跃清单边界行+规律账本 +TOP-1 召回行(`RG_PROMPT_CONTEXT=off` 逃生)——防线从"拦截时才教育"前移到"开工前就看见"。 + +(v1.78)Stop 级版本漂移警示:每轮收尾顺带比对已装副本与源仓版本,新漂移对 +首次出现 stderr+chat 各一(消除前不重复)——SessionStart 只跑一次的时代盲区补上。 + +- 会话启动:自愈/自动升级/老项目迁移/活跃清单哨兵(指路 /regress:resume) +- 编辑时:先读后改 + 文件指纹 + 边界拦截(AI 是第一现场,人看到的只是行为变好) +- 失败/风险/纠正:自动入考古地层,跨会话可考古 +- 断点续作:`/regress:resume` 一句话重建现场 +- 机器级经验:finish 代谢缝自动落机器事实卡(v1.31,见下节) + +## 产品+架构层(v1.35:治"跑通即终点") + +对话式开发的产品功能深度/广度瑕疵有四个结构性根因:计划从单一视角推导 +(无角色枚举/无伴侣功能/无竞品对照)、首版即终版(无 DoD 状态完备性)、 +AI 看不见渲染结果(代码审查发现不了空态尴尬)、架构盲区(无连接点地图、 +选型理由随对话蒸发)。四个机制对四因: + +| 机制 | 挂点 | 机器强制 | +|------|------|---------| +| 广度矩阵(角色×任务+伴侣10项+竞品,每行配验证路径) | plan 4.9 → 清单「广度矩阵」节 | 卡片行 + 伴侣留空=没想过 | +| DoD 状态完备性(空/错/载真渲染看过) | plan 脆弱点 kind=product → verify | **open 挡提交**(搭脆弱点既有强制) | +| DoD 自检+弹性打磨 | finish 步骤 2.5 | 缺口三选一(修/挂牌/进卡片),不许蒸发 | +| 模块卡片(完成度/能力/依赖/契约/缺口/验证路径) | init 生成 → finish 回写 | 验证路径走不通的卡=过期卡 | +| ADR(context/options/decision/红线) | plan 4.10 → `.regress/adr/` | 红线=grep 类 verify 进 arch 脆弱点 | + +知识层配套(装机 Skill,流程层仍以上表为准):brainstorming/writing-plans/ +TDD/systematic-debugging/verification-before-completion/executing-plans +(superpowers 六件)、frontend-design(Anthropic)、office-hours(YC)、 +ui-ux-pro-max。 + +## 全貌+对齐层(v1.40:理解是强制产物,对标 spec-first) + +行业对标(Kiro/spec-kit):spec-first 的本质是理解本身成为强制产物。我们的 +等价实现是机器位,不是仪式位(病例:028 新增桥脚本而模块卡片 8 天未回写、 +两批跳过 finish 卡片步骤,无机器拦——纪律对狗food实践者本人都会漏): + +| 机制 | 挂点 | 机器强制 | +|------|------|---------| +| scan 三行(入口/测试/动的哪张卡) | plan 步骤 4 清单 frontmatter | **M/L 缺任一行门禁拦**(规则A,空值/占位不算) | +| understood_intent 三件(复述/边界/判据) | 同上 | M/L 缺件拦——复述=行业对齐标配 restatement | +| 卡片新鲜度 scan_check(fresh/stale/absent) | plan 步骤 2a | stale 强制重扫受影响卡;absent M/L 先建卡 | +| 结构变更同步卡片(ADR 事件,tests/docs/md 豁免) | 提交门禁 | **全档含 S/quick**:staged 有结构性增删/改名而卡片未随同 → 拦(规则B);纯脚手架 scan.card_sync: false 显式豁免;无卡片项目警示不拦 | +| EARS-lite 验收行(When…则…验:命令) | 模板验收标准节 | M/L 用;验命令复用脆弱点 verify 惯例 | +| rollback 能力断言(v1.41) | 清单 frontmatter 一行 | **全档必填**;触及迁移/schema/破坏性 SQL(触发表)时"git revert"默认失效须真答案 | +| self_review 触发表键(v1.41) | finish 步骤 1.5 → 清单 | 键按触发激活(计划外=actual_changes 非空;调试残留=diff 命中模式且非 tests/);不适用=键不出现,「无」=查过没有——堵空转根源 | + +分工逻辑:**S 档豁免的是全貌仪式(规则A),不豁免结构纪律(规则B/rollback)**—— +新增/删除/改名文件本就不是"轻量内部改动"。对齐层的歧义检测/假设账本/广度 +矩阵不动(已是强项);本层补的是它们的原材料新鲜度——看不全就对不准。 +收官原则(v1.41,顾问):字段不可验又无后果必空转——**硬化接引信(rollback +默认在逃逸面失效),不接解释(self_review 不触发不填)**;校准看机器代理 +(revert 实际率/计划外对上 F3 率),不看填无比例;恒零键删除不措辞。 + +## 供应链层(v1.42:门禁只验测试不够,还要验安全) + +业界用三个真实 CVE 论证 hook 治理必要性(Endor Labs)——本层补密钥与依赖两面: + +| 机制 | 机器强制 | +|------|---------| +| secret_scan(gitleaks-lite 零依赖) | 扫 staged **新增行**(历史密钥是全仓审计工具的职责):高精度模式(AKIA/ghp_/私钥块/xox/AIza/sk-)不豁免任何路径——真密钥漏在测试里也是漏;通用 key=value 对 tests/ 与 *.md 豁免;命中 → **拦**(bypass 唯一出口);文档示例串内置允许表 | +| 依赖审计(best-effort) | 锁文件 staged 才触发 `npm audit --json --package-lock-only`(解析漏洞计数只认 high/critical,不信 exit code——顾问修正);**infra fail-open**(工具缺/超时/网络败 → warn+留痕),**findings fail-closed**(高危才拦);非 npm 锁 v1 提示人工审计 | +| 降级 | `supply_chain.secrets/deps=false`(沿用 strict 降级先例);`supply_chain.allowlist` 项目级追加 | +| 项目级 channels 信任(v1.66/1.67/1.68) | 项目 channels 仅机器侧信任表(`~/.zcode/regress-trusted-projects.json`)内项目可用,未受信回退机器级通道+stderr 提示;**授信=人工编辑该表**(`notify.py trust` 只读视图);**信任解析零环境变量影响(v1.68)**——表路径经 passwd 数据库派生(HOME 间接层同封),RG_TRUST_PROJECT_CHANNELS/RG_TRUSTED_PROJECTS 缝已删(git commit 前缀注入即旁路,测试迁 monkeypatch 模块属性);**内容钉(v1.72)**:边车 `regress-trusted-projects.fpr.json` 快照受信项目 notify 块——敏感面(channels/wecom 凭据/外发名单)变更即拒回退,人工出口=刷新信任表时间戳;非敏感变更 TOFU 重钉;部署自举=边车缺失首跑钉现状;共残差=agent 直接写表文件(transcript 可见,人侧定期 diff) | +| 钩子入口 env 白名单(v1.69) | launcher.js 构造洁净子进程环境:白名单只透传系统基础(PATH/HOME/LANG/TZ/TMPDIR)+代理+宿主契约五变量(SESSION/PROJECT_DIR/ZCODE_HOME/PLUGIN_ROOT)——`RG_*`/`WECOM_*`/`GIT_*` 注入在入口层失效(宿主 env 的信任/git 读数干扰整族封死;直接 CLI 调用不受影响,其信任面由 v1.68 表制管) | +| wecom 凭据字段门+基域钉住(v1.70) | 项目级 `wecom.corpid/secret/api_base_allowlist` 覆盖须项目受信,否则回退机器级+提示(克隆仓库携假凭据把通知发往自己企微的洞;`agentid/touser/事件开关`不受限=绑机器凭据的合法覆盖);`WECOM_API_BASE` 仅许本机桩(测试)或机器配置 allowlist,恶域钉回官方域——凭据 POST 外泄面 | + +## 机器事实卡(v1.32:跨项目地层,三层结构) + +机器级经验(服务器拓扑/通道/环境坑/跨项目工具坑)不属于任何项目——沉在单项目地层里 +别的项目借不到,人类被迫人肉跨会话转述(病例:企微通知任务跨会话交接)。 + +三层结构(形状由构造保证,不靠纪律维持——调研定稿见 REGRESS-2026-017): + +1. **路由层**:skill 描述常驻上下文,pushy 措辞对抗欠触发(平台官方指导) +2. **索引层**:SKILL.md 触发时加载——只有用法两行+日期倒序索引(recency 一眼可扫), + `facts.py record` 每次落账后机械重建 +3. **本体层**:`references/<域>.md` 按需整文件读,同键重录刷新日期+最新表述为准 + +三条纪律(涉机器事务时): + +1. **先查卡**:服务器/网络通道/本机环境与工具坑 → 读事实卡索引再动手 +2. **否定式断言有保质期**:「没有 X 权限/X 不可用」超过一天,引用前必须复查 + (病例:另一会话已部署 ssh 直连,本会话还揣着几小时前的"无权"前提继续推理) +3. **接手跨会话任务先读 journal 尾部**:落账即交接,人不当传输层 + +铁律:**事实带日期(=最后验证为真)、只当线索不当结论**——可信但过期的事实带权威性 +撒谎(病例:/opt 旧端口文件误导排查)。卡的生死判据在 /regress:stats:长期零新增=死重, +摘掉 finish 分支即退场(机制也要代谢)。 + +## 降级 + +- `.regress/config.json` → `"strict": false`:门禁降级为仅警告 +- `"boundary_enforced": false`:关闭边界守卫 +- `/regress:bypass <分钟>`:限时赦免 + 审计(不可赦名单:执行阀=物理不可逆、先读后改=防盲改) diff --git a/plugins/regress-guard/docs/feature-graph-design.md b/plugins/regress-guard/docs/feature-graph-design.md new file mode 100644 index 0000000..0c4ef27 --- /dev/null +++ b/plugins/regress-guard/docs/feature-graph-design.md @@ -0,0 +1,177 @@ +# 特性图设计稿(REGRESS-2026-086 · run8 波1-C) + +> 状态:**纯设计稿,未实现**。灵感源=ZCode 源码仓两个技能(只读调研): +> `architecture-governance`(yaml 政策+规则目录+基线感知违例)与 +> `feature-boundary-planner`(37 节点特征图+关系分级+稳定 ID+graph-drift 候选)。 +> 本文档的产出是**判断**(§6 裁决:不建),两个可打捞的漂移探针模式见 §5。 +> 写法遵循 PHILOSOPHY §13:写决定不写状态,史实句带日期与版本号。 + +--- + +## 1. 动机:特性/规则/测试三者间的漂移面 + +regress-guard 治理的对象里有三样东西各自独立演化: + +- **特性**——代码里活着的机制(门禁、召回、内容钉…),随版本推进; +- **规则**——对特性的声明(product-arch 模块卡片、规律账本、PHILOSOPHY 断言), + 由人/AI 手工维护,更新靠 finish 回写纪律; +- **测试**——验证路径(卡片"验证路径"字段里的 pytest 命令、套件本身), + 随重构改名/迁移。 + +三者两两之间都有漂移面,且**本仓已交过三次学费**: + +1. **卡片停更标本(特性→规则漂移)**:2026-09-21 自迭代 run 的卡片停在 v1.83 + 被对齐审计逮住(frontier-focus 082 候选的由来);更早一例是通知卡 wecom 段 + 描述的仍是 v1.70 前行为而代码已到 v1.85.4(product-arch 注记"卡片行为 v1.70 + 前存货指针")。声明滞后于实况,靠人记得去对才发现。 +2. **v1.80 治理行未首火(特性→事件漂移)**:UserPromptSubmit 治理上下文前置 + v1.80 上线,至今零次野外开火。观察位是 frontier-focus 里一行手工指针 + ("下次活跃清单")——**没有机制保证"上了但没响"的特性会被看见**,只有人记性。 +3. **rules_ledger 冷启动(规则→数据漂移)**:规律要求跨会话 ≥2 沉淀(公理三), + 新项目/新部署前 N 次失败**必然**零召回——特性活着、数据结构性为空, + 且这个空窗期没有任何产物显式标注它的存在与预期时长。 + +共性:都是"声明与实况的时差",都靠事后人工对账。图形化的原始动机是一个 +当前只能 grep 的问题:**改特性 X 时,哪些卡片段落/规律/验证路径会被波及**—— +卡片只有模块级粒度,规律只有文本,测试只有文件名。 + +## 2. 节点模型:节点是什么,稳定 ID 怎么定 + +**ZCode 的做法**(`zcode-feature-graph.yaml`,37 节点):节点=**语义边界**而非文件。 +七类 kind 前缀:`capability.` / `surface.` / `shared-ui.` / `state.` / `service.` / +`persistence.` / `boundary.`,如 `capability.model-selection`。稳定 ID 政策 +(stableIdPolicy)一句话:**语义边界不变则 ID 永不变,术语演化走 aliases** +(节点上挂 "model picker/模型选择/切换模型" 等别名承接改名)——ID 与命名解耦, +改名不再是图的操作。 + +**本仓等价设计**: + +- 节点五类,kind 前缀:`feature.`(能力,如 `feature.gate-cache`)、 + `module.`(模块卡对应的四个模块)、`rule.`(规律账本条目+PHILOSOPHY 断言)、 + `test.`(验证路径,锚到 pytest 文件/`-k` 表达式)、`task.`(清单)。 +- **稳定 ID 双轨制**:实体层用 `kind.slug`(长命锚,语义边界);事件层沿用既有 + REGRESS-YYYY-NNN(任务锚,已有 file_lock 临界区分配纪律,v1.56)。二者关系是 + 事件溯源的经典分层:**任务是事件,特性是实体;实体=对事件的折叠(fold)**。 + slug 不需要锁——它是声明不是稀缺配号。 +- 每节点带 codeSeeds(ZCode 同款:file+symbol/pytest 目标),对我们最自然的 + 锚是卡片"验证路径"字段里的命令本身。 +- 别名机制对本仓尤其值钱:中文仓里"门禁/提交门禁/gate"、"召回/rule_recall" + 是同一实体的多种叫法,规律账本 match() 的 bigram 匹配已在为此付费。 + +## 3. 边模型:关系分级 + +**ZCode 的做法**:50 条边、27 种语义动词(`renders-in` / `commits-to` / +`persists-to` / `must-not-replace`…),每边一个 rank,五级: +`must-inspect` / `should-inspect` / `conditional` / `invariant-only` / +`evidence-only`——rank 服务**遍历剪枝**(改动分析时决定先看哪条边)。 + +**本仓改造**:rank 语义不抄"检查优先级",换成**证据等级**——这更贴本仓的第一性 +原理(PHILOSOPHY §3:done 由门禁发,不由人自称)。边的可信度而非查看顺序: + +| 级 | 含义 | 例 | +|---|---|---| +| L1 机器复跑 | 门禁每提交实测的验证路径 | 卡片"验证路径"里被门禁复验的 pytest 命令 | +| L2 事件证实 | history.jsonl 里有过真实关联记录 | 某 task 落盘过 acceptance_passed | +| L3 声明 | 卡片/文档自述,无人复验 | 卡片"依赖"字段 | +| L4 推断 | grep/静态证据 | import 关系、文本共现 | +| INV 负边 | must-not 类不变量 | "验证主权不外借""子代理写通道不经过拦截链" | + +边类型初版**收缩到 5 种**(ZCode 的 27 种是 UI 产品面的 richness,我们不需要): +`verified-by`(feature→test)、`documented-in`(feature→卡片段落)、 +`recalled-by`(rule→feature)、`depends-on`(feature→module/feature)、 +`inhibits`(INV 负边)。防重叠硬规则:**边不进卡片正文**——卡片"依赖"字段保持 +人类叙事,图只是机器投影;两边各说各话,图错了不污染卡片,卡片改了图可派生。 + +## 4. 数据源:哪个最可靠 + +三个候选: + +| 源 | 富度 | 可靠性 | 结构性弱点 | +|---|---|---|---| +| manifests(79 份) | 最高(understood_intent/planned_changes/EARS) | 中——AI 写的声明 | 无实体键,只有文件路径弱关联 | +| **history.jsonl(289 事件)** | 中(五类事件:commit_passed 129 / commit_blocked 44 / fragile_verify 74 / test_failed 33 / acceptance_passed 9) | **最高** | 只看得见"走过的路" | +| 测试标签 | 低 | 高(ground truth) | pytest 无特性标签体系,test→feature 映射最弱 | + +**裁决:history.jsonl 做脊柱**,理由三点,核心是事件溯源优势: + +1. **写入者是门禁不是 AI**——证据律的直接应用:自述(manifests 是 AI 落的意图) + 与实况(门禁落的结果)分级,图的天性应该建立在后者上; +2. **append-only + 不可改写**——图可以**派生**(fold events → entities)而非手工 + 维护。派生图零维护成本,且消灭 Goodhart 面(§13"叙事手写,事实派生":没有 + 手写数字就没有可绕过的检查)——手工维护的图恰恰是 §13 判定的"对系统的声明", + 注定腐烂; +3. **双键已备**(manifest_id/session_id),join manifests 即可拿到意图富化层, + 不需要新写任何埋点。 + +manifests 按 manifest_id join 做意图富化;卡片与测试做探针叶子(§5)。 +手工只维护 ZCode 式 curated seeds 中事件看不到的部分——跨特性不变量(INV 负边)。 +诚实边界:事件源看不见**没发生过的事**——v1.80 治理行零开火意味着它在事件层 +零痕迹,所以 §5 Pattern B 需要一个极小的手工特性注册表(slug+shipped_at 版本), +这是全套设计里唯一的新手工产物。 + +## 5. 漂移检测提案:图能查出什么卡片查不出的 + +两个模式,各自挂着本仓真实标本: + +**Pattern A:幽灵验证路径(documented-in / verified-by 断链)** +卡片"验证路径"字段里的 pytest 命令做成 codeSeeds 探针:测试文件存在 + +`-k` 表达式 collect 非零 + (强形式)干跑不报错。断链即红。 +标本:通知卡 wecom 段停在 v1.70 前行为(若当时验证路径指向的测试已被改名/ +迁移,探针即红,不必等对齐审计)。**这正是 frontier-focus 082 候选 +(卡片对齐入硬清单)的机器化形态**——差异只在 082 是终报加一行人工/AI 对账, +本模式是探针自动跑。 + +**Pattern B:零命中间(feature→事件断链)** +特性注册表(slug+shipped_at)× history.jsonl 窗口 fold → 出厂后 N 天零事件的 +特性清单。标本:v1.80 治理行——今天靠 frontier-focus 手工指针盯,图化后变成 +一条可重复查询。同一查询同构服务两个既有需求:规律账本的衰变义务 +(§14:半年零命中降级候选)与 v1.83 热图"新键标注"的对称面(老键多久没响)。 + +图相对卡片的**本质**增量只有一条:边的传递性(改 feature.X → 反查 documented-in +得卡段落 → 反查 verified-by 得测试面,一次闭包回答"波及谁")。但诚实地说: +本仓 4 模块规模下,这个闭包 grep + 卡片也能做——这条诚实判断直接进入 §6。 + +## 6. 成本与裁决 + +**建设成本三档**(都含维护成本,维护才是大头): + +- ZCode 式全图(手工 curated 37 节点 50 边 + 每任务维护纪律):**大**。他们撑得住 + 是因为 monorepo 14 包、每次改码前必须生成 context-package(消费频率摊薄维护); + 本仓 4 模块,curation 负载与规模不成比例。 +- 纯派生图(fold history.jsonl):**小**。但只看得见事件层,卡片/测试断链全盲。 +- 混合(派生脊柱 + 卡片探针 + INV 手工种子):**中**。 + +**与既有机制划界(防重叠)**: + +| 机制 | 管什么 | 粒度 | +|---|---|---| +| scan_check 三态(v1.40) | 卡↔码新鲜度:卡片整体旧没旧 | 模块级,提交量代理 | +| product-arch 卡片 | 人类叙事档案:这个模块是什么/怎么连 | 模块级,人读 | +| 拦截热图(v1.62) | 拦截点热度:哪里常被拦 | 拦截点级 | +| 特性图(若建) | 特性/规则/测试**之间**的线断没断 | 边级,证据分级 | + +四者不重叠的前提是图不复制任何一者的输出:图不做新鲜度判定(scan_check 的), +不写模块叙事(卡片的),不数拦截(热图的)——只答"哪条边断了"。 + +**裁决:不建(现阶段)。** + +一句话理由:§5 的两个真实漂移模式各自都有更便宜的实现——Pattern A 就是 +082 候选本身(已在池,载体是终报+哨兵各一行),Pattern B 是"特性注册表 × +history 零命中"的 20 行哨兵查询——**都不需要图的传递性**;而图独有的传递闭包 +价值在 4 模块/79 清单的规模下 grep+卡片已覆盖。边际收益 < scan_check 三态 + +082 卡片对齐硬清单的组合,为边际零收益引入一个需要持续喂养的结构,违反 +§12(复用不重建)与 §13(手工维护的声明注定腐烂)。 + +拒绝也是资产(§12),留三条 falsifiable 重评触发器,任一命中则本裁决作废: + +- **T1**:模块卡 >8 张,或卡片"依赖"字段出现 ≥3 处对同一目标的相互矛盾描述 + (结构漂移超出 grep 能力,需要实体级锚); +- **T2**:双仓/多产品面成为常态(scan_check FP2 的治理仓活动代理失明恶化, + 迫切需要不依赖单仓 git 的实体锚); +- **T3**:规律/特性需要跨项目复用对齐(rules_ledger 出现第二个项目用户, + slug 成为公共契约)。 + +**顺手打捞(独立于本裁决成立)**:Pattern B 的零命中查询不依赖图,建议以 +20 行哨兵查询形态挂进缓存周检类闲时任务(特性注册表先只登记 v1.80 治理行 +一条,作为最小可行观察位);Pattern A 随 082 落地时,若探针跑通,本文件的 +codeSeeds 设计(§2)可直接复用。 diff --git "a/plugins/regress-guard/docs/reviews/2026-09-08-\345\205\250\346\226\271\344\275\215\350\257\204\345\256\241.md" "b/plugins/regress-guard/docs/reviews/2026-09-08-\345\205\250\346\226\271\344\275\215\350\257\204\345\256\241.md" new file mode 100644 index 0000000..979b224 --- /dev/null +++ "b/plugins/regress-guard/docs/reviews/2026-09-08-\345\205\250\346\226\271\344\275\215\350\257\204\345\256\241.md" @@ -0,0 +1,141 @@ +# regress-guard 全方位评审报告(2026-09-08) + +> 证据基准:源码快照 `bc0753a`(v1.35.0);治理产物=本工作区 `.regress/`、 +> `~/.zcode/wecom-send.log`、`~/.zcode/regress-pending.jsonl`、`/tmp/regress-guard-*`。 +> 行号随修复会漂移——以快照为准。 +> 方法:三路只读子代理(功能完备性 / 代码鲁棒性 / 流程体验×真实数据)+ +> 顾问复核(方向与 P0 重组)。原始发现 44 条,去重合并后 36 条。 + +## 置顶:方向性结论 + +**停止加仪式,接通已装机闭环。**(顾问复核:方向成立,高把握) + +真实数据把机制劈成两半: + +- **写账类 100% 存活**:企微送达 128/128(errcode=0 为腾讯对端确认,非自报); + 地层五日不断层(本地自报,证据等级低一档);会话中继/盖章链活体全通。 +- **回流与强制类九项全线空转**(见下表)——门禁"hook 发 done"零次发生、 + blocked 推送从未送达、待决 resolve 零回流、quick 模式零使用。 + +v1.35 三新节(广度矩阵/DoD/架构影响)装机当天至今零真实使用——不是机制坏, +是**前一个闭环还没接通就开了新仪式**。修复批次一交付前,冻结新机制。 + +--- + +## P0(4 条,顾问重组后) + +### P0-1 门禁 fail-closed 统一入口(第一优先) + +三处"崩溃/解析失败 = 静默放行",与 fail-safe 语义相反: + +- `lib/manifest_parser.py:23-25` 只捕 `(IOError, OSError)`,非 UTF-8 清单抛 + `UnicodeDecodeError`(ValueError 子类,实测非 OSError)穿透 + `pre_commit_guard.py:235` 无保护循环 → exit 1 = 放行 +- 坏 YAML 清单走 loose_match + fallback 返回 `{}`(非 None)→ + `pre_commit_guard.py:236` 判 `parsed is None` 为假 → status 取不到 → + 走 `no_active_manifest` 放行(与 224 行注释方向相反) +- `execution_valve.py:150` `data.get` 无 isinstance 保护,非 dict stdin 载荷 + 抛 AttributeError → exit 1 = 放行(灾难级命令阀 fail-open) + +**修复方向**:门禁/阀门 main() 顶层"未预期异常 → emit_block"兜底; +解析器读文件 `errors="replace"`;fallback 结果无 `id` 且无 `status` 视同 None。 + +### P0-2 插件安装一致性 + +- `hooks/hooks.json` 仅注册 6 条钩子,`boundary_guard`/`execution_valve`/ + `fail_watch`/`risk_watch`/`compact_notice`/`stop_notify` 六个缺席 + (install.sh:217-297 注册了它们,插件模式却装不上)——插件模式下核心强制力全无 +- `install.sh` 注册 Stop→stop_notify.py 但 cp 清单(72-82 行)不含该文件—— + 安装后到下次 SessionStart 自愈前每轮 Stop 报文件不存在 +- `hooks.json:68` UserPromptSubmit 空 matcher——与 install.sh:214 自己的 + "空串违反 schema 会致整份 config 被丢弃"注释矛盾(v1.34 修了 Stop 漏了这处) + +**修复方向**:install.sh 九类事件条目同步进 hooks.json;cp 段补 stop_notify.py; +删空 matcher。 + +### P0-3 待决回流接线 + +`plan_approve.py` 批准/取消时不自动 resolve 同 id 待决——闭环上线即断 +(台账首条 REGRESS-001 plan_approval 至今悬挂;本次评审的待决#2 靠手工 resolve +完成首回流)。**修复方向**:approve/cancel 时按清单 id 匹配自动 resolve。 + +### P0-4 门禁发 done 零发生 + +runner 探测(`lib/test_runner.py:21`)只看 project_dir 根的 +pytest.ini/conftest.py——嵌套仓布局(.regress 在工作区根、代码在子仓)永远 +探测不到 → 17 份清单 0 份 `test_verified_by: hook`,"done 是门禁发的不是 +自称的"成了纸面承诺(当前逃生=finish 先翻 done 再提交,记在事实卡)。历史 +69 事件中 41 条 commit_passed 全部是 no_active_manifest 空转。 +**修复方向**:runner 探测向上/向子目录找 marker,或允许清单 frontmatter +显式声明 `runner_cwd:`。 + +--- + +## P1(13 条) + +| # | 发现 | 证据 | 修复方向 | +|---|------|------|---------| +| 5 | blocked 推送断线:门禁 blocked 分支只 record 无 notify | history 6 个 blocked 事件期间 wecom 台账 0 条 | 门禁各 blocked 分支接 notify.py | +| 6 | uninstall 残留死钩子:Stop 只滤 reflection_check、PreToolUse 只滤 launcher、PostToolUseFailure/SessionStart 不清、命令列表漏 resume/finish/stats | uninstall.sh:42,77-79,121 | 过滤统一为 command 含 "regress-guard",清单与 REQUIRED_COMMANDS 同源 | +| 7 | plan_approve 先截断后计算:`open(w)` 先清零再在 write 实参里算替换;note 含 `\1` 类文本触发 re.error → 清单 0 字节 | plan_approve.py:214-215,296-297(实测复现路径) | 先算后开文件;替换用 lambda;temp+rename | +| 8 | 通知超时预算倒挂:父进程 5s 处决 vs wecom 子进程 2×10s | notify.py:128 vs wecom_notify.py:74/98 | 外层 ≥ 内层之和,或内层各降 2s | +| 9 | 反思自动咨询挂死:冷却戳只在成功后写,顾问进程 hang 时每次 Stop 等 30 分钟 | reflection_check.py:148-174,timeout=1805 | 发起前写尝试戳;超时降到分钟级 | +| 10 | 清单字段塌方曲线:011-020 十份连续 0 脆弱点 0 假设(007-010 全字段) | manifests/ 17 份扫描 | plan 按规模分层:小改动只强制 F 列表+边界+1 脆弱点 | +| 11 | 事件双写:blocked 6/6 成对同毫秒双写,tool_fail 同样成对 | history.jsonl/journal 时间戳 | record() 调用路径去重 | +| 12 | 模板行内注释废掉 open 检测:`status: open # 注释` vs `_OPEN_FP_RE` 行尾即止 | templates/regress-manifest.md:45 vs manifest_fields.py:18 | 注释移独立行或解析器剥 `#` 尾注 | +| 13 | progress 心跳未接线:唯一指令在 WORKFLOW.md(不进常驻上下文) | grep 命令文档与 AGENTS 契约无 progress | finish/plan 授权段补推送指令 | +| 14 | stats.md 第 4 项路径断链:check_docs.py 不在 HOOK_HOME | stats.md:24 vs install.sh:379 | 改指 regress-guard-docs 或并入 HOOK_HOME | +| 15 | test 事件名存实亡:EVENTS 含 test 且豁免开关,但零发送点 | notify.py:31,96 全仓 grep | 删除或接线(门禁测试失败时推) | +| 16 | config bypass 清除无锁读改写+非原子 | pre_commit_guard.py:216-219 | filelock+temp/rename | +| 17 | read_before_edit 状态全会话共享无锁 | read_before_edit_guard.py:46-63 | 按 session 分文件(键已现成) | +| 18 | history 归档竞态:readlines→追加→重写,并发丢行 | history.py:64-84 | 持锁归档或大小短路判定 | +| 19 | quick/完整分界失灵:0 份 mode:quick,小改动走"剥字段的 full" | manifests 扫描 + quick.md:70 承诺与现实矛盾 | 分界改机器判据(文件数/类型);quick 豁免 runner 探测失败 | + +另录观察(不计条数):v1.35 三新节零真实使用;user_correction 误捕命令源文 +(本评审触发即活体复现,修法:过滤 `Run custom command` 前缀)。 + +## P2(13 条) + +| # | 发现 | 证据 | +|---|------|------| +| 20 | notify 通道模板 format 在 try 外,一个坏模板废全部通道 | notify.py:125-127 | +| 21 | pending 台账 id 分配竞态(max+1 后 append 无锁) | pending.py:66-72 | +| 22 | fail/risk_watch 截断重写竞态丢遥测 | fail_watch.py:105-112 | +| 23 | self_heal 升级就地覆盖运行中脚本(半截源码窗口) | self_heal.py:140-148 | +| 24 | 门禁热路径 O(n):history 每事件全量归档扫描+journal 无限增长 | history.py:61-77;pre_commit_guard.py:408-417 | +| 25 | read_before_edit ratio 缺省 2 与 config 缺省 3、docstring 3 三处矛盾 | read_before_edit_guard.py:108-112 | +| 26 | 漂移检查仓错配:.regress 向上撞到外层仓,拿别仓 HEAD 比对(本评审活体:假警报 da7482b) | plan_approve._drift_info | +| 27 | /tmp 状态文件零 GC:5 天 238 个 | /tmp/regress-guard-* | +| 28 | event 维度覆盖率 2/4,sensory/finish_open 零样本;stop_notify 直连通道 event=- | wecom-send.log 128 行 | +| 29 | commit_observed 观测链死链:post-commit 钩子从未装上嵌套仓 | history 0 条 + .expect-commit 残留 | +| 30 | WORKFLOW 声称"init 自动兜底"不存在 | prompt_intercept.py 无 init 分支 | +| 31 | 孤儿模板 ×2(git-post-commit.sh/regress-config.json 零引用,与 init 内联双份漂移) | grep 全仓 | +| 32 | 手写计数债四处 + 事件口径三处不一(四类/五类/七类)+ gen_reference 用 matcher 关键字计数是伪派生 | install.sh:7,399 等 | + +## 机制空转清单(9 项) + +| 机制 | 设计位置 | 真实数据 | +|------|---------|---------| +| blocked 事件推送 | config events.blocked=true | wecom 台账 0 条 | +| bypass_used 记账 | history 事件类型 | 0 次 | +| commit_observed 提交观测 | init 步骤 4 | 0 条 | +| quick 模式 | regress:quick.md | 0 份清单 | +| sensory / finish_open 推送 | DECISION_EVENTS | 各 0 条 | +| 待决 resolve 回流 | pending.py | 0 次(1 条悬挂)| +| adr: 清单关联 | plan 4.10 | 0/17 | +| 假设账本 | v1.23 模板 | 仅 007 有 1 行 | +| 门禁自动标 done | finish 步骤 3 | 0 份 | + +## 修复批次建议 + +- **批次一(P0,单独清单单独验证)**:P0-1 → P0-2 → P0-3 → P0-4。 + 只修一条修 P0-1(堵静默放行);求快速见效先 P0-3/P0-4(已装机断环点)。 +- **批次二(P1 闭环接线族)**:5/6/7/8/9/10/11/12/13。 +- **批次三(P2 卫生)**:竞态族 16-18/21-23 + GC + 口径统一。 + +## 证据等级声明 + +- 企微送达率:**对端确认**(errcode 来自腾讯 API 应答体) +- 地层/history 连续性、字段塌方:**本地自报/人工扫描**(低一档,顾问风险提示采纳) +- 代码类发现:静态通读 + 关键假设实测(UnicodeDecodeError 继承链、re 坏转义、 + format 花括号均有只读片段复现) diff --git a/plugins/regress-guard/docs/subagent-playbook.md b/plugins/regress-guard/docs/subagent-playbook.md new file mode 100644 index 0000000..5cd6262 --- /dev/null +++ b/plugins/regress-guard/docs/subagent-playbook.md @@ -0,0 +1,125 @@ +# 子代理实现手册(肌肉记忆移植版) + +> 用途:把主会话 ~20 批(v1.66→v1.85)攒下的流程纪律与已知坑移交给并行子代理。 +> 你(子代理)领到一份清单(.regress/manifests/REGRESS-2026-0XX-*.md),按本手册 +> 实现到自测通过为止;**集成阶段(git/门禁/发布/清单盖章)回主会话串行**。 +> 每条坑都带标本出处——不是风格偏好,是真实翻过车的地方。 + +## 一、你的边界纪律(先读这个) + +1. **不跑任何 git 写命令**(add/commit/push/amend 都不行)——门禁是 PreToolUse + 钩子,git 提交会被拦;集成是主会话的活。 +2. **不改 .regress/manifests/ 下任何文件**——清单由主会话管理(指纹守卫会拦外部改动)。 +3. **只实现 + 自测**:自测 = 跑你自己批的测试文件(`python3 -m pytest tests/test_<你的>.py -q`), + **不跑全量**——全量留给主会话门禁(有缓存也只在同树时才命中)。 +4. 只动清单 `planned_changes` 声明的文件;要扩界就停下来报告,别绕(边界守卫会拦, + 拦了就是拦了)。 +5. 复用既有接口前**先读它的函数签名**(见坑 #7)。 + +## 二、实现阶段已知坑(按踩坑频率排序) + +### #1 指纹守卫:Read→Edit→Read→Edit(两连 Edit 必翻车) +对同一文件连续两次 Edit,第二次必被 🪞 拦("文件指纹不匹配")——第一次 Edit +本身就让读取态过期。纪律:每次 Edit 前重 Read 目标段落。 +标本:074 pre_commit_guard.py 两连 Edit;run4-6 每批至少一次。 + +### #2 锚点不中:别盲猜,Read 实文 +Edit 的 old_string 必须逐字符匹配(含缩进)。不中时禁止基于记忆改拼——Read +目标行区间,按实文重写锚点。**行号会漂**(别的钩子/会话改过文件),记忆里的 +行号只当近似值。 +标本:073 post_install_check 101/104 行"戳"字混合,两次不中后 Read 实文才落。 + +### #3 gen_reference 顺序:加完所有测试文件之后、跑全量之前 +`python3 scripts/gen_reference.py` 从测试目录推导计数写 README 派生区。顺序 +错了 `test_check_passes_on_fresh_generation` 会真红(它抓的是真实计数漂移, +不是误报——074 又验证了一次)。 +标本:074 加 `test_key_staged_equals_unstaged` 后忘了重新 gen,全量 1 failed。 + +### #4 git 三步分立 + 被拦重试必须重新 add +复合命令(add 与 commit 串在一起)会被 PreToolUse 拦截,**整条没跑**但 +你以为是 add 成了 commit 没成——暂存悄悄丢失。纪律:暂存 → `git status --short` +核对 → 提交,三个独立 Bash 调用。**被拦后重试尤其危险**:重试命令必须重新 +暂存并重新 status 验证——077 标本:重试只跑提交命令,提交里只剩旧暂存 +(ac0bdf1 实际只含 playbook 8 行,清单宣称的测试文件漏提交,run8 边界检查才逮住)。 +标本:run5 某批暂存丢失;run7 077 重试丢暂存(更隐蔽)。 +姊妹坑:**文档文本里出现提交命令字样同样触发门禁**(heredoc 写文档被拦实证)—— +文档写入用 Edit 工具或改措辞。 +**机器位(v1.87/091)**:此形态已由门禁直接拦(reason=compound_stage_commit), +教学消息自带正确姿势——本条从纪律位退役为背景知识,但引号/heredoc 载荷文本 +仍可能骗过你的眼睛,理解原理依然有用。 + +### #5 门禁跑的是已装副本:install.sh 先于门禁 +门禁钩子从安装位(~/.zcode/...)执行,不是从工作树。改了代码不 `bash install.sh` +就 commit = 门禁拿旧代码验证新提交。纪律:全量绿 → install.sh → add → commit。 +标本:058 鸡生蛋问题(timeout 钮修完门禁还是旧值)。 + +### #6 发布双坑(主会话专用,子代理不发布,备档) +- Git Data API 发树必须带 `git ls-files -s` 的**真实 mode**(本仓 15 个 100755 + 脚本,全发 100644 树 sha 必不等)。 +- 等值校验走 **ref→commit→tree 两跳链**;`GET /git/trees/main` 回显的是请求 + 对象 sha 不是根树 sha,直查必伪报 MISMATCH。 +标本:074 发布三跑(mode 坑→校验伪影→链式校验通过)。 + +### #7 复用接口先读签名:第一参/字段名别靠猜 +本仓有既成接口(rules_ledger.match() 第一参是 project_dir 不是查询串;条目 +字段叫 sig 不叫 signature)。用错不报错,只是永远查空——比崩溃更隐蔽。 +纪律:调用陌生函数前先 Read 它的定义处。 +标本:069 两次读错 match() 接口。 + +### #8 测试隔离:共享文件名/硬编码路径会互相覆盖 +tmp_path 里多个用例写同名文件、或硬编码 "proj" 路径,后写的覆盖先写的, +表现为"前面的用例随机红"。纪律:文件名带 marker(`stub-{marker.name}.sh`), +_mk 类工厂函数加 name= 参数。 +标本:057 通道 stub 互相覆盖;057 _mk 路径冲突。 + +### #9 时间戳方向:谁晚谁是"新" +"内容钉 ts 晚于信任表 ts = 人重新授信过"这类方向判断,方向反了规则永远走 +不到(deny 分支不可达 = 测试全绿但功能没验证)。写这类测试前先在纸上标一遍 +时间轴。 +标本:072 内容钉 08:00 早于表 09:00,deny 不可达,测试假绿。 + +### #10 EARS 验收行三件齐才算判据,勾了才算验过 +`- When 条件,则 结果(验:命令)` 三件齐;验完在行尾加 ✅(v1.73 起任意位置 +计勾)。缺(验:命令)= 不完整判据,门禁会拦(acceptance_missing/open)。 +标本:v1.73 前行尾只有 ✅ 没命令被拦的整族。 + +### #11 heredoc/引号载荷会骗过你自己 grep 的眼睛 +边界守卫标本四连:heredoc 里的路径、引号里的命令词、载荷文本里的 `>=3`—— +写拦截规则时先想"载荷和真写操作的区分特征是什么",别在原文上改正则。 +标本:v1.77/1.82 边界守卫四标本。 + +### #12 门禁 120s 默认超时:全量 >120s 的项目要配 test_runner.timeout +`.regress/config.json` 的 `test_runner.timeout`(秒)。本仓设 300(套件 ~119s +余量小)。新项目套件涨过 120s 会表现为"0 通过 + 超时"——不是测试坏了。 +标本:058(v1.69)。 + +### #13 门禁拦的是整条 Bash 命令,时机=命令提交瞬间 +命令串里含 `git commit` 字样就触发,门禁验的是**彼时**的工作树——复合命令 +(`seed && install && add && git commit`)里的步骤在门禁放行后才跑,内层 commit +不会被二次拦截。推论:想用测试缓存播种,seed 必须是**独立的前置调用**(命令串 +不含 git commit),下一提交调用才会命中;把 seed 和 commit 放同一复合命令 = +seed 与门禁全量并行双跑(想省的以并发形态重演)。 +标本:076 取证(guard_version 1.85.1 事件与 0 秒计时矛盾旋梯)。 + +### #14 gen_reference 无 dry-run:任何调用都是真写 +`scripts/gen_reference.py` 不校验未知参数——`--dry-run` 会被静默忽略并**真执行 +刷新**(改 README 派生区=边界外写入)。想检查一致性用 `--check`;别的 flag 一律 +别传。 +标本:run8 波1-C 臂(--dry-run 真写后外科还原)。 + +## 三、批流程七步(你在第 4 步) + +1. 清单落盘(主会话)——tier S/M/L、scan 三行、understood_intent 三件、EARS 验收、rollback +2. 顾问预审化石 + plan_approve --provisional(主会话) +3. **实现**(←你在这里:Read 相关文件 ≥2 → 改 planned_changes 声明的文件) +4. **自测**(←你在这里:本批测试文件绿 + gen_reference 刷新派生区) +5. 集成(主会话):install.sh → git 三步 → 门禁(全量或缓存命中) +6. 发布(主会话):Git Data API 全树替换 + ref→commit→tree 等值校验 +7. 落账(主会话):journal task_done / advisor_adoption、清单 ✅、decisions + +## 四、自测通过的判据 + +- 你的测试文件全绿(`python3 -m pytest tests/test_<你的>.py -q`) +- 没动边界外的文件(`git status --short` 里只有清单声明的路径) +- gen_reference 已刷新且 `--check` 过(`python3 scripts/gen_reference.py && python3 scripts/gen_reference.py --check`) +- 报告:改了什么/测试结果/有没有踩上面的坑/有没有计划外发现 diff --git "a/plugins/regress-guard/docs/\345\260\217\347\231\275\346\214\207\345\215\227.md" "b/plugins/regress-guard/docs/\345\260\217\347\231\275\346\214\207\345\215\227.md" new file mode 100644 index 0000000..79ca827 --- /dev/null +++ "b/plugins/regress-guard/docs/\345\260\217\347\231\275\346\214\207\345\215\227.md" @@ -0,0 +1,166 @@ +# regress-guard 小白使用指南 + +> 你不需要懂代码,跟着做就行。**你甚至不需要记任何命令**——正常说话即可。 + +--- + +## 这东西是干嘛的? + +简单说:**防止 AI 写代码时偷偷改坏东西你不知道。** + +AI 帮你写代码时,经常会"顺手"改一些你没让它改的文件,或者"自信地"留下没测过的坑。 +regress-guard 是一层**全天候的门卫+宪法**: + +1. 动手前:出一张 30 秒能看完的计划卡片,**你批准了它才能动手**(方向错误的最后纠偏点) +2. 动手中:越界的编辑被**物理拦截**;卡住了会带着精确的问题来找你,不会瞎磨 +3. 提交时:门卫自动跑测试、复核证据,**测试过了才放行** + +--- + +## 谁做什么?(先看这个,别的都可以忘) + +| | 做什么 | 多久一次 | +|---|---|---| +| **你在 ZCode 里** | 说需求 → 批卡片(30秒)→ 被问就答(受阻给东西/终验答一个词) | 每次任务 4 句话 | +| **你在 ZCode 外** | 物理事件:开权限、给凭据、听音质看画面;和批准前动脑理解 | 按需 | +| **AI 自动** | 分析、写码、修测试、收尾、提交(门卫接住) | 全程 | +| **门卫自动** | 拦越界、跑测试、验证据、断点指路、自愈升级 | 全程无感 | + +三条铁律:物理世界的动作只能你做;方向对不对只能你批;其余全自动,你随时可一句话介入。 +**你不需要说"提交"**——AI 自己提交,门卫把着关。 + +## 安装(就一句话) + +对 ZCode 的 AI 说: + +``` +/regress:install +``` + +**搞定。** 装完所有项目自动生效;项目第一次用时 AI 会自动初始化,你不用管。 + +> 手动党:`bash install.sh` + +### 装了什么? + +你想了解的话(数字会随版本变,以 README 为准): +- 13 个命令(AI 自动调用为主,你几乎不用敲) +- 11 个 hook 注册(边界拦截/提交门禁/自愈/哨兵/失败采集……全部无感) +- 项目里的 `.regress/` 数据目录(迷失时打开里面的 README) + +### 更新 + +**你什么都不用做。** 每次 ZCode 启动自动检测新版本静默升级;老项目缺什么也会自动补。 + +### 卸载 + +对 AI 说 `/regress:uninstall`,或 `bash uninstall.sh`。 + +--- + +## 日常使用:你只需要会说三类话 + +**第一类:说需求**(正常说话) + +``` +你:"帮我给登录页面加个记住密码功能" + +AI:(自动分析、出计划卡片——改哪些文件/有什么风险/按什么顺序做) +``` + +**第二类:回应卡片**(你的核心动作,30 秒) + +``` +AI:📋 计划卡片……等待批准 +你:"批准" ← 没问题就动手 +你:"不对,X 应该是 Y" ← AI 改完再给你看 +你:"取消" ← 不做了 +``` + +**第三类:被问到时回答**(系统不会没事烦你) + +``` +AI:🛑 受阻:需要你开一下防火墙白名单 ← 给它要的东西 +AI:改完了,对讲声音清晰吗?通过/不通过 ← 回答一个词 +``` + +然后(你不用管,AI 自己走完): + +``` +AI:收尾(/regress:finish)→ 自己提交 +🛡️ 门卫自动检查:文件都在清单?测试都过了?证据都齐? + → 都OK 放行 ✅ / 有问题 AI 自己修 +``` + +### 如果门卫拦住了(这是好事!) + +- **AI 越界改了文件** → AI 自己会收到拦截并找路(通常自动走 track 回写);它来问你时说"确实要改"即可 +- **测试没过** → 对 AI 说"修一下",它修完重提 +- **你着急** → `/regress:bypass 10` 开 10 分钟紧急通道——**会记账**,之后让 AI 补回归测试还债 + +--- + +## 高级用法(可选,记不住就忘掉) + +| 命令 | 什么时候用 | +|------|-----------| +| `/regress:resume` | 隔了几天回来续作(其实新会话开场哨兵会自动提示) | +| `/regress:finish` | 一条命令收尾(回写+验证+沉淀+报告),AI 也常自动走 | +| `/regress:quick 需求` | 小改动快速模式(跳过计划仪式) | +| `/regress:bypass 10` | 紧急通道(赦后记债) | +| `/regress:learn` | 让它总结这个项目的历史规律(越用越聪明) | + +**做产品功能时**:第一次会问你"用户是谁/什么不可牺牲"——答一次就记进 +`product-context.md`(产品上下文卡),以后每个设计都会对照这张卡,还会拿草图给你过目 +(否决发生在草图,不用等做完再返工)。 + +**你唯一值得记的一个:紧急时 `/regress:bypass 10`。其余全部可以说人话解决。** + +--- + +## 常见问题 + +### Q:不小心删了文件,会坏吗? +**不会。** 每次启动 ZCode 自动检查并修复(自愈)。 + +### Q:每次提交都要等测试,太慢了? +改了文档或小东西:`/regress:bypass 5`(会记债,别忘了还)。 + +### Q:团队其他人不用 ZCode 直接提交,门卫管得到吗? +**拦不到,但看得见。** git 观测钩子会记录"未走门禁的提交"——采用率有诚实镜子。 + +### Q:AI 拿着计划卡片来找我,我看不懂技术细节怎么办? +扫三样就够:**做什么/不做什么**(第一行)、**改动哪些文件**(大概)、**它假设了什么**(如有不符指出)。其余交给门禁复核。 + +### Q:不想用了怎么关掉? +- 临时关:`.regress/config.json` 把 `"strict"` 设为 `false` +- 彻底删:`/regress:uninstall` + +### Q:什么是 F1、F2、F3? +不用管。你只需要知道:AI 改的所有文件都会被记录、验证、留痕。 + +--- + +## 一张图 + +``` +你说需求 + ↓ +AI 出计划卡片 ──→ 你批准(30秒) ← 人类决策点 + ↓ +AI 干活(越界被拦/卡住会精确求助) + ↓ +AI 收尾(/regress:finish)──→ 感官终验问你 通过/不通过 + ↓ +AI 收尾后自己提交 + ↓ +🛡️ 门卫:清单齐?测试过?证据在? + ├─ 拦!AI 自己修 + └─ 放行 ✅ +``` + +--- + +## 需要记住的就 1 句话 + +**正常说需求、看卡片说批准、被问就答——其余的门卫在值班。** diff --git a/plugins/regress-guard/hooks/hooks.json b/plugins/regress-guard/hooks/hooks.json new file mode 100644 index 0000000..a63988d --- /dev/null +++ b/plugins/regress-guard/hooks/hooks.json @@ -0,0 +1,159 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/launcher.js\"", + "timeout": 300, + "statusMessage": "regress-guard: 提交门禁检查..." + }, + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/boundary_guard.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 边界检查(Bash)..." + }, + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/execution_valve.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 执行阀检查..." + } + ] + }, + { + "matcher": "Edit|Write|ApplyPatch", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/read_before_edit_guard.py\" pre", + "timeout": 5, + "statusMessage": "regress-guard: 先读后改检查..." + }, + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/boundary_guard.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 边界检查..." + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/risk_watch.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 风险与重复信号采集..." + } + ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/read_before_edit_guard.py\" post", + "timeout": 5, + "statusMessage": "regress-guard: 读计数..." + } + ] + }, + { + "matcher": "ExitPlanMode", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/plan_bridge.py\" post", + "timeout": 5, + "statusMessage": "regress-guard: 原生计划转录..." + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": "Bash|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/fail_watch.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 失败信号采集..." + } + ] + }, + { + "matcher": "ExitPlanMode", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/plan_bridge.py\" fail", + "timeout": 5, + "statusMessage": "regress-guard: 拒绝计划化石..." + } + ] + } + ], + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/self_heal.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 检查完整性..." + } + ] + }, + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/compact_notice.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 压缩检查..." + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/reflection_check.py\"", + "timeout": 90, + "statusMessage": "regress-guard: 反思检查..." + }, + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/stop_notify.py\"", + "timeout": 8, + "statusMessage": "regress-guard: 阶段完成推送(授权轮)..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/scripts/prompt_intercept.py\"", + "timeout": 5, + "statusMessage": "regress-guard: 需求入口检查..." + } + ] + } + ] + } +} diff --git a/plugins/regress-guard/hooks/scripts/boundary_guard.py b/plugins/regress-guard/hooks/scripts/boundary_guard.py new file mode 100644 index 0000000..9095fc5 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/boundary_guard.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""boundary_guard — 开发边界守卫(PreToolUse Edit|Write|ApplyPatch 拦截)。 + +思想(2026-08-22 用户对话启示):检测≠拦截——Stop 钩子的方向漂移提醒是 +"事后追认",token 已烧、熵已生;边界必须在动作发生前物理拦截。 +实证:无边界的 agent 一天 6B 消耗半个月完不成任务、复检几十轮; +加边界后 <1B/天、多任务/天、自动复检即达标。约束不是拖慢,是提速。 + +拦截顺序(短路): + 0. 赦免:config.bypass_until 未过期 → 全放行(与 commit 门禁同源的唯一逃生口) + 1. 项目级禁改区:config.boundary.forbidden 通配,任何任务状态都拦(冻结区/遗留区) + 2. 任务边界:活跃清单(planning/in-progress/verifying/blocked)取并集 + planning=待批准拦编辑;approved.at 非空=人类产物直通视同已批准; + blocked=受阻拦编辑(合法停止不是绕过理由) + 3. 无边界信息 → 放行(fail-open,不误锁) +豁免:.regress/**、AGENTS.md、regress-guard 自身文件。 +越界的合法出口:/regress:track 回写 actual_changes(扩界留痕); +禁改区的合法出口:/regress:bypass <分钟>(赦后记债)或改 config。 +关闭:.regress/config.json 设 "boundary_enforced": false。 + +v1.20:字段读取与可编辑性判定收敛到 lib/manifest_fields.py(单一来源)。 + +退出码:0=放行,2=阻断 +""" +import sys +import os +import json +import re +import fnmatch +from datetime import datetime + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LIB_DIR = os.path.join(SCRIPT_DIR, "lib") +if LIB_DIR not in sys.path: + sys.path.insert(0, LIB_DIR) + +from manifest_fields import ( # noqa: E402 + ACTIVE_STATUS_RE, block_value, editable, parse_core, +) +from manifest_parser import read_frontmatter # noqa: E402 长寿扫描:只读 frontmatter + +# 自定位绝对路径(源/安装两种布局同构)——拦截消息不留给读者猜的相对引用 +APPROVE_SCRIPT = os.path.join(LIB_DIR, "plan_approve.py") + + +def _load_config(project_dir): + cfg = os.path.join(project_dir, ".regress", "config.json") + try: + with open(cfg, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (IOError, OSError, json.JSONDecodeError): + return {} + + +def find_project_dir(): + d = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + d = os.path.abspath(d) + for _ in range(10): + if os.path.isdir(os.path.join(d, ".regress")): + return d + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + return None + + +def config_disables_boundary(project_dir): + return _load_config(project_dir).get("boundary_enforced") is False + + +def bypass_active(project_dir): + """限时赦免(v1.20 赦免权普适化):与 pre_commit_guard 同源读 bypass_until。""" + until = str(_load_config(project_dir).get("bypass_until") or "").strip() + if not until: + return False + try: + return datetime.now() < datetime.fromisoformat(until) + except (ValueError, TypeError): + return False # 格式坏 = 过期 + + +def forbidden_globs(project_dir): + """项目级禁改区(v1.20):boundary.forbidden 通配列表。 + + 形状错误 fail-loud:stderr 警告但不砖(返回 [] 放行——禁改区是增强不是依赖)。 + """ + v = (_load_config(project_dir).get("boundary") or {}).get("forbidden") + if v is None: + return [] + if isinstance(v, list) and all(isinstance(x, str) for x in v): + return v + print("REGRESS-GUARD: ⚠️ config boundary.forbidden 形状错误(应为字符串数组)," + "禁改区未生效", file=sys.stderr) + return [] + + +def boundary_include(project_dir): + """config 级常设白名单(v1.34,020 装机债根因):boundary.forbidden 一直 + 被读而 include 从不被读——不对称陷阱。装机类机级绝对路径 + (如 /etc/systemd/system/**)的合法出口,不再逼 bypass(赦免记债)。 + path_matches 对绝对路径本就对称匹配,缺的只是读它的人。""" + v = (_load_config(project_dir).get("boundary") or {}).get("include") + if v is None: + return [] + if isinstance(v, list) and all(isinstance(x, str) for x in v): + return v + print("REGRESS-GUARD: ⚠️ config boundary.include 形状错误(应为字符串数组)," + "常设白名单未生效", file=sys.stderr) + return [] + + +def _manifest_session(manifest_path): + """清单的 session 戳(v1.34)——只认 frontmatter;无戳/读不到返回 "" + (共享语义:老清单不受会话作用域影响)。""" + try: + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + except (IOError, OSError): + return "" + fm_text = content.split("---", 2)[1] if content.startswith("---") else "" + m = re.search(r"^session:\s*[\"']?([^\s\"'\n]+)", fm_text, re.M) + return m.group(1) if m else "" + + +def parse_boundary(manifest_path): + """从清单 frontmatter 提取边界:显式 include 通配 或 精确文件集。 + + 返回 (patterns, exact_files, manifest_id, status, approved_at); + patterns 与 exact 皆空 = 无边界信息。 + """ + try: + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + except (IOError, OSError): + return [], [], "", "", "" + core = parse_core(content) + if not core: + return [], [], "", "", "" + fm_text = content.split("---", 2)[1] if content.startswith("---") else "" + + patterns = [] + # 显式 boundary: include: 列表(支持 "- glob" 行与行内 [a, b]) + bm = re.search(r"^boundary:\s*\n((?:[ \t]+.*\n?)+)", fm_text, re.M) + if bm: + block = bm.group(1) + im = re.search(r"^[ \t]+include:\s*(.*)$", block, re.M) + if im: + inline = im.group(1).strip() + if inline.startswith("[") and inline.endswith("]"): + patterns = [p.strip().strip('"\'') for p in inline[1:-1].split(",") if p.strip()] + else: + for ln in block.splitlines(): + s = ln.strip() + if s.startswith("- "): + patterns.append(s[2:].strip().strip('"\'')) + + exact = set() + # 精确集(planned+actual)永远算边界的一部分——track 回写 actual_changes + # 即扩界(逃逸出口);显式 include 通配只是在精确集之上加宽。 + for fm_file in re.finditer(r"file:\s*[\"']?([^\"'\n#]+)", fm_text): + exact.add(fm_file.group(1).strip().strip("/")) + return patterns, sorted(exact), core["id"], core["status"], core["approved_at"] + + +def active_manifests(project_dir): + import glob + out = [] + mdir = os.path.join(project_dir, ".regress", "manifests") + if os.path.isdir(mdir): + for f in sorted(glob.glob(os.path.join(mdir, "*.md")), reverse=True): + # v1.23.2 长寿扫描:只搜 frontmatter——done 堆积后仍是 O(frontmatter), + # 且正文引用状态词不再让 done 清单诈尸(旧实现两头的坑) + try: + if ACTIVE_STATUS_RE.search(read_frontmatter(f)): + out.append(f) + except (IOError, OSError): + pass + return out + + +_HEREDOC_RE = re.compile( + r"(<<-?\s*['\"]?)(\w+)(['\"]?\n).*?\n[ \t]*\2(?:\n|$)", re.S) + + +def _strip_heredoc_bodies(cmd): + """剔除 heredoc 体(v1.77,066):体是文件内容不是命令——体内 rm/重定向 + 样文本曾成假目标;保留 << 定界行(其重定向目标仍可判)。""" + return _HEREDOC_RE.sub(r"\1\2\3", cmd) + + +def _dequote(seg): + """引号内容置空保结构(v1.77,066):命令词匹配跑在这份上——载荷/文档 + 内容里的英文命令词(活体:install smoke)不再被当命令,其后文本不再 + 成参数。重定向另有引号目标补充模式兜真写。""" + seg = re.sub(r"'[^']*'", "''", seg) + return re.sub(r'"[^"]*"', '""', seg) + + +def _seg_write_targets(seg): + """单段命令的高置信写目标(原始字符串)。 + + v1.77 误判根治:命令词/sed/of= 在去引号文本上匹配(引号内是数据不是 + 命令);重定向在原文与去引号文本双轨(原文捕裸目标,另加引号目标模式 + 捕 > 'my file' 形态)。""" + targets = set() + dq = _dequote(seg) + # v1.82(071):裸重定向改跑去引号文本+断言 (?![<=]) 跳过比较符与 + # herestring——第四标本(载荷 >=3 被捕 "=3")根治;引号目标补充模式 + # 保留在原文(> 'my file' 真写不因去引号而漏)。 + for m in re.finditer(r'(?{1,2}(?![<=])\s*([^\s;|&]+)', dq): + targets.add(m.group(1)) + for m in re.finditer(r'&>{1,2}\s*([^\s;|&]+)', dq): + targets.add(m.group(1)) + for m in re.finditer(r">(?{0,1}\s*'([^']+)'", seg): + targets.add(m.group(1)) + for m in re.finditer(r">{1,2}\s*\"([^\"]+)\"", seg): + targets.add(m.group(1)) + for m in re.finditer( + r'(?:^|[;|&\s])(rm|rmdir|mv|cp|tee|truncate|touch|install|shred)\s+([^;|&\n]+)', + dq): + name, rest = m.group(1), m.group(2) + toks = [t for t in rest.split() if not t.startswith("-")] + if not toks: + continue + if name in ("mv", "cp", "install"): + if len(toks) >= 2: + targets.add(toks[-1]) + else: + targets.update(toks) + for m in re.finditer(r'(?:^|[;|&\s])sed\s+(?:-[^\s]*i[^\s]*\s+)?(?:-[^\s]*\s+)*([^;|&\n]+)', dq): + toks = [t for t in m.group(1).split() if not t.startswith("-")] + if toks: + targets.add(toks[-1]) + for m in re.finditer(r'\bof=([^\s;|&]+)', dq): + targets.add(m.group(1)) + return targets + + +def extract_write_targets(cmd, project_dir): + """Bash 高置信度写目标,返回绝对路径(v1.29 旁路收口;v1.30 cd 链跟踪)。 + + 只认高置信形态,不确定一律漏过——fail-open 防误拦测试命令(2> 不收: + stderr 类误拦代价高于漏拦;/dev/null 豁免)。cd 链跟踪(病例 2026-09-03 + 首例活体误拦):`cd sub && cat > f` 的相对目标曾被解析到项目根——cd 后续 + 段的相对目标按链上目录解析;cd 目标含变量($)则后续相对目标放弃(fail-open)。 + """ + out, seen = [], set() + cwd = None + cwd_known = True + for seg in re.split(r"&&|\|\||;", _strip_heredoc_bodies(cmd)): + seg = seg.strip() + m = re.match(r"^cd\s+([^\s;&|]+)", seg) + if m: + d = m.group(1).strip('\'"') + if d.startswith("$") or d == "-": + cwd_known = False + else: + cwd = d if os.path.isabs(d) else os.path.join(cwd or project_dir, d) + continue + for t in _seg_write_targets(seg): + t = t.strip('\'"') + if (not t or t.startswith("$") or t in ("/dev/null", "/dev/stdout", "/dev/stderr") + or t.isdigit()): + continue + if os.path.isabs(t): + p = t + elif cwd_known and cwd: + p = os.path.normpath(os.path.join(cwd, t)) + elif cwd_known: + p = os.path.join(project_dir, t) + else: + continue + if p not in seen: + seen.add(p) + out.append(p) + return out + + +def path_matches(path_rel, patterns, exact_files): + p = path_rel.strip("/") + for e in exact_files: + e = e.strip().strip('"').strip("/") + if not e: + continue + if p == e or p.endswith("/" + e) or e.endswith("/" + p): + return True + for pat in patterns: + pat = pat.strip().strip("/") + if not pat: + continue + if fnmatch.fnmatch(p, pat) or fnmatch.fnmatch(p, pat + "/*"): + return True + # src/** 也直接匹配 src 自身路径前缀下的所有深度 + if pat.endswith("/**") and (p == pat[:-3] or p.startswith(pat[:-3] + "/")): + return True + return False + + +def is_exempt(fp_rel, fp_abs): + if ".regress/" in fp_rel or fp_rel.startswith(".regress"): + return True + if fp_rel.endswith("AGENTS.md") or "regress-guard" in fp_abs: + return True + return False + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.exit(0) + + tool = data.get("tool_name", "") + if tool not in ("Edit", "Write", "ApplyPatch", "Bash"): + sys.exit(0) + ti = data.get("tool_input", {}) + if not isinstance(ti, dict): + sys.exit(0) + project_dir = find_project_dir() + if project_dir is None: + sys.exit(0) + if tool == "Bash": + # v1.29 旁路收口:Bash 写目标走与 Edit 相同的边界判定(v1.30:cd 链跟踪防误拦) + fps = extract_write_targets(str(ti.get("command") or ""), project_dir) + if not fps: + sys.exit(0) + else: + fp = str(ti.get("file_path") or ti.get("path") or "") + if not fp: + sys.exit(0) + fps = [fp] + via = "(Bash 写目标)" if tool == "Bash" else "" + + if config_disables_boundary(project_dir): + sys.exit(0) + + # ─── 0. 赦免权(统一逃生口):bypass 有效期内放行一切编辑 ─── + if bypass_active(project_dir): + sys.exit(0) + + forb = forbidden_globs(project_dir) + stand_include = boundary_include(project_dir) # v1.34 常设白名单 + my_sid = (os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") or "") + manifests = active_manifests(project_dir) + boundaries = [] + for mf in manifests: + patterns, exact, mid, status, approved_at = parse_boundary(mf) + if (patterns or exact) and status not in ("done", "completed", "cancelled"): + boundaries.append((mf, patterns, exact, mid, status, approved_at)) + if not boundaries and not forb: + sys.exit(0) # 无边界信息,fail-open(含无活跃清单——设计语义) + + for fp in fps: + fp_abs = os.path.abspath( + fp if os.path.isabs(fp) else os.path.join(project_dir, fp)) + try: + fp_rel = os.path.relpath(fp_abs, project_dir).replace(os.sep, "/") + except ValueError: + fp_rel = fp_abs.replace(os.sep, "/") + if fp_rel.startswith("../"): + # 项目目录之外的文件:一律视为边界外(除非豁免路径) + fp_rel = os.path.normpath(fp_abs).replace(os.sep, "/") + if is_exempt(fp_rel, fp_abs): + continue + + # ─── 1. 项目级禁改区:与任务无关,任何状态都拦 ─── + if forb and path_matches(fp_rel, forb, []): + print( + f"REGRESS-GUARD: ⛔ 项目禁改区\n" + f" {fp_rel}{via}\n" + f" 命中 .regress/config.json 的 boundary.forbidden(冻结区/遗留区)。\n\n" + f" 禁改区与任务边界无关——它保护的是「不该再被碰」的代码。\n" + f" · 确有必要 → /regress:bypass <分钟>(限时赦免,赦后记债)\n" + f" · 冻结已解除 → 从 config 的 forbidden 列表移除(留痕于 git)", + file=sys.stderr, + ) + sys.exit(2) + + # ─── 1.5 常设白名单(v1.34):config boundary.include——装机类机级 + # 路径的合法出口。forbidden 先于 include(deny 优先于 allow)。 + if stand_include and path_matches(fp_rel, stand_include, []): + continue + + matched = [(mf, mid, status, appr, _manifest_session(mf)) + for mf, patterns, exact, mid, status, appr in boundaries + if path_matches(fp_rel, patterns, exact)] + # ─── 1.6 跨会话编辑守卫(v1.34,标本5 活体编辑竞态):文件在他会话 + # 活跃清单边界内——冲突只在集成态现形,编辑时就对焦比提交后 + # 回滚便宜。无戳清单不受影响(共享语义)。 + if my_sid: + foreign = [m for m in matched if m[4] and m[4] != my_sid] + own = [m for m in matched if not m[4] or m[4] == my_sid] + if foreign: + f_ids = ", ".join(f"{mid or os.path.basename(mf)}({sid[:8]}…)" + for mf, mid, _, _, sid in foreign[:3]) + both = "(你的清单也声明了此文件——双方都计划了它,真冲突)" if own else "" + print( + f"REGRESS-GUARD: 🔀 跨会话作业中\n" + f" {fp_rel}{via}\n" + f" 在他会话的活跃清单边界内:{f_ids}{both}\n\n" + f" 多会话的冲突只在集成点现形——现在对焦比事后回滚便宜。\n" + f" · 哨兵视图看全局归属:python3 hooks/scripts/lib/sentinel.py\n" + f" · 与该会话串行作业,或让人类仲裁文件归属\n" + f" · 该会话已死?收尾其清单或重盖 session 戳后再动此文件\n" + f" · 确要并行抢改 → /regress:bypass <分钟>(限时赦免,赦后记债)", + file=sys.stderr, + ) + sys.exit(2) + if any(editable(status, appr) for _, _, status, appr, _ in matched): + continue # 已批准(status 翻转 或 approved.at 产物直通) + if matched: + blocked_hits = [(mf, mid) for mf, mid, status, _, _ in matched + if status == "blocked"] + if blocked_hits: + # 命中受阻清单:受阻是合法的停止状态,编辑被拦直到解阻 + mf0, mid0 = blocked_hits[0] + with open(mf0, encoding="utf-8") as f: + content = f.read() + reason = block_value(content, "blocked", "reason") if "---" in content else "" + need = block_value(content, "blocked", "need") if "---" in content else "" + ids = ", ".join(mid or os.path.basename(mf) for mf, mid in blocked_hits[:3]) + extra = "" + planning_ids = [mid or os.path.basename(mf) + for mf, mid, status, _, _ in matched if status == "planning"] + if planning_ids: + extra = f"\n (另有待批准清单:{', '.join(planning_ids[:3])})" + print( + f"REGRESS-GUARD: 🛑 任务受阻\n" + f" {fp_rel}{via}\n" + f" 在受阻清单({ids})的边界内——受阻是合法停止,不是绕过的理由。\n\n" + f" 阻塞:{reason or '(读清单 blocked 块)'}\n" + f" 需要人类:{need or '(读清单 blocked.need)'}\n\n" + f" · 把 need 转达给人类——这是受阻的出口,不是继续硬磨\n" + f" · 阻塞解除 → python3 {APPROVE_SCRIPT} <清单> --unblock 后恢复编辑\n" + f" · 认为不该受阻 → 在回复中说明理由,经人类同意后解阻" + f"{extra}", + file=sys.stderr, + ) + sys.exit(2) + # 只命中待批准(planning)清单:计划审批的机器强制——批准前拦编辑 + ids = ", ".join(mid or os.path.basename(mf) + for mf, mid, _, _, _ in matched[:3]) + print( + f"REGRESS-GUARD: ⏸ 计划待批准\n" + f" {fp_rel}{via}\n" + f" 在清单({ids})的边界内,但该计划尚未获得人类批准(status: planning)。\n\n" + f" 批准前只读探索,不动手——这是方向错误的最后低价纠偏点。\n" + f" · 人类回复「批准/开始/ok」→ python3 {APPROVE_SCRIPT} <清单> 完成转写\n" + f" (status→in-progress + approved 落产物 + 漂移检查 + 入地层)\n" + f" · 人类也可直接编辑清单填 approved.at(产物直通),守卫视同已批准\n" + f" · 计划需修改 → /regress:plan 继续对话完善(保持 planning)\n" + f" · 你认为不该等批准 → 在回复中说明理由,请人类显式批准", + file=sys.stderr, + ) + sys.exit(2) + + ids = ", ".join(b[3] or os.path.basename(b[0]) for b in boundaries[:3]) + print( + f"REGRESS-GUARD: 🚧 开发边界拦截\n" + f" {fp_rel}{via}\n" + f" 不在任何活跃清单({ids})的边界内。\n\n" + f" 边界 = 计划的物理化:越界动作在发生前被拦截,而不是烧完 token 后被提醒。\n" + f" 三条路(选一):\n" + f" 1. 这确实是本任务需要的 → /regress:track 把它回写 actual_changes" + f"(边界随之扩展,扩界留痕)\n" + f" 2. 这属于另一个任务 → 先收尾当前清单(status: done 解除边界),再开新清单\n" + f" 3. 误锁 → .regress/config.json 设 \"boundary_enforced\": false 项目级关闭\n\n" + f" 需要临时绕过:/regress:bypass <分钟>", + file=sys.stderr, + ) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/compact_notice.py b/plugins/regress-guard/hooks/scripts/compact_notice.py new file mode 100644 index 0000000..9c7ece8 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/compact_notice.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""SessionStart(compact) hook:上下文压缩刚发生时的记忆降级警告。 + +压缩 = 无损摘要也丢细节(文件行号、报错原文、已否决的方案都可能残缺)。 +注入一条提醒:重要事实回读核对,别凭残缺记忆自决策;断点续作优先 /regress:resume +(v1.17 前指向 /handoff,已过时——第一入口是产物层单侧重建)。 +""" +import sys +import json + + +def main(): + print(json.dumps({"additionalContext": ( + "【regress-guard 压缩警告】上下文刚被压缩——你的记忆是摘要不是原文:\n" + " • 断点续作优先 /regress:resume:从 .regress/ 产物层单侧重建现场" + "(零号入口 .regress/README.md:三步上手 + 完成判据)\n" + " • 关键事实(文件路径/行号、版本号、报错原文、清单状态)先重新 Read 核对," + "禁止引用记忆里的细节直接改代码\n" + " • 已否决过的方案可能被遗忘——认知已物质化在文件里,别靠回忆:\n" + " - .regress/decisions.md(决策日志:决定/依据/否决过的方案)\n" + " - .regress/manifests/(清单:含脆弱点挂牌状态 fragile_points)\n" + " - .regress/journal/events.jsonl(考古地层:历次失败/风险/纠正化石)\n" + " 动手前先读这三处,避免把否决路线再走一遍\n" + " • 任务复杂且 resume 后仍缺关键上下文时,再考虑 /handoff 重开会话," + "优于在残缺记忆上硬撑" + )}, ensure_ascii=False)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/execution_valve.py b/plugins/regress-guard/hooks/scripts/execution_valve.py new file mode 100644 index 0000000..6063bee --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/execution_valve.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""execution_valve — 执行阀(公理四:建议权与执行权彻底剥离)。 + +LLM 的建议是概率猜测,不可逆执行是物理确定性动作——两者之间必须有 +"拆弹密码"级的单向阀门。本阀只认令牌,不听理由: + + 灾难级命令(mkfs / dd if= / force push / DROP/TRUNCATE / chmod -R 777 / + 项目外 rm -rf)默认 exit 2 阻断; + 命令中显式含令牌 REGRESS_CONFIRM=YES 才放行——令牌必须由意图方亲手敲入, + AI 不得替用户默认携带。 + +关闭途径(显式、留痕): + - env REGRESS_VALVE=off(紧急,如 /regress:bypass 场景) + - .regress/config.json {"execution_valve": false}(项目级长期关闭) + +顾问意见(auto-consult)与此阀的关系:顾问永远只产生文本 additionalContext, +不持有任何执行权限——猜测流与执行流物理隔离。 + +事件: PreToolUse(Bash),stdin JSON 同其他 hook +退出码: 0=放行,2=阻断(stderr 展示拆弹说明) +""" +import sys +import os +import re +import json +import tempfile + +TOKEN = "REGRESS_CONFIRM=YES" + +# 灾难级模式:几乎无合法未确认用途 → 无令牌即阻断 +CATASTROPHIC = [ + ("格式化磁盘", re.compile(r"\bmkfs(\.\w+)?\b")), + ("裸写磁盘 dd", re.compile(r"\bdd\s+if=")), + ("强推覆盖远端", re.compile(r"\bgit\s+push\b.*(\s--force\b|\s-f\b)(\s|$)")), + ("删库 DROP/TRUNCATE", re.compile(r"\b(DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE)\b", re.I)), + ("全员可写 chmod 777", re.compile(r"\bchmod\s+-\w*R\w*\s+777\b")), +] + +# rm -rf 单列:合法清理太常见,只拦"物理上不可恢复"的目标—— +# 绝对路径且在 /tmp 与项目目录之外(项目内相对路径 git 可恢复,归 risk_watch 记录) +RM_RF = [ + re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b"), +] + +_SEGMENT_SPLIT = re.compile(r"&&|\|\||;|\n") + + +def _project_dir(): + d = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + return os.path.abspath(d) + + +def _rm_targets_dangerous(segment, project_dir): + """提取 rm 的目标参数,判断是否落在不可恢复区(/tmp 与项目外绝对路径)。""" + m = RM_RF[0].search(segment) + if not m: + return None + tokens = segment[m.end():].split() + # 标准 rm 语义:flags 之后的第一个非 flag token 起全部是目标 + targets = [] + for i, tk in enumerate(tokens): + if tk == "--": + targets.extend(tokens[i + 1:]) + break + if tk.startswith("-"): + continue + targets.extend(tokens[i:]) + break + if not targets: + return None + home = os.path.expanduser("~") + tmp_root = os.path.realpath(tempfile.gettempdir()) + dangerous = [] + for t in targets: + t_exp = os.path.expanduser(t) + if "$" in t_exp: + t_exp = home # $VAR 展开结果不可知,按最坏情况处理 + if not os.path.isabs(t_exp): + continue # 相对路径 = 项目内,git 可恢复 + real = (os.path.realpath(t_exp) if os.path.exists(t_exp) + else os.path.normpath(t_exp)) + if real == "/": + dangerous.append(t) + continue + if real == tmp_root or real.startswith(tmp_root + os.sep): + continue # /tmp 清理可接受 + if real == project_dir or real.startswith(project_dir + os.sep): + continue # 项目内,git 可恢复 + dangerous.append(t) + return dangerous or None + + +def _valve_disabled(): + """显式关闭(env 或项目 config)。关闭是显式决策,不是默认。""" + if os.environ.get("REGRESS_VALVE", "").lower() in ("off", "0", "false"): + return True + d = _project_dir() + for _ in range(10): + cfg = os.path.join(d, ".regress", "config.json") + if os.path.exists(cfg): + try: + with open(cfg, encoding="utf-8") as f: + if json.load(f).get("execution_valve") is False: + return True + except (IOError, OSError, json.JSONDecodeError): + pass + break + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return False + + +def evaluate(command, project_dir=None): + """返回阻断原因列表(空=放行)。project_dir 缺省取 env/cwd。""" + if not command or TOKEN in command: + return [] + if project_dir is None: + project_dir = _project_dir() + reasons = [] + segments = _SEGMENT_SPLIT.split(command) + for seg in segments: + for label, pat in CATASTROPHIC: + if pat.search(seg): + reasons.append(f"{label} ← 「{seg.strip()[:80]}」") + if RM_RF[0].search(seg): + dangerous = _rm_targets_dangerous(seg, project_dir) + if dangerous: + reasons.append( + f"项目外不可恢复删除: rm 目标 {', '.join(dangerous[:3])}" + f" ← 「{seg.strip()[:80]}」" + ) + return reasons + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.exit(0) + + # 灾难级命令阀 fail-closed(评审批次一 P0-1c):顶层非 dict 载荷 + # 旧代码 data.get 直接 AttributeError → exit 1 = 放行,与阀门语义相反 + # (tool_input 非 dict 由下方 isinstance 判断兜住,无需强转) + if not isinstance(data, dict): + sys.exit(2) + + if data.get("tool_name", "") != "Bash": + sys.exit(0) + ti = data.get("tool_input", {}) + command = (ti.get("command") or "") if isinstance(ti, dict) else "" + if not command: + sys.exit(0) + + if _valve_disabled(): + sys.exit(0) + + reasons = evaluate(command) + if not reasons: + sys.exit(0) + + print( + "REGRESS-GUARD 执行阀(公理四:不听猜测,只看令牌):\n" + "以下操作物理不可逆,AI 的任何理由都属概率猜测,不能作为执行依据:\n" + + "\n".join(f" 💣 {r}" for r in reasons) + + "\n\n拆弹方式(二选一):\n" + f" 1. 确认真的要做:在命令前显式加令牌 {TOKEN},例如:\n" + f" {TOKEN} <原命令>\n" + " (令牌必须是人类或你基于人类明确授权敲入的,禁止习惯性携带)\n" + " 2. 改用可恢复路径:git 可恢复的项目内清理不用令牌;" + "数据先备份再操作\n\n" + "关闭阀门(留痕):.regress/config.json 设 \"execution_valve\": false," + "或环境变量 REGRESS_VALVE=off", + file=sys.stderr, + ) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/fail_watch.py b/plugins/regress-guard/hooks/scripts/fail_watch.py new file mode 100755 index 0000000..8c7cc19 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/fail_watch.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""fail_watch — PostToolUseFailure 探测器(轮内卡死信号,用户无感层)。 + +只写事实,不做判断:每次工具失败追加一条事件到会话状态文件。 +判断由 reflection_check(Stop hook)统一完成——探测器哑、评估器独裁。 + +事件: {"ts": ISO, "tool": 名, "sig": 归一化签名(命令首token+子命令 或 文件路径)} +状态: /tmp/regress-guard-fails-.jsonl +""" +import sys +import os +import json +import re +import tempfile +from datetime import datetime, timedelta + +# 考古地层(公理三):失败事件同时埋进项目内 append-only 地层(/tmp 重启即失) +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) +try: + from journal import journal_append +except ImportError: # lib 缺失时地层降级关闭,不影响 /tmp 探测 + def journal_append(*a, **k): + return False + + +def session_id(): + return ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default" + ) + + +def state_path(): + return os.path.join(tempfile.gettempdir(), f"regress-guard-fails-{session_id()}.jsonl") + + +def normalize_sig(tool, tool_input): + """归一化签名:Bash 取命令前两段;Edit/Write 取文件路径。""" + if not isinstance(tool_input, dict): + return "?" + if tool == "Bash": + cmd = (tool_input.get("command") or "").strip() + parts = cmd.split() + return " ".join(parts[:2])[:60] if parts else "?" + fp = tool_input.get("file_path") or tool_input.get("path") or "?" + return str(fp)[:120] + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.exit(0) + + tool = data.get("tool_name", "?") + tool_input = data.get("tool_input", data if isinstance(data, dict) else {}) + event = { + "ts": datetime.now().isoformat(), + "tool": tool, + "sig": normalize_sig(tool, tool_input), + } + try: + with open(state_path(), "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + except (IOError, OSError): + pass + journal_append("tool_fail", tool=tool, sig=event["sig"]) # 化石入地层 + sys.exit(0) + + +def _parse_ts(val): + """容错解析时间戳:统一剥时区(aware/naive 混比会 TypeError,静默杀探测器)。""" + try: + dt = datetime.fromisoformat(val) + return dt.replace(tzinfo=None) if dt.tzinfo else dt + except (ValueError, TypeError, AttributeError): + return None + + +def recent_failures(window_min=10): + """读近 window_min 分钟的失败事件(供 reflection_check 调用)。""" + path = state_path() + events = [] + try: + with open(path, encoding="utf-8") as f: + for line in f: + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except (IOError, OSError): + return [] + cutoff = datetime.now() - timedelta(minutes=window_min) + recent = [] + for e in events: + ts = _parse_ts(e.get("ts")) + if ts is not None and ts >= cutoff: + recent.append(e) + # 顺手清理过期文件内容(超过窗口的截掉,防无限增长) + # P2#22:temp+os.replace 原子截断——旧 open("w") 与并发 append 交错时 + # 新失败事件被静默删掉,反思层低估失败数、卡死检测漏报 + if len(recent) < len(events): + try: + tmp = path + ".trim.tmp" + with open(tmp, "w", encoding="utf-8") as f: + for e in recent: + f.write(json.dumps(e, ensure_ascii=False) + "\n") + os.replace(tmp, path) + except (IOError, OSError): + pass + return recent + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/launcher.js b/plugins/regress-guard/hooks/scripts/launcher.js new file mode 100644 index 0000000..895348d --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/launcher.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * launcher.js — 跨平台 hook 启动器。 + * + * ZCode 的 process hook command 固定为一个可执行文件名。 + * Windows 上 python3 不存在(通常是 python 或 py),所以用 Node(ZCode 必有) + * 作为入口,由它找到正确的 Python 解释器并转发。 + * + * hook.json 配置: + * { "type": "process", "command": "node", "args": ["${ZCODE_PLUGIN_ROOT}/hooks/scripts/launcher.js"] } + */ +const { spawn } = require("child_process"); +const path = require("path"); + +const guardPy = path.join(__dirname, "pre_commit_guard.py"); + +// 候选 Python 解释器(按优先级) +const candidates = ["python3", "python", "py"]; + +// env 白名单(v1.69,run4 R1):钩子入口构造洁净环境——RG_*/WECOM_*/GIT_* 等 +// 覆盖类变量不透传给守卫子进程(env 缝隙族的入口层根治:宿主进程 env 里任何 +// 对守卫行为/信任判定/git 读数的注入在此失效)。键按大写归一比较(Windows 不区分)。 +const ENV_KEEP = new Set([ + // 系统基础:找解释器/git、编码、临时区、Windows python 依赖 + "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "TMPDIR", "SYSTEMROOT", + // 代理(通知外发走系统代理语义) + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", + // 宿主契约变量(ZCode 注入的会话/项目/插件根——守卫合法依赖) + "ZCODE_SESSION_ID", "CLAUDE_SESSION_ID", + "ZCODE_PROJECT_DIR", "CLAUDE_PROJECT_DIR", + "ZCODE_HOME", "ZCODE_PLUGIN_ROOT", +]); + +function cleanEnv(env) { + const out = {}; + for (const k of Object.keys(env)) { + if (ENV_KEEP.has(k) || ENV_KEEP.has(k.toUpperCase())) out[k] = env[k]; + } + return out; +} + +module.exports = { cleanEnv, ENV_KEEP }; + +function tryPython(idx) { + if (idx >= candidates.length) { + console.error("REGRESS-GUARD: ❌ 找不到 Python 3 解释器。"); + console.error(" 尝试过: " + candidates.join(", ")); + console.error(""); + console.error(" 解决方法:"); + console.error(" Windows: 从 https://python.org 安装,勾选 'Add to PATH'"); + console.error(" macOS: brew install python3"); + console.error(" Linux: sudo apt install python3 / sudo yum install python3"); + console.error(""); + console.error(" 安装后重启 ZCode 再试。"); + console.error(" 如需临时跳过:.regress/config.json 设 \"strict\": false"); + process.exit(2); + } + + const py = candidates[idx]; + const child = spawn(py, [guardPy], { + stdio: ["inherit", "inherit", "inherit"], + env: cleanEnv(process.env), + }); + + child.on("error", (err) => { + if (err.code === "ENOENT") { + // 这个解释器不存在,试下一个 + tryPython(idx + 1); + } else { + console.error(`REGRESS-GUARD: ❌ 启动 ${py} 失败: ${err.message}`); + console.error(" 这通常是权限问题。尝试: chmod +x " + guardPy); + process.exit(2); + } + }); + + child.on("exit", (code) => { + process.exit(code ?? 1); + }); +} + +// 直接运行才启动;被 require(测试)时只导出 cleanEnv,不 spawn +if (require.main === module) { + tryPython(0); +} diff --git a/plugins/regress-guard/hooks/scripts/lib/cochange_rules.py b/plugins/regress-guard/hooks/scripts/lib/cochange_rules.py new file mode 100755 index 0000000..28731dd --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/cochange_rules.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""框架级 co-change 规则库。 + +来源:社区工具 Chameleon 的实践 + 学术界 evolutionary coupling 研究。 +这些是"改A必改B"的确定性规则——不需要历史数据就能知道。 + +用途: + 1. /regress:plan 时提示"改了A别忘了B" + 2. /regress:track 时发现"改了A但没改B"→ 警告 missing co-change + 3. /regress:learn 冷启动时作为预置知识 + +规则格式: + { + "framework": "Django", + "detect": "models.py 中有 class(Model)" # 怎么检测项目用了这个框架 + "rules": [ + { + "trigger": "**/models.py", # 改了这个文件 + "expect": "**/migrations/*.py", # 应该同时改这个 + "message": "Django model 改动需要配套 migration" + } + ] + } +""" + +# ─── 规则定义 ────────────────────────────────────────── + +FRAMEWORK_RULES = [ + # Django + { + "framework": "Django", + "detect_files": ["manage.py", "settings.py"], + "detect_patterns": ["from django", "import django"], + "rules": [ + { + "trigger_pattern": "models.py", + "expect_pattern": "migrations/", + "message": "Django model 改动通常需要配套 migration(python manage.py makemigrations)", + "severity": "high", + }, + { + "trigger_pattern": "settings.py", + "expect_pattern": "requirements.txt", + "message": "改 settings.py 中的 INSTALLED_APPS 可能需要更新 requirements.txt", + "severity": "low", + }, + { + "trigger_pattern": "urls.py", + "expect_pattern": "views.py", + "message": "Django urls.py 新增路由通常需要配套 view", + "severity": "medium", + }, + ], + }, + # Rails + { + "framework": "Rails", + "detect_files": ["Gemfile", "config/routes.rb"], + "detect_patterns": ["Rails.application", "ActiveRecord"], + "rules": [ + { + "trigger_pattern": "app/models/", + "expect_pattern": "db/migrate/", + "message": "Rails model 改动通常需要配套 migration", + "severity": "high", + }, + { + "trigger_pattern": "app/controllers/", + "expect_pattern": "config/routes.rb", + "message": "Rails controller 新增 action 通常需要在 routes.rb 注册路由", + "severity": "high", + }, + { + "trigger_pattern": "app/models/", + "expect_pattern": "spec/models/ 或 test/models/", + "message": "Rails model 改动通常需要配套测试", + "severity": "medium", + }, + ], + }, + # NestJS + { + "framework": "NestJS", + "detect_files": ["nest-cli.json"], + "detect_patterns": ["@Module", "@Controller", "@nestjs"], + "rules": [ + { + "trigger_pattern": ".controller.ts", + "expect_pattern": ".module.ts", + "message": "NestJS controller 必须注册到对应的 module", + "severity": "high", + }, + { + "trigger_pattern": ".service.ts", + "expect_pattern": ".module.ts", + "message": "NestJS service 必须注册到 module 的 providers", + "severity": "high", + }, + ], + }, + # Spring Boot / Java + { + "framework": "Spring Boot", + "detect_files": ["pom.xml", "build.gradle"], + "detect_patterns": ["@SpringBootApplication", "@RestController", "org.springframework"], + "rules": [ + { + "trigger_pattern": "entity/", + "expect_pattern": "repository/", + "message": "Spring Entity 改动通常需要检查 Repository", + "severity": "medium", + }, + { + "trigger_pattern": "controller/", + "expect_pattern": "service/", + "message": "Spring Controller 改动通常需要检查 Service 层", + "severity": "medium", + }, + { + "trigger_pattern": "entity/", + "expect_pattern": "resources/db/migration/ 或 resources/mapper/", + "message": "Entity 字段改动可能需要 DB migration 或 MyBatis mapper 更新", + "severity": "high", + }, + { + "trigger_pattern": "pom.xml", + "expect_pattern": None, + "message": "pom.xml 依赖改动需要 mvn clean install 确认编译通过", + "severity": "low", + }, + ], + }, + # React / Redux + { + "framework": "Redux", + "detect_files": [], + "detect_patterns": ["createSlice", "configureStore", "@reduxjs/toolkit"], + "rules": [ + { + "trigger_pattern": "slice.ts 或 slice.js", + "expect_pattern": "store.ts 或 store.js", + "message": "Redux slice 新增必须注册到 store", + "severity": "high", + }, + ], + }, + # Go + { + "framework": "Go", + "detect_files": ["go.mod"], + "detect_patterns": ["package main", "go.mod"], + "rules": [ + { + "trigger_pattern": "handler 或 controller", + "expect_pattern": "router 或 route", + "message": "Go handler 改动通常需要检查路由注册", + "severity": "medium", + }, + ], + }, + # Next.js + { + "framework": "Next.js", + "detect_files": ["next.config.js", "next.config.mjs"], + "detect_patterns": ["next/", "next/image", "next/link"], + "rules": [ + { + "trigger_pattern": "app/api/ 或 pages/api/", + "expect_pattern": None, + "message": "Next.js API route 改动需要确认前端调用方同步更新", + "severity": "medium", + }, + ], + }, + # 通用规则(任何框架) + { + "framework": "通用", + "detect_files": [], + "detect_patterns": [], + "rules": [ + { + "trigger_pattern": ".env.example 或 application.yml", + "expect_pattern": "README 或部署文档", + "message": "配置文件改动需要同步更新文档", + "severity": "low", + }, + { + "trigger_pattern": "package.json 的 dependencies", + "expect_pattern": "package-lock.json 或 yarn.lock", + "expect_auto": True, # npm install 会自动更新 + "message": "依赖改动需要重新 install(lock 文件会自动更新)", + "severity": "low", + }, + ], + }, +] + + +def detect_framework(project_dir): + """检测项目用了什么框架。返回匹配的框架规则列表。""" + import os + import glob + matched = [] + + for fw in FRAMEWORK_RULES: + # 检测文件 + found = False + for pattern in fw.get("detect_files", []): + if glob.glob(os.path.join(project_dir, "**", pattern), recursive=True): + found = True + break + + # 如果文件没匹配,检测内容模式(只检查根目录的关键文件) + if not found and fw.get("detect_patterns"): + for check_file in ("package.json", "pom.xml", "build.gradle", + "go.mod", "Gemfile", "requirements.txt", + "manage.py", "settings.py"): + fp = os.path.join(project_dir, check_file) + if os.path.exists(fp): + try: + with open(fp, encoding="utf-8") as f: + content = f.read()[:5000] + for pattern in fw["detect_patterns"]: + if pattern in content: + found = True + break + except (IOError, OSError): + pass + if found: + break + + if found: + matched.append(fw) + + # 通用规则总是包含 + for fw in FRAMEWORK_RULES: + if fw["framework"] == "通用": + matched.append(fw) + + return matched + + +def check_cochange(changed_files, project_dir): + """检查改动的文件是否有 missing co-change。 + + Args: + changed_files: 本次改动的文件列表 + project_dir: 项目根目录 + + Returns: + list of {trigger, expect, message, severity, missing: bool} + """ + import fnmatch + + frameworks = detect_framework(project_dir) + warnings = [] + + for fw in frameworks: + for rule in fw.get("rules", []): + trigger = rule["trigger_pattern"] + expect = rule.get("expect_pattern") + + # 检查是否触发了规则(改了 trigger 文件) + triggered = any( + fnmatch.fnmatch(f, f"*{trigger}*") or trigger in f + for f in changed_files + ) + + if not triggered: + continue + + # 如果 expect 为 None,只提示不检查 + if expect is None or rule.get("expect_auto"): + warnings.append({ + "framework": fw["framework"], + "trigger": trigger, + "expect": expect or "(无配套文件检查)", + "message": rule["message"], + "severity": rule["severity"], + "missing": False, + "note": "提示", + }) + continue + + # 检查 expect 文件是否也在改动列表中 + has_expect = any( + fnmatch.fnmatch(f, f"*{expect}*") or expect in f + for f in changed_files + ) + + if not has_expect: + warnings.append({ + "framework": fw["framework"], + "trigger": trigger, + "expect": expect, + "message": rule["message"], + "severity": rule["severity"], + "missing": True, + "note": f"改了 {trigger} 但没看到 {expect} 的改动", + }) + + return warnings + + +if __name__ == "__main__": + import sys + import json + project_dir = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() + changed = sys.argv[2].split(",") if len(sys.argv) > 2 else [] + + if changed: + # 检查 missing co-change + result = check_cochange(changed, project_dir) + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + # 只检测框架 + frameworks = detect_framework(project_dir) + print(json.dumps( + [{"framework": f["framework"], "rules": len(f.get("rules", []))} + for f in frameworks], + ensure_ascii=False, indent=2 + )) diff --git a/plugins/regress-guard/hooks/scripts/lib/facts.py b/plugins/regress-guard/hooks/scripts/lib/facts.py new file mode 100644 index 0000000..bef3661 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/facts.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""facts — 机器事实卡(跨项目地层)v1.32:三层结构。 + +SKILL.md = 薄索引+路由话(触发时只加载这层,索引每次 record 后机械重建); +事实本体按域落 references/<域>.md(按需整文件读)——平台三层加载的正形, +形状由构造保证,不靠纪律散文/阈值报警维持(调研定稿,见 REGRESS-2026-017)。 + +行业对照:Claude Auto Memory=学习时刻自动记(finish 代谢缝);Cursor 可见面= +索引可扫可删;Generative Agents=recency 一等公民(索引日期倒序,新近在顶)。 +铁律:事实带日期=最后一次验证为真;只当线索,动手前照旧取证。 +同键(域内标题)重录=刷新日期+以最新表述为准;卡不是审计日志。 +v1.31 单文件卡由重录迁移(仅本机存在过一天)。 + +用法: + facts.py record "<标题>" "<内容>" [域] + facts.py health +路径:$RG_FACTS_CARD(指 SKILL.md;references/ 在其同级目录,缺省 +~/.zcode/skills/machine-facts/) +""" +import datetime +import os +import re +import sys + +STALE_DAYS = 180 +RETIRE_DAYS = 270 # v1.51 退场档:>270 天零刷新=死重候选(机制也要代谢) +_HEADING = re.compile(r"^### (\d{4}-\d{2}-\d{2}) · (.+)$") +_FRONTMATTER = """--- +name: machine-facts +description: 涉服务器(VPS/SSH/端口/隧道/代理)、通知通道、本机环境与工具坑(zsh/Debian/浏览器自动化/frp)或任何"这台机器踩过的坑"时先读——跨项目沉淀、带日期验证过的机器事实。哪怕只是疑似相关,也值得花十秒扫一眼索引。 +--- + +# 机器事实卡(跨项目地层) + +索引按域分文件,本页只是目录——相关域整文件读 references/<域>.md。 +事实带日期=最后一次验证为真;只当线索,动手前照旧取证。 + + + +""" + + +def _paths(): + skill = os.environ.get("RG_FACTS_CARD") or os.path.expanduser( + "~/.zcode/skills/machine-facts/SKILL.md") + return skill, os.path.join(os.path.dirname(skill), "references") + + +def _ensure(skill): + os.makedirs(os.path.dirname(skill), exist_ok=True) + if not os.path.isfile(skill) or "" not in open( + skill, encoding="utf-8").read(): + with open(skill, "w", encoding="utf-8") as f: + f.write(_FRONTMATTER) # v1.31 无标记旧卡一次性归零(事实本体已在重录迁移里) + + +def _domain_file(domain, refs_dir): + safe = re.sub(r"[^\w\-]+", "_", domain) or "通用" + return os.path.join(refs_dir, safe + ".md") + + +def record(title, body, domain="通用", when=None): + """同键重录=刷新日期+最新表述;返回 'appended'/'refreshed';随后重建索引。""" + skill, refs = _paths() + _ensure(skill) + os.makedirs(refs, exist_ok=True) + day = (when or datetime.date.today()).isoformat() + df = _domain_file(domain, refs) + try: + with open(df, encoding="utf-8") as f: + text = f.read() + except (IOError, OSError): + text = f"# {domain}\n" + lines = text.split("\n") + action = "appended" + for i, ln in enumerate(lines): + m = _HEADING.match(ln) + if m and m.group(2).strip() == title: + j = i + 1 + while j < len(lines) and not lines[j].startswith("### "): + j += 1 + lines = lines[:i] + [f"### {day} · {title}"] + body.strip().split("\n") + [""] + lines[j:] + action = "refreshed" + break + else: + lines = text.rstrip("\n").split("\n") + ["", f"### {day} · {title}"] + body.strip().split("\n") + [""] + with open(df, "w", encoding="utf-8") as f: + f.write("\n".join(lines).rstrip("\n") + "\n") + _rebuild_index(skill, refs) + return action + + +def _entries(refs): + out = [] + if not os.path.isdir(refs): + return out + for fn in sorted(os.listdir(refs)): + if not fn.endswith(".md"): + continue + domain = fn[:-3] + try: + with open(os.path.join(refs, fn), encoding="utf-8") as f: + for ln in f: + m = _HEADING.match(ln) + if m: + out.append((m.group(1), domain, m.group(2))) + except (IOError, OSError): + pass + return out + + +def _rebuild_index(skill, refs): + entries = sorted(_entries(refs), key=lambda e: (e[0], e[1]), reverse=True) + rows = [f"- {d} · [{dom}] {t}(references/{dom}.md)" for d, dom, t in entries] + with open(skill, encoding="utf-8") as f: + body = f.read() + new = re.sub(r".*?", + "\n".join([""] + rows + [""]), + body, flags=re.S) + with open(skill, "w", encoding="utf-8") as f: + f.write(new) + + +def health(): + """返回 dict(count, oldest_days, stale=[...], retire=[...])。 + + 两档代谢(v1.51,B9——机制也要代谢):stale(>STALE_DAYS)=待验证; + retire(>RETIRE_DAYS 零刷新)=退场候选——重验/确认失效/退场三选一, + 永不自动删(凝固不删原则不变,考古地层里有史)。""" + today = datetime.date.today() + entries = _entries(_paths()[1]) + stale, retire, oldest = [], [], 0 + for date_s, domain, title in entries: + try: + age = (today - datetime.date.fromisoformat(date_s)).days + except ValueError: + continue + oldest = max(oldest, age) + if age > RETIRE_DAYS: + retire.append((date_s, domain, title)) + elif age > STALE_DAYS: + stale.append((date_s, domain, title)) + return {"count": len(entries), "oldest_days": oldest, + "stale": stale, "retire": retire} + + +def main(argv=None): + args = list(sys.argv[1:] if argv is None else argv) + if len(args) >= 2 and args[0] == "record": + title, body = args[1], args[2] + domain = args[3] if len(args) > 3 else "通用" + print(record(title, body, domain)) + return 0 + if args and args[0] == "health": + h = health() + print(f"机器事实卡:{h['count']} 条 | 最老 {h['oldest_days']} 天 | " + f"陈旧(>{STALE_DAYS}d) {len(h['stale'])} 条") + for date_s, domain, title in h["stale"]: + # 凝固不删(v1.34,sunset 启示):过期≠失效,先当"待验证"线索用—— + # 验证仍真→重录刷新日期;确证失效→整条删(考古地层里有史) + print(f" 🧊 {date_s} · {domain} · {title}" + f"(待验证:真则重录刷新日期,假则删除)") + for date_s, domain, title in h["retire"]: + # v1.51 退场档(B9):零刷新超龄=死重候选——重验/确认失效/从索引退场 + # 三选一,永不自动删 + print(f" 🥀 {date_s} · {domain} · {title}" + f"(退场候选:长期零刷新——重验、删除、或退场三选一)") + return 0 + print("用法: facts.py record <标题> <内容> [域] | health", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/filelock.py b/plugins/regress-guard/hooks/scripts/lib/filelock.py new file mode 100644 index 0000000..4265e69 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/filelock.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""跨平台文件锁,保护 manifest 的并发读写。 + +Unix: fcntl.flock( advisory lock) +Windows: msvcrt.locking +无两者时: 降级为无锁 + stderr 警告(不阻断主流程) +""" +import os +import sys +from contextlib import contextmanager + +try: + import fcntl + _HAS_FCNTL = True +except ImportError: + _HAS_FCNTL = False + +try: + import msvcrt + _HAS_MSVCRT = True +except ImportError: + _HAS_MSVCRT = False + + +@contextmanager +def file_lock(filepath, exclusive=True): + """对 filepath 加文件锁(通过同路径的 .lock 文件)。 + + 用法: + with file_lock(manifest_path): + data = parse(manifest_path) + update(manifest_path, data) + + Args: + filepath: 要保护的文件路径(锁文件是其同目录下的 ..lock) + exclusive: True=排他锁,False=共享锁 + """ + lock_path = os.path.join( + os.path.dirname(filepath), + "." + os.path.basename(filepath) + ".lock" + ) + + lock_fd = None + locked = False + + try: + lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644) + + if _HAS_FCNTL: + # Unix: flock + op = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + fcntl.flock(lock_fd, op) + locked = True + + elif _HAS_MSVCRT: + # Windows: locking + try: + msvcrt.locking(lock_fd, msvcrt.LK_LOCK if exclusive else msvcrt.LK_RLOCK, 1) + locked = True + except OSError: + pass # 锁失败不阻断 + else: + # 无锁原语可用 → 降级 + print( + "REGRESS-GUARD: 警告 - 当前平台无文件锁支持,并发写入可能竞争", + file=sys.stderr + ) + + yield + + finally: + if lock_fd is not None: + try: + if _HAS_FCNTL and locked: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + elif _HAS_MSVCRT and locked: + try: + os.lseek(lock_fd, 0, 0) + msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1) + except OSError: + pass + except OSError: + pass + try: + os.close(lock_fd) + except OSError: + pass diff --git a/plugins/regress-guard/hooks/scripts/lib/git_diff_analyzer.py b/plugins/regress-guard/hooks/scripts/lib/git_diff_analyzer.py new file mode 100755 index 0000000..beabe15 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/git_diff_analyzer.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""分析 git diff,返回实际改动的文件列表。 + +负责: +1. 调用 git 获取改动文件 +2. 应用过滤规则(忽略 .regress/、锁文件、文档等) +3. 输出 JSON 格式的改动文件列表 +""" +import sys +import os +import re +import json +import subprocess + + +# 默认忽略的文件模式(不算计划外改动) +DEFAULT_IGNORE_PATTERNS = [ + ".regress/", # 框架自身产物 + "AGENTS.md", # 框架注入的契约文件(由 /regress:init 管理) + "package-lock.json", # npm 锁文件 + "yarn.lock", # yarn 锁文件 + "pnpm-lock.yaml", # pnpm 锁文件 + "poetry.lock", # poetry 锁文件 + "Cargo.lock", # cargo 锁文件 + "go.sum", # go 锁文件 + ".gitignore", + ".gitattributes", + "LICENSE", + ".DS_Store", +] + + +def get_changed_files(git_dir="."): + """获取所有改动的文件(tracked 改动 + untracked 新增)。 + + Returns: + list of file path strings (relative to git root) + """ + try: + # 先获取 git 根目录 + root = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, cwd=git_dir, timeout=10 + ).stdout.strip() + if not root: + return [] + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return [] # git 不可用或超时 → 无改动文件 + + changed = set() + + # 1. 已跟踪文件的改动(staged + unstaged,相对于 HEAD) + try: + out = subprocess.run( + ["git", "diff", "--name-only", "HEAD"], + capture_output=True, text=True, cwd=root, timeout=10 + ).stdout.strip() + if out: + changed.update(out.split("\n")) + except (subprocess.TimeoutExpired, OSError): + pass # git diff 失败 → 跳过,继续其他方式 + + # 2. 如果上面为空,单独看 staged 和 unstaged + if not changed: + for args in (["git", "diff", "--name-only", "--staged"], + ["git", "diff", "--name-only"]): + try: + out = subprocess.run( + args, capture_output=True, text=True, cwd=root, timeout=10 + ).stdout.strip() + if out: + changed.update(out.split("\n")) + except (subprocess.TimeoutExpired, OSError): + pass + + # 3. 新增的未跟踪文件 + try: + out = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, text=True, cwd=root, timeout=10 + ).stdout.strip() + if out: + changed.update(out.split("\n")) + except (subprocess.TimeoutExpired, OSError): + pass + + return sorted([f for f in changed if f]) + + +def get_staged_files(git_dir="."): + """只获取 staged(已 git add)的文件。""" + try: + root = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, cwd=git_dir, timeout=10 + ).stdout.strip() + if not root: + return [] + out = subprocess.run( + ["git", "diff", "--name-only", "--staged"], + capture_output=True, text=True, cwd=root, timeout=10 + ).stdout.strip() + return [f for f in out.split("\n") if f] if out else [] + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return [] + + +def is_commit_command(command_str): + """判断一个 shell 命令字符串是否是 git commit。 + + 用于 PreToolUse hook 中匹配 Bash 工具的 command。 + """ + if not command_str: + return False + # 匹配各种形式:git commit, git ci (alias), hub commit, gh ... + commit_patterns = [ + r"\bgit\s+commit\b", + r"\bgit\s+ci\b", + r"\bhub\s+commit\b", + ] + for p in commit_patterns: + if __import__("re").search(p, command_str): + return True + return False + + +def filter_files(files, ignore_patterns=None): + """过滤掉应忽略的文件。""" + patterns = ignore_patterns or DEFAULT_IGNORE_PATTERNS + result = [] + for f in files: + if any(f.startswith(p) or f.endswith(p) or p in f for p in patterns): + continue + # 忽略纯 .md 文档(AGENTS.md 已在 DEFAULT_IGNORE_PATTERNS 中) + if f.endswith(".md"): + continue + # 测试文件不算 F3(是改动的配套产物,不是业务改动) + if re.search(r'(test|spec|__tests__)', f): + continue + result.append(f) + return result + + +def find_untracked_changes(changed_files, manifest_files): + """找出在清单中没有的改动文件(即 F3/F4)。 + + Args: + changed_files: git diff 中的文件列表 + manifest_files: 清单中记录的文件列表 + Returns: + 在 changed_files 中但不在 manifest_files 中的文件列表 + """ + manifest_set = set(manifest_files) + return [f for f in changed_files if f not in manifest_set] + + +if __name__ == "__main__": + # CLI 用法: + # python3 git_diff_analyzer.py changed → 所有改动文件(过滤后) + # python3 git_diff_analyzer.py staged → staged 文件 + # python3 git_diff_analyzer.py diff → 清单外的改动文件 + cmd = sys.argv[1] if len(sys.argv) > 1 else "changed" + + if cmd == "changed": + files = get_changed_files() + print(json.dumps({"files": filter_files(files)})) + elif cmd == "staged": + files = get_staged_files() + print(json.dumps({"files": filter_files(files)})) + elif cmd == "diff": + # 对比清单,需要 manifest_files 作为 JSON 数组从 stdin 或参数 + if len(sys.argv) < 3: + print(json.dumps({"error": "usage: diff "})) + sys.exit(1) + manifest_files = json.loads(sys.argv[2]) + changed = filter_files(get_changed_files()) + untracked = find_untracked_changes(changed, manifest_files) + print(json.dumps({"untracked": untracked, "total_changed": len(changed)})) + else: + print(json.dumps({"error": f"unknown command: {cmd}"})) + sys.exit(1) diff --git a/plugins/regress-guard/hooks/scripts/lib/history.py b/plugins/regress-guard/hooks/scripts/lib/history.py new file mode 100644 index 0000000..7369381 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/history.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +"""记录和查询 regress-guard 的运行历史(越用越聪明的数据层)。 + +历史格式:.regress/history.jsonl(每行一个 JSON 事件) + +事件类型: + - commit_blocked: commit 被阻断(原因:untracked_files / test_failed / no_test_runner) + - commit_passed: commit 放行 + - bypass_used: bypass 模式使用 + - f3_discovered: track 发现计划外文件 + - test_failed: 测试失败(记录失败用例) + +每条事件含: + timestamp, event, manifest_id, session_id, details{}, files[] +""" +import os +import json +from datetime import datetime + + +def _current_session_id(): + """从环境变量推断当前 ZCode 会话 ID(证据链的过程锚点)。""" + return ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "" + ) + + +def record(regress_dir, event, manifest_id="", **details): + """记录一条历史事件。 + + 证据链设计(借鉴 Harness Inspector 的 Intent→Process→Output): + manifest_id = 意图锚点(哪个需求) + session_id = 过程锚点(哪次会话) + commit_sha = 产出锚点(哪个提交,commit 事件才有) + + Args: + regress_dir: .regress/ 目录路径 + event: 事件类型(commit_blocked/commit_passed/bypass_used/f3_discovered/test_failed) + manifest_id: 关联的清单 ID + **details: 额外字段(如 files, reason, test_name, runner 等) + """ + history_path = os.path.join(regress_dir, "history.jsonl") + entry = { + "timestamp": datetime.now().isoformat(), + "event": event, + "manifest_id": manifest_id, + # 证据链锚点:自动从环境推断(不依赖调用方传) + "session_id": _current_session_id(), + } + entry.update(details) + + try: + with open(history_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass # 历史记录不阻断主流程 + + # 遗忘机制(Ch9):超过阈值时自动归档旧事件 + _maybe_archive(regress_dir, history_path) + + +def _maybe_archive(regress_dir, history_path, max_events=500): + """history 超过 max_events 时,把旧事件移到 archive 文件。 + + P2#18:先按大小短路(≈80B/行估)——旧实现每次 record() 都全量 + readlines 判归档,门禁热路径 O(n²);不足阈值一行都不读。 + """ + try: + if os.path.getsize(history_path) < max_events * 80: + return # 快路径:体积远未到阈值,不可能超条数 + with open(history_path, encoding="utf-8") as f: + lines = f.readlines() + if len(lines) <= max_events: + return + # 保留最近 max_events 条,旧的归档 + keep = lines[-max_events:] + archive = lines[:-max_events] + archive_path = os.path.join(regress_dir, "history-archive.jsonl") + with open(archive_path, "a", encoding="utf-8") as f: + f.writelines(archive) + with open(history_path, "w", encoding="utf-8") as f: + f.writelines(keep) + except Exception: + pass # 归档失败不阻断 + + +def load_history(regress_dir): + """加载所有历史事件,返回 list[dict]。""" + history_path = os.path.join(regress_dir, "history.jsonl") + events = [] + if not os.path.exists(history_path): + return events + try: + with open(history_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except Exception: + pass + return events + + +def summarize(regress_dir): + """分析历史,输出项目级洞察(供 /regress:learn 使用)。 + + Returns: + { + "total_commits": int, + "blocked_count": int, + "bypass_count": int, + "top_f3_files": [(file, count)], # 最常被遗漏的文件 + "top_f3_patterns": [(pattern, count)], # 最常被遗漏的目录/模式 + "frequent_failures": [(test_name, count)], # 最常失败的测试 + "test_runner": str, # 项目主要用的测试运行器 + "bypass_rate": float, # bypass 使用率 + "block_rate": float, # 阻断率 + } + """ + events = load_history(regress_dir) + + total_commits = sum(1 for e in events if e.get("event") in ("commit_passed", "commit_blocked")) + blocked = [e for e in events if e.get("event") == "commit_blocked"] + bypassed = [e for e in events if e.get("event") == "bypass_used"] + + # F3 统计(含噪声过滤,借鉴 Harness Inspector:频次≠经验) + # 同一 session 内的重复 F3 = 重试噪声(AI 反复尝试同一提交) + # 跨 session 的重复 F3 = 稳定经验(真正值得沉淀的规律) + f3_all = {} # 原始计数 + f3_sessions = {} # 每个 F3 文件出现在几个不同 session + for e in events: + if e.get("event") in ("f3_discovered", "commit_blocked") and e.get("untracked_files"): + sid = e.get("session_id", "") + for f in e["untracked_files"]: + f3_all[f] = f3_all.get(f, 0) + 1 + f3_sessions.setdefault(f, set()).add(sid) + + # 只保留跨 session 出现 ≥2 次的(单 session 的高频是噪声) + f3_files = {} + f3_noise = {} + for f, count in f3_all.items(): + session_count = len(f3_sessions.get(f, {""})) + if session_count >= 2: + f3_files[f] = session_count # 用跨session次数作为稳定度 + else: + f3_noise[f] = count # 记为噪声(不进经验) + + # 按目录模式聚合 + f3_patterns = {} + for fpath, count in f3_files.items(): + parts = fpath.split("/") + if len(parts) > 1: + pattern = parts[0] + "/**" + f3_patterns[pattern] = f3_patterns.get(pattern, 0) + count + + # 测试失败统计 + test_failures = {} + runners = {} + for e in events: + if e.get("event") == "test_failed" and e.get("test_name"): + name = e["test_name"] + test_failures[name] = test_failures.get(name, 0) + 1 + if e.get("runner"): + runners[e["runner"]] = runners.get(e["runner"], 0) + 1 + + top_f3 = sorted(f3_files.items(), key=lambda x: -x[1])[:10] + top_patterns = sorted(f3_patterns.items(), key=lambda x: -x[1])[:5] + top_failures = sorted(test_failures.items(), key=lambda x: -x[1])[:10] + + bypass_rate = (len(bypassed) / total_commits) if total_commits else 0 + block_rate = (len(blocked) / total_commits) if total_commits else 0 + main_runner = max(runners, key=runners.get) if runners else "unknown" + + # 债务追踪:bypass 后是否有对应的 verify(还债) + # 简单逻辑:每次 bypass_used = 欠 1 笔债;之后每次 commit_passed(测试通过)= 还 1 笔 + debt = 0 + for e in events: + if e.get("event") == "bypass_used": + debt += 1 + elif e.get("event") == "commit_passed" and debt > 0: + debt -= 1 # 测试通过的提交 = 还了一笔债 + + # Ch24 生产指标:质量 / 安全 / 效率 / 成本 + passed_commits = sum(1 for e in events if e.get("event") == "commit_passed") + quality_score = round(passed_commits / total_commits, 2) if total_commits else 0 # 质量:通过率 + f3_discoveries = sum(1 for e in events if e.get("event") == "commit_blocked" + and e.get("reason") == "untracked_files") + f3_rate = round(f3_discoveries / total_commits, 2) if total_commits else 0 # F3 发现率 + + # 效率:一次通过率 + test_interactions = sum(1 for e in events if e.get("event") in ("commit_passed", "test_failed")) + efficiency = round(total_commits / max(test_interactions, 1), 2) + + # 覆盖率信号:历次放行提交的平均行覆盖率(有 jest coverage 时才有值) + covs = [e.get("coverage_pct") for e in events + if e.get("event") == "commit_passed" and e.get("coverage_pct") is not None] + avg_coverage = round(sum(covs) / len(covs)) if covs else None + + # (v1.75:外部提交观测消费段已删——写端模板早删,真机 252 事件零发射, + # 恒零计数是死码;观测器复活条件=install 接线 post-commit 观测+攻击面评估) + + # Ch21 阻断原因分布 + block_reasons = {} + for e in blocked: + r = e.get("reason", "unknown") + block_reasons[r] = block_reasons.get(r, 0) + 1 + + return { + "total_commits": total_commits, + "blocked_count": len(blocked), + "bypass_count": len(bypassed), + "top_f3_files": top_f3, + "top_f3_patterns": top_patterns, + "top_f3_noise": sorted(f3_noise.items(), key=lambda x: -x[1])[:5], # 已过滤的噪声 + "avg_coverage_pct": avg_coverage, # 平均行覆盖率(None=无覆盖率数据) + "frequent_failures": top_failures, + "test_runner": main_runner, + "bypass_rate": round(bypass_rate, 2), + "block_rate": round(block_rate, 2), + "tech_debt": debt, + # Ch24 四维指标 + "quality_score": quality_score, # 质量:commit 通过率(越高越好) + "f3_rate": f3_rate, # 安全:计划外改动发现率 + "efficiency": efficiency, # 效率:一次通过率 + "bypass_rate_pct": round(bypass_rate * 100), # 安全:绕过百分比 + # Ch21 阻断原因分布 + "block_reasons": block_reasons, + } + + +def nudge_effectiveness(regress_dir): + """块消息有效性(v1.47,B2 影子采集——只测不拦,阈值先影子)。 + + GEPA 评分环落地:拦截消息本身就是提示词(steer agent 下一动作), + 同清单同原因反复被拦 = 消息没把 agent 教会的直接信号。 + 判据(顾问定,影子期只标注不拦截): + ≥2 次 → repeat(告警级) + ≥3 次 且(跨 ≥2 会话 或 时间跨 ≥7 天)→ ineffective_candidate + 分母恒带(该键总拦截数)——小样本噪声的第一道防线。 + """ + from collections import defaultdict + events = load_history(regress_dir) + groups = defaultdict(list) + for e in events: + if e.get("event") == "commit_blocked": + groups[(str(e.get("manifest_id") or ""), + str(e.get("reason") or "unknown"))].append(e) + rows = [] + for (mid, reason), es in sorted(groups.items(), + key=lambda kv: -len(kv[1])): + sessions = {str(e.get("session_id") or "") for e in es} + ts = sorted(str(e.get("timestamp") or "") for e in es) + try: + from datetime import datetime as _dt + span_days = (_dt.fromisoformat(ts[-1]) - _dt.fromisoformat(ts[0]) + ).days if len(ts) > 1 else 0 + except ValueError: + span_days = 0 + flag = "" + if len(es) >= 3 and (len(sessions) >= 2 or span_days >= 7): + flag = "ineffective_candidate" + elif len(es) >= 2: + flag = "repeat" + rows.append({"manifest_id": mid, "reason": reason, + "blocks": len(es), # 分母恒带 + "sessions": len(sessions), "span_days": span_days, + "flag": flag}) + return rows + + +def recall_effectiveness(regress_dir): + """召回有效性代理(v1.58,B3——弱证据,只排序不回流)。 + + 对每条 rule_recall 沿事件序找同 manifest 的下一个 commit_passed: + 其间无同 manifest 的 commit_blocked → resolved_clean(顾问"干净才计") + 有 → resolved_shadow(解决时另有干预,召回贡献未知——单列不加总) + 无放行 → pending + 诚实边界:事件序代理非因果(GEPA 6.3 同防线);真值仍靠人读与 + advisor_adoption 同族的前向采集。 + """ + events = load_history(regress_dir) + rows = [] + for i, e in enumerate(events): + if e.get("event") != "rule_recall": + continue + mid = str(e.get("manifest_id") or "") + outcome = "pending" + for e2 in events[i + 1:]: + if str(e2.get("manifest_id") or "") != mid: + continue # 跨清单事件不干扰 + ev = e2.get("event") + if ev == "commit_blocked": + outcome = "resolved_shadow" + break + if ev == "commit_passed": + outcome = "resolved_clean" + break + rows.append({"manifest_id": mid, "reason": str(e.get("reason") or ""), + "n": e.get("n"), "outcome": outcome}) + return rows + + +def block_heatmap(regress_dir): + """拦截热力图(v1.62,B7):commit_blocked 按 reason 聚合频次。 + + 用途:召回接线扩点判据的数据接口(v1.54 只挂高频3点——"扩点看数据", + 高频 reason 即下一个该接召回的拦截点)。reason 缺失归 unknown 桶。 + v1.83(072)新键标注:每 reason 附首现时间,14 天内首现标 new=True—— + "老病复发"与"新病露头"一眼分开(顾问降级版:先量测可见性,淹没活体 + 出现再谈冻结)。""" + from collections import defaultdict + import datetime as _dt + groups = defaultdict(list) + for e in load_history(regress_dir): + if e.get("event") == "commit_blocked": + groups[str(e.get("reason") or "unknown")].append(e) + now = _dt.datetime.now() + rows = [] + for reason, es in sorted(groups.items(), key=lambda kv: -len(kv[1])): + first = min((str(e.get("timestamp") or "") for e in es), default="") + is_new = False + if first: + try: + is_new = (now - _dt.datetime.fromisoformat(first)).days <= 14 + except ValueError: + pass + rows.append({"reason": reason, "blocks": len(es), + "manifests": len({str(e.get("manifest_id") or "") for e in es}), + "last": max((str(e.get("timestamp") or "") for e in es), default=""), + "first": first, "new": is_new}) + return rows + + +def build_trace(regress_dir): + """构建交付链视图(借鉴 Harness Inspector 的 Intent→Process→Output)。 + + 把 history.jsonl 按 意图(manifest) → 过程(session) → 产出(commit) 组织。 + 输出人类可读的文本交付链,供 /regress:trace 展示。 + """ + events = load_history(regress_dir) + + # 按 manifest 分组(意图锚点) + by_manifest = {} + for e in events: + mid = e.get("manifest_id") or "(无清单)" + by_manifest.setdefault(mid, []).append(e) + + lines = [] + for mid, evts in sorted(by_manifest.items()): + evts.sort(key=lambda x: x.get("timestamp", "")) + + # 意图 + first = evts[0] + lines.append(f"📌 {mid} ({first.get('timestamp', '?')[:19]})") + + # 按 session 分组(过程锚点) + by_session = {} + for e in evts: + sid = e.get("session_id") or "?" + by_session.setdefault(sid, []).append(e) + + for sid, sevts in by_session.items(): + sid_label = sid[:12] if sid and sid != "?" else "未知会话" + lines.append(f" └─ 会话 {sid_label}") + + for e in sevts: + ev = e.get("event", "?") + ts = e.get("timestamp", "")[11:19] + icon = {"commit_passed": "✅", "commit_blocked": "🚫", + "bypass_used": "⚡", "test_failed": "❌", + "f3_discovered": "🔍", "error": "⚠️"}.get(ev, "·") + detail = "" + if ev == "commit_blocked": + reason = e.get("reason", "?") + files = e.get("untracked_files", []) + detail = f" {reason}" + (f" → {files[0]}" if files else "") + elif ev == "commit_passed": + runner = e.get("runner", "?") + head = e.get("base_head", "") + detail = f" ({runner})" + (f" @{head}" if head else "") + elif ev == "bypass_used": + detail = f" 到期 {e.get('expires', '?')[:19]}" + lines.append(f" {icon} {ts} {ev}{detail}") + + # 产出锚点 + passed = [e for e in evts if e.get("event") == "commit_passed"] + if passed: + lines.append(f" 📦 产出: {len(passed)} 次放行提交") + lines.append("") + + return "\n".join(lines) if lines else "(暂无历史事件)" + + +def telemetry(regress_dir): + """遥测双文件统一视图(v1.74,063):一屏看两套并行账本。 + + history.jsonl = guard record() 所写(字段 event/timestamp——门禁拦截/通过/ + 自检类);journal/events.jsonl = journal.py 所写(字段 kind/ts——生命周期/ + 顾问/哨兵类)。grep 前先认对文件(2026-09-20 哨兵误切文件烧四刀的教训)。 + """ + import glob + out = {} + hp = os.path.join(regress_dir, "history.jsonl") + try: + evs = [json.loads(l) for l in + open(hp, encoding="utf-8").read().splitlines() if l.strip()] + out["history"] = {"file": "history.jsonl", "fields": "event/timestamp", + "events": len(evs), + "last": evs[-1].get("event") if evs else None, + "last_ts": evs[-1].get("timestamp") if evs else None} + except FileNotFoundError: + out["history"] = {"file": "history.jsonl", "missing": True} + jp = os.path.join(regress_dir, "journal", "events.jsonl") + try: + evs = [json.loads(l) for l in + open(jp, encoding="utf-8").read().splitlines() if l.strip()] + out["journal"] = {"file": "journal/events.jsonl", "fields": "kind/ts", + "events": len(evs), + "last": evs[-1].get("kind") if evs else None, + "last_ts": evs[-1].get("ts") if evs else None} + except FileNotFoundError: + out["journal"] = {"file": "journal/events.jsonl", "missing": True} + arch = sorted(os.path.basename(a) for a in + glob.glob(os.path.join(regress_dir, "history-archive*.jsonl"))) + if arch: + out["history"]["archives"] = arch + return out + + +def feature_fire_health(regress_dir, registry_path=None): + """特性零火探测(v1.87.5,098:086 Pattern B 探针打捞)。 + + 特性注册表(工作区 .regress/feature-registry.json,本仓播种)× history + 折叠 → 每特性 {slug, shipped_at, days_since, fires, status}: + firing(窗口内有 marker 命中)/ zero-fire(超窗零命中)/ unmeasurable + (marker 无法映射到 history——如 v1.80 治理行不写事件,不可测性本身 + 即发现:要么补量测位要么承认永远盲)。marker 语法:{"event": 名} 或 + {"event": 名, "field": k, "value": v}。""" + import time as _time + if registry_path is None: + registry_path = os.path.join(regress_dir, "feature-registry.json") + try: + with open(registry_path, encoding="utf-8") as f: + registry = json.load(f) + except (IOError, OSError, json.JSONDecodeError): + return {"error": "registry missing or corrupt", "features": []} + events = load_history(regress_dir) + now = _time.time() + rows = [] + for feat in registry if isinstance(registry, list) else []: + slug = str(feat.get("slug") or "?") + marker = feat.get("fire_marker") or {} + ev_name = marker.get("event") + window = float(feat.get("window_days") or 30) + shipped = float(feat.get("shipped_at") or 0) + days_since = (now - shipped) / 86400 if shipped else None + if not ev_name: + fires, status = 0, "unmeasurable" + else: + hits = [e for e in events if e.get("event") == ev_name and ( + not marker.get("field") + or e.get(marker["field"]) == marker.get("value"))] + recent = [e for e in hits if not shipped or + _time.mktime(_time.strptime( + str(e.get("timestamp") or "1970-01-01")[:19], + "%Y-%m-%dT%H:%M:%S")) >= shipped] + fires = len(recent) + status = "firing" if fires > 0 else ( + "zero-fire" if (days_since or 0) > window else "warmup") + rows.append({"slug": slug, "shipped_at": feat.get("shipped_at"), + "days_since": round(days_since, 1) if days_since else None, + "fires": fires, "status": status}) + return {"features": rows} + + +def cache_stats(regress_dir): + """缓存命中遥测(v1.85.3,077):commit_passed 的 cached 字段聚合。 + + v1.85 测试缓存上线但无观测面——命中几次/省了多少秒从这里查。 + est_saved_seconds = hits × 118:118s 是本仓全量套件均时常数(估算常数, + 不自欺——字段名带 est 即此意;真实节省要等未命中事件带实测时长才可替换)。 + cached 字段缺位的旧事件计 miss(当时确实全量跑了,诚实计数)。 + """ + events = load_history(regress_dir) + passed = [e for e in events if e.get("event") == "commit_passed"] + hits = sum(1 for e in passed if e.get("cached")) + misses = len(passed) - hits + return { + "total": len(passed), + "hits": hits, + "misses": misses, + "rate": round(hits / len(passed), 3) if passed else 0.0, + "est_saved_seconds": hits * 118, + } + + +if __name__ == "__main__": + import sys + regress_dir = sys.argv[1] if len(sys.argv) > 1 else ".regress" + cmd = sys.argv[2] if len(sys.argv) > 2 else "summary" + if cmd == "summary": + s = summarize(regress_dir) + print(json.dumps(s, ensure_ascii=False, indent=2)) + elif cmd == "telemetry": + print(json.dumps(telemetry(regress_dir), ensure_ascii=False, indent=2)) + elif cmd == "raw": + for e in load_history(regress_dir): + print(json.dumps(e, ensure_ascii=False)) + elif cmd == "trace": + print(build_trace(regress_dir)) + elif cmd == "nudge": + rows = nudge_effectiveness(regress_dir) + if not rows: + print("(无拦截记录——块消息有效性暂无可测)") + else: + print(f"块消息有效性(影子采集):{len(rows)} 键|" + f"repeat {sum(1 for r in rows if r['flag'] == 'repeat')}" + f"|无效候选 {sum(1 for r in rows if r['flag'] == 'ineffective_candidate')}") + for r in rows[:10]: + mark = {"repeat": "⚠️ ", "ineffective_candidate": "🚨"}.get(r["flag"], " ") + print(f" {mark}{r['manifest_id'] or '-'} [{r['reason']}] ×{r['blocks']}" + f"({r['sessions']} 会话/{r['span_days']} 天)") + elif cmd == "recall": + rows = recall_effectiveness(regress_dir) + if not rows: + print("(无 rule_recall 记录——召回有效性暂无可测)") + else: + from collections import Counter + c = Counter(r["outcome"] for r in rows) + print(f"召回有效性(事件序代理·弱证据只排序):{len(rows)} 次|" + f"干净解决 {c.get('resolved_clean', 0)}|" + f"带干预解决 {c.get('resolved_shadow', 0)}|未决 {c.get('pending', 0)}") + for r in rows[:10]: + mark = {"resolved_clean": "✅", "resolved_shadow": "◐", + "pending": "⏳"}.get(r["outcome"], " ") + print(f" {mark}{r['manifest_id'] or '-'} [{r['reason']}] n={r.get('n', '?')}") + elif cmd == "heatmap": + rows = block_heatmap(regress_dir) + if not rows: + print("(无拦截记录——热力图空)") + else: + print(f"拦截热力图(reason×频次,召回扩点看这里):{len(rows)} 种原因") + for r in rows[:10]: + new_mark = " 🆕" if r.get("new") else "" + first = str(r.get("first") or "")[:10] + print(f" 🔥 {r['reason']} ×{r['blocks']}" + f"({r['manifests']} 清单,最近 {r['last'][:16]}" + f",首现 {first or '?'}{new_mark})") + elif cmd == "cache": + s = cache_stats(regress_dir) + print(f"缓存命中:{s['total']} 次过门禁|命中 {s['hits']}" + f"({s['rate']:.1%})|未命中 {s['misses']}" + f"|估算节省 {s['est_saved_seconds']}s(hits×118s 估算常数)") + elif cmd == "features": + r = feature_fire_health(regress_dir) + if r.get("error"): + print(f"特性探测:注册表缺失/损坏——{r['error']}") + else: + for f in r["features"]: + d = f"{f['days_since']}天" if f["days_since"] is not None else "?" + print(f" {f['status']:>13} {f['slug']}(出厂 {d},火 {f['fires']} 次)") diff --git a/plugins/regress-guard/hooks/scripts/lib/journal.py b/plugins/regress-guard/hooks/scripts/lib/journal.py new file mode 100644 index 0000000..993f43c --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/journal.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""journal — 考古地层(公理三:时间熵 → 地质资产)。 + +/tmp 状态是易挥发的认知介质(重启即失);地层把失败/风险/纠正事件 +append-only 地埋进项目 .regress/journal/events.jsonl,随 git 入库。 + +与 history.py 的分工: + history.jsonl = 门禁决策史(commit 放行/阻断,由 pre_commit_guard 写) + journal/events.jsonl = 执行现场化石(工具失败/风险动作/用户纠正,由探测器写) + +未来维护者不靠回忆,像考古学家一样挖地层: + /regress:learn 聚类跨会话重复失败 → 回写为规则。 +""" +import os +import json +from datetime import datetime + +# 单字段截断上限(防一条超大报错撑爆地层) +_FIELD_CAP = 400 + + +def _find_project_dir(start_dir=None): + """从 start_dir(默认 env 或 cwd)向上找含 .regress/ 的目录。""" + d = start_dir or ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + d = os.path.abspath(d) + for _ in range(10): + if os.path.isdir(os.path.join(d, ".regress")): + return d + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + return None + + +def journal_path(project_dir): + return os.path.join(project_dir, ".regress", "journal", "events.jsonl") + + +def journal_append(kind, start_dir=None, **fields): + """埋一条化石。无 .regress/(未接入)或被关闭时静默跳过——地层是增强,不是依赖。 + + kind: tool_fail / risk_action / user_correction / decision 等 + """ + if os.environ.get("REGRESS_JOURNAL", "").lower() in ("off", "0", "false"): + return False + project_dir = _find_project_dir(start_dir) + if not project_dir: + return False + event = { + "ts": datetime.now().isoformat(), + "kind": kind, + "session": ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default" + ), + } + for k, v in fields.items(): + if isinstance(v, str): + v = v[:_FIELD_CAP] + event[k] = v + try: + os.makedirs(os.path.dirname(journal_path(project_dir)), exist_ok=True) + with open(journal_path(project_dir), "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + except (IOError, OSError): + return False + return True + + +def load_journal(project_dir): + """读取全部地层事件(供 /regress:learn 考古挖掘)。""" + path = journal_path(project_dir) + events = [] + try: + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except (IOError, OSError): + pass + return events + + +def journal_digest(project_dir, top=8): + """简单聚合(顾问降级建议:不做聚类算法,按签名计数 + 跨会话过滤)。 + + 返回跨会话出现 ≥2 次的重复签名——单会话高频是重试噪声, + 跨会话重复才是稳定经验(与 history.py 的噪声过滤哲学一致)。 + """ + from collections import Counter + + events = [e for e in load_journal(project_dir) if e.get("kind") == "tool_fail"] + sig_sessions = {} + for e in events: + sig = e.get("sig") or "?" + sig_sessions.setdefault(sig, set()).add(e.get("session", "?")) + stable = {s: len(sess) for s, sess in sig_sessions.items() if len(sess) >= 2} + total = Counter(e.get("sig") or "?" for e in events) + return [ + {"sig": sig, "sessions": n, "total": total.get(sig, 0)} + for sig, n in sorted(stable.items(), key=lambda x: -x[1])[:top] + ] + + +def journal_stats(project_dir): + """规模测量仪器(v1.50,B6——先测 n 再谈 O(n) 优化,顾问纪律)。 + + 决策规则:digest_ms > 500 或 events > 50000 才立项滚动索引; + 读数不过线不优化——YAGNI 用数据说。""" + import time + from collections import Counter + t0 = time.perf_counter() + events = load_journal(project_dir) + journal_digest(project_dir) + ms = round((time.perf_counter() - t0) * 1000, 1) + path = journal_path(project_dir) + size = os.path.getsize(path) if os.path.exists(path) else 0 + return {"events": len(events), "by_kind": dict(Counter( + str(e.get("kind")) for e in events)), + "file_bytes": size, "digest_ms": ms, + "optimize_threshold": "digest_ms>500 或 events>50000 才立项"} + + +def advisor_adoption(project_dir): + """顾问采纳率(v1.49,B4——给裁判装评分器,GEPA 精神:judge 也要被评分)。 + + 数据源=advisor_adoption 事件(finish 代谢位落,与回复中的标注义务同源 + ——回复里写「已咨询第二意见:采纳/部分采纳/不采纳」,台账里也要有同条)。 + 前向采集:历史批次未落此事件不计入分母。""" + from collections import Counter + evs = [e for e in load_journal(project_dir) + if e.get("kind") == "advisor_adoption"] + by = Counter(str(e.get("adoption") or "?") for e in evs) + n = sum(by.values()) + adopted = by.get("adopted", 0) + by.get("partial", 0) + return {"total": n, "adopted": adopted, + "rate": round(adopted / n, 2) if n else None, + "by": dict(by)} + + +if __name__ == "__main__": + import sys + _d = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() + _cmd = sys.argv[2] if len(sys.argv) > 2 else "digest" + if _cmd == "raw": + for _e in load_journal(_d): + print(json.dumps(_e, ensure_ascii=False)) + elif _cmd == "add": + # journal.py add '' —— 命令层埋化石的统一出口 + # 例:journal.py . add assumption_broken '{"manifest_id":"R1","vid":"V5","was":"..."}' + if len(sys.argv) < 4: + print("用法: journal.py add ''", file=sys.stderr) + sys.exit(2) + try: + _fields = json.loads(sys.argv[4]) if len(sys.argv) > 4 else {} + except json.JSONDecodeError as _e: + print(f"json 字段解析失败: {_e}", file=sys.stderr) + sys.exit(2) + print(json.dumps({"ok": journal_append(sys.argv[3], start_dir=_d, **_fields)}, + ensure_ascii=False)) + elif _cmd == "stats": + print(json.dumps(journal_stats(_d), ensure_ascii=False, indent=2)) + elif _cmd == "adoption": + print(json.dumps(advisor_adoption(_d), ensure_ascii=False, indent=2)) + else: + print(json.dumps(journal_digest(_d), ensure_ascii=False, indent=2)) diff --git a/plugins/regress-guard/hooks/scripts/lib/manifest_fields.py b/plugins/regress-guard/hooks/scripts/lib/manifest_fields.py new file mode 100644 index 0000000..30c5655 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/manifest_fields.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""manifest_fields — 清单 frontmatter 字段读取的单一来源(v1.20 解析收敛)。 + +历史教训(v1.13–v1.19):approved/blocked/provisional 块解析正则散布五处, +每加一个状态块要五处同步——4 元组解包 bug(v1.14)与漏改一处(v1.18 冒烟) +都是这笔债的利息。本库只统一"读";改写(plan_approve 的正则替换)保持本地。 + +架构守卫:validate.sh 检查 hooks/scripts/*.py(lib 之外)不得出现块解析正则。 +""" +import re + +FM_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) +STATUS_RE = re.compile( + r"^status:\s*[\"']?(planning|in-progress|verifying|done|completed|cancelled|blocked)", + re.M) +ACTIVE_STATUS_RE = re.compile(r"status:\s*(planning|in-progress|verifying|blocked)") +ACTIVE_STATUSES = ("planning", "in-progress", "verifying", "blocked") +_OPEN_FP_RE = re.compile(r"^\s+status:\s*open\s*(?:#.*)?$", re.M) # P1#12:容忍行尾注释(旧模板带注释的清单 open 检测不再恒不中) + + +def frontmatter(content): + """frontmatter 原文(不含 --- 包裹);无则 None。""" + m = FM_RE.match(content) + return m.group(1) if m else None + + +def field(text, key): + """顶层标量字段(id / base_head / …)。""" + m = re.search(rf"^{key}:\s*[\"']?([^\"'\n#]*)", text, re.M) + return m.group(1).strip() if m else "" + + +def block_value(text, block, key): + """状态块内字段(approved.at / blocked.need / provisional.at …);空串=未填。""" + m = re.search(rf"^{block}:\s*\n((?:[ \t]+.*\n?)+)", text, re.M) + if not m: + return "" + vm = re.search(rf"^[ \t]+{key}:\s*[\"']?([^\"'\n#]*)", m.group(1), re.M) + return vm.group(1).strip() if vm else "" + + +def filled(text, block, key="at"): + """块字段非空(approved.at 非空 = 人类产物直通批准)。""" + return bool(block_value(text, block, key)) + + +def parse_core(content): + """一次读全。返回 {id, status, approved_at, provisional_at, + blocked_reason, blocked_need, open_fragiles};非清单返回 {}。""" + fm = frontmatter(content) + if fm is None: + return {} + sm = STATUS_RE.search(fm) + return { + "id": field(fm, "id"), + "status": sm.group(1) if sm else "", + "approved_at": block_value(fm, "approved", "at"), + "provisional_at": block_value(fm, "provisional", "at"), + "blocked_reason": block_value(fm, "blocked", "reason"), + "blocked_need": block_value(fm, "blocked", "need"), + "open_fragiles": len(_OPEN_FP_RE.findall(fm)), + } + + +def editable(status, approved_at): + """该清单当前是否允许边界内编辑——可编辑性判定的单一来源。 + + in-progress/verifying 可编辑;planning 仅当 approved.at 非空(人类产物直通); + blocked 一律拦(批准赋予的编辑权在受阻期间冻结)。 + """ + if status in ("in-progress", "verifying"): + return True + if status == "planning": + return bool(approved_at) + return False diff --git a/plugins/regress-guard/hooks/scripts/lib/manifest_parser.py b/plugins/regress-guard/hooks/scripts/lib/manifest_parser.py new file mode 100755 index 0000000..20fea6e --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/manifest_parser.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""解析和更新 .regress/ 回归清单的 YAML frontmatter。 + +优先使用 PyYAML(鲁棒),fallback 到手写解析器(零依赖)。 +""" +import sys +import os +import re +import json +import glob + +# ─── 尝试加载 PyYAML ────────────────────────────────── +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +def parse_frontmatter(filepath): + """解析 Markdown 文件的 YAML frontmatter,返回 dict。 + + fail-safe 语义(评审批次一 P0-1b): + - 读文件 errors="replace"——非 UTF-8 清单不再让 UnicodeDecodeError 穿透 + 到门禁主流程翻成 exit 1(放行);替换字符解析出的假 status 不在活跃 + 词表里,走"无活跃清单"路径,由门禁顶层兜底与调用方校验把关 + - 手写 fallback 的结果若连 id 和 status 都没有,视同解析失败返回 None + ——坏 YAML 清单必须交阻断(pre_commit_guard 的 None 分支), + 不能静默混成"无活跃清单"放行(2026-09-08 评审 P0 活体路径) + """ + try: + with open(filepath, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + except (IOError, OSError): + return None + + fm_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) + if not fm_match: + # 容错:有开头 --- 但没结尾 ---(用户忘加闭合标记) + # 尝试取 --- 后到文件尾的全部内容当 YAML + loose_match = re.match(r"^---\s*\n(.+)", content, re.DOTALL) + if not loose_match: + return None + yaml_text = loose_match.group(1) + else: + yaml_text = fm_match.group(1) + + if HAS_YAML: + try: + data = yaml.safe_load(yaml_text) + if isinstance(data, dict): + return data + if data is not None: # YAML 顶层是列表/标量 = 坏清单 + return None + except yaml.YAMLError: + pass # fallback 到手写 + + fallback = _parse_fallback(yaml_text) + if not fallback.get("id") and not fallback.get("status"): + return None # 手写解析也拿不到任何字段 → 解析失败,交阻断 + return fallback + + +def read_frontmatter(filepath, line_cap=200): + """只读文件的 frontmatter(到闭合 --- 为止),返回文本。 + + 长寿扫描(v1.23.2):done 清单逐年堆积后,守卫/反思钩子每次触发仍只付 + O(frontmatter) 而非 O(整文件);同时天然只搜 frontmatter,正文中引用 + 状态词(如报错自救表里写着 status: in-progress)不会让 done 清单诈尸回 + active——旧实现 ACTIVE_STATUS_RE.search(全文) 两头都吃亏。 + 无闭合 --- 的容错文件读到 line_cap 行为止(与 parse_frontmatter 的容错语义对齐)。 + """ + out = [] + try: + with open(filepath, "r", encoding="utf-8", errors="replace") as f: + for i, line in enumerate(f): + out.append(line) + if i > 0 and line.strip() == "---": + break + if i >= line_cap: + break + except (IOError, OSError): + return "" + return "".join(out) + + +def update_frontmatter(filepath, updates): + """更新清单的 frontmatter 字段(保留正文不变,加文件锁防并发竞争)。 + + Args: + filepath: 清单文件路径 + updates: dict,要更新/新增的字段 + """ + from filelock import file_lock + + try: + with file_lock(filepath): + return _do_update_frontmatter(filepath, updates) + except Exception: + # 锁失败时仍尝试写入(降级,不阻断主流程) + return _do_update_frontmatter(filepath, updates) + + +def _do_update_frontmatter(filepath, updates): + """update_frontmatter 的实际实现(无锁)。""" + try: + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + except (IOError, OSError): + return False + + fm_match = re.match(r"^(---\s*\n)(.*?)(\n---)", content, re.DOTALL) + if not fm_match: + return False + + yaml_text = fm_match.group(2) + body = content[fm_match.end():] + + # 解析现有数据 + if HAS_YAML: + try: + data = yaml.safe_load(yaml_text) or {} + except yaml.YAMLError: + data = _parse_fallback(yaml_text) or {} + else: + data = _parse_fallback(yaml_text) or {} + + # 合并更新 + data.update(updates) + + # 序列化回 YAML + if HAS_YAML: + new_yaml = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) + else: + new_yaml = _dump_fallback(data) + + new_content = f"---\n{new_yaml}---{body}" + with open(filepath, "w", encoding="utf-8") as f: + f.write(new_content) + return True + + +def find_active_manifest(regress_dir): + """找到最新的活跃清单。 + + 语义反转:只有明确的"活跃" status 才算活跃(本框架自己写的词), + 其余任何词(done/completed/analysis-done/AI自造词)都视为非活跃。 + 原因:status 是开放词表,AI 会自造词(如 analysis-done), + 封闭终态表会把自造词误判为活跃 → 误卡提交。 + """ + manifests_dir = os.path.join(regress_dir, "manifests") + files = sorted(glob.glob(os.path.join(manifest_dir := manifests_dir, "*.md")), reverse=True) + active_statuses = ("planning", "in-progress", "verifying", "blocked") + for f in files: + data = parse_frontmatter(f) + if data and data.get("status") in active_statuses: + return f + return None + + +def get_all_changed_files(manifest_path): + """提取所有改动文件(planned + actual)。""" + data = parse_frontmatter(manifest_path) + if not data: + return [] + files = [] + for key in ("planned_changes", "actual_changes"): + for change in data.get(key, []) or []: + if isinstance(change, dict) and change.get("file"): + files.append(change["file"]) + return files + + +def get_manifest_status(manifest_path): + data = parse_frontmatter(manifest_path) + return data.get("status") if data else None + + +def get_fragile_points(manifest_path): + """提取脆弱点条目(公理一:脆弱性前置挂牌)。 + + 每条: {id, kind, description, verify, status} + status 语义(封闭词表): + open = 已识别但既没锁死也没挂牌 → 禁止提交(pre_commit_guard 拦) + locked = verify 命令通过,已锁死 + flagged = 显式带病挂牌(写明知悉原因) + 未列出的脆弱点 = 真正的未知风险(/regress:plan 负责穷举)。 + """ + data = parse_frontmatter(manifest_path) + if not data: + return [] + return [fp for fp in (data.get("fragile_points") or []) if isinstance(fp, dict)] + + +# ─── 手写 fallback 解析器 ───────────────────────────── + +def _parse_fallback(yaml_text): + """零依赖的 YAML frontmatter 解析(处理我们定义的有限结构)。""" + result = {} + current_list_key = None + current_list_item = None + + def flush_item(): + nonlocal current_list_item + if current_list_key is not None and current_list_item is not None: + result.setdefault(current_list_key, []).append(current_list_item) + current_list_item = None + + for line in yaml_text.split("\n"): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + if stripped.startswith("- "): + item_body = stripped[2:] + if ":" in item_body: + flush_item() + key, _, val = item_body.partition(":") + key = key.strip() + val = val.strip().strip('"').strip("'") + current_list_item = {} + if val: + current_list_item[key] = ( + _parse_list(val) if key == "tests_required" else _parse_scalar(val) + ) + else: + if current_list_key is not None: + flush_item() + result.setdefault(current_list_key, []).append(_parse_scalar(item_body)) + continue + + if current_list_item is not None and line.startswith(" ") and ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + if key == "tests_required": + current_list_item[key] = _parse_list(val) + elif val: + current_list_item[key] = _parse_scalar(val) + continue + + if ":" in stripped and not stripped.startswith("-"): + flush_item() + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + if val == "": + result[key] = [] + current_list_key = key + current_list_item = None + elif val == "[]": + result[key] = [] + current_list_key = None + else: + result[key] = _parse_scalar(val) + current_list_key = None + + flush_item() + return result + + +def _dump_fallback(data): + """把手写解析的 dict 序列化回 YAML(简单格式)。""" + lines = [] + for key, val in data.items(): + if isinstance(val, list): + if not val: + lines.append(f"{key}: []") + else: + lines.append(f"{key}:") + for item in val: + if isinstance(item, dict): + first = True + for k, v in item.items(): + if first: + lines.append(f" - {k}: {_fmt_scalar(v)}") + first = False + else: + lines.append(f" {k}: {_fmt_scalar(v)}") + else: + lines.append(f" - {_fmt_scalar(item)}") + elif isinstance(val, bool): + lines.append(f"{key}: {'true' if val else 'false'}") + elif val is None: + lines.append(f"{key}: null") + else: + lines.append(f"{key}: {_fmt_scalar(val)}") + return "\n".join(lines) + "\n" + + +def _fmt_scalar(val): + if isinstance(val, str): + return val if val.isdigit() or val in ("true", "false") else f'"{val}"' + return str(val) + + +def _parse_scalar(val): + if val.lower() == "true": return True + if val.lower() == "false": return False + if val.lower() in ("null", ""): return None + try: return int(val) + except ValueError: pass + return val.strip('"').strip("'") + + +def _parse_list(val): + val = val.strip("[]").strip() + return [_parse_scalar(v.strip()) for v in val.split(",")] if val else [] + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(json.dumps({"error": "usage: manifest_parser.py [cmd]"})) + sys.exit(1) + path = sys.argv[1] + cmd = sys.argv[2] if len(sys.argv) > 2 else "full" + if cmd == "all-changed-files": + print(json.dumps(get_all_changed_files(path))) + elif cmd == "status": + print(json.dumps({"status": get_manifest_status(path)})) + elif cmd == "yaml-status": + print("PyYAML: " + ("available" if HAS_YAML else "NOT available, using fallback")) + else: + print(json.dumps(parse_frontmatter(path), ensure_ascii=False, indent=2, default=str)) diff --git a/plugins/regress-guard/hooks/scripts/lib/notify.py b/plugins/regress-guard/hooks/scripts/lib/notify.py new file mode 100644 index 0000000..80d8ef1 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/notify.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""notify — 人类介入通知(v1.30:人类的一寸的最后一段)。 + +决策点到、人不在屏前:计划待批准 / 受阻待输入 / 感官终验 / 收尾 open—— +往配置的通道发通知(桌面通知、声音、手机推送……通道=命令,参数化到头)。 + +设计(用户令:可配置化、参数化先): +- 通道 = .regress/config.json 的 notify.channels:命令字符串列表,{title}/{body} + 占位符会被 shlex.quote 后替换(模板里不要再加引号);无占位符的命令原样跑 + (声音类)。默认自动探测:notify-send 在装则桌面通知;aplay+wav 在则提示音 + (声音出口由系统音频层决定——蓝牙耳机连着即走蓝牙)。 +- 事件 = notify.events 开关(plan_approval/blocked/sensory/finish_open/done/ + progress,默认全开);test 事件是**手动验收通道**(`notify.py . test` 全链 + 验收用,豁免开关)——不是自动发送点。notify.enabled=false 一刀关。 +- 纪律:best-effort——任一通道失败只 stderr 一行,绝不非零退出(通知是增强, + 不是依赖;不许让通知故障阻塞主流程)。 + +用法: + notify.py . plan_approval --title "📋 REGRESS-x 待批准" --body "改动 3 文件" + notify.py . blocked --title "🛑 受阻" --body "需要:开 Redis 白名单" +""" +import argparse +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import time + +EVENTS = ("plan_approval", "blocked", "sensory", "finish_open", "done", + "progress", "chat", "test") +# 决策型事件(v1.34 推送闭环):送出即落待决台账,人类 outcome 回流成误报率—— +# 广播升级为闭环(collar 启示:误报标注反过来校准告警策略本身,防 alert fatigue) +DECISION_EVENTS = ("plan_approval", "blocked", "sensory", "finish_open") +# chat(v1.38):轮末提醒独立事件——stop_notify 曾冒充 done,发送台账 done×375 +# 几乎全是轮末提醒(真 done 仅 3 次),统计失真;拆开后 done 恢复纯净语义 +# blocked 合并窗口(v1.38 降噪):同键未决 30 分钟内折叠不重发(病例:同清单 +# 6 分钟 6 连推主动制造 alert fatigue,污染误报率校准) +BLOCKED_COALESCE_S = 30 * 60 +# chat 折叠窗(v1.64,B9):同项目+同主题 30 分钟纯去重(哨兵/固化候选/轮末 +# 提醒叠加时防刷屏)。chat 无待决生命周期——不进 pending,机器级小状态文件 +CHAT_COALESCE_S = 30 * 60 + + +def _chat_fold_check(pname, title): + """chat 同键窗口去重(v1.64):键=项目+标题指纹。哨兵日频天然不受影响; + 坏状态文件从零重建、时间解析失败按过期处理——发送优先于折叠。""" + if os.environ.get("RG_CHAT_FOLD") == "off": + return False + import datetime as _dt + import hashlib as _hl + key = _hl.sha1(f"{pname}|{title}".encode("utf-8", "ignore")).hexdigest()[:12] + path = os.path.expanduser( + os.environ.get("RG_CHAT_STATE") or "~/.zcode/regress-chat-fold.json") + now = _dt.datetime.now() + + def _age(v): + try: + return (now - _dt.datetime.fromisoformat(str(v))).total_seconds() + except ValueError: + return float("inf") + + try: + state = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {} + if not isinstance(state, dict): + state = {} + except Exception: + state = {} + hit = state.get(key) + if hit is not None and _age(hit) < CHAT_COALESCE_S: + return True + try: # 写回顺手 prune >24h,状态文件不无限长 + fresh = {k: v for k, v in state.items() if _age(v) < 86400} + fresh[key] = now.isoformat(timespec="seconds") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(fresh, f, ensure_ascii=False) + except Exception: + pass + return False + +_SND_CANDIDATES = ( + "/usr/share/sounds/alsa/Front_Center.wav", + "/usr/share/sounds/freedesktop/stereo/complete.oga", +) + + +def _default_channels(): + ch = [] + if shutil.which("notify-send"): + ch.append("notify-send -a regress-guard -u critical {title} {body}") + wav = next((p for p in _SND_CANDIDATES + if p.endswith(".wav") and os.path.exists(p)), None) + if wav and shutil.which("aplay"): + ch.append(f"aplay -q {wav}") + return ch + + +def _read_notify_block(path): + try: + with open(path, encoding="utf-8") as f: + cfg = json.load(f) + block = cfg.get("notify") if isinstance(cfg, dict) else None + return block if isinstance(block, dict) else {} + except (IOError, OSError, json.JSONDecodeError): + return {} + + +def machine_conf_path(): + """机器级通知配置路径(v1.85.4,079 半收口)。 + + 默认路径走 _pwuid_home()(passwd 账本)而非 expanduser——HOME env 可注入, + v1.68 同族旁路在此调用点的最后一处未转换债(docstring 点名过)。RG_MACHINE_NOTIFY + env 覆盖保留为明文运维缝(顾问裁):生产钩子路径 launcher 白名单已剥离该变量 + (tests/test_launcher_env.py 25 用例钉死),env 注入仅直连 CLI 可达,而能跑 CLI + 者本可直接写真实配置文件——同效更响亮,env 不新增攻击面。改选全拆(信任表式 + env 零影响)的触发条件:出现 env 可达但文件写不可达的新调用面,或采纳安全 + 配置统一 env 零影响审计口径。""" + return os.environ.get("RG_MACHINE_NOTIFY") or os.path.join( + _pwuid_home(), ".zcode", "regress-notify.json") + + +def _pwuid_home(): + """家目录取 passwd 数据库而非 HOME env(v1.68)——expanduser('~') 读 + HOME,被注入诱导的 agent 前缀 HOME=/tmp/x git commit 即可换信任表/ + 机器配置路径(同族旁路);getpwuid 是系统账本不受进程环境操纵。 + 非 POSIX 兜底 expanduser(本插件面向 Linux/mac 用户级)。""" + try: + import pwd + return pwd.getpwuid(os.getuid()).pw_dir + except (ImportError, KeyError): + return os.path.expanduser("~") + + +_TRUST_TABLE_PATH = os.path.join(_pwuid_home(), ".zcode", + "regress-trusted-projects.json") + + +def trusted_projects_path(): + """机器侧信任表(v1.66 供应链加固):abs 路径 → 信任时间。 + v1.68 起零环境覆盖(顾问裁定:env 缝=生产旁路——RG_TRUSTED_PROJECTS + 可被 git commit 前缀注入换表自授信,HOME 间接层同罪)。测试控制= + monkeypatch 本模块 _TRUST_TABLE_PATH 属性,不留 env 缝。""" + return _TRUST_TABLE_PATH + + +def _trusted_projects(): + """读信任表(坏 JSON 从零重建;realpath 防软链绕过比较)。""" + try: + data = json.load(open(trusted_projects_path(), encoding="utf-8")) + return {os.path.realpath(k): v for k, v in data.items()} \ + if isinstance(data, dict) else {} + except Exception: + return {} + + +# v1.72 内容钉(run4 R4,顾问部署期自举裁定):同路径静默换内容防线 +_TRUST_FPR_PATH = os.path.join(_pwuid_home(), ".zcode", + "regress-trusted-projects.fpr.json") +_PIN_SENSITIVE = ("channels",) +_PIN_WECOM_SENSITIVE = ("corpid", "secret", "api_base_allowlist") + + +def _notify_block_of(project_dir): + try: + return _read_notify_block( + os.path.join(project_dir, ".regress", "config.json")) or {} + except Exception: + return {} + + +def _sensitive_changed(old, new): + """敏感面(执行通道+凭据+外发域名单)是否变动——含出现/消失。""" + for k in _PIN_SENSITIVE: + if old.get(k) != new.get(k): + return True + ow, nw = old.get("wecom") or {}, new.get("wecom") or {} + return any(ow.get(k) != nw.get(k) for k in _PIN_WECOM_SENSITIVE) + + +def _load_fpr(): + """读边车;None=文件整体缺失(部署自举信号),{}=存在但空。""" + try: + with open(_TRUST_FPR_PATH, encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + return None + except Exception: + return {} + + +def _save_fpr(side): + try: + os.makedirs(os.path.dirname(_TRUST_FPR_PATH), exist_ok=True) + with open(_TRUST_FPR_PATH, "w", encoding="utf-8") as f: + json.dump(side, f, ensure_ascii=False, indent=1, sort_keys=True) + except Exception: + pass # 边车是增强:写不进=下次重判,不阻断通知 + + +def _dt_now(): + import datetime as _dt + return _dt.datetime.now().isoformat(timespec="seconds") + + +def _pin_entry(side, key, notify_block): + side[key] = {"ts": _dt_now(), "notify": notify_block} + _save_fpr(side) + + +def _project_channels_allowed(project_dir): + """项目级 channels 是否可执行(v1.66,哨兵首轮 055)。 + + 2026-04 PyPI 蠕虫同款攻击面:克隆仓库携带 .regress/config.json,其 + notify.channels 模板会被 shell=True 执行——信任决定必须落机器侧 + (顾问否决项目内 trust 开关:攻击者自授权)。v1.68 起唯一判据=表内 + realpath(RG_TRUST_PROJECT_CHANNELS 直通缝已删——env 可被 + git commit 前缀注入,缝即旁路;同族 RG_TRUSTED_PROJECTS 同批收口)。 + v1.72 内容钉:表内还须过边车指纹——敏感面(channels/wecom 凭据/外发 + 名单)变更即拒,人工出口=刷新信任表时间戳;非敏感变更 TOFU 重钉; + 部署自举=边车整体缺失时一次钉住现状(攻击窗口=部署前换内容,记档)。""" + key = os.path.realpath(os.path.abspath(project_dir)) + table = _trusted_projects() + if key not in table: + return False + new = _notify_block_of(key) + side = _load_fpr() + if side is None: # v1.72 首跑:一次钉住全部受信项目现状(顾问高把握) + side = {k: {"ts": _dt_now(), "notify": _notify_block_of(k)} + for k in table} + _save_fpr(side) + print(f"notify: 信任指纹边车首次部署——已钉住 {len(side)} 项受信项目配置" + f"现状({_TRUST_FPR_PATH});此后敏感面变更将被拒", + file=sys.stderr) + ent = side.get(key) + if ent is None: # 自举后又新增授信的项目:人工刚授信,钉住现状 + _pin_entry(side, key, new) + return True + if _sensitive_changed(ent.get("notify") or {}, new): + # 人工出口:信任表时间戳晚于钉时间 = 人工重授信信号 + if str(table.get(key, "")) > str(ent.get("ts", "")): + _pin_entry(side, key, new) + return True + print(f"notify: 受信项目配置的敏感面已变({key})——已拒并回退机器级" + "(v1.72 内容钉,防同路径换内容)。人工重授信=编辑信任表刷新" + "该项目时间戳", file=sys.stderr) + return False + if (ent.get("notify") or {}) != new: # 非敏感变更:TOFU 自动重钉 + _pin_entry(side, key, new) + return True + + +def load_conf(project_dir): + """两层合并(v1.31.3):机器级 ~/.zcode/regress-notify.json 为底, + 项目 .regress/config.json 按键覆盖——其他项目零配置即得手机推送。 + wecom/events 按键深合并(项目可只覆盖 agentid/单个开关),其余浅合并项目胜; + 项目级只该放差异键(name/事件微调),凭据放机器级一处改处处生效。 + channels 是唯一执行面(v1.66):未受信项目的 channels 在 notify() 里回退。""" + merged = _read_notify_block(machine_conf_path()) + proj = _read_notify_block(os.path.join(project_dir, ".regress", "config.json")) + for k, v in proj.items(): + if isinstance(v, dict) and isinstance(merged.get(k), dict): + sub = dict(merged[k]) + sub.update(v) + merged[k] = sub + else: + merged[k] = v + # v1.70 wecom 凭据字段门(run4 R2,顾问字段级裁定):corpid/secret/ + # api_base_allowlist 三键=凭据与外发目标决定,未受信项目的覆盖回退机器级 + # (机器无该键则剔除)——克隆仓库携假凭据把通知发往自己企微的洞就此关闭; + # agentid/touser/name/事件开关不受限(绑机器凭据的合法差异覆盖)。 + _pw = proj.get("wecom") or {} + _SENS = ("corpid", "secret", "api_base_allowlist") + if any(k in _pw for k in _SENS) and not _project_channels_allowed(project_dir): + _mw = _read_notify_block(machine_conf_path()).get("wecom") or {} + _wec = dict(merged.get("wecom") or {}) + for k in _SENS: + if k in _pw: + if k in _mw: + _wec[k] = _mw[k] + else: + _wec.pop(k, None) + merged["wecom"] = _wec + print("notify: 项目级 wecom 凭据字段未受信已回退机器级(v1.70 供应链加固)——" + "corpid/secret/allowlist 覆盖须项目先受信(人工编辑信任表);" + "agentid/touser/事件开关不受限", + file=sys.stderr) + return merged + + +def notify(project_dir, event, title, body="", source_id=""): + """发通知(best-effort)。返回实际执行的通道数。 + + 格式统一在层内注入(v1.31.2,用户令"应含项目名/任务名/时间"): + 标题加【项目名】前缀(cfg notify.name,缺省目录名);正文缀 🕐 本地时间。 + 调用方只写任务名——五个事件的推送点分散,约定放调用方必然漏。 + source_id(P0-3 回流接线):清单 id——入待决台账的 ref 字段, + plan_approve 批准/取消时按它精确自动 resolve。 + """ + cfg = load_conf(project_dir) + if cfg.get("enabled", True) is False: + return 0 + # v1.66 供应链加固(哨兵 055):项目级 channels 是执行面,未受信即回退 + # 机器级/默认——克隆来的仓库携带 config 不再能借推送执行任意命令 + if cfg.get("channels") and not _project_channels_allowed(project_dir): + mconf = _read_notify_block(machine_conf_path()) + cfg["channels"] = mconf.get("channels") # None → 走 _default_channels() + print("notify: 项目级通道未受信已回退(v1.66 供应链加固)——" + "信任出口:python3 notify.py trust <项目目录>(仿 direnv allow," + "写 ~/.zcode/regress-trusted-projects.json);克隆来的仓库别信任", + file=sys.stderr) + events = cfg.get("events", {}) + if event != "test" and events and not events.get(event, True): + return 0 + pname = cfg.get("name") or os.path.basename(os.path.abspath(project_dir)) + title = f"【{pname}】{title}" + if event == "chat" and _chat_fold_check(pname, title): + print(f"notify: chat 折叠(同项目同主题 {CHAT_COALESCE_S // 60} 分钟内已发)", + file=sys.stderr) + return 0 + if event in DECISION_EVENTS: + # v1.38 blocked 合并(降噪,顾问指纹修正):同「项目+ref+原因指纹」未决 + # 且窗口内 → 折叠(不重发不重记账不刷新窗口——持续失败最多每 30 分钟 + # 重推一次;首推即使通道失败也是锚点:台账记决策不记送达)。 + # 已知边界:窗口内提交成功后同因再拦仍被折叠(首推已告知,最多延迟 + # 半小时重提);查账异常则照旧发送(合并是增强不是依赖)。 + fp = "" + if event == "blocked": + import datetime as _dt + import hashlib as _hl + fp = _hl.sha1(body.encode("utf-8", "ignore")).hexdigest()[:8] + try: + from pending import merge_note, newest_open + prev = newest_open(pname, source_id, fp) + if prev: + age = (_dt.datetime.now() + - _dt.datetime.fromisoformat(prev["ts"]) + ).total_seconds() + if age < BLOCKED_COALESCE_S: + merge_note(prev["id"]) + print(f"notify: blocked 推送合并(同键 " + f"{int(age // 60)} 分钟内已推,待决#{prev['id']})", + file=sys.stderr) + return 0 + except Exception: + pass + # 预分配待决号进正文(v1.34):人类裁决时对着号说话,agent 记 pending。 + # 台账记决策不记送达——决策点真实存在(计划在等批准),通道失败也留账。 + try: + from pending import add as _padd + body += f"\n〔待决#{_padd(pname, event, title, ref=source_id, fp=fp)}〕处理后回「有用/误报/忽略」" + except Exception: + pass + if body: + body = f"{body}\n🕐 {time.strftime('%m-%d %H:%M')}" + else: + body = f"🕐 {time.strftime('%m-%d %H:%M')}" + channels = list(cfg.get("channels") or _default_channels()) + wc = cfg.get("wecom") or {} + env = dict(os.environ, RG_NOTIFY_EVENT=event) + if wc.get("corpid") and wc.get("secret") and wc.get("agentid"): + # 企业微信自动第一通道(体验最优:手机先响,机内声音/桌面次之)。 + # 合并后的 wecom 块经 env 传给子进程——wecom_notify 自己只读项目级文件, + # 不传则机器级回退在子进程失效(2026-09-05 演示项目静默失败病例)。 + lib = os.path.dirname(os.path.abspath(__file__)) + channels.insert(0, 'python3 "%s" "%s" {title} {body}' + % (os.path.join(lib, "wecom_notify.py"), project_dir)) + env["RG_NOTIFY_WECOM_JSON"] = json.dumps(wc, ensure_ascii=False) + ran = 0 + for tpl in channels: + # P2#20:format 进逐通道 try——坏模板(如 awk 花括号)只跳过自身, + # 不再废掉全部通道含企微(评审实测 KeyError 中断整个循环) + try: + cmd = tpl.format(title=shlex.quote(title), body=shlex.quote(body)) \ + if ("{title}" in tpl or "{body}" in tpl) else tpl + # P1#8 超时预算:wecom 内层 4s×2(gettoken+push)+余量 → 12s; + # 其他通道保持 5s(旧行为 5s 处决 2×10s 内层=结构性永远失败) + tmo = 12 if "wecom_notify" in tpl else 5 + r = subprocess.run(cmd, shell=True, timeout=tmo, + capture_output=True, text=True, env=env) + if r.returncode == 0: + ran += 1 + else: + print(f"notify: 通道失败 rc={r.returncode}(忽略): {cmd.split()[0]}", + file=sys.stderr) + except (OSError, subprocess.SubprocessError) as e: + print(f"notify: 通道失败(忽略): {cmd.split()[0]}: {e}", + file=sys.stderr) + if ran == 0 and channels: + # 全通道失败兜底(v1.33 企业级):手机不通至少本机响一声—— + # 通道故障期不再完全静默,回来的人从桌面/声音知道出过事 + for tpl in _default_channels(): + try: + cmd = tpl.format(title=shlex.quote(title), body=shlex.quote(body)) \ + if ("{title}" in tpl or "{body}" in tpl) else tpl + subprocess.run(cmd, shell=True, timeout=5) + except (OSError, subprocess.SubprocessError): + pass + return ran + + +def _stats(): + """观察仪表盘(v1.34):发送台账 + 待决台账聚合——观察期的数字层。 + 北极星候选:送达率(可达段)、未决数与最老悬停(闭环段)、误报率(校准段)。 + 台账行格式见 wecom_notify;event= 维度 v1.34 起有(旧行归"旧格式")。 + 末行缓存命中摘要(083,仪表盘单屏化):history.cache_stats 只读聚合, + 项目定位走 journal._find_project_dir(同 main 的单一来源)。""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + print("═══ regress-guard 观察仪表盘 ═══") + ledger = os.path.expanduser( + os.environ.get("RG_SEND_LEDGER") or "~/.zcode/wecom-send.log") + total = ok = 0 + by_event = {} + try: + with open(ledger, encoding="utf-8") as f: + for line in f: + m = re.match(r"^\d{2}-\d{2} \d{2}:\d{2}:\d{2} errcode=(\d+)", line) + if not m: + continue + total += 1 + ok += m.group(1) == "0" + ev = re.search(r" event=(\S+)", line) + k = ev.group(1) if ev else "旧格式" + by_event[k] = by_event.get(k, 0) + 1 + except (IOError, OSError): + pass + if total: + print(f"企微发送:{total} 条|送达 {ok}|送达率 {ok / total:.0%}") + for k in sorted(by_event, key=lambda x: -by_event[x]): + print(f" {k:<14} ×{by_event[k]}") + else: + print("企微发送:暂无记录(v1.32.6 起真实发送入台账)") + from pending import stats as _pstats + s = _pstats() + fp = "—" if s["fp_rate"] is None else f"{s['fp_rate']:.0%}" + print(f"待决闭环:未决 {s['pending']}(最老 {s['oldest_pending'] or '—'})|" + f"裁决 有用{s['resolved']['useful']}/误报{s['resolved']['fp']}/" + f"忽略{s['resolved']['ignored']}|误报率 {fp}") + if s.get("merged"): + print(f"blocked 折叠:{s['merged']} 次(同键 30 分钟窗口内降噪)") + if s.get("auto_resolved"): + print(f"自动闭环:{s['auto_resolved']} 笔(同清单过门禁自动 resolve," + f"不计入误报率)") + for e in s["open"][-5:]: + print(f" ⏳ #{e['id']} {e['ts'][:16]} [{e['event']}] {e['title'][:40]}") + # 缓存行(083):巡检二看一眼即得缓存健康,不必另跑 history.py cache。 + # 增强不是依赖——history 缺席/损坏/项目未接入时静默跳行,其余行照常输出。 + try: + from history import cache_stats + from journal import _find_project_dir + pd = _find_project_dir() + if pd: + c = cache_stats(os.path.join(pd, ".regress")) + print(f"缓存命中:{c['total']} 次过门禁|命中 {c['hits']}" + f"({c['rate']:.1%})|估算节省 {c['est_saved_seconds']}s") + except Exception: + pass + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "stats": + _stats() + return 0 + if argv and argv[0] == "trust": + # v1.67 trust 转只读(顾问高置信 B):授信=人的决定,CLI 不留写路径—— + # 被提示注入诱导的 agent 一句 trust 自授信的洞(llms.txt/克隆仓库注入野外 + # 实证)就此关闭。共残差(A/B 同有,如实记档不做假承诺):agent 直接 + # Write 表文件仍在台面(transcript 可见;缓解=人侧定期 diff 此表)。 + path = trusted_projects_path() + target = (os.path.realpath(os.path.abspath(argv[1])) + if len(argv) > 1 else None) + table = _trusted_projects() + print(f"信任表(只读视图):{path}") + if not table: + print("(空——尚无受信项目)") + for k in sorted(table): + mark = " ←" if target and k == target else "" + print(f" {k} 授信于 {table[k]}{mark}") + print( + "\n授信是人的决定,本命令不写表。人工授信/撤销:直接编辑上述文件,\n" + f' 授信一行: "<项目 realpath>": ""\n' + " (JSON 对象,键=项目绝对路径,值=授信时间;删行即撤销)") + if target and target not in table: + print(f"\n提示:{target} 当前不在表中(未受信)") + return 0 + ap = argparse.ArgumentParser(description="人类介入通知") + ap.add_argument("project_dir", help="项目目录(. 通常够用)") + ap.add_argument("event", choices=EVENTS) + ap.add_argument("--title", required=True) + ap.add_argument("--body", default="") + ap.add_argument("--ref", default="", + help="来源清单 id(P0-3 回流接线:批准/取消时自动 resolve 同 ref 待决)") + args = ap.parse_args(argv) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from journal import _find_project_dir # 项目定位单一来源 + project_dir = _find_project_dir(args.project_dir) + if not project_dir: + return 0 # 未接入项目:静默(通知是增强不是依赖) + notify(project_dir, args.event, args.title, args.body, source_id=args.ref) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/pending.py b/plugins/regress-guard/hooks/scripts/lib/pending.py new file mode 100644 index 0000000..32f8938 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/pending.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""pending — 决策推送待决台账(v1.34 推送闭环)。 + +告警三段位的第二三段:可达(不沉默)已打赢,本层补「可度量+可校准」—— +决策型推送(plan_approval/blocked/sensory/finish_open)送出即落一条待决 +记录,人类的 outcome(useful/fp/ignored)回流后形成误报率——推送策略的 +校准指标(防 alert fatigue:噪音推送腐蚀信任;「推送了但没用」与「该推没推」 +是对称的病,都要可见)。 + +账本:~/.zcode/regress-pending.jsonl(机器级 append-only:add/resolve 各一行, +重放重建状态;RG_PENDING_LEDGER 可注入测试)。**台账记决策不记送达**—— +决策点真实存在(计划在等批准、任务在受阻),推送失败也留账。 + +v1.38 blocked 合并(降噪):同「项目+ref+拦截原因指纹」的未决记录在窗口内 +被 notify 层折叠——折叠不重发不重记账,只追加 merge_into 旁路行(折叠量 +本身是校准数据:治了多少 alert fatigue 要看得见)。 + +用法: + pending.py add --project X --event blocked --title "🛑 受阻 REGRESS-x" + pending.py list [--pending] + pending.py resolve 3 useful # useful=有用 fp=误报 ignored=忽略 + pending.py stats +""" +import argparse +import datetime +import json +import os +import sys + +OUTCOMES = ("useful", "fp", "ignored") + + +def _path(): + return os.environ.get("RG_PENDING_LEDGER") or os.path.join( + os.path.expanduser("~/.zcode"), "regress-pending.jsonl") + + +def _now(): + return datetime.datetime.now().isoformat(timespec="seconds") + + +def _append(rec): + os.makedirs(os.path.dirname(_path()), exist_ok=True) + with open(_path(), "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + + +def _load(): + """重放账本:返回 (待决 dict, 已决 dict)。坏行跳过(台账是增强不是依赖)。""" + adds, resolves = {}, {} + try: + with open(_path(), encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except json.JSONDecodeError: + continue + if "resolve_id" in e: + resolves[int(e["resolve_id"])] = e + elif "id" in e: + adds[int(e["id"])] = e + except (IOError, OSError): + pass + return adds, resolves + + +def add(project, event, title, ref="", fp=""): + """落一条待决记录,返回分配的 id(notify 层预分配进推送正文〔待决#N〕)。 + + ref(P0-3 回流接线,评审批次一):结构化来源标识(清单 id)—— + plan_approve 批准/取消时按它精确 resolve,不靠标题猜。 + fp(v1.38):拦截原因指纹——blocked 合并键第三维(顾问修正:同清单 + 不同原因的拦截是不同决策点,不能互相折叠)。 + P2#21:max+1 分配加 flock——两会话同推不再拿到同 id(重放去重吞记录)。""" + from filelock import file_lock + with file_lock(_path()): + adds = _load()[0] + nid = (max(adds) if adds else 0) + 1 + _append({"id": nid, "ts": _now(), "project": str(project)[:60], + "event": str(event)[:20], "title": str(title)[:80], + "ref": str(ref)[:60], "fp": str(fp)[:16]}) + return nid + + +def resolve(pid, outcome): + """回流人类裁决。outcome ∈ useful/fp/ignored;重复 resolve 以最后为准。""" + if outcome not in OUTCOMES: + raise ValueError(f"outcome 必须是 {'/'.join(OUTCOMES)}") + _append({"resolve_id": int(pid), "ts": _now(), "outcome": outcome}) + return True + + +def resolve_by_ref(ref, outcome="useful"): + """按结构化 ref 精确回流(P0-3):resolve 该 ref 的全部未决记录, + 返回条数。兜底:无 ref 字段的旧记录按标题词边界唯一命中才处理 + (顾问补强:标题匹配只作唯一命中兜底,防误匹配)。""" + import re as _re + if not ref: + return 0 + adds, resolves = _load() + open_ids = [k for k in sorted(adds) if k not in resolves] + hit_ref = [k for k in open_ids if adds[k].get("ref") == ref] + legacy = [k for k in open_ids if not adds[k].get("ref")] + pat = _re.compile(r"(? best.get("ts", ""): + best = e + return best + + +def merge_note(nid): + """折叠旁路行:不带 id/resolve_id,重放自然忽略,stats 单独计数。""" + _append({"merge_into": int(nid), "ts": _now()}) + + +def count_merged(): + try: + with open(_path(), encoding="utf-8") as f: + return sum(1 for line in f if '"merge_into"' in line) + except (IOError, OSError): + return 0 + + +def pending_records(): + adds, resolves = _load() + return {k: v for k, v in adds.items() if k not in resolves} + + +def stats(): + adds, resolves = _load() + open_p = [e for k, e in sorted(adds.items()) if k not in resolves] + res = list(resolves.values()) + by = {o: sum(1 for e in res if e.get("outcome") == o) for o in OUTCOMES} + oldest = "" + if open_p: + ages = [(datetime.datetime.now() - datetime.datetime.fromisoformat( + e["ts"])).total_seconds() / 86400 for e in open_p] + oldest = f"{max(ages):.1f}天" + decided = by["useful"] + by["fp"] + by["ignored"] + auto = sum(1 for e in res if e.get("outcome") == "resolved") + return {"total": len(adds), "pending": len(open_p), "oldest_pending": oldest, + "resolved": by, "auto_resolved": auto, + "fp_rate": (by["fp"] / decided) if decided else None, + "merged": count_merged(), "open": open_p} + + +def _fmt_row(e, mark): + return f" #{e['id']} {mark} {e['ts'][:16]} [{e['event']}] {e['title'][:50]}" + + +def main(argv=None): + ap = argparse.ArgumentParser(description="决策推送待决台账") + sub = ap.add_subparsers(dest="cmd", required=True) + a = sub.add_parser("add", help="落待决(通常由 notify 层自动调用)") + a.add_argument("--project", required=True) + a.add_argument("--event", required=True) + a.add_argument("--title", required=True) + l = sub.add_parser("list", help="列出记录(未决在前,已决只列最近 5 条)") + l.add_argument("--pending", action="store_true", help="只看未决") + r = sub.add_parser("resolve", help="回流人类裁决") + r.add_argument("pid", type=int) + r.add_argument("outcome", choices=OUTCOMES) + sub.add_parser("stats", help="聚合:未决/裁决分布/误报率/折叠数") + args = ap.parse_args(argv) + + if args.cmd == "add": + print(f"〔待决#{add(args.project, args.event, args.title)}〕") + elif args.cmd == "list": + # v1.38:旧版判定查 adds 记录里的 resolve_id(永远不存在)→ 全显 ⏳, + # 已决结局不可见(病例:18 行全 ⏳、stats 未决 14 对不上) + adds, resolves = _load() + _zh = {"useful": "有用", "fp": "误报", "ignored": "忽略", + "resolved": "闭环(自动)"} + open_ids = sorted(k for k in adds if k not in resolves) + for k in open_ids: + print(_fmt_row(adds[k], "⏳")) + if args.pending: + if not open_ids: + print(" (空)") + else: + recent = sorted(resolves.values(), + key=lambda e: (e.get("ts", ""), + int(e.get("resolve_id", 0))), + reverse=True) # 同秒按 resolve_id 决胜:后裁决优先 + for e in recent[:5]: + r = adds.get(int(e["resolve_id"])) + if not r: + continue + print(_fmt_row(r, f"✔{_zh.get(e.get('outcome'), '?')}")) + if len(recent) > 5: + print(f" …另有 {len(recent) - 5} 笔已裁决(--pending 只看未决)") + if not adds: + print(" (空)") + elif args.cmd == "resolve": + resolve(args.pid, args.outcome) + print(f"✔ #{args.pid} → {args.outcome}") + else: + s = stats() + fp = "—" if s["fp_rate"] is None else f"{s['fp_rate']:.0%}" + merged = f"|blocked 折叠 {s['merged']} 次" if s["merged"] else "" + print(f"待决台账:共 {s['total']} 条|未决 {s['pending']}" + f"(最老 {s['oldest_pending'] or '—'})|" + f"裁决 有用{s['resolved']['useful']}/误报{s['resolved']['fp']}/" + f"忽略{s['resolved']['ignored']}|误报率 {fp}{merged}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/plan_approve.py b/plugins/regress-guard/hooks/scripts/lib/plan_approve.py new file mode 100644 index 0000000..35d16b5 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/plan_approve.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""plan_approve — 清单状态转写器(v1.13 批准落产物 / v1.14 受阻一等状态)。 + +「以产物为中心」的缺口补齐: + 1. 批准事件只活在对话里 → 转写为清单 frontmatter 的 approved.{at,note} + + 考古地层 plan_approved 化石(何时、附言、漂移、**批准时工作区脏文件快照**)。 + 2. 人↔产物直通:人类可直接编辑清单填 approved.at 批准(不经过对话), + 边界守卫视同已批准;本脚本随后对齐 status(不覆盖人类的时间戳)。 + 3. 受阻是一等状态(v1.14):卡住别硬磨别绕过——四问落产物 + (阻塞在哪/已试什么/为什么不能安全继续/需要人类什么), + 边界守卫拦编辑直到解阻。受阻是合法的停止,不是失败。 + +用法: + plan_approve.py [--note "批准附言"] # 批准:planning→in-progress + plan_approve.py --cancel [--note ...] # 取消:planning→cancelled + plan_approve.py --block --reason R [--tried T] [--unsafe U] [--need N] + # 受阻:in-progress/verifying→blocked + plan_approve.py --unblock [--resolution ...] # 解阻:blocked→in-progress + +漂移检查(版本确定性):清单创建时戳 base_head;批准时 HEAD 已变则警示 +"计划基于 ,其间已有 N 个新提交"——只警示不阻断,由人类定夺。 +""" +import argparse +import os +import re +import subprocess +import sys +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import journal +from journal import journal_append, load_journal +from manifest_fields import frontmatter, field, block_value # v1.20 单一来源 + +# 行首(无缩进)的 status 行——脆弱点的缩进 status 不受影响 +_STATUS_LINE = re.compile(r"^status:\s*\S+.*$", re.M) +# approved / blocked / provisional 块 + 其连续缩进行(删除后重建,避免局部修补出错) +_APPROVED_BLOCK = re.compile(r"^approved:\s*\n(?:[ \t]+.*\n?)*", re.M) +_BLOCKED_BLOCK = re.compile(r"^blocked:\s*\n(?:[ \t]+.*\n?)*", re.M) +_PROVISIONAL_BLOCK = re.compile(r"^provisional:\s*\n(?:[ \t]+.*\n?)*", re.M) +_BLOCK_KEYS = ("reason", "tried", "unsafe_why", "need", "at") +_SESSION_LINE = re.compile(r"^session:\s*\S+.*$", re.M) + + +def _stamp_session(content, manifest_dir): + """批准/临行/受阻时盖 session 戳(v1.34 会话作用域)。 + + 身份来源:会话中继(UserPromptSubmit 钩子每轮写的 last-writer-wins 文件) + 优先,本进程 env 兜底——Bash 工具进程通常无会话 env,靠中继;两会话同项目 + 并发时可能错归属一拍(错向=自己的清单被当他人的,可见可修,顾问预审认可)。 + """ + try: + from session_relay import read_relay, sid_from_env + project_dir = journal._find_project_dir(manifest_dir) or manifest_dir + sid = (read_relay(project_dir) or {}).get("sid") or sid_from_env() + except Exception: + sid = "" + if not sid: + return content + if _SESSION_LINE.search(content): + return _SESSION_LINE.sub(f"session: {sid}", content, count=1) + return _STATUS_LINE.sub(lambda m: m.group(0) + f"\nsession: {sid}", + content, count=1) + + +def _resolve_pending(mid): + """P0-3 回流接线(评审批次一):批准/取消/解阻 = 人类对该清单相关 + 待决推送的事实裁决(useful)——自动 resolve 同 ref 的未决记录, + 闭环不再依赖人手工清账(旧记录无 ref 时按标题词边界唯一命中兜底)。 + best-effort:台账故障不影响转写主流程。""" + if not mid: + return 0 + try: + from pending import resolve_by_ref + return resolve_by_ref(mid) + except Exception: + return 0 + + +def _read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def _git(manifest_dir, *args): + try: + out = subprocess.run(["git", "-C", str(manifest_dir), *args], + capture_output=True, text=True, timeout=5) + if out.returncode == 0: + return out.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass + return "" + + +def _drift_info(fm_text, manifest_dir): + """计划基线 vs 当前 HEAD。返回 (可读描述, 附加入地层的字段)。 + + P2#26 仓错配(评审活体:假警报 da7482b):.regress 向上找 git 会撞到 + 外层仓(工作区本身是 git 仓),拿别仓 HEAD 比对。清单 frontmatter 可选 + `repo:`(相对 .regress 父目录的路径)显式声明产物仓——显式优于聪明。""" + base = field(fm_text, "base_head") + if not base or "{{" in base: # 模板占位符未填 = 无基线 + return "", {} + repo = field(fm_text, "repo") + if repo: # repo: 相对 .regress 父目录(项目根)的产物仓路径 + project_root = os.path.dirname(os.path.dirname(os.path.abspath(manifest_dir))) + git_dir = os.path.join(project_root, repo) + else: + git_dir = manifest_dir + cur = _git(git_dir, "rev-parse", "--short", "HEAD") + if not cur or cur == base: + return "", {} + fields = {"base_head": base, "current_head": cur} + n = _git(git_dir, "rev-list", "--count", f"{base}..HEAD") + if n.isdigit(): + fields["commits_behind"] = int(n) + cnt = fields.get("commits_behind", "?") + return f"计划基于 {base},当前 HEAD {cur}(其间 {cnt} 个新提交)", fields + + +def _apply(content, new_status, at, note): + """重写 frontmatter:status 行 + approved 块。at=None 表示只改状态(取消)。 + + P1#7:repl 走 lambda——re.sub 对普通替换串做转义处理,note 里的 + `C:\\docs` / `\\1` 会触发 re.error: bad escape(评审实测可清空清单)。""" + content = _APPROVED_BLOCK.sub("", content) + repl = f"status: {new_status}" + if at is not None: + repl += f'\napproved:\n at: "{at}"\n note: "{note}"' + return _STATUS_LINE.sub(lambda _: repl, content, count=1) + + +def _set_blocked(content, fields): + """status→blocked + blocked 四问块重建(approved 块不动)。 + + 四问文本来自 --reason/--tried/--unsafe/--need(用户输入)→ lambda 替换 + 防转义炸(同 P1#7)。""" + content = _BLOCKED_BLOCK.sub("", content) + block = "blocked:" + for k in _BLOCK_KEYS: + block += f'\n {k}: "{fields.get(k, "")}"' + return _STATUS_LINE.sub(lambda _: f"status: blocked\n{block}", content, count=1) + + +def _append_resolved(content, resolved_at, resolution): + """解阻:status→in-progress,blocked 块保留并追加 resolved_*(历史可见)。""" + content = _STATUS_LINE.sub("status: in-progress", content, count=1) + m = re.search(r"^blocked:\s*\n(?:[ \t]+.*\n?)*", content, re.M) + if not m: + return content + block = m.group(0).rstrip("\n") + block += f'\n resolved_at: "{resolved_at}"\n resolution: "{resolution}"' + return content[:m.start()] + block + "\n" + content[m.end():] + + +def _set_provisional(content, at, advisor): + """临行(伪全自动):status→in-progress + provisional 块。顾问有否决权无批准权—— + 临行的执行授权来自人类事前预授权,顾问预审只是安全网。advisor 文本 lambda 替换(P1#7)。""" + content = _PROVISIONAL_BLOCK.sub("", content) + block = f'provisional:\n at: "{at}"\n advisor: "{advisor}"' + return _STATUS_LINE.sub(lambda _: f"status: in-progress\n{block}", content, count=1) + + +def _dirty_files(manifest_dir, cap=20): + """批准时刻的工作区原点:git status --short(思想二·基线冻结的补全)。 + + 以仓库根为基准(manifest 目录是子目录,相对路径会失真)。 + """ + root = _git(manifest_dir, "rev-parse", "--show-toplevel") + if not root: + return [] + out = _git(root, "status", "--short") + if not out: + return [] + return [l.strip() for l in out.splitlines() if l.strip()][:cap] + + +def _already_journaled(kind, mid, manifest_dir): + project_dir = journal._find_project_dir(manifest_dir) + if not project_dir: + return False + for e in load_journal(project_dir): + if e.get("kind") == kind and e.get("manifest_id") == mid: + return True + return False + + +def main(): + ap = argparse.ArgumentParser(description="清单状态转写器(批准/取消/受阻/解阻)") + ap.add_argument("manifest", help="清单路径 (.regress/manifests/*.md)") + ap.add_argument("--note", default="", help="批准附言/取消原因") + ap.add_argument("--cancel", action="store_true", help="取消计划(planning→cancelled)") + ap.add_argument("--block", action="store_true", + help="标受阻(in-progress/verifying→blocked,四问落产物)") + ap.add_argument("--reason", default="", help="受阻:阻塞在哪(具体命令/代码位置/环境)") + ap.add_argument("--tried", default="", help="受阻:已尝试的方法") + ap.add_argument("--unsafe", default="", help="受阻:为什么不能安全继续") + ap.add_argument("--need", default="", help="受阻:需要人类提供什么(信息/权限/决策)") + ap.add_argument("--unblock", action="store_true", help="解阻(blocked→in-progress)") + ap.add_argument("--resolution", default="", help="解阻:阻塞如何被解除的") + ap.add_argument("--provisional", action="store_true", + help="临行(预授权+顾问预审无异议:planning→in-progress,进入否决窗)") + ap.add_argument("--advisor", default="", help="顾问预审一句话结论(--provisional 必填;必须来自真实 consult,audit 可查)") + args = ap.parse_args() + + if sum(bool(x) for x in (args.cancel, args.block, args.unblock, args.provisional)) > 1: + print("plan_approve: --cancel/--block/--unblock/--provisional 一次只能一个", file=sys.stderr) + return 2 + + path = os.path.abspath(args.manifest) + mdir = os.path.dirname(path) + try: + content = _read(path) + except (IOError, OSError): + print(f"plan_approve: 读不了清单 {path}", file=sys.stderr) + return 2 + fm = frontmatter(content) + if fm is None: + print(f"plan_approve: {path} 无 frontmatter", file=sys.stderr) + return 2 + + mid = field(fm, "id") or os.path.basename(path) + status = field(fm, "status") + note = (args.note or "").replace('"', "'") + now = datetime.now().isoformat(timespec="seconds") + + if args.block: + if status not in ("in-progress", "verifying"): + print(f"plan_approve: 只有进行中的任务能标受阻(当前 status={status};" + f"planning 本身就是在等人类)", file=sys.stderr) + return 1 + if not args.reason: + print("plan_approve: --block 需要 --reason(阻塞在哪都说不清就不是受阻)", + file=sys.stderr) + return 1 + fields = { + "reason": args.reason.replace('"', "'"), + "tried": args.tried.replace('"', "'"), + "unsafe_why": args.unsafe.replace('"', "'"), + "need": args.need.replace('"', "'"), + "at": now, + } + new_content = _stamp_session(_set_blocked(content, fields), mdir) # P1#7:先算后写 + with open(path, "w", encoding="utf-8") as f: + f.write(new_content) + journal_append("task_blocked", start_dir=mdir, manifest_id=mid, + reason=fields["reason"], need=fields["need"], + tried=fields["tried"], unsafe_why=fields["unsafe_why"]) + # 人类介入通知(v1.30):受阻即推送——need 在等一个不在屏幕前的人 + try: + from notify import notify as _notify + _notify(os.path.dirname(os.path.dirname(mdir)), "blocked", + f"🛑 受阻 {mid}", (fields["need"] or fields["reason"] or "")[:80]) + except Exception: + pass + print(f"🛑 已受阻:{mid} → blocked(边界守卫将拦编辑,直到解阻)") + print(f" 阻塞:{fields['reason']}") + if fields["need"]: + print(f" 需要人类:{fields['need']}") + print(" → 把 need 转达给人类;解阻后 plan_approve.py <清单> --unblock") + return 0 + + if args.provisional: + if status != "planning": + print(f"plan_approve: 只有待批准计划能临行(当前 status={status})", file=sys.stderr) + return 1 + if not args.advisor: + print("plan_approve: --provisional 需要 --advisor(预审结论;顾问有一票否决权," + "有方向性异议时严禁临行)", file=sys.stderr) + return 1 + if not _already_journaled("plan_advisor_review", mid, mdir): + print("plan_approve: 临行需要真实的顾问预审化石在场(plan_advisor_review)——" + "先跑 /regress:plan 步骤 5a:consult 后 journal.py . add " + "plan_advisor_review '{\"manifest_id\":\"..\",\"verdict\":\"..\",\"summary\":\"..\"}'" + "(防代笔:预审必须留下可审计的痕迹)", file=sys.stderr) + return 1 + new_content = _stamp_session(_set_provisional( # P1#7:先算后写 + content, now, args.advisor.replace('"', "'")), mdir) + with open(path, "w", encoding="utf-8") as f: + f.write(new_content) + journal_append("provisional_start", start_dir=mdir, manifest_id=mid, + advisor=args.advisor[:400]) + print(f"🚀 已临行:{mid} → in-progress(否决窗内,直到 done)") + print(f" 预审:{args.advisor}") + print(" · 执行授权来自人类的预授权,不来自顾问(顾问无批准权)") + print(" · 人类否决 → plan_approve.py <清单> --cancel;给修改意见则完善计划重新预审") + return 0 + + if args.unblock: + if status != "blocked": + print(f"plan_approve: 无需解阻(当前 status={status})", file=sys.stderr) + return 1 + with open(path, "w", encoding="utf-8") as f: + f.write(_append_resolved(content, now, + (args.resolution or args.note).replace('"', "'"))) + journal_append("task_unblocked", start_dir=mdir, manifest_id=mid, + resolution=(args.resolution or args.note)[:400]) + print(f"✅ 已解阻:{mid} → in-progress(blocked 块保留 resolved 记录)") + n = _resolve_pending(mid) + if n: + print(f"🔄 已自动回流 {n} 条待决(blocked 推送的裁决=阻塞已解除)") + return 0 + + if args.cancel: + # 只有待批准或临行中(否决窗内)的计划可取消——正式批准/受阻的任务不许一销了之 + cancellable = status == "planning" or ( + status in ("in-progress", "verifying") and block_value(fm, "provisional", "at")) + if not cancellable: + print(f"plan_approve: 无需取消(当前 status={status};" + f"只有待批准或临行中的计划可取消,正式任务走受阻/完成)", file=sys.stderr) + return 1 + new_content = _apply(content, "cancelled", None, None) # P1#7:先算后写 + with open(path, "w", encoding="utf-8") as f: + f.write(new_content) + if _already_journaled("plan_cancelled", mid, mdir): + print(f"🗑️ 已取消:{mid} → cancelled(地层已有此事件,不重复埋)") + else: + journal_append("plan_cancelled", start_dir=mdir, manifest_id=mid, note=note) + print(f"🗑️ 已取消:{mid} → cancelled(归档不实施,已入考古地层)") + n = _resolve_pending(mid) + if n: + print(f"🔄 已自动回流 {n} 条待决(否决也是裁决)") + return 0 + + if status != "planning": + print(f"plan_approve: 无需批准(当前 status={status})", file=sys.stderr) + return 1 + + drift, dfields = _drift_info(fm, mdir) + human_at = block_value(fm, "approved", "at") # 人类产物直通的时间戳——保留,不覆盖 + at = human_at or datetime.now().isoformat(timespec="seconds") + if not note: + note = "产物直通(人类直接编辑 approved.at)" if human_at else "对话批准转写" + new_content = _stamp_session(_apply(content, "in-progress", at, note), mdir) # P1#7:先算后写 + with open(path, "w", encoding="utf-8") as f: + f.write(new_content) + + dirty = _dirty_files(mdir) # 批准时刻的工作区原点(思想二·基线冻结) + + already = _already_journaled("plan_approved", mid, mdir) + if not already: + journal_append("plan_approved", start_dir=mdir, manifest_id=mid, + approved_at=at, note=note, drift=drift or "none", + dirty_count=len(dirty), dirty_files=dirty, **dfields) + print(f"✅ 已批准:{mid} → in-progress(approved.at={at})") + n = _resolve_pending(mid) + if n: + print(f"🔄 已自动回流 {n} 条待决(plan_approval 推送的裁决=已批准)") + if dirty: + print(f"📸 基线快照:{len(dirty)} 个未提交脏文件已入地层(此为工作区原点," + f"后续新脏文件对照此原点识别)") + for d in dirty[:3]: + print(f" {d}") + if len(dirty) > 3: + print(f" … 共 {len(dirty)} 个") + if drift: + print(f"⚠️ 计划漂移:{drift}") + print(" 计划基于的文件可能已变——把差异报告给人类确认后再继续实施") + print("📜 已入考古地层(plan_approved)" if not already + else "📜 地层已有此事件,不重复埋") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/rules_ledger.py b/plugins/regress-guard/hooks/scripts/lib/rules_ledger.py new file mode 100644 index 0000000..559e61d --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/rules_ledger.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""规律账本:沉淀 / 命中 / 衰变——带证据律的自改进的记账侧(v1.24)。 + +代谢链:地层是脂肪(原始病例),规律是肌肉(跨会话≥2 沉淀),skill 是骨骼 +(命中≥3 经人批准固化)。账本只记账和提示,**永不自动删 AGENTS.md**(人类文件红线), +**固化建议只出卡片,批准权在人**(自动固化的错误经验会以技能的形式高速复发)。 + +用法: + rules_ledger.py . record --sig "<失败签名>" --occurrences 5 # learn 沉淀/再检出时 + rules_ledger.py . health # 衰变候选 + 固化候选 + rules_ledger.py . match --query "<拦截原因/报错关键词>" # 召回:失败现场读路径(v1.53) + rules_ledger.py . match --query "..." --json --top 3 # 机器读 + +数据:.regress/rules-ledger.json(随 git 入库)。命中定义:learn 再检出同一签名 +(真实"被咨询"无法自动探测,再检出是务实代理——诚实边界,记录在案)。 +""" +import argparse +import hashlib +import json +import os +import re +import sys +from collections import Counter +from datetime import date + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from journal import _find_project_dir # noqa: E402 项目定位单一来源 + +DEFAULT_DECAY_DAYS = 180 +PROMOTE_HITS = 3 + + +def ledger_path(project_dir): + return os.path.join(project_dir, ".regress", "rules-ledger.json") + + +def load(project_dir): + try: + with open(ledger_path(project_dir), encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (IOError, OSError, json.JSONDecodeError): + return {} + + +def _save(project_dir, data): + os.makedirs(os.path.dirname(ledger_path(project_dir)), exist_ok=True) + with open(ledger_path(project_dir), "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=1, sort_keys=True) + + +NEAR_DUP_MIN = 6 # 近重复门槛:高于召回地板 3——误报提示比漏报更烦人 + + +def record(project_dir, sig, occurrences=0): + """记账:首次沉淀 hits=1;同签名再检出 = 命中一次(hits+1,last_hit 刷新)。 + + 新沉淀时自检近重复(v1.57 源头去重):同一规律换个说法就成两条新账目是 + 账本膨胀的主路径(实测:真实项目 3 周 18 节)。提示 print-only—— + 自动合并的错误比膨胀更贵,人看一眼再决定 supersede。 + """ + data = load(project_dir) + key = hashlib.sha1(sig.encode("utf-8")).hexdigest()[:12] + today = date.today().isoformat() + entry = data.get(key) + if entry is None: + data[key] = {"sig": sig, "captured_at": today, "last_hit": today, + "hits": 1, "occurrences": occurrences} + action = "新沉淀" + else: + entry["sig"] = sig + entry["hits"] = int(entry.get("hits", 1)) + 1 + entry["last_hit"] = today + entry["occurrences"] = max(int(entry.get("occurrences", 0)), occurrences) + action = f"命中(第 {entry['hits']} 次)" + _save(project_dir, data) + print(f"📒 {action}: 「{sig[:60]}」 occurrences={data[key]['occurrences']}") + if action == "新沉淀": + try: + near = [r for r in match(project_dir, sig, top=2, min_shared=NEAR_DUP_MIN) + if r["sig"] != sig] + for r in near: + print(f"⚠️ 近重复候选: 「{r['sig'][:60]}」(hits={r['hits']})——" + f"若是同一规律的改写,优先 supersede 旧版而非新增:\n" + f" rules_ledger.py . supersede --old \"{r['sig'][:40]}…\" --new \"{sig[:40]}…\"") + except Exception: + pass # 自检是增强:任何异常不碍记账 + return data[key] + + +def _days_since(iso): + try: + return (date.today() - date.fromisoformat(iso)).days + except (ValueError, TypeError): + return 0 + + +def supersede(project_dir, old_sig, new_sig): + """版本链接(v1.48,B3 迷你 Pareto 记忆):learn 重写规律时把旧版链到新版。 + + 旧规律过气是**预期**不是病——被取代条目从降级候选摘出、health 单列。 + 回滚信号判据(影子,账本有货前不启用):新条目衰变 ≥2 周期 且 旧条目 + 曾稳定 ≥2 周期 且 总命中下降,才提示回滚(顾问三条件防早停误回滚)。""" + data = load(project_dir) + ok = hashlib.sha1(old_sig.encode("utf-8")).hexdigest()[:12] + nk = hashlib.sha1(new_sig.encode("utf-8")).hexdigest()[:12] + sup = data.get("_superseded") + if not isinstance(sup, dict): + sup = {} + sup[ok] = {"by": nk, "ts": date.today().isoformat(), + "old_sig": old_sig[:60]} + data["_superseded"] = sup + _save(project_dir, data) + print(f"🔗 已链接:旧「{old_sig[:40]}」→ 新「{new_sig[:40]}」") + return True + + +MATCH_MIN_SHARED = 3 # 噪声地板:共享 bigram 少于此数不算相关 +MATCH_STOP_RATIO = 0.6 # IDF-lite:bigram 出现在超过此比例的签名中 → 样板停用 + + +def _bigrams(text): + """字符 bigram(去空白、小写)——无分词依赖的中英混排召回基础。""" + t = re.sub(r"\s+", "", str(text or "")).lower() + return {t[i:i + 2] for i in range(len(t) - 1)} + + +def query_from_manifest(manifest_path): + """从清单派生召回查询(v1.61 结构化查询面):planned files + 脆弱点描述 + (前3条)+ scan.card 卡名——清单本身就是"动哪里/怕什么"的结构化自述。 + + plan 步骤 2b 前召回/learn 沉淀前查重用;召回从"拦截至"扩到"开工前"。 + cap 300 字符防稀释;读不到返空串(查询是增强不是依赖)。 + """ + try: + from manifest_parser import get_all_changed_files, get_fragile_points, parse_frontmatter + terms = [f.replace(os.sep, "/") for f in get_all_changed_files(manifest_path)] + for fp in get_fragile_points(manifest_path)[:3]: + terms.append(str(fp.get("description") or "")[:60]) + fm = parse_frontmatter(manifest_path) or {} + terms.append(str((fm.get("scan") or {}).get("card") or "")) + return " ".join(t for t in terms if t)[:300] + except Exception: + return "" + + +def match(project_dir, query, top=3, min_shared=MATCH_MIN_SHARED): + """召回(v1.53 读路径):按 bigram 重叠数排序历史规律——骨架库不只回流,还能供给。 + + 排序 = 共享 bigram 数降序,同分先比 hits(历史命中)再比 last_hit(新鲜度) + ——相关性优先,频率只作断路器(顾问:乘子会把"高频"伪装成"相关")。 + 样板停用(IDF-lite):≥5 条时,出现在 >60% 签名里的 bigram("定位/归因"这类 + 格式样板词)不参与匹配,否则万物皆相关。被取代的旧签名不召回。 + """ + top = max(0, int(top)) + query = str(query or "").strip() + if top <= 0 or not query: + return [] + data = load(project_dir) + entries = [e for k, e in data.items() + if not str(k).startswith("_") and isinstance(e, dict) and e.get("sig")] + if not entries: + return [] + sup_map = data.get("_superseded") if isinstance(data.get("_superseded"), dict) else {} + sup_sigs = {str(v.get("old_sig", "")) for v in sup_map.values()} + sig_bgs = [(_bigrams(e["sig"]), e) for e in entries if e["sig"] not in sup_sigs] + if not sig_bgs: + return [] + q = _bigrams(query) + stop = set() + if len(sig_bgs) >= 5: # 太小的账本停用过滤反而失真 + df = Counter(b for bgs, _ in sig_bgs for b in bgs) + stop = {b for b, n in df.items() if n > len(sig_bgs) * MATCH_STOP_RATIO} + q -= stop + out = [] + for bgs, e in sig_bgs: + shared = len(q & (bgs - stop)) + if shared >= min_shared: + out.append({"sig": e["sig"], "hits": int(e.get("hits", 1)), + "last_hit": e.get("last_hit", ""), "score": shared}) + out.sort(key=lambda h: str(h["last_hit"]), reverse=True) # 稳定预排:新鲜度断路 + out.sort(key=lambda h: (-h["score"], -h["hits"])) # 主排序保持断路序 + return out[:top] + + +def health(project_dir, decay_days=DEFAULT_DECAY_DAYS, promote_hits=PROMOTE_HITS): + """规律健康:降级候选(>decay_days 零命中)+ 固化候选(hits≥promote_hits 且未腐化)。 + + 降级候选只提示人工修剪;固化候选只建议(人批准后经 skill-creator 固化为宿主 skill)。 + """ + data = load(project_dir) + entries = sorted((e for k, e in data.items() + if k != "_superseded" and isinstance(e, dict) + and e.get("sig")), key=lambda e: -int(e.get("hits", 0))) + sup_map = data.get("_superseded") if isinstance(data.get("_superseded"), dict) else {} + sup_sigs = {str(v.get("old_sig", "")) for v in sup_map.values()} + stale = [e for e in entries + if _days_since(e.get("last_hit", "")) > decay_days + and e["sig"] not in sup_sigs] # 被取代规律过气=预期,非降级候选 + stale_keys = {id(e) for e in stale} + promotable = [e for e in entries + if id(e) not in stale_keys and int(e.get("hits", 0)) >= promote_hits] + print(f"规律总数 {len(entries)}|命中≥{promote_hits}(固化候选){len(promotable)}|" + f">{decay_days}天零命中(降级候选){len(stale)}") + for e in promotable: + print(f" 🦴 固化候选 hits={e['hits']} 「{e['sig'][:50]}」" + f"(建议经人批准用 skill-creator 固化)") + for e in stale: + print(f" 🍂 降级候选 last_hit={e.get('last_hit')} 「{e['sig'][:50]}」" + f"(提示人工修剪——本工具永不自动删)") + if sup_map: + for v in list(sup_map.values())[:5]: + print(f" 🔗 已取代 {v.get('ts')} 「{str(v.get('old_sig', ''))[:40]}」" + f"→ 新版(过气=预期,不算降级候选)") + if not entries: + print(" (空账本——learn 沉淀规律时自动记账)") + return {"total": len(entries), "promotable": promotable, "stale": stale} + + +def main(argv=None): + ap = argparse.ArgumentParser(description="规律账本(代谢链记账侧)") + ap.add_argument("project_dir", help="项目目录(. 通常够用)") + sub = ap.add_subparsers(dest="cmd", required=True) + r = sub.add_parser("record", help="沉淀/再检出记账") + r.add_argument("--sig", required=True, help="失败签名(规律的唯一键)") + r.add_argument("--occurrences", type=int, default=0, help="累计出现次数(跨会话)") + su = sub.add_parser("supersede", help="版本链接:旧规律被新版取代") + su.add_argument("--old", required=True) + su.add_argument("--new", required=True) + m = sub.add_parser("match", help="召回:按关键词匹配历史规律(失败现场读路径)") + m.add_argument("--query", default="", help="查询文本(拦截原因/报错关键词)") + m.add_argument("--from-manifest", default="", + help="从清单派生查询(结构化:文件+脆弱点+卡名;与 --query 互斥)") + m.add_argument("--top", type=int, default=3, help="召回条数上限") + m.add_argument("--json", action="store_true", help="机器读(B2 门禁消费)") + h = sub.add_parser("health", help="规律健康:降级候选 + 固化候选") + h.add_argument("--decay-days", type=int, default=DEFAULT_DECAY_DAYS) + h.add_argument("--promote-hits", type=int, default=PROMOTE_HITS) + args = ap.parse_args(argv) + project_dir = _find_project_dir(args.project_dir) + if not project_dir: + print("rules_ledger: 未找到 .regress/(项目未接入)", file=sys.stderr) + return 1 + if args.cmd == "record": + record(project_dir, args.sig, args.occurrences) + elif args.cmd == "supersede": + supersede(project_dir, args.old, args.new) + elif args.cmd == "match": + query = args.query or query_from_manifest(args.from_manifest) + if args.from_manifest and not args.json: + print(f"🔍 派生查询(from {os.path.basename(args.from_manifest)}): {query[:120]}") + res = match(project_dir, query, top=args.top) + if args.json: + print(json.dumps(res, ensure_ascii=False)) + elif not res: + print("📚 无相关规律(账本空或共享 bigram 未达阈值)") + else: + print(f"📚 相关规律 TOP-{len(res)}(骨架库召回——提示不是行动,采纳前先对照本次现场):") + for i, r in enumerate(res, 1): + print(f" {i}. 「{r['sig'][:60]}」 命中×{r['hits']}(最近 {r['last_hit']})") + else: + import io as _io, contextlib as _cb + with _cb.redirect_stdout(_io.StringIO()) as buf: + health(project_dir, args.decay_days, args.promote_hits) + out = buf.getvalue() + print(out, end="") + # 固化候选=需要人类批准的决策点(v1.33 企业级):出现即推送, + # 不再只躺在报表里等人跑 stats(conftest 以 RG_NO_NOTIFY 隔离测试) + if "固化候选" in out and "固化候选)0" not in out and \ + not os.environ.get("RG_NO_NOTIFY"): + try: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from notify import notify as _notify + n = out.count("🦴 固化候选") + _notify(project_dir, "plan_approval", + f"🦴 固化候选 ×{n}", + "规律命中≥3 可固化为 skill——需要你批准(/regress:stats 查看详情)") + except Exception: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/scan_check.py b/plugins/regress-guard/hooks/scripts/lib/scan_check.py new file mode 100644 index 0000000..89b3f90 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/scan_check.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""scan_check — 全貌新鲜度判定(v1.40:理解是强制产物,不是气氛)。 + +对标 spec-first(Kiro/spec-kit):理解本身是可检查的产物。本脚本回答 +「模块卡片落后代码多少」——卡片最后提交 vs 期间代码提交量 / 结构性增删文件。 +plan 步骤 2a 消费(--json);人读直接跑。 + +判定(阈值:<3 次代码提交且无结构性增删 = fresh): + fresh 卡片与代码同步 + stale 期间代码提交 >=3 或有结构性增删(ADR 事件,tests/docs/md 豁免)→ 重扫受影响卡 + absent 无卡片 → M/L 先 init 产品层或声明纯库项目(命令纪律位,门禁另有警示) + +已知边界(FP2):双仓项目(卡片在外仓、代码在内仓)以治理仓活动为代理—— +代码大动而治理仓零提交时会误报 fresh;门禁规则B 看 staged 实况,不受此影响。 + +用法: + scan_check.py . # 人读一行判定 + scan_check.py . --json # 机器读(plan 2a) +""" +import argparse +import json +import os +import subprocess +import sys + + +def _git(repo, *args): + try: + r = subprocess.run(["git", "-C", repo, *args], capture_output=True, + text=True, timeout=10) + return r.stdout.strip() if r.returncode == 0 else "" + except (OSError, subprocess.SubprocessError): + return "" + + +def _structural(path): + """结构性路径判据(与门禁规则B 同口径:tests/docs/md 是模块的元数据不是模块)。""" + p = path.replace(os.sep, "/") + return not (p.startswith("tests/") or p.startswith("docs/") + or p.endswith(".md") or p.startswith(".regress/")) + + +def check(project_dir): + """返回判定 dict。卡片从未提交(刚 init)视为 fresh——时间锚取当天。""" + card_rel = ".regress/product-arch.md" + card = os.path.join(project_dir, card_rel) + if not os.path.exists(card): + return {"verdict": "absent", "card": card_rel, + "note": "无模块卡片——M/L 先 init 产品层,或纯库项目声明无产品面"} + sha = _git(project_dir, "log", "-1", "--format=%H", "--", card_rel) + iso = _git(project_dir, "log", "-1", "--format=%cI", "--", card_rel) + commits, structural = 0, [] + if sha: + # 用 sha 区间而非 --since:同秒提交会让时间戳粒度把卡片提交之前的 + # init 也圈进来(测试实测),区间无此歧义且天然排除卡片提交自身 + rng = f"{sha}..HEAD" + log = _git(project_dir, "log", "--oneline", rng, "--", ".", + ":(exclude).regress") + commits = len([l for l in log.splitlines() if l.strip()]) + ad = _git(project_dir, "log", "--diff-filter=ADR", "--name-only", + "--format=", rng, "--", ":(exclude).regress", + ":(exclude)tests", ":(exclude)docs", ":(exclude)*.md") + structural = sorted({l for l in ad.splitlines() + if l.strip() and _structural(l)}) + verdict = "stale" if (commits >= 3 or structural) else "fresh" + return {"verdict": verdict, "card": card_rel, + "card_last_commit": iso or "未提交", + "code_commits_since": commits, "structural_changes": structural[:8]} + + +def main(argv=None): + ap = argparse.ArgumentParser(description="全貌新鲜度判定(v1.40)") + ap.add_argument("project_dir", help="项目目录(. 通常够用)") + ap.add_argument("--json", action="store_true", help="机器读") + args = ap.parse_args(argv) + project_dir = os.path.abspath(args.project_dir) + r = check(project_dir) + if args.json: + print(json.dumps(r, ensure_ascii=False)) + return 0 + zh = {"fresh": "✅ fresh 卡片与代码同步", "stale": "🧊 stale 卡片落后代码", + "absent": "⚠️ absent 无模块卡片"} + print(zh[r["verdict"]]) + if r["verdict"] == "stale": + print(f" 卡片最后提交:{r['card_last_commit']}") + print(f" 期间代码提交:{r['code_commits_since']} 次") + if r["structural_changes"]: + print(f" 结构性增删:{', '.join(r['structural_changes'])}") + print(" → plan 步骤 2a:重扫受影响模块卡再继续(看不全就对不准)") + elif r["verdict"] == "absent": + print(f" {r['note']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/secret_scan.py b/plugins/regress-guard/hooks/scripts/lib/secret_scan.py new file mode 100644 index 0000000..83a4776 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/secret_scan.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""secret_scan — gitleaks-lite(v1.42 供应链层,REGRESS-2026-031)。 + +门禁只验"测试过了"不验"密钥漏没漏"——本层补安全面:扫 staged diff 的 +**新增行**(历史密钥审计是全仓工具的职责,不是提交门禁的)。零依赖纯 Python。 + +双层模式: +- 高精度(误报成本=一次 bypass+修模式,不值得豁免任何路径——真密钥漏在 + 测试里也是漏):AWS AccessKey / GitHub token / 私钥块 / Slack / Google / + OpenAI sk- +- 通用 key=value 高熵对(噪音大):tests/ 与 *.md 豁免(夹具与文档示例是 + 合法的假密钥聚集地) + +内置允许表:各家文档的标准示例值(AWS 文档的 AKIAIOSFODNN7EXAMPLE 等)—— +项目级追加走 config supply_chain.allowlist。 + +用法(由 pre_commit_guard 4.7 调用,也可独立诊断): + secret_scan.py <<< "$(git diff --staged -U0)" +""" +import re +import sys + +HIGH = [ + ("AWS AccessKey", re.compile(r"AKIA[0-9A-Z]{16}")), + ("GitHub Token", re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}")), + ("私钥块", re.compile( + r"-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----")), + ("Slack Token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}")), + ("Google API Key", re.compile(r"AIza[0-9A-Za-z\-_]{35}")), + ("OpenAI Key", re.compile(r"sk-[A-Za-z0-9_\-]{30,}")), +] +GENERIC = re.compile( + r"(?:api[_-]?key|secret|token|password)\s*[:=]\s*['\"]" + r"[A-Za-z0-9_\-/+=.]{20,}['\"]", re.I) + +# 各家官方文档的标准示例值(截断处不遮蔽:这些是公开的教学串) +BUILTIN_ALLOWLIST = { + "AKIAIOSFODNN7EXAMPLE", # AWS 文档 AccessKey 示例 + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", # AWS 文档 Secret 示例 +} + + +def scan_added_lines(diff_text, allowlist=()): + """扫 git diff(建议 -U0)新增行。返回 [(模式, 文件, 行号, 截断串)]。 + + 行号语义:hunk @@ 头的新侧起始行 + 前缀 + 行数;删除行(^-)不扫 + ——只扫新增(本次提交引入了什么)。""" + allow = set(BUILTIN_ALLOWLIST) | set(allowlist) + hits, cur_file, cur_line = [], "", 0 + for raw in diff_text.splitlines(): + if raw.startswith("+++ b/"): + cur_file = raw[6:] + elif raw.startswith("@@"): + m = re.search(r"\+(\d+)", raw) + cur_line = int(m.group(1)) if m else 0 + elif raw.startswith("+") and not raw.startswith("+++"): + line = raw[1:] + hit = None + for name, pat in HIGH: + m = pat.search(line) + if m and m.group(0) not in allow: + hit = (name, cur_file, cur_line, m.group(0)[:14] + "…") + break + if hit is None and not (cur_file.startswith("tests/") + or cur_file.endswith(".md")): + m = GENERIC.search(line) + if m and m.group(0) not in allow: + hit = ("通用密钥对", cur_file, cur_line, m.group(0)[:18] + "…") + if hit: + hits.append(hit) + cur_line += 1 + elif raw.startswith("-"): + continue + return hits + + +def main(): + diff = sys.stdin.read() if not sys.stdin.isatty() else "" + hits = scan_added_lines(diff) + if not hits: + print("✅ 新增行无密钥命中") + return 0 + for name, f, ln, snip in hits[:10]: + print(f"🚨 {name} {f}:{ln} {snip}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/sentinel.py b/plugins/regress-guard/hooks/scripts/lib/sentinel.py new file mode 100644 index 0000000..ef6b0b1 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/sentinel.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""sentinel — 哨兵视图(v1.34 会话作用域):一屏看清"谁在动什么"。 + +collar-daemon 启示(淘天文章研判 2026-09-07):多会话的冲突只在集成点 +现形,任何单个会话都看不见别人的活跃清单。本视图不检测不拦截(那是 +门禁/边界守卫的活),只提供"谁的开发机都没有的全局视野"——提交/动手 +前扫一眼,跨会话文件重叠在变成事故前先对焦。 + +悬停判定:in-progress/verifying >24h ⚠️(卡死或被遗忘);planning >48h +(等批准等太久);blocked 一律 🛑(在等人类)。 + +用法:sentinel.py [project_dir](缺省 .) +""" +import glob +import os +import sys +import time +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from journal import _find_project_dir # noqa: E402 项目定位单一来源 + +_ACTIVE = ("planning", "in-progress", "verifying", "blocked") + + +def _age_days(path): + try: + return (time.time() - os.path.getmtime(path)) / 86400 + except (IOError, OSError): + return 0.0 + + +def _fm_get(path, key): + """frontmatter 顶层字段(轻量正则,不拉 YAML 依赖)。""" + try: + with open(path, encoding="utf-8") as f: + content = f.read() + except (IOError, OSError): + return "" + fm = content.split("---", 2)[1] if content.startswith("---") else "" + import re + m = re.search(rf"^{key}:\s*[\"']?([^\s\"'\n#]+)", fm, re.M) + return m.group(1) if m else "" + + +def scan(project_dir): + """返回 [(path, id, status, session, age_days, files_n)],活跃清单。""" + mdir = os.path.join(project_dir, ".regress", "manifests") + out = [] + for p in sorted(glob.glob(os.path.join(mdir, "*.md"))): + status = _fm_get(p, "status") + if status not in _ACTIVE: + continue + from manifest_parser import get_all_changed_files + try: + files_n = len(get_all_changed_files(p)) + except Exception: + files_n = 0 + out.append((p, _fm_get(p, "id") or os.path.basename(p), status, + _fm_get(p, "session"), _age_days(p), files_n)) + return out + + +def render(project_dir): + rows = scan(project_dir) + my_sid = (os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") or "") + print("═══ 哨兵视图:活跃清单 × 会话归属 ═══") + if not rows: + print(" (无活跃清单——疆域干净)") + return rows + for p, mid, status, sid, age, files_n in rows: + who = "本会话" if (sid and sid == my_sid) else ("共享/无戳" if not sid else f"他会话 {sid[:8]}…") + hover = "" + if status == "blocked": + hover = " 🛑 在等人类" + elif status in ("in-progress", "verifying") and age > 1: + hover = f" ⚠️ 悬停 {age:.0f} 天(卡死或被遗忘?)" + elif status == "planning" and age > 2: + hover = f" ⚠️ 待批准 {age:.0f} 天" + print(f" [{status:<10}] {mid} · {who} · {files_n} 文件 · 改于 {age:.1f} 天前{hover}") + # 跨会话文件重叠(真正的集成态风险点):只看带 session 戳的清单 + from manifest_parser import get_all_changed_files + owners = {} + for p, mid, _, sid, _, _ in rows: + if not sid: + continue + try: + for f in get_all_changed_files(p): + owners.setdefault(f.replace(os.sep, "/"), set()).add(sid[:8]) + except Exception: + pass + clash = {f: s for f, s in owners.items() if len(s) > 1} + if clash: + print(" 🔀 多会话声明了同一文件(提交前先对焦):") + for f, s in sorted(clash.items())[:8]: + print(f" {f} ← {' & '.join(sorted(s))}") + return rows + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + start = argv[0] if argv else "." + project_dir = _find_project_dir(start) + if not project_dir: + print("sentinel: 未找到 .regress/(项目未接入)", file=sys.stderr) + return 1 + render(project_dir) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/lib/session_relay.py b/plugins/regress-guard/hooks/scripts/lib/session_relay.py new file mode 100644 index 0000000..982f21e --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/session_relay.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""session_relay — 会话身份中继(v1.34 清单会话作用域)。 + +事实基础:PreToolUse/UserPromptSubmit 钩子进程带 CLAUDE_SESSION_ID / +ZCODE_SESSION_ID(活体证据:/tmp/regress-guard-fails-sess_*.jsonl 按会话 +分文件),而 Bash 工具进程不带——agent 侧经 Bash 跑的脚本(plan_approve +盖章)无法自证会话身份,只能靠钩子侧中继。 + +写入点:UserPromptSubmit 每轮(含 / 命令轮——命令轮也是本会话的轮)。 +读取点:plan_approve 批准/临行/受阻时给清单盖 session 戳。 + +last-writer-wins:两会话同项目并发时盖章可能错归属一拍——错向是 +「自己的清单被当他人的」(拦截可见可修),不是静默放行(顾问预审认可)。 +原子写:临时文件 + rename,防并发写半行(顾问补强点)。 +""" +import datetime +import hashlib +import json +import os +import tempfile + + +def sid_from_env(): + return ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "" + ) + + +def relay_path(project_dir): + key = hashlib.md5(os.path.abspath(project_dir).encode()).hexdigest()[:8] + return os.path.join(tempfile.gettempdir(), f"regress-guard-session-{key}.json") + + +def write_relay(project_dir, sid=None): + """钩子侧每轮写:当前在该项目说话的会话号。""" + sid = sid if sid is not None else sid_from_env() + if not sid: + return False + p = relay_path(project_dir) + try: + tmp = p + f".tmp{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"sid": sid, "ts": datetime.datetime.now().isoformat( + timespec="seconds")}, f, ensure_ascii=False) + os.replace(tmp, p) # 原子:并发 last-writer-wins,不出现半行 + return True + except (IOError, OSError): + return False + + +def read_relay(project_dir): + """agent 侧读:最近一轮在本项目说话的会话号(缺省空 dict=无中继)。""" + try: + with open(relay_path(project_dir), encoding="utf-8") as f: + d = json.load(f) + return d if isinstance(d, dict) else {} + except (IOError, OSError, json.JSONDecodeError): + return {} diff --git a/plugins/regress-guard/hooks/scripts/lib/test_cache.py b/plugins/regress-guard/hooks/scripts/lib/test_cache.py new file mode 100644 index 0000000..27f3e7f --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/test_cache.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""门禁测试结果缓存(v1.85,074)。 + +病例:重试场景同一棵树三跑全量(run4-R1 实证 118s×3)——重试多因门禁规则 +(staging 丢失/验收格式),树本身没动,全量是纯重复。 + +立场(顾问已裁):优化位,非安全边界。伪造缓存跳测者本有更廉价的 +/regress:bypass;命中必须大声可审计(stderr ♻️ + commit_passed 事件带 +cached/cache_key),TTL 收窄 4h 兜环境漂移窗。 + +键设计:测试目录所在 git 仓的**内容规范形**——tracked ∪ 未跟踪全路径排序,逐文 +件哈希工作树内容。与暂存态/git diff 输出格式/HEAD 彻底解耦(074 两个狗粮标本 +的教训:porcelain 状态列与 diff 补丁格式都随暂存态变,同内容会误判不同树)。 +.regress/ 整体排除——治理运行时产物(含本缓存文件自身与 history.jsonl)不得 +入键,否则写缓存即改键、永不命中(死循环)。只缓存通过结果(失败前必改树, +fail 条目无消费者)。任何异常 = 旁路,行为与无缓存完全一致(缓存是增强不是 +依赖)。 +""" +import hashlib +import json +import os +import subprocess +import time + +_CACHE_FILE = "test-cache.jsonl" +_DEFAULT_TTL_MIN = 240 # 顾问:24h→4h(覆盖分钟级重试窗,收窄环境漂移暴露) +_MAX_ENTRIES = 50 + + +def _cfg(project_dir): + try: + with open(os.path.join(project_dir, ".regress", "config.json"), + encoding="utf-8") as f: + return json.load(f).get("test_cache") or {} + except Exception: + return {} + + +def enabled(project_dir): + """默认开;RG_TEST_CACHE=off 或 config test_cache.enabled=false 关。""" + if os.environ.get("RG_TEST_CACHE", "").lower() in ("off", "0", "false"): + return False + return bool(_cfg(project_dir).get("enabled", True)) + + +def _run_git(repo, *args): + r = subprocess.run(["git", "-C", repo] + list(args), + capture_output=True, text=True, timeout=10) + return r.stdout if r.returncode == 0 else None + + +def tree_key(test_dir): + """测试目录所在 git 仓的内容规范形哈希;非 git 仓/任何失败 → None(旁路)。 + + 键只认"哪些文件、什么内容":tracked(ls-files)∪ 未跟踪(porcelain ??) + 全路径排序后逐文件哈希工作树内容,缺失记 absent。与暂存态、git diff 输出 + 格式、HEAD 完全解耦——074 两个狗粮标本的根治:①porcelain 状态列区分暂存/ + 未暂存,②未跟踪以路径+哈希入键而暂存后以 unified diff 入键(格式不对称), + 两种形态下"同内容不同暂存态"都会误判为不同树、门禁白跑全量。.regress/ + 整体排除(治理运行时产物:缓存自身/history/清单 done 戳——它们变了不算树变)。 + """ + try: + repo = _run_git(test_dir, "rev-parse", "--show-toplevel") + if not repo or not repo.strip(): + return None + repo = repo.strip() + paths = set() + tracked = _run_git(repo, "ls-files") or "" + for p in tracked.splitlines(): + p = p.strip().strip('"') + if p: + paths.add(p) + status = _run_git(repo, "status", "--porcelain", "-uall") or "" + for line in status.splitlines(): + if not line.startswith("??"): + continue # tracked 变更的文件已在 ls-files 集合里(键看内容不看状态) + p = line[3:].strip().strip('"') + if p: + paths.add(p) + h = hashlib.sha256() + for p in sorted(paths): + if p == ".regress" or p.startswith(".regress/"): + continue + fp = os.path.join(repo, p) + if os.path.isfile(fp): + with open(fp, "rb") as f: + h.update(f"{p}:{hashlib.sha256(f.read()).hexdigest()}\n".encode()) + else: + h.update(f"{p}:absent\n".encode()) # tracked 已删(工作树缺失) + return h.hexdigest()[:16] + except Exception: + return None + + +def _detect_test_dir(project_dir): + """测试实际运行目录(run_tests 的探测结果,键必须跟着测试走)。""" + try: + from test_runner import _detect + _runner, _cmd, rcwd = _detect(project_dir) + return rcwd + except Exception: + return project_dir + + +def lookup(project_dir, regress_dir): + """命中返回 {key, ts, age_min, result};未命中/关闭/异常 → None。""" + if not enabled(project_dir): + return None + key = tree_key(_detect_test_dir(project_dir)) + if not key: + return None + ttl = _cfg(project_dir).get("ttl_minutes") or _DEFAULT_TTL_MIN + path = os.path.join(regress_dir, _CACHE_FILE) + try: + with open(path, encoding="utf-8") as f: + lines = [l for l in f.read().splitlines() if l.strip()] + except (IOError, OSError): + return None + for line in reversed(lines): # 最新优先 + try: + e = json.loads(line) + except json.JSONDecodeError: + continue + if e.get("key") != key or e.get("status") != "pass": + continue + age_min = (time.time() - float(e.get("ts") or 0)) / 60 + if age_min > ttl: + continue + return {"key": key, "ts": e.get("ts"), "age_min": round(age_min), + "result": e.get("result") or {}} + return None + + +def record(project_dir, regress_dir, result): + """pass 才落账;append jsonl + 裁剪到上限(丢最老)。""" + if result.get("status") != "pass": + return False + key = tree_key(_detect_test_dir(project_dir)) + if not key: + return False + entry = { + "key": key, "ts": time.time(), "status": "pass", + "runner": result.get("runner"), "passed": result.get("passed"), + "total": result.get("total"), + "result": {k: v for k, v in result.items() + if k not in ("failures", "raw_snippet")}, + } + path = os.path.join(regress_dir, _CACHE_FILE) + try: + os.makedirs(regress_dir, exist_ok=True) + lines = [] + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + lines = [l for l in f.read().splitlines() if l.strip()] + lines.append(json.dumps(entry, ensure_ascii=False)) + cap = _cfg(project_dir).get("max_entries") or _MAX_ENTRIES + lines = lines[-int(cap):] + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + os.replace(tmp, path) + return True + except Exception: + return False diff --git a/plugins/regress-guard/hooks/scripts/lib/test_runner.py b/plugins/regress-guard/hooks/scripts/lib/test_runner.py new file mode 100755 index 0000000..03fee42 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/test_runner.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""检测项目类型并运行测试,返回结构化结果。 + +被 pre_commit_guard.sh 调用。不信任任何 AI 写的状态,hook 自己跑测试验证。 + +支持的测试运行器(自动探测): + - Node.js: jest(package.json 含 jest 依赖) + - Python: pytest(存在 pytest.ini / conftest.py / setup.cfg [tool:pytest]) + - Java: mvn test(存在 pom.xml)/ gradle test(存在 build.gradle) + - Go: go test(存在 go.mod) + +如果找不到测试运行器 → 返回 skip(不阻断,因为可能纯前端/文档项目)。 +""" +import sys +import os +import json +import subprocess +import re + + +def _detect_in(project_dir): + """单目录探测(原 detect_runner 主体,P0-4 抽出复用)。""" + # Node.js / Jest + # Node.js / Jest + pkg = os.path.join(project_dir, "package.json") + if os.path.exists(pkg): + try: + with open(pkg) as f: + data = json.load(f) + deps = {} + deps.update(data.get("dependencies", {})) + deps.update(data.get("devDependencies", {})) + if "jest" in deps: + return ("jest", ["npx", "jest", "--json", + "--outputFile=.regress/.jest-result.json", + "--coverage", "--coverageReporters=json-summary", + "--coverageDirectory=.regress/.coverage", + "--silent", "--passWithNoTests"]) + if "vitest" in deps: + return ("vitest", ["npx", "vitest", "run", "--reporter=json"]) + except (json.JSONDecodeError, OSError): + pass # package.json 损坏 → 跳过 Node.js 探测 + # 有 package.json 但没 jest/vitest → 看 test script + try: + with open(pkg) as f: + data = json.load(f) + test_script = data.get("scripts", {}).get("test", "") + if test_script and "no test" not in test_script.lower(): + return ("npm-test", ["npm", "test", "--", "--passWithNoTests"]) + except (json.JSONDecodeError, OSError): + pass + + # Python / pytest + for marker in ("pytest.ini", "conftest.py", "setup.cfg", "pyproject.toml"): + if os.path.exists(os.path.join(project_dir, marker)): + return ("pytest", ["python3", "-m", "pytest", "-q", "--tb=line"]) + + # Java / Maven + if os.path.exists(os.path.join(project_dir, "pom.xml")): + return ("maven", ["mvn", "test", "-q"]) + + # Java / Gradle + if os.path.exists(os.path.join(project_dir, "build.gradle")) or \ + os.path.exists(os.path.join(project_dir, "build.gradle.kts")): + return ("gradle", ["./gradlew", "test", "--quiet"]) + + # Go + if os.path.exists(os.path.join(project_dir, "go.mod")): + return ("go", ["go", "test", "./..."]) + + return (None, None) + + +def detect_runner(project_dir): + """探测项目的测试运行器。返回 (runner_name, test_cmd) 或 (None, None)。 + + 向后兼容 2 元组(既有调用方/测试);嵌套仓探测与 cwd 判定走 _detect。""" + runner, cmd, _cwd = _detect(project_dir) + return runner, cmd + + +# 宿主身份变量(评审批次一三连标本的源头收口):门禁在钩子 env 里跑测试, +# CLAUDE/ZCODE 的会话号与项目目录泄漏进测试子进程——测试本就不该感知宿主身份 +# (实测三族泄漏:会话名文件错配 / 项目目录抢跑 / 钩子链 env 污染)。 +# 剥这四个,其余照常继承。 +_HOST_IDENTITY_VARS = ("CLAUDE_SESSION_ID", "ZCODE_SESSION_ID", + "CLAUDE_PROJECT_DIR", "ZCODE_PROJECT_DIR") + + +def hermetic_env(): + env = dict(os.environ) + for k in _HOST_IDENTITY_VARS: + env.pop(k, None) + return env + + +_SKIP_DIRS = {".git", ".regress", "node_modules", "venv", ".venv", + "__pycache__", "docs", "dist", "build"} + + +def _looks_like_tests(d): + """子目录像不像测试现场(P0-4 顾问补强:marker 必须与测试文件同时在场, + 防误扫依赖/样例目录选错 cwd)。""" + if os.path.isdir(os.path.join(d, "tests")): + return True + try: + return any(f.startswith("test_") or f.endswith("_test.py") + or f == "conftest.py" for f in os.listdir(d)) + except (IOError, OSError): + return False + + +def _detect(project_dir): + """探测(runner, cmd, cwd):project_dir 优先;找不到再扫一层子目录 + (嵌套仓布局:.regress 在工作区根、代码+pytest.ini 在子仓——评审批次一 + P0-4,治"17 份清单 0 份 hook 标 done"的根因)。子目录命中需 marker 与 + 测试文件同在。""" + runner, cmd = _detect_in(project_dir) + if runner: + return runner, cmd, project_dir + try: + subs = sorted(os.listdir(project_dir)) + except (IOError, OSError): + subs = [] + for name in subs: + sub = os.path.join(project_dir, name) + if not os.path.isdir(sub) or name in _SKIP_DIRS or name.startswith("."): + continue + runner, cmd = _detect_in(sub) + if runner and _looks_like_tests(sub): + return runner, cmd, sub + return None, None, project_dir + + +def _timeout_for(project_dir): + """超时配置钮(v1.69,058-F4):项目 .regress/config.json 的 + test_runner.timeout 可调(秒);缺省 120。套件规模增长越过硬编码 + 窗口时门禁会误杀(本批活体:458 例 155s vs 120s 窗口 0 通过超时)。""" + try: + with open(os.path.join(project_dir, ".regress", "config.json"), + encoding="utf-8") as f: + v = (json.load(f).get("test_runner") or {}).get("timeout") + return int(v) if v else 120 + except Exception: + return 120 + + +def run_tests(project_dir, timeout=None): + """运行测试,返回结果 dict。 + + Returns: + { + "runner": "jest" | "pytest" | ... | "none", + "status": "pass" | "fail" | "skip", + "total": int, "passed": int, "failed": int, + "duration_ms": int, + "failures": [{"test": str, "message": str}], + "raw_snippet": str # 失败时的输出片段 + } + """ + if timeout is None: + timeout = _timeout_for(project_dir) + runner, cmd, rcwd = _detect(project_dir) + + if runner is None: + return { + "runner": "none", + "status": "skip", + "total": 0, "passed": 0, "failed": 0, + "duration_ms": 0, + "failures": [], + "raw_snippet": "No test runner detected (no jest/pytest/maven/go.mod found)" + } + + try: + proc = subprocess.run( + cmd, + capture_output=True, text=True, + cwd=rcwd, timeout=timeout, + env=hermetic_env(), + ) + output = proc.stdout + proc.stderr + exit_code = proc.returncode + except subprocess.TimeoutExpired: + return { + "runner": runner, "status": "fail", + "total": 0, "passed": 0, "failed": 0, + "duration_ms": timeout * 1000, + "failures": [{"test": "(timeout)", "message": f"Tests timed out after {timeout}s"}], + "raw_snippet": "" + } + except FileNotFoundError: + return { + "runner": runner, "status": "skip", + "total": 0, "passed": 0, "failed": 0, + "duration_ms": 0, + "failures": [], + "raw_snippet": f"{cmd[0]} not found in PATH" + } + + # 解析结果 + if runner == "jest": + return _parse_jest(output, exit_code, project_dir) + elif runner == "pytest": + return _parse_pytest(output, exit_code) + else: + # mvn/gradle/go:靠 exit code 判断,不精细解析 + return { + "runner": runner, + "status": "pass" if exit_code == 0 else "fail", + "total": 0, "passed": 0, "failed": 0, + "duration_ms": 0, + "failures": [] if exit_code == 0 else [{"test": "(unknown)", "message": output[-300:]}], + "raw_snippet": output[-200:] if exit_code != 0 else "" + } + + +def _read_jest_coverage(project_dir): + """读取 jest coverage json-summary 的全量行覆盖率(无配置时返回 None)。""" + summary = os.path.join(project_dir, ".regress", ".coverage", "coverage-summary.json") + try: + with open(summary) as f: + data = json.load(f) + total = data.get("total", {}).get("lines", {}).get("pct") + return round(total) if isinstance(total, (int, float)) else None + except (IOError, json.JSONDecodeError, KeyError): + return None + + +def _parse_jest(output, exit_code, project_dir=None): + """解析 jest --json 输出。优先读 --outputFile 文件。""" + data = None + # 优先读 outputFile(避免 stdout 混入诊断信息) + # jest 以 cwd=project_dir 运行,文件写在 project_dir/.regress/ 下 + base = project_dir or os.getcwd() + jest_file = os.path.join(base, ".regress", ".jest-result.json") + if os.path.exists(jest_file): + try: + with open(jest_file) as f: + data = json.load(f) + except (json.JSONDecodeError, IOError): + data = None + finally: + try: os.remove(jest_file) + except OSError: pass + + # fallback:从 stdout 提取 + if not data: + matches = list(re.finditer(r'\{.*\}', output, re.DOTALL)) + for m in reversed(matches): + try: + data = json.loads(m.group()) + break + except json.JSONDecodeError: + continue + + if data and "testResults" in data: + suites = data.get("testResults", []) + total = sum(len(s.get("assertionResults", [])) for s in suites) + passed = sum( + 1 for s in suites for a in s.get("assertionResults", []) + if a.get("status") == "passed" + ) + failed = sum( + 1 for s in suites for a in s.get("assertionResults", []) + if a.get("status") == "failed" + ) + failures = [ + { + "test": a.get("fullName", ""), + "message": (a.get("failureMessages", [""]) or [""])[0][:200] + } + for s in suites for a in s.get("assertionResults", []) + if a.get("status") == "failed" + ] + return { + "runner": "jest", + "status": "pass" if failed == 0 and exit_code == 0 else "fail", + "total": total, "passed": passed, "failed": failed, + "duration_ms": 0, + "failures": failures[:20], + "raw_snippet": "", + "coverage_pct": _read_jest_coverage(base), + } + + # 完全无法解析 → 靠 exit code(覆盖率独立于测试数解析,仍尝试读取) + return { + "runner": "jest", + "status": "pass" if exit_code == 0 else "fail", + "total": 0, "passed": 0, "failed": 0, + "duration_ms": 0, + "coverage_pct": _read_jest_coverage(base), + "failures": [] if exit_code == 0 else [{"test": "(parse error)", "message": output[-200:]}], + "raw_snippet": output[-200:] + } + + +def _parse_pytest(output, exit_code): + """解析 pytest 输出。""" + # pytest 末尾通常有:===== 3 passed in 0.12s ===== + match = re.search( + r'(\d+) passed(?:.*?(\d+) failed)?(?:.*?(\d+) error)?', + output + ) + passed = failed = errors = 0 + if match: + passed = int(match.group(1)) + failed = int(match.group(2) or 0) + errors = int(match.group(3) or 0) + + if exit_code == 0 and failed == 0 and errors == 0: + return { + "runner": "pytest", "status": "pass", + "total": passed, "passed": passed, "failed": 0, + "duration_ms": 0, "failures": [], "raw_snippet": "" + } + + # 失败时提取失败用例 + failures = [] + for line in output.split("\n"): + if "FAILED" in line: + failures.append({"test": line.strip()[:200], "message": ""}) + + return { + "runner": "pytest", "status": "fail", + "total": passed + failed + errors, + "passed": passed, "failed": failed + errors, + "duration_ms": 0, + "failures": failures[:20] if failures else [{"test": "(unknown)", "message": output[-200:]}], + "raw_snippet": output[-300:] + } + + +if __name__ == "__main__": + # CLI 用法:python3 test_runner.py [project_dir] + project_dir = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() + result = run_tests(project_dir) + print(json.dumps(result, ensure_ascii=False, indent=2)) + sys.exit(0 if result["status"] in ("pass", "skip") else 1) diff --git a/plugins/regress-guard/hooks/scripts/lib/wecom_notify.py b/plugins/regress-guard/hooks/scripts/lib/wecom_notify.py new file mode 100644 index 0000000..a115a2d --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/lib/wecom_notify.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""wecom_notify — 企业微信自建应用消息推送(v1.30 人类介入通知·体验最优通道)。 + +为什么是企业微信(用户令:体验最优):Android 上腾讯系推送不被厂商电池策略 +虐待;自建应用免费无限量;不占用业务服务号(运维流量与业务资产隔离)。 +若"微信插件"路线可用,消息可直达微信主 APP(配置后实测即知)。 + +配置(.regress/config.json): + "notify": { "wecom": { "corpid": "ww...", "secret": "...", + "agentid": 1000002, "touser": "@all", + "proxy": "http://user:pass@ip:port" } } + ——有 wecom 凭据时 notify() 自动把它插为第一通道(手机优先,机内声音/桌面次之)。 + proxy 可选:出口走固定 IP 中转(企业可信IP 白名单的机器,家宽动态 IP 场景)。 + +机制:access_token 缓存 $WECOM_TOKEN_DIR(默认 /tmp,key=cropid+secret 哈希), +过期前 300s 刷新;markdown 消息。best-effort:失败只 stderr、exit 0/1,不抛异常 +(通知是增强不是依赖)。可测性:WECOM_API_BASE 指向桩服务器。 + +用法:wecom_notify.py <body> +""" +import hashlib +import json +import os +import sys +import time +import urllib.parse +import urllib.request + + +def _conf(project_dir): + """env 优先(v1.31.4):notify 层把两层合并后的 wecom 块经 + RG_NOTIFY_WECOM_JSON 传入——机器级回退对子进程才生效;无 env 回落项目文件。""" + raw = os.environ.get("RG_NOTIFY_WECOM_JSON") + if raw: + try: + c = json.loads(raw) + return c if isinstance(c, dict) else {} + except json.JSONDecodeError: + pass + try: + with open(os.path.join(project_dir, ".regress", "config.json"), + encoding="utf-8") as f: + cfg = json.load(f) + return (cfg.get("notify") or {}).get("wecom") or {} + except (IOError, OSError, json.JSONDecodeError): + return {} + + +def _token_path(c): + h = hashlib.sha1((str(c.get("corpid", "")) + str(c.get("secret", ""))).encode()).hexdigest()[:10] + return os.path.join(os.environ.get("WECOM_TOKEN_DIR", "/tmp"), f"wecom_token_{h}.json") + + +def _opener(c): + """有 proxy 配置时走固定 IP 中转(可信 IP 白名单),否则直连。""" + proxy = c.get("proxy") + if proxy: + return urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy, "https": proxy})) + return urllib.request.build_opener() + + +def get_token(c, api): + p = _token_path(c) + now = time.time() + try: + with open(p, encoding="utf-8") as f: + t = json.load(f) + if t.get("expires_at", 0) > now + 300: + return t["access_token"] + except (IOError, OSError, json.JSONDecodeError): + pass + q = urllib.parse.urlencode({"corpid": c["corpid"], "corpsecret": c["secret"]}) + with _opener(c).open(f"{api}/gettoken?{q}", timeout=4) as r: # P1#8:内层预算 4s×2,父进程 12s 兜住 + d = json.load(r) + if d.get("errcode"): + raise RuntimeError(f"gettoken {d.get('errcode')}: {d.get('errmsg')}") + tok = d["access_token"] + try: + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + json.dump({"access_token": tok, + "expires_at": now + int(d.get("expires_in", 7200))}, f) + except (IOError, OSError): + pass + return tok + + +def push(c, title, body, api): + tok = get_token(c, api) + payload = {"touser": c.get("touser", "@all"), "msgtype": "markdown", + "agentid": int(c["agentid"]), + "markdown": {"content": f"**{title}**\n{body}"}} + req = urllib.request.Request( + f"{api}/message/send?access_token={tok}", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}) + with _opener(c).open(req, timeout=4) as r: # P1#8 + d = json.load(r) + # 发送台账(v1.32.6):所有推送的唯一咽喉——不依赖调用方日志习惯, + # 钩子环境 TMPDIR 漂移也不失明(2026-09-05 22:19 推送送达但调用方零痕迹的盲区补口)。 + # WECOM_API_BASE 指向桩(测试环境)时不记,防 pytest 噪音污染台账。 + # v1.34:event= 维度(notify 层经 RG_NOTIFY_EVENT 注入)+ RG_SEND_LEDGER 可重定向(测试)。 + if not os.environ.get("WECOM_API_BASE"): + try: + import time as _t + ledger = os.path.expanduser( + os.environ.get("RG_SEND_LEDGER") or "~/.zcode/wecom-send.log") + with open(ledger, "a") as f: + f.write(f"{_t.strftime('%m-%d %H:%M:%S')} errcode={d.get('errcode')} " + f"event={os.environ.get('RG_NOTIFY_EVENT') or '-'} " + f"agent={c.get('agentid')} {title[:50]}\n") + except Exception: + pass + if d.get("errcode"): + raise RuntimeError(f"send {d.get('errcode')}: {d.get('errmsg')}") + + +def _api_base(wecom_conf): + """API 基域钉住(v1.70,059):凭据会被 POST 到该域,重定向=凭据外泄面 + (顾问字段级裁定)——WECOM_API_BASE 仅许本机测试桩(127.0.0.1/localhost/ + ::1)或机器配置 wecom.api_base_allowlist 条目(URL 或 host 精确匹配; + allowlist 键本身在 notify.load_conf 属凭据类,未受信项目注入无效)。""" + env = os.environ.get("WECOM_API_BASE") + default = "https://qyapi.weixin.qq.com/cgi-bin" + if not env: + return default + try: + import urllib.parse + host = urllib.parse.urlparse(env).hostname or "" + except ValueError: + host = "" + allow = set(wecom_conf.get("api_base_allowlist") or []) + if host in ("127.0.0.1", "localhost", "::1") or env in allow or host in allow: + return env + print(f"wecom_notify: WECOM_API_BASE={env} 非本机且未在 allowlist," + "已钉回官方域(凭据外泄防护)", file=sys.stderr) + return default + + +def main(argv=None): + args = list(sys.argv[1:] if argv is None else argv) + if len(args) < 3: + print("用法: wecom_notify.py <project_dir> <title> <body>", file=sys.stderr) + return 1 + project_dir, title, body = args[0], args[1], args[2] + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from journal import _find_project_dir + pd = _find_project_dir(project_dir) or project_dir + c = _conf(pd) + if not (c.get("corpid") and c.get("secret") and c.get("agentid")): + print("wecom_notify: notify.wecom 未配置(corpid/secret/agentid)", file=sys.stderr) + return 1 + api = _api_base(c) + try: + push(c, title, body, api) + except Exception as e: # best-effort:不炸调用方 + print(f"wecom_notify: 推送失败(忽略): {e}", file=sys.stderr) + return 2 # 失败不计入 notify 的 ran 计数(rc=0 才算通道跑通) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/plan_bridge.py b/plugins/regress-guard/hooks/scripts/plan_bridge.py new file mode 100644 index 0000000..66e702a --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/plan_bridge.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""plan_bridge — 原生计划模式桥(v1.39:批准点对齐,单钩子原子)。 + +ZCode 原生计划模式的批准不产生任何产物——无清单、无边界、无门禁覆盖 +(无活跃清单时 fail-open)。本桥把 ExitPlanMode 的批准时刻转录成治理清单: + + ExitPlanMode(计划原文) ──批准──→ PostToolUse → plan_bridge.py post: + 转录+盖章原子完成(approved+session+via:native) + ──拒绝──→ PostToolUseFailure → plan_bridge.py fail: + 零清单残留 + design_rejected 化石(带摘录可考古) + +设计取舍(REGRESS-2026-028,2026-09-16 原生计划模式批准): +- 单钩子原子:不用 Pre+Post 两段式(避免 planning 悬空与盖章竞态) +- 幂等键 = session + plan_hash(顾问修正采纳):同 session 活跃 via:native 清单 + 且 hash 不变 → 完全 no-op;hash 变 → 视为计划修订,重写正文并落 plan_refined +- 双轨合一:同 session 已有 /regress:plan 的 planning 清单 → 直接盖章它 + (两条批准通道,产物层只有一份) +- 顾问预审豁免有据:原生批准时人刚逐字读完计划=意图裁决(责任矩阵最高裁决, + 顾问否决权为保护不在场者) +- 双重防御:post 模式下 tool_response 带拒绝/错误特征也按拒绝处理(拒绝载荷 + 语义未证——failure 事件与 response 特征双保险) +- 边界尽力提取(cap 12)+ F3/track 扩界留痕兜底;提取不到留空并在脆弱点声明 +- stdout 静默:additionalContext 对 PostToolUse 是否合法未证,v1 不冒验 + schema 险(观察项) +- best-effort:任何异常 exit 0(桥是增强不是依赖);REGRESS_PLAN_BRIDGE=off 关闭 +- 已知边界:编号自推无锁,两会话同时首建可能撞号(罕见,哨兵可点名孤儿) +""" +import hashlib +import json +import os +import re +import sys + +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +try: + from journal import journal_append # noqa: E402 + from session_relay import sid_from_env # noqa: E402 +except ImportError: # lib 不在(未安装/半升级)——桥是增强不是依赖 + journal_append = None + sid_from_env = None + +ACTIVE = ("planning", "in-progress", "verifying", "blocked") +_PATH_TICK = re.compile(r"`([^`\n]{1,120})`") +_PATH_BARE = re.compile(r"(?<![\w./~-])((?:[\w-]+/)+[\w.-]+\.[A-Za-z0-9]{1,6})") +_ID_Y = re.compile(r"^(REGRESS-\d{4}-)(\d+)$") +_ID_N = re.compile(r"^(REGRESS-)(\d+)$") + + +def find_project_dir(): + """三段式定位(与 boundary_guard 同惯例):PROJECT_DIR env → cwd → 向上找 .regress。""" + start = (os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd()) + d = os.path.abspath(start) + for _ in range(10): + if os.path.isdir(os.path.join(d, ".regress")): + return d + nxt = os.path.dirname(d) + if nxt == d: + break + d = nxt + return None + + +def _read_fm(path): + try: + with open(path, encoding="utf-8", errors="replace") as f: + content = f.read() + except OSError: + return None, "" + parts = content.split("---", 2) + if len(parts) < 3: + return None, content + return parts[1], content + + +def _fm_get(fm_text, key): + m = re.search(rf"^{key}:\s*[\"']?(.+?)[\"']?\s*$", fm_text, re.M) + return m.group(1).strip() if m else "" + + +def allocate_and_write(manifests_dir, title, content_fn): + """编号分配临界区(v1.56,五标本之一机器收口):扫号+落盘同锁。 + + 两桥并发批准时 flock 串行化临界区,不再撞号;AI 手写清单路径不经此函数 + (纪律位:plan.md 选号前 ls)。content_fn(mid, stem) → 清单全文。 + """ + from filelock import file_lock + with file_lock(os.path.join(manifests_dir, "id-alloc")): + mid, stem = next_id_and_name(manifests_dir, title) + path = os.path.join(manifests_dir, stem + ".md") + with open(path, "w", encoding="utf-8") as f: + f.write(content_fn(mid, stem)) + return mid, path + + +def next_id_and_name(manifests_dir, title): + """扫现有清单自推编号,保持项目既有格式(REGRESS-YYYY-NNN / REGRESS-NNN)。""" + import glob + ids = [] + for p in glob.glob(os.path.join(manifests_dir, "*.md")): + fm, _ = _read_fm(p) + mid = _fm_get(fm or "", "id") + if mid: + ids.append(mid) + year_fmt = [i for i in ids if _ID_Y.match(i)] + if year_fmt: + m = max((_ID_Y.match(i) for i in year_fmt), key=lambda x: int(x.group(2))) + prefix, n = m.group(1), int(m.group(2)) + 1 + elif ids and all(_ID_N.match(i) for i in ids): + n = max(int(_ID_N.match(i).group(2)) for i in ids) + 1 + prefix = "REGRESS-" + else: + prefix, n = "REGRESS-", 1 + mid = f"{prefix}{n:03d}" + stem = f"{n:03d}-native-plan" + if prefix != "REGRESS-": + stem = f"{prefix.split('-')[1]}-{n:03d}-native-plan" + return mid, stem + + +def extract_paths(plan_text, cap=12): + """从计划文本尽力提取文件路径(反引号段优先,裸路径样 token 兜底)。 + + 过滤:先剥 URL(裸路径正则会把 x.y/a/b.py 从 https:// 里抠出来), + 不含 / 的丢弃(纯文件名无边界价值)、含空白丢弃。""" + seen, out = set(), [] + text = re.sub(r"https?://\S+", " ", plan_text) + cands = [m for m in _PATH_TICK.findall(text)] + cands += [m for m in _PATH_BARE.findall(text)] + for c in cands: + c = c.strip().strip("`.,;:()[]") + if not c or " " in c or "/" not in c or c.startswith("http"): + continue + if c not in seen: + seen.add(c) + out.append(c) + if len(out) >= cap: + break + return out + + +def plan_title(plan_text): + for line in plan_text.splitlines(): + s = line.strip().lstrip("#").strip() + if s: + return s[:60] + return "原生计划(无标题)" + + +def looks_rejected(data): + """拒绝载荷双保险之一:成功的 PostToolUse 也可能内含拒绝语义。""" + tr = data.get("tool_response") or data.get("tool_result") + if isinstance(tr, dict): + err = tr.get("error") or tr.get("isError") + if err: + return True + if isinstance(tr, str) and re.search(r"reject|denied|拒绝", tr, re.I): + return True + return False + + +def _ensure_field(content, key, value): + """frontmatter 无则插(status 行后),有则原样——盖章既有清单时补 via/plan_hash。""" + if re.search(rf"^{key}:", content, re.M): + return content + return re.sub(r"^(status:\s*\S+.*)$", + rf"\1\n{key}: {value}", content, count=1, flags=re.M) + + +def _build_manifest(mid, title, sid, plan, paths, at): + sha = hashlib.sha1(plan.encode("utf-8", "ignore")).hexdigest()[:12] + if paths: + planned = "\n".join( + f' - id: F{i + 1}\n file: "{p}"\n type: from-plan' + for i, p in enumerate(paths)) + bnote = f"边界由计划文本尽力提取({len(paths)} 文件,cap 12)" + else: + planned = "[]" + bnote = "计划文本未提取到路径——边界为空,一切靠 F3/track 扩界留痕兜底" + body = f"""--- +id: {mid} +requirement: "{title}" +status: in-progress +session: {sid} +tier: S +via: native-plan-bridge +plan_hash: {sha} +approved: + at: "{at}" + note: "原生计划模式批准转录(人在环即最高裁决,顾问预审豁免)" +created_at: {at[:10]} +planned_changes: +{planned} +actual_changes: [] +test_results: {{}} +--- + +# 原生计划转录:{title} + +> 以下为 ExitPlanMode 批准原文(证据律:验收判据以此为准,不改写)。 + +{plan} + +## 脆弱点 + +### FP1: 原生转译边界尽力提取(kind: machine) +{bnote}。计划没料到的文件由 F3/track 回写(扩界留痕)兜底。 +verify: 提交时门禁自跑测试全绿;F3 回写后边界闭合。 +""" + return body + + +def _active_by_session(manifests_dir, sid): + """同 session 的活跃清单(最新的优先):返回 (via_native 的, planning 的)。""" + import glob + native, planning = None, None + for p in sorted(glob.glob(os.path.join(manifests_dir, "*.md")), reverse=True): + fm, content = _read_fm(p) + if not fm: + continue + status = _fm_get(fm, "status") + if status not in ACTIVE: + continue + if _fm_get(fm, "session") != sid: + continue + if _fm_get(fm, "via") == "native-plan-bridge": + native = native or (p, fm, content) + elif status == "planning": + planning = planning or (p, fm, content) + if native and planning: + break + return native, planning + + +def _receipt(mid, n=0): + """additionalContext 回执(v1.52 试验位 → v1.79 转默认开)。 + + 契约已源码证实(2026-09-20 ZCode 3.14.0 开源仓考证):PostToolUse 的 + additionalContext 经 call-runner appendHookAdditionalContexts 拼进工具 + 结果尾;hookSpecificOutput.hookEventName 须严格匹配事件名(本实现合规); + stdout 以 { 开头才被解析且须唯一 JSON 输出(本文件唯一 stdout print 即此)。 + 逃生:RG_PLAN_BRIDGE_RECEIPT=off/0/false。""" + if os.environ.get("RG_PLAN_BRIDGE_RECEIPT", "").lower() in ("off", "0", "false"): + return + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": f"📋 原生计划已转录为清单 {mid}" + f"(via:native-plan-bridge,边界尽力提取 {n} 文件)" + f"——边界守卫与提交门禁已对该任务激活", + }}, ensure_ascii=False)) + + +def do_post(project_dir, data, sid): + import datetime + manifests_dir = os.path.join(project_dir, ".regress", "manifests") + os.makedirs(manifests_dir, exist_ok=True) + plan = (data.get("tool_input") or {}).get("plan") or "" + if not plan: + return + if looks_rejected(data): # 双保险:成功事件内含拒绝语义 → 按拒绝处理 + _fossil(project_dir, plan, sid) + return + title = plan_title(plan) + sha = hashlib.sha1(plan.encode("utf-8", "ignore")).hexdigest()[:12] + at = datetime.datetime.now().isoformat(timespec="seconds") + native, planning = _active_by_session(manifests_dir, sid) + if native: + path, fm, _ = native + if _fm_get(fm, "plan_hash") == sha: # 幂等键命中:重复事件 no-op + return + paths = extract_paths(plan) + mid = _fm_get(fm, "id") + content = _build_manifest(mid, title, sid, plan, paths, at) + with open(path, "w", encoding="utf-8") as f: + f.write(content) # 计划修订:整档重写,approved 保留原批准时刻语义由重建承担 + if journal_append: + journal_append("plan_refined", start_dir=project_dir, + manifest_id=mid, via="native-plan-bridge", + plan_hash=sha) + _receipt(mid, len(paths)) + return + if planning: + path, fm, content = planning + mid = _fm_get(fm, "id") + try: # 复用批准盖章三件套(plan_approve 单一来源) + from plan_approve import _apply, _stamp_session + content = _apply(content, "in-progress", at, + "原生计划模式批准(人在环即最高裁决,顾问预审豁免)") + content = _stamp_session(content, os.path.dirname(path)) + except ImportError: + content = re.sub(r"^status:\s*planning\s*$", "status: in-progress", + content, count=1, flags=re.M) + content = _ensure_field(content, "via", "native-plan-bridge") + content = _ensure_field(content, "plan_hash", sha) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + if journal_append: + journal_append("plan_approved", start_dir=project_dir, + manifest_id=mid, via="native-plan-bridge", + plan_hash=sha, note="dual-track: 盖章既有 planning 清单") + _receipt(mid) + return + paths = extract_paths(plan) + mid, _ = allocate_and_write( + manifests_dir, title, + lambda m, s: _build_manifest(m, title, sid, plan, paths, at)) + if journal_append: + journal_append("plan_approved", start_dir=project_dir, manifest_id=mid, + via="native-plan-bridge", plan_hash=sha) + _receipt(mid, len(paths)) + + +def _fossil(project_dir, plan, sid, kind="design_rejected"): + if not journal_append: + return + excerpt = plan.strip().replace("\n", " ")[:200] + journal_append(kind, start_dir=project_dir, via="native-plan-bridge", + session=sid, title=plan_title(plan), excerpt=excerpt) + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + mode = argv[0] if argv else "post" + if os.environ.get("REGRESS_PLAN_BRIDGE", "").lower() in ("off", "0", "false"): + return 0 + try: + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + data = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + data = {} + if "PlanMode" not in str(data.get("tool_name", "")): # matcher 已滤,防御 + return 0 + project_dir = find_project_dir() + sid = (sid_from_env() if sid_from_env else None) or "default" + if not project_dir: # 未接入项目:桥不惊动(无 .regress 不建产物) + return 0 + try: + if mode == "fail": + plan = (data.get("tool_input") or {}).get("plan") or "" + if plan: + _fossil(project_dir, plan, sid) + else: + do_post(project_dir, data, sid) + except Exception as e: # best-effort:桥是增强不是依赖 + print(f"plan_bridge: {e}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/hooks/scripts/pre_commit_guard.py b/plugins/regress-guard/hooks/scripts/pre_commit_guard.py new file mode 100755 index 0000000..28a5019 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/pre_commit_guard.py @@ -0,0 +1,1214 @@ +#!/usr/bin/env python3 +"""pre_commit_guard.py — regress-guard PreToolUse hook(跨平台纯 Python 版)。 + +取代 pre_commit_guard.sh。用 type:"process" 的 hook 直接调用,无需 shell。 + +校验顺序(短路): + 1. stdin 解析:是否 git commit?否则 exit 0 + 2. 有 .regress/?否则 exit 0 + 3. bypass 有效?记日志 + exit 0 + 4. 活跃清单按会话归属选择(v1.34):mine=无戳或戳==本会话(hook env); + env 缺失时全部视为 mine(fail-safe 老行为)。他人清单只在本提交 + staged 撞其清单文件时拦(跨会话冲突=集成态检查),否则放行+警示—— + 治 2026-09-07 标本1(被他人 in-progress 清单挡住干等) + 5. staged 文件都在清单内?否则 exit 2 + 6. 自己跑测试:pass→exit0+标done / fail→exit2 / skip→降级检查status + (5.5 V门禁:open 禁提交;5.6 证据律:locked 门禁复验 verify 命令, + human_check: 前缀验化石存在性不复跑感官) + +退出码:0=放行,2=阻断 +""" +import sys +import os +import re +import json +import getpass +import subprocess +import traceback +from datetime import datetime + +# ─── 定位 lib 目录(兼容被 process hook 调用时的各种 CWD)───────── +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LIB_DIR = os.path.join(SCRIPT_DIR, "lib") +if LIB_DIR not in sys.path: + sys.path.insert(0, LIB_DIR) + +from manifest_parser import find_active_manifest, get_all_changed_files, get_manifest_status, update_frontmatter, get_fragile_points, parse_frontmatter # noqa: E402 +from git_diff_analyzer import get_staged_files, filter_files, find_untracked_changes # noqa: E402 +from test_runner import run_tests # noqa: E402 +from history import record as _history_record # noqa: E402 + + +def _running_version(): + """门禁自身版本(v1.59):044 病例——活体门禁是旧已装副本时无从查证。 + + 源仓布局读 plugin.json(本文件在 hooks/scripts/ 下,上三级即插件根); + 已装布局无 plugin.json → 读 install.sh 落的 .source 戳(source_version=)。 + """ + try: + pj = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))), ".zcode-plugin", "plugin.json") + with open(pj, encoding="utf-8") as f: + return str(json.load(f).get("version") or "unknown") + except Exception: + pass + try: + meta = os.path.join(os.path.expanduser("~/.zcode"), + "regress-guard-hooks", ".source") + with open(meta, encoding="utf-8") as f: + for line in f: + if line.startswith("source_version="): + return line.split("=", 1)[1].strip() or "unknown" + except Exception: + pass + return "unknown" + + +def record(rd, event, manifest_id="", **details): + """一点包装(v1.59):所有事件盖 guard_version——谁在把关,事后可查。""" + details.setdefault("guard_version", _running_version()) + _history_record(rd, event, manifest_id, **details) + + +def emit_pass(): + sys.exit(0) + +# blocked 推送的上下文(P1#5):main 定位项目/清单后回填——record 只留本机痕, +# 手机才是人所在的屏。评审病例:history 6 个 blocked 事件期间 wecom 台账 0 条 +_NOTIFY_STATE = {"project_dir": None, "manifest_id": ""} + +# 拦截现场召回(v1.54):拦截给"新问题的新信息",召回附"老问题的老答案"。 +# 只接高频3点——stderr 越长越被忽略(顾问),扩展留给字段数据说话 +_RECALL_REASONS = ("test_failed", "fragile_verify_failed", "scan_missing") + + +def _recall_hint(reason_key, msg): + """召回段(v1.54 稠密反馈:骨架库从写多读少接到失败现场)。 + + 只进 stderr(Agent 必读),不进手机推送(首行是给人的摘要); + RG_RECALL=off 一键关;任何异常静默降级——召回是增强不是依赖。 + 开火记 rule_recall 事件(量测位:先计数,有没有用等字段说话)。 + """ + if reason_key not in _RECALL_REASONS or os.environ.get("RG_RECALL") == "off": + return "" + pd = _NOTIFY_STATE.get("project_dir") + if not pd: + return "" + try: + from rules_ledger import match + res = match(pd, f"{reason_key} {msg[:300]}", top=3) + if not res: + return "" + try: + record(os.path.join(pd, ".regress"), "rule_recall", + _NOTIFY_STATE.get("manifest_id") or "?", + reason=reason_key, n=len(res), + sigs=[r["sig"][:60] for r in res]) + except Exception: + pass + lines = ["\n📚 相关历史规律(骨架库召回——提示不是行动,采纳前对照本次现场):"] + lines += [f" {i}. 「{r['sig'][:70]}」 命中×{r['hits']}(最近 {r['last_hit']})" + for i, r in enumerate(res, 1)] + return "\n".join(lines) + except Exception: + return "" + + +# 验收行"已勾"标记(v1.55 验收入环): +# EARS 列表行 → 行尾标记(✅/已验/(过)/通过),行内散文不误判; +# 表格行 → 状态列(末列)行尾标记或含 pass/done/ok/locked/✔(短单元格低散文风险) +_ACC_END_MARKS = ("✅", "已验", "(过)", "通过") +_ACC_CELL_KEYWORDS = ("pass", "done", "ok", "locked", "✔") + + +def _acceptance_state(manifest_path): + """解析验收标准节(v1.55):返回 (present, total, open_rows)。 + + present=False = 无「## 验收标准」节;total = 认出的判据行数(0=节在但没写判据); + open_rows = 未勾判据行的短描述。判据行两种形态:EARS 列表行(单行 + "- When…则…(验:…)" 或两行判据+缩进(验:…)续行——先合并成逻辑行再判) + 与表格行。无(验: 的 EARS 行 = 不完整判据(v1.40 三件齐才算一条)= 未勾; + {{占位}} 行 = 没写 = 未勾;✅ 标记放逻辑行最尾(验命令之后)。 + 解析永不抛错(读不到=不在此处拦)。 + """ + try: + text = open(manifest_path, encoding="utf-8").read() + except Exception: + return True, 1, [] + m = re.search(r"^## 验收标准.*?(?=^## |\Z)", text, re.M | re.S) + if not m: + return False, 0, [] + # 逻辑行合并:列表项的缩进续行拼回同一条判据 + logical, rows = [], [] + for line in m.group(0).splitlines(): + s = line.strip() + if s.startswith("|"): + if logical: + rows.append(" ".join(logical)) + logical = [] + rows.append(s) + elif s.startswith("- "): + if logical: + rows.append(" ".join(logical)) + logical = [s] + elif logical and s and not s.startswith(">") and not s.startswith("#"): + logical.append(s) + elif logical: + rows.append(" ".join(logical)) + logical = [] + if logical: + rows.append(" ".join(logical)) + total, open_rows = 0, [] + for s in rows: + is_row = "When" in s or "(验" in s or "则" in s + if s.startswith("|"): # 表格行(状态列看末列) + cells = [c.strip() for c in s.strip("|").split("|")] + if len(cells) < 2 or cells[0] in ("#", "—") or set(cells[0]) <= {"-", "#", ":", " "}: + continue # 表头/分隔行 + total += 1 + if "{{" in s: + open_rows.append(cells[0][:40] + "(占位未填)") + continue + status = cells[-1] + if not (status.endswith(_ACC_END_MARKS) + or any(k in status.lower() for k in _ACC_CELL_KEYWORDS)): + open_rows.append(cells[0][:40]) + elif is_row: + total += 1 + if "{{" in s: + open_rows.append(s.strip("- ").split("(")[0][:40] + "(占位未填)") + elif not ("(验:" in s and any(m in s for m in _ACC_END_MARKS)): + # v1.73 宽松化:通过标记任意位置计勾(标记本身即人的验证声明, + # 位置无语义——056 两次被 endswith 咬的狗粮);缺(验:命令) + # 仍=未勾(三件齐才算一条,防假勾) + open_rows.append(s.strip("- ")[:40]) + return True, total, open_rows + + +def emit_block(msg, reason_key=""): + if _NOTIFY_STATE["project_dir"] and not os.environ.get("RG_NO_NOTIFY"): + try: + from notify import notify as _notify + _notify(_NOTIFY_STATE["project_dir"], "blocked", + f"⛔ 提交被拦 {_NOTIFY_STATE['manifest_id']}".replace(" ", " "), + msg.split("\n")[0][:100], + source_id=_NOTIFY_STATE["manifest_id"]) # v1.38:合并键/自动回流的 ref + except Exception: + pass # 推送是增强不是依赖 + print(f"REGRESS-GUARD: {msg}{_recall_hint(reason_key, msg)}", file=sys.stderr) + sys.exit(2) + +def emit_warn(msg): + print(f"REGRESS-GUARD (warning): {msg}", file=sys.stderr) + sys.exit(0) + + +def is_git_commit(tool_input_str): + """从 hook 的 stdin JSON 判断是否提交类命令。 + + 覆盖:git commit, git ci, npm version(会触发 commit), + pnpm/pnpm publish, yarn version, cz (commitizen), + husky pre-commit 执行链。 + """ + if not tool_input_str: + return False + try: + data = json.loads(tool_input_str) + ti = data.get("tool_input", data) if isinstance(data, dict) else {} + cmd = ti.get("command", "") if isinstance(ti, dict) else "" + # git commit / git ci + if re.search(r'\bgit\s+commit\b|\bgit\s+ci\b', cmd): + return True + # npm version / npm publish(npm version 会自动 commit) + if re.search(r'\bnpm\s+(version|publish)\b', cmd): + return True + # pnpm publish / yarn version + if re.search(r'\b(pnpm|yarn)\s+(publish|version)\b', cmd): + return True + # commitizen (cz) + if re.search(r'\bcz\b|\bgit-cz\b', cmd): + return True + # husky run hook + if re.search(r'\bhusky\s+run\b', cmd): + return True + return False + except Exception: + return False + + +def _message_from_cmd(tool_input_str): + """从命令串提取 -m 信息(v1.87.1,092)。 + + 引号感知(双/单引号体与裸词);-qm 类组合旗标同样命中。提取不到返回 + 空串(三查对空信息不生效——无 -m 的提交形态走编辑器,门禁层无文本)。""" + try: + data = json.loads(tool_input_str) + ti = data.get("tool_input", data) if isinstance(data, dict) else {} + cmd = ti.get("command", "") if isinstance(ti, dict) else "" + except Exception: + return "" + m = re.search(r'(?:^|\s)-\w*m\w*\s+("([^"]*)"|\'([^\']*)\'|(\S+))', cmd) + if not m: + return "" + return m.group(2) or m.group(3) or m.group(4) or "" + + +def is_compound_stage_commit(tool_input_str): + """同一命令串里既暂存又提交(v1.87,091:077/088 标本机器位收口)。 + + 两个真实代价:①门禁在命令提交瞬间评估暂存区——此刻暂存未执行,归因 + 交集退化为回退(088 活体);②门禁因他规则拦下时暂存被整条吞掉,重试 + 若不重新暂存即丢(077 漏提交实证)。检测前剔除 heredoc 体(复用 + boundary_guard 同源防御)与引号段——载荷文本不算命令(v1.77/1.82 + 标本族教训)。--amend 豁免:它复用既有暂存,不变更暂存状态。""" + try: + data = json.loads(tool_input_str) + ti = data.get("tool_input", data) if isinstance(data, dict) else {} + cmd = ti.get("command", "") if isinstance(ti, dict) else "" + except Exception: + return False + if not cmd: + return False + t = cmd + try: + from boundary_guard import _strip_heredoc_bodies + t = _strip_heredoc_bodies(t) + except Exception: + pass + t = re.sub(r"'[^']*'", "''", t) + t = re.sub(r'"[^"]*"', '""', t) + if re.search(r'\bgit\s+(?:-\S+\s+)*add\b', t): + return True + m = re.search(r'\bgit\s+(?:commit|ci)\s+(-[a-zA-Z-]+)?', t) + if m and m.group(1): + flag = m.group(1) + if (not flag.startswith("--") and "a" in flag) or flag == "--all": + return True + return False + + +def find_regress_dir(): + """从多个来源查找 .regress/ 目录。 + + 查找顺序: + 1. CLAUDE_PROJECT_DIR / ZCODE_PROJECT_DIR(ZCode 传入的工作目录) + 2. git rev-parse --show-toplevel(当前 git 仓库根)→ 看它有没有 .regress/ + 3. 从 git root 向上逐级查找(支持 monorepo:.regress/ 在父目录) + 4. 当前工作目录 + """ + # 收集候选目录 + candidates = [] + env_dir = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + ) + if env_dir: + candidates.append(env_dir) + candidates.append(os.getcwd()) + + # 从 git 获取仓库根 + try: + import subprocess + git_root = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, timeout=5 + ).stdout.strip() + if git_root: + candidates.append(git_root) + # 向上查找(monorepo 场景:.regress/ 在父目录) + parent = os.path.dirname(git_root) + while parent and parent != "/": + candidates.append(parent) + parent = os.path.dirname(parent) + except Exception: + pass + + # 找第一个有 .regress/ 的 + for d in candidates: + regress_dir = os.path.join(d, ".regress") + if os.path.isdir(regress_dir): + return d, regress_dir + + return None, None + + +def _session_id(): + """本钩子进程的会话身份(v1.34 作用域键)。钩子进程带会话 env + (活体证据:/tmp/regress-guard-fails-sess_*.jsonl),Bash 工具进程不带—— + 缺失返回 "":门禁退回共享语义(fail-safe 老行为),不误锁。""" + return ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "" + ) + + +def _mark_expected(regress_dir, kind): + """放行前写标记,供 git 观测钩子区分提交来源(gated/bypass vs 外部直提)。 + + 观测钩子消费新鲜标记(<5min)后删除;过期标记视为残留,忽略。 + """ + try: + with open(os.path.join(regress_dir, ".expect-commit"), "w", encoding="utf-8") as f: + f.write(f"kind={kind}\n") + except OSError: + pass + + +def _git_head_sha(): + """获取当前 HEAD sha(证据链的产出锚点:本次 commit 基于哪个提交)。""" + try: + import subprocess + return subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=5 + ).stdout.strip()[:12] + except Exception: + return "" + + +def main(): + # ─── 1. 是否 git commit?────────────────────────── + raw_input = sys.stdin.read() if not sys.stdin.isatty() else "" + if not is_git_commit(raw_input): + emit_pass() + + # ─── 2. 定位项目 + .regress/ ────────────────────── + project_dir, regress_dir = find_regress_dir() + if not regress_dir: + emit_pass() # 未接入的项目(找不到 .regress/) + _NOTIFY_STATE["project_dir"] = project_dir # blocked 推送上下文(P1#5) + + # ─── 2.5 复合暂存+提交形态(v1.87,091)────────── + if is_compound_stage_commit(raw_input): + record(regress_dir, "commit_blocked", "", + reason="compound_stage_commit") + emit_block( + "提交被拦:**同一命令串里既暂存又提交**(复合形态)。\n\n" + "两个真实代价(077/088 标本):\n" + "· 门禁在命令提交瞬间评估暂存区——此刻暂存未执行,归因交集退化为回退;\n" + "· 门禁因其他规则拦下时,暂存被整条命令吞掉,重试若不重新暂存会丢\n" + " (077:3 个测试用例漏提交一周后才被发现)。\n\n" + "正确姿势(分立三步,各自独立调用):\n" + " 1. 暂存(git 加路径的命令)\n" + " 2. git status --short 核对暂存内容\n" + " 3. 提交命令\n\n" + "确要复合(如一次性脚本):/regress:bypass <分钟>(限时赦免,赦后记债)。" + ) + + # 读配置 — fail-safe:config 损坏时用最严格默认(阻断) + config = {} + config_file = os.path.join(regress_dir, "config.json") + if os.path.exists(config_file): + try: + with open(config_file, encoding="utf-8") as f: + config = json.load(f) + except (json.JSONDecodeError, OSError) as e: + # 配置文件损坏 → 不能猜配置,fail-safe 阻断 + record(regress_dir, "error", error=f"config.json parse failed: {e}") + emit_block( + f".regress/config.json 解析失败:{e}\n\n" + "配置文件损坏,无法确定 strict/bypass 状态。\n" + "fail-safe 原则:阻断 commit。请修复 config.json 后重试。" + ) + strict = config.get("strict", True) + bypass_until = config.get("bypass_until", "") + + # ─── 3. 检查 bypass ─────────────────────────────── + if bypass_until: + try: + expired = datetime.now() >= datetime.fromisoformat(bypass_until) + except (ValueError, TypeError): + expired = True # 格式坏 = 过期 + + if not expired: + # bypass 有效 → 记审计日志 + 放行 + log_path = os.path.join(regress_dir, "bypass.log") + user = getpass.getuser() if hasattr(getpass, "getuser") else os.environ.get("USER", "unknown") + try: + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"{datetime.now().isoformat()} | bypass commit by {user}\n") + except OSError: + pass # 日志写失败不影响放行(bypass 日志是审计辅助,非关键路径) + record(regress_dir, "bypass_used", "", expires=bypass_until, user=user) + _mark_expected(regress_dir, "bypass") + emit_warn(f"bypass 模式生效(到期: {bypass_until}),已记审计日志。事后请补回归。") + else: + # 过期 → 清除 bypass_until(P2#16:filelock+原子写——无锁读改写会 + # 丢并发会话刚写入的新 bypass,写一半崩溃留下半截 JSON=每次提交 + # 都命中"配置损坏 fail-safe 阻断"的 DoS) + config.pop("bypass_until", None) + try: + from filelock import file_lock + tmp = config_file + ".tmp" + with file_lock(config_file): + with open(tmp, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + os.replace(tmp, config_file) + except OSError: + pass # 清除失败不阻断(下次会再试清除) + + # ─── 4. 查找活跃清单 ────────────────────────────── + # fail-safe:如果 manifest 文件存在但解析失败 → 阻断(而非放行) + manifests_dir = os.path.join(regress_dir, "manifests") + manifest_files_on_disk = sorted( + __import__("glob").glob(os.path.join(manifests_dir, "*.md")), reverse=True + ) if os.path.isdir(manifests_dir) else [] + + manifest = None + manifest_id = "" + my_sid = _session_id() + mine, others = [], [] # (path, id):会话作用域(v1.34) + for mf_path in manifest_files_on_disk: + parsed = parse_frontmatter(mf_path) + if parsed is None: + # 文件存在但无法解析 frontmatter → 格式损坏 + record(regress_dir, "error", error=f"manifest parse failed: {mf_path}") + emit_block( + f"回归清单解析失败:{mf_path}\n\n" + "该文件不是合法的 YAML frontmatter 格式(缺少 --- 包裹或 YAML 语法错误)。\n" + "fail-safe 原则:阻断 commit。请修复清单格式后重试。" + ) + # 语义反转:只有明确活跃 status 才算(开放词表下自造词≠活跃) + if parsed.get("status") in ("planning", "in-progress", "verifying", "blocked"): + entry = (mf_path, parsed.get("id", "")) + m_sid = str(parsed.get("session") or "") + (others if my_sid and m_sid and m_sid != my_sid else mine).append(entry) + + def _others_files(): + """他人活跃清单声明的文件并集(集成态冲突检查的对照面)。""" + files = set() + for p, _oid in others: + files.update(f.replace(os.sep, "/") for f in get_all_changed_files(p)) + return files + + _relevant_subs = None + + def _relevant_subrepos(): + """声明相关性锚定(088 二修):只扫活跃清单(mine+others)声明文件 + 落在的一级子仓——全子仓并集会捞进无关项目的历史暂存(活体:demo-project + 的 src/math.js 拦了 regress-guard 的提交)。声明文件在哪个子仓存在, + 哪个子仓才是本治理现场。""" + nonlocal _relevant_subs + if _relevant_subs is None: + _relevant_subs = [] + decl = set() + for p, _m in mine + others: + decl.update(f.replace(os.sep, "/") + for f in get_all_changed_files(p)) + if decl: + try: + for name in sorted(os.listdir(project_dir)): + sub = os.path.join(project_dir, name) + if (name.startswith(".") or not os.path.isdir(sub) + or not os.path.exists(os.path.join(sub, ".git"))): + continue + if any(os.path.exists(os.path.join(sub, *d.split("/"))) + for d in decl): + _relevant_subs.append(sub) + except Exception: + pass + return _relevant_subs + + def _staged_list(): + """暂存清单(088 拓扑补:并集工作区仓+相关子仓)。 + + .regress/ 是治理数据不参与 F3;嵌套仓拓扑下提交发生在子仓——只读 + 工作区仓暂存恒空(088 活体标本:交集归因两连退回 fallback),故并入 + 相关子仓(声明锚定)的暂存,路径保持子仓相对=清单声明空间。 + F3/跨会话冲突检查同获此修正——它们此前对嵌套仓提交同盲。""" + base = [s for s in filter_files(get_staged_files(project_dir)) + if not s.replace(os.sep, "/").startswith(".regress/")] + for sub in _relevant_subrepos(): + try: + r = subprocess.run( + ["git", "-C", sub, "diff", "--cached", "--name-only"], + capture_output=True, text=True, timeout=10) + if r.returncode == 0: + base += [l.strip().replace(os.sep, "/") + for l in r.stdout.splitlines() + if l.strip() + and not l.strip().replace(os.sep, "/").startswith(".regress/")] + except Exception: + pass # 子仓扫描是增强:失败回到仅工作区仓(老行为) + return base + + if mine: + def _attribute_mine(mine_list, staged): + """归因(v1.86.2,088):多活跃清单在场按 staged 交集选归因者。 + + run8 双标本根治:旧 mine[0](文件名倒序首个)与提交文件无关且不筛 + 临行状态——planning 清单被路过盖 done(084)、真清单反漏盖 + (081/082/083)。planning(未临行)不参与交集归因;全无交集=异常态 + 回退有 provisional 戳者保检查面(弱化不允许),再退旧序。""" + staged_set = {s.replace(os.sep, "/") for s in staged} + best, best_n = None, 0 + for p, mid in mine_list: + parsed = parse_frontmatter(p) or {} + if str(parsed.get("status") or "") == "planning": + continue + declared = {f.replace(os.sep, "/") + for f in get_all_changed_files(p)} + n = len(declared & staged_set) + if n > best_n: + best, best_n = (p, mid), n + if best: + return best + for p, mid in mine_list: + if (parse_frontmatter(p) or {}).get("provisional"): + return (p, mid) + return mine_list[0] + + manifest, manifest_id = _attribute_mine(mine, _staged_list()) + if len(mine) > 1: + record(regress_dir, "note", manifest_id, + note="co_active", co_active=len(mine), + attributed=manifest_id or "planning_fallback") + _NOTIFY_STATE["manifest_id"] = manifest_id # blocked 推送带清单号 + + # ─── 2.6 提交信息早查(v1.87.1,092)──────────── + # 行业空白位(探子 2026-09-21:无人做信息-实况对账)+077 标本(信息宣称 + # 536/536+3 用例而树里没有,溜一周)。晚查(行尾计数对账)挂 pass 路径—— + # 需门禁实测数。 + _msg = _message_from_cmd(raw_input) + if _msg: + _mtier = str((parse_frontmatter(manifest) or {}).get("tier") or "") + _short = manifest_id.rsplit("-", 1)[-1] if manifest_id else "" + # 前缀形态((097 或 (097)即认——含「(097,附注)」实践形态; + # 096 活体:发布道曾硬拦自家镜像消息,门禁侧 M 档同款误拦面 + _has_ref = (manifest_id and manifest_id in _msg) or bool( + _short and re.search(r"[((]" + re.escape(_short), _msg)) + if not _has_ref: + if _mtier in ("M", "L"): + record(regress_dir, "commit_blocked", manifest_id, + reason="message_no_manifest_ref") + emit_block( + f"提交被拦:信息缺归因清单号(077 反谎报配套——git log 溯源锚)。\n\n" + f"归因清单 <id {manifest_id}>,信息中嵌入全 ID 或缩写" + f"「({_short})」形态即可。git log --grep 清单号=批的物理\n" + "提交秒查(我们直提 main,提交信息是唯一溯源面——top1 有 PR 层" + "放 PR body,拓扑不同)。" + ) + else: + record(regress_dir, "note", manifest_id, + note="message_no_manifest_ref_warn") + print(f"REGRESS-GUARD: ⚠️ 信息缺清单号 {manifest_id}(S/quick 档告警," + "建议嵌入全 ID 或(缩写))", file=sys.stderr) + if "<" in _msg or ">" in _msg: + record(regress_dir, "commit_blocked", manifest_id, + reason="message_angle_bracket") + emit_block( + "提交被拦:信息含尖括号 < 或 >。\n\n" + "运营实证双坑:边界守卫会把 <路径> 形态当重定向目标误判;" + "发布链路的文案约定同样禁尖括号。改用「路径」或(路径)形态。" + ) + + if not manifest: + if others: + # 我无清单、他会话有活跃清单:只拦真冲突(staged 撞其清单文件), + # 否则放行+警示——他人清单不再挡我的提交(标本1 根治) + try: + clash = sorted({s.replace(os.sep, "/") for s in _staged_list()} + & _others_files()) + except Exception as e: + record(regress_dir, "error", "", error=f"diff analysis failed: {e}") + emit_block( + f"git diff 分析失败:{e}\n\n" + "fail-safe 原则:阻断 commit。请检查 git 状态后重试。" + ) + ids = ", ".join(oid or os.path.basename(p) for p, oid in others[:3]) + if clash: + files_str = "\n ".join(clash) + record(regress_dir, "commit_blocked", "", + reason="cross_session_clash", clash_files=clash, + foreign=ids, session=my_sid) + emit_block( + f"commit 被阻断。以下 staged 文件在他会话的活跃清单内" + f"(跨会话文件冲突——各自分支都对,合到一起才现形):\n" + f" {files_str}\n\n对方清单:{ids}\n\n" + "· 与该会话串行作业,或让人类仲裁归属\n" + "· 该会话已死?哨兵视图确认后收尾其清单或重盖 session 戳:\n" + " python3 hooks/scripts/lib/sentinel.py\n" + "· 确要抢收:/regress:bypass <分钟>(限时赦免,赦后记债)" + ) + record(regress_dir, "commit_passed", "", + runner="none", passed=0, total=0, + note="foreign_active_manifest_untouched", foreign=ids) + _mark_expected(regress_dir, "gated") + emit_warn( + f"其他会话有活跃清单({ids}),但不涉本次提交文件——已放行" + "(会话作用域 v1.34:他人清单不再挡你的提交)。") + else: + # 没有活跃清单 → 放行,但要记录(否则 history 永远空) + record(regress_dir, "commit_passed", "", + runner="none", passed=0, total=0, + note="no_active_manifest") + _mark_expected(regress_dir, "gated") + emit_pass() + + # ─── 4.5 全貌层两规则(v1.40:理解是强制产物)──────── + # 病例:028 新增桥脚本(模块结构变更)而卡片 8 天未回写,两批跳过 finish + # 卡片步骤,无机器拦——纪律位升机器位。对标 spec-first:理解必须落产物。 + def _scan_ok(v): + v = str(v or "").strip() + return bool(v) and "{{" not in v and v.upper() != "TODO" + + _mp_scan = parse_frontmatter(manifest) or {} + _tier = str(_mp_scan.get("tier") or "").strip().upper() + + # 规则A(仅 M/L——S 档轻量合法不背全貌仪式):scan 三行 + understood_intent 三件 + if _tier in ("M", "L"): + _missing = [k for k in ("entry", "test", "card") + if not _scan_ok((_mp_scan.get("scan") or {}).get(k))] + _ui = _mp_scan.get("understood_intent") + _ui_ok = (isinstance(_ui, dict) and + all(_scan_ok(_ui.get(k)) for k in ("复述", "边界", "判据"))) + if _missing or not _ui_ok: + record(regress_dir, "commit_blocked", manifest_id, + reason="scan_missing", missing=_missing, + ui_ok=bool(_ui_ok), tier=_tier) + emit_block( + f"M/L 档清单缺全貌产物(v1.40 规则A):<id {manifest_id}>\n\n" + + (f"· scan 三行缺项:{', '.join(_missing)}\n" if _missing else "") + + ("" if _ui_ok else "· understood_intent 须三件(复述/边界/判据)," + "空值/占位不算\n") + + "\n补法:清单 frontmatter 加\n scan:\n entry: <入口在哪>\n" + " test: <测试怎么跑>\n card: <动的是哪张模块卡>\n" + " understood_intent:\n 复述/边界/判据 各一行非空\n" + "(对标 spec-first:理解是强制产物——看不全就对不准)", + reason_key="scan_missing" + ) + + # 规则B(全档含 S/quick——结构变更本就不是轻量内部,028 标本即 S 可绕的洞): + # staged 有结构性增删改名(ADR;tests/docs/md 是模块元数据豁免)→ 卡片须随同 staged + try: + import subprocess as _sp + _r = _sp.run(["git", "-C", project_dir, "diff", "--staged", + "--diff-filter=ADR", "--name-only", "-z"], + capture_output=True, text=True, timeout=10) + _ad = [f for f in (_r.stdout.split("\0") if _r.returncode == 0 else []) + if f and not f.replace(os.sep, "/").startswith(".regress/") + and not f.replace(os.sep, "/").startswith("tests/") + and not f.replace(os.sep, "/").startswith("docs/") + and not f.endswith(".md")] + except Exception: + _ad = [] + if _ad: + _card_path = os.path.join(regress_dir, "product-arch.md") + _card_staged = any( + s.replace(os.sep, "/") == ".regress/product-arch.md" + for s in filter_files(get_staged_files(project_dir))) + # 显式豁免位(FP1 rescue):纯脚手架确不需进卡,清单声明并写明理由 + _sync_exempt = (_mp_scan.get("scan") or {}).get("card_sync") is False + if os.path.exists(_card_path) and not _card_staged and not _sync_exempt: + record(regress_dir, "commit_blocked", manifest_id, + reason="card_stale", structural=_ad[:6], tier=_tier or "S?") + emit_block( + f"模块结构变更而卡片未同步(v1.40 规则B):<id {manifest_id}>\n\n" + f"结构性增删/改名:\n {chr(10).join(_ad[:6])}\n\n" + "模块卡片是活档案(.regress/product-arch.md)——结构变了卡片要跟着动" + "(finish 步骤 4),本次提交未包含卡片变更。\n" + "· 补法:更新对应模块卡(能力增行/缺口增删)后一并 staged\n" + "· 纯脚手架确不需进卡:清单 scan 加 card_sync: false 并写明理由" + ) + elif not os.path.exists(_card_path): + # 无卡片的盲区(顾问补强):不拦(未接入产品层),但警示留痕 + record(regress_dir, "commit_warned", manifest_id, + note="structural_change_without_cards", structural=_ad[:6]) + print(f"REGRESS-GUARD (warning): 结构性变更({len(_ad)} 文件)但项目无模块卡片——" + "建议 /regress:init 建产品层(全貌是强制产物的起点)", file=sys.stderr) + + # ─── 4.6 收官两规则(v1.41:触发表激活,防空转)───── + # 顾问重塑:rollback=能力断言+引信(全档,写不出即不该提交;默认只在无逃逸面 + # 时合法);self_review 键触发表激活——不适用=键不出现(合法),「无」只表示 + # 查过没有——堵"无/不适用混装"的空转根源(REGRESS-2026-030)。 + def _v_ok(v): + v = str(v or "").strip() + return bool(v) and "{{" not in v and v.upper() != "TODO" + + _rollback = str(_mp_scan.get("rollback") or "").strip() + if not _v_ok(_rollback): + record(regress_dir, "commit_blocked", manifest_id, + reason="finish_missing", part="rollback") + emit_block( + f"清单缺 rollback(v1.41:能力断言+引信——写不出即不该提交):<id {manifest_id}>\n\n" + "· 一行即可:rollback: git revert 即回滚\n" + "· 触及迁移/删数据/外部状态时默认失效,须写具体回滚路径" + "(数据怎么回/迁移怎么退)" + ) + + # 触发表数据一次取齐:staged 全量 + diff 文本 + try: + import subprocess as _sp2 + _dr = _sp2.run(["git", "-C", project_dir, "diff", "--staged", "-U0"], + capture_output=True, text=True, timeout=10) + _diff_text = _dr.stdout if _dr.returncode == 0 else "" + _staged_all = filter_files(get_staged_files(project_dir)) + except Exception: + _diff_text, _staged_all = "", [] + + _ESCAPE_PATH = re.compile(r"migrations?/|schema|db/seed|alembic|flyway", re.I) + _ESCAPE_SQL = re.compile(r"DROP\s+TABLE|TRUNCATE|ALTER\s+TABLE|DELETE\s+FROM", re.I) + if ((any(_ESCAPE_PATH.search(s) for s in _staged_all) + or _ESCAPE_SQL.search(_diff_text)) + and "git revert" in _rollback.lower() and len(_rollback) < 40): + record(regress_dir, "commit_blocked", manifest_id, + reason="finish_missing", part="rollback_default_on_escape") + emit_block( + f"rollback 默认值在逃逸面上失效(v1.41):<id {manifest_id}>\n\n" + "本次提交触及迁移/schema/破坏性 SQL——「git revert 即回滚」不够" + "(revert 得回代码回不了数据),须写具体回滚路径:\n" + "· 迁移怎么退(down 脚本?手工 SQL?备份恢复?)\n" + "· 数据怎么回(备份点/可重放源?)" + ) + + _sr = _mp_scan.get("self_review") or {} + _keys_needed = [] + if _mp_scan.get("actual_changes"): + _keys_needed.append("计划外") + if (re.search(r"console\.(?:log|debug)|debugger\b|pdb\.set_trace|breakpoint\(", + _diff_text) + and any(not s.replace(os.sep, "/").startswith("tests/") + for s in _staged_all)): + _keys_needed.append("调试残留") + _missing_sr = [k for k in _keys_needed if not _v_ok(_sr.get(k))] + if _missing_sr: + record(regress_dir, "commit_blocked", manifest_id, + reason="finish_missing", part="self_review", keys=_missing_sr) + emit_block( + f"self_review 缺键(触发表已激活,v1.41):<id {manifest_id}>\n\n" + f"需补:{', '.join(_missing_sr)}\n" + "· 计划外键:清单回写过计划外文件(F3)——逐个有意识看过吗?" + "值=具体条目或「无」(查过没有)\n" + "· 调试残留键:diff 命中调试模式(print/console.log/debugger/pdb)——" + "值=条目或「无」\n" + "(不适用=键不出现即合法;本清单触发了,键必须在——防空转)" + ) + + # ─── 4.7 供应链(v1.42:门禁只验测试不够,还要验安全)─── + # secrets:gitleaks-lite 扫 staged 新增行(历史密钥是全仓审计工具的职责) + # deps:锁文件 staged 才触发 npm audit --json(顾问修正:解析漏洞计数, + # exit code 不可信)——infra 失败 fail-open(warn+留痕), + # findings fail-closed(high/critical 才拦) + _sc_cfg = config.get("supply_chain") or {} + if _sc_cfg.get("secrets", True): + try: + from secret_scan import scan_added_lines + _hits = scan_added_lines(_diff_text, + allowlist=_sc_cfg.get("allowlist") or ()) + except Exception: + _hits = [] + if _hits: + record(regress_dir, "commit_blocked", manifest_id, + reason="secret_leak", + hits=[list(h[:3]) for h in _hits[:5]]) + _hit_str = "\n".join(f" · {n} {f}:{ln} {s}" for n, f, ln, s in _hits[:5]) + emit_block( + f"staged 新增行疑似密钥泄漏(v1.42 供应链层):<id {manifest_id}>\n\n" + f"{_hit_str}\n\n" + "· 真密钥:撤销轮换(进了 git 历史就算删了也已泄漏),用 env/凭据库\n" + "· 文档示例/教学串:config supply_chain.allowlist 加值\n" + "· 确要提交:/regress:bypass <分钟>(限时赦免+审计留痕)" + ) + + if _sc_cfg.get("deps", True): + _lock_bases = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml", + "poetry.lock", "go.sum", "Cargo.lock") + # 原始 staged(不经 filter_files——它会滤 .md/test/锁类"噪音",锁恰是本规则的靶) + _locks = [s for s in get_staged_files(project_dir) + if os.path.basename(s) in _lock_bases + or s.replace(os.sep, "/").startswith("requirements")] + if any(os.path.basename(l) == "package-lock.json" for l in _locks): + import subprocess as _sp3 + _tmo = float(os.environ.get("RG_AUDIT_TIMEOUT_S", "60")) + try: + _ar = _sp3.run( + [os.environ.get("RG_NPM_CMD", "npm"), "audit", "--json", + "--package-lock-only"], + cwd=project_dir, capture_output=True, text=True, timeout=_tmo) + _vulns = ((json.loads(_ar.stdout or "{}").get("metadata") or {}) + .get("vulnerabilities") or {}) + _hi = int(_vulns.get("high", 0)) + int(_vulns.get("critical", 0)) + if _hi > 0: + record(regress_dir, "commit_blocked", manifest_id, + reason="deps_vulnerable", high=_hi, + total=_vulns.get("total", 0)) + emit_block( + f"依赖存在 { _hi } 个 high/critical 已知漏洞" + f"(v1.42 供应链层):<id {manifest_id}>\n\n" + f"npm audit 汇总:{json.dumps(_vulns, ensure_ascii=False)}\n" + "· npm audit 逐条看影响路径,升级或豁免有据后重试\n" + "· 误报/暂不修:config supply_chain.deps=false(降级要写明理由)" + ) + except Exception as _e: # 工具缺/超时/JSON 坏/网络败(SystemExit 不受捕) + record(regress_dir, "commit_warned", manifest_id, + note="deps_audit_infra_fail", error=str(_e)[:80]) + print(f"REGRESS-GUARD (warning): npm audit 未完成({_e.__class__.__name__})" + "——infra fail-open 放行,findings 才 fail-closed", file=sys.stderr) + elif _locks: + print(f"REGRESS-GUARD (warning): 锁文件变更({os.path.basename(_locks[0])})" + "但该生态审计器 v1 未接入(仅 npm)——建议人工审计", file=sys.stderr) + + # ─── 5. staged 文件在清单内?────────────────────── + try: + manifest_files = get_all_changed_files(manifest) + staged = _staged_list() + untracked = find_untracked_changes(staged, manifest_files) + except Exception as e: + record(regress_dir, "error", manifest_id, error=f"diff analysis failed: {e}") + emit_block( + f"git diff 分析失败:{e}\n\n" + "fail-safe 原则:阻断 commit。请检查 git 状态后重试。" + ) + + # 5.4 跨会话冲突(v1.34):先于 untracked 检查——否则"不在清单中"的提示 + # 会误导去 /regress:track 把他人文件认领进我的清单(方向反了) + if my_sid and others: + clash = sorted({s.replace(os.sep, "/") for s in staged} & _others_files()) + if clash: + files_str = "\n ".join(clash) + ids = ", ".join(oid or os.path.basename(p) for p, oid in others[:3]) + record(regress_dir, "commit_blocked", manifest_id, + reason="cross_session_clash", clash_files=clash, + foreign=ids, session=my_sid) + emit_block( + f"commit 被阻断。以下 staged 文件同时在他会话的活跃清单内" + f"(跨会话文件冲突——各自分支都对,合到一起才现形):\n" + f" {files_str}\n\n对方清单:{ids}\n\n" + "· 与该会话串行作业,或让人类仲裁归属\n" + "· 该会话已死?哨兵视图确认后收尾其清单或重盖 session 戳:\n" + " python3 hooks/scripts/lib/sentinel.py\n" + "· 确要抢收:/regress:bypass <分钟>(限时赦免,赦后记债)" + ) + + if untracked: + files_str = "\n ".join(untracked) + msg = f"commit 被阻断。以下 staged 文件不在回归清单中:\n {files_str}\n\n请先运行 /regress:track 回写,或 git reset 撤销。\n清单:{manifest}" + record(regress_dir, "commit_blocked", manifest_id, + reason="untracked_files", untracked_files=untracked) + if strict: + emit_block(msg) + else: + emit_warn(msg) + + # ─── 5.5 脆弱点挂牌检查(公理一:未挂牌的脆弱点才是真正的未知风险)── + fps = get_fragile_points(manifest) + open_fps = [fp for fp in fps if str(fp.get("status", "open")).lower() == "open"] + flagged_fps = [fp for fp in fps if str(fp.get("status", "")).lower() == "flagged"] + if open_fps: + lines = "\n ".join( + f"{fp.get('id', '?')} [{fp.get('kind', '?')}] {fp.get('description', '')}" + for fp in open_fps + ) + msg = ( + f"commit 被阻断。清单有 {len(open_fps)} 个脆弱点未挂牌(status: open):\n" + f" {lines}\n\n" + "公理一:成功不是跑通,而是所有已知脆弱点被锁死或显式挂牌。\n" + "每个脆弱点二选一后回写清单:\n" + " - locked:verify 命令实测通过(跑 /regress:verify 或手动执行后回写)\n" + " - flagged:显式带病挂牌(description 里写明知悉的原因)" + ) + record(regress_dir, "commit_blocked", manifest_id, + reason="fragile_point_open", + fragile_ids=[fp.get("id", "?") for fp in open_fps]) + if strict: + emit_block(msg) + else: + emit_warn(msg) + elif flagged_fps: + # 带病挂牌 = 显式知情,放行但留痕(不刷屏,stderr 一行) + print( + f"REGRESS-GUARD: ⚠️ {len(flagged_fps)} 个脆弱点带病挂牌(flagged)随本提交入库:" + + ", ".join(fp.get("id", "?") for fp in flagged_fps), + file=sys.stderr, + ) + + # ─── 5.6 证据律复验(公理一:locked = verify 现在能过,不是曾经能过)── + # locked 是唯一宣称"已锁死"的状态——门禁处机器复跑 verify 命令, + # AI 自封的 locked 不算数。无 verify 命令的 locked = 证据链缺环(警告)。 + # 感官分支(v1.19 AVS 公理三):verify 以 human_check: 开头的 locked 条目, + # 机器验证"人确认过"这个事实的化石存在——不复跑感官(人只是传感器, + # 传感器读数入档即证据)。 + import subprocess as _sp + locked_no_verify = [ + fp for fp in fps + if str(fp.get("status", "")).lower() == "locked" and not str(fp.get("verify") or "").strip().strip('"') + ] + for fp in locked_no_verify: + print( + f"REGRESS-GUARD: ⚠️ {fp.get('id', '?')} 自称 locked 但无 verify 命令——" + f"证据链缺环,建议补命令(/regress:verify 拿证据)或改 flagged 显式挂牌", + file=sys.stderr, + ) + verify_failed = [] + + # 感官分支:human_check 化石存在性检查 + human_fps = [f for f in fps + if str(f.get("status", "")).lower() == "locked" + and str(f.get("verify") or "").strip().startswith("human_check")] + if human_fps: + jevents = [] + try: + with open(os.path.join(regress_dir, "journal", "events.jsonl"), + encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + jevents.append(json.loads(line)) + except json.JSONDecodeError: + pass + except (IOError, OSError): + pass + for fp in human_fps: + vid = fp.get("id", "?") + has = any(e.get("kind") == "human_check" + and e.get("manifest_id") == manifest_id + and e.get("vid") == vid for e in jevents) + record(regress_dir, "fragile_verify", manifest_id, vid=vid, ok=has) + if not has: + verify_failed.append( + (vid, "human_check", "无 human_check 化石(人工确认未落产物):" + f'journal.py . add human_check \'{{"manifest_id":"{manifest_id}","vid":"{vid}","result":"pass"}}\'')) + + for fp in [f for f in fps + if str(f.get("status", "")).lower() == "locked" + and str(f.get("verify") or "").strip().strip('"') + and not str(f.get("verify") or "").strip().startswith("human_check")][:5]: # 上限5条防门禁拖延 + vcmd = str(fp["verify"]).strip() + try: + r = _sp.run(["bash", "-c", vcmd], capture_output=True, text=True, + timeout=15, cwd=project_dir) + ok = r.returncode == 0 + tail = (r.stderr or r.stdout or "")[-150:].strip() + except Exception as e: + ok, tail = False, str(e)[:150] + record(regress_dir, "fragile_verify", manifest_id, + vid=fp.get("id", "?"), ok=ok) + if not ok: + verify_failed.append((fp.get("id", "?"), vcmd[:60], tail)) + if verify_failed: + lines = "\n ".join( + f"{vid}: `{cmd}` → {tail or 'exit≠0(无输出)'}" + for vid, cmd, tail in verify_failed + ) + msg = ( + f"commit 被阻断。{len(verify_failed)} 个 locked 脆弱点门禁复验失败" + f"(证据律:locked 意为 verify 此刻能过):\n {lines}\n\n" + "修复后重试,或降级为 flagged 显式带病挂牌(写明知悉原因)。" + ) + record(regress_dir, "commit_blocked", manifest_id, + reason="fragile_verify_failed", + failed_ids=[vid for vid, _, _ in verify_failed]) + if strict: + emit_block(msg, reason_key="fragile_verify_failed") + else: + emit_warn(msg) + + # ─── 6. hook 自己跑测试 ─────────────────────────── + print("REGRESS-GUARD: 正在运行测试...", file=sys.stderr) + # v1.85 测试结果缓存(074):同树重试三跑全量是最大浪费;顾问裁=优化位非安全 + # 边界。命中只替代 6 节全量,6.5 验收入环照跑——验收读的是清单不是树。 + _cache_hit = None + try: + from test_cache import lookup as _tc_lookup + _cache_hit = _tc_lookup(project_dir, regress_dir) + except Exception: + _cache_hit = None # 缓存是增强不是依赖,任何异常=旁路 + if _cache_hit: + result = dict(_cache_hit.get("result") or {}) + result["cached"] = True + print(f"REGRESS-GUARD: ♻️ 测试缓存命中(同一棵树 " + f"{str(_cache_hit.get('key'))[:8]}, {str(_cache_hit.get('age_min'))} 分钟前" + f"的通过结果)——跳过全量重跑;RG_TEST_CACHE=off 可关", file=sys.stderr) + else: + try: + result = run_tests(project_dir) + except Exception as e: + record(regress_dir, "error", manifest_id, error=f"test_runner crashed: {e}") + emit_block( + f"测试运行器异常崩溃:{e}\n\n" + "fail-safe 原则:阻断 commit。请检查测试运行器配置。\n" + f"Traceback:\n{traceback.format_exc()[-500:]}" + ) + status = result.get("status", "fail") + runner = result.get("runner", "unknown") + + if status == "pass": + # ─── 6.5 验收入环(v1.55:M/L done 盖章前验收行全勾)───────── + # 验收=需求侧 done 定义——没有它 done 就是自我宣布。验命令本批不 + # 复跑(顾问:全量测试已跑,复跑多重复;自证谎报等字段数据再上执行器)。 + # S 档/quick 豁免(轻量合法不破)。 + if _tier in ("M", "L") and str((_mp_scan or {}).get("mode") or "") != "quick": + present, total, open_rows = _acceptance_state(manifest) + if not present or total == 0: + record(regress_dir, "commit_blocked", manifest_id, + reason="acceptance_missing", tier=_tier) + emit_block( + f"M/L 清单缺验收标准判据(v1.55 验收入环):<id {manifest_id}>\n\n" + "验收标准节缺失或没有可认出的判据行——门禁复验脆弱点、" + "跑全量测试,唯独没人查「需求做没做对」。\n\n" + "补法:清单加「## 验收标准」节,每行 EARS-lite:\n" + " - When 条件,则 可观察结果(验:当场可跑命令)\n" + "验证过一行就在行尾加 ✅;S 档/quick 模式豁免本规则" + ) + elif open_rows: + _rows = "\n ".join(f"· {r}" for r in open_rows[:5]) + record(regress_dir, "commit_blocked", manifest_id, + reason="acceptance_open", n=len(open_rows), tier=_tier) + emit_block( + f"M/L 清单有 {len(open_rows)} 行验收未勾(v1.55 验收入环):<id {manifest_id}>\n\n" + f"{_rows}\n\n" + "验收行没验证过就盖章 done = 纸面反馈。补法:逐行跑(验:命令)," + "通过后行内加 ✅/已验/通过(v1.73 起任意位置计勾,如" + "(验:命令)5/5 passed ✅;表格式状态列写 pass/done/locked);\n" + "判据行本身缺(验:命令)= 不完整判据(三件齐才算一条),补全再勾" + ) + else: + # 通过也留痕(v1.60):拦截/通过频次比 = FP2(✅ 自证谎报) + # 要不要上验命令复跑执行器的决策数据——先有开火数据再谈执行器 + record(regress_dir, "acceptance_passed", manifest_id, + rows=total, tier=_tier) + passed = f"{result['passed']}/{result['total']}" + # 行尾计数对账(v1.87.1,092 晚查):「;N/N」结尾=套件计数宣称(家规 + # 形态)——077 的「536/536」正是此形态,当场会被拦。非行尾 N/N(如 + # 「清单健康 59/59」作用域计数)只告警留痕(090 不误伤)。 + _msg_late = _message_from_cmd(raw_input) + _marker = re.search(r"[;;]\s*(\d+)\s*/\s*(\d+)\s*$", _msg_late.strip()) + if _marker: + _cp, _ct = int(_marker.group(1)), int(_marker.group(2)) + if not (_cp == result.get("passed") and _ct == result.get("total")): + record(regress_dir, "commit_blocked", manifest_id, + reason="message_count_mismatch", + claimed=f"{_cp}/{_ct}", + actual=f"{result.get('passed')}/{result.get('total')}") + emit_block( + f"提交被拦:信息行尾「;{_cp}/{_ct}」与门禁实测 " + f"{result.get('passed')}/{result.get('total')} 不符。\n\n" + "行尾「;N/N」是套件计数宣称(077 反谎报闸:信息宣称 536/536" + "而树里没有的那次,溜了一周)。改法:改成实测数,或去掉行尾" + "计数(作用域计数写中间位置不受此查)。" + ) + else: + _claims = re.findall(r"\d+\s*/\s*\d+", _msg_late) + if _claims: + record(regress_dir, "note", manifest_id, + note="message_count_claim", claims=_claims[:3]) + _attrib_status = str((parse_frontmatter(manifest) or {}).get("status") or "") + if _attrib_status == "planning": + # 未临行的计划不接 done 盖章(088:084 被路过盖章标本的兜底闸) + record(regress_dir, "note", manifest_id, + note="planning_not_stamped") + print("REGRESS-GUARD: 归因清单为 planning(未临行)——不盖 done,仅放行留痕", + file=sys.stderr) + else: + try: + update_frontmatter(manifest, { + "status": "done", + "test_verified_by": "hook", + "test_result": f"{passed} passed", + }) + except Exception as e: + # 写清单失败不阻断(测试已通过,清单写入是辅助记录) + print(f"REGRESS-GUARD: ⚠️ 清单更新失败(不影响放行): {e}", file=sys.stderr) + record(regress_dir, "commit_passed", manifest_id, + runner=runner, passed=result.get("passed"), total=result.get("total"), + base_head=_git_head_sha(), coverage_pct=result.get("coverage_pct"), + cached=bool(result.get("cached")), + cache_key=(str(_cache_hit.get("key")) if _cache_hit else None)) + # 缓存落账(074):真跑通过才写(命中路径不重写——条目已在);增强位,失败静默 + if not result.get("cached"): + try: + from test_cache import record as _tc_record + _tc_record(project_dir, regress_dir, result) + except Exception: + pass + # v1.71 待决自动回流(run4 R3):同清单过门禁=该清单的 blocked 告警自然 + # 闭环——唯一自动策略(顾问禁令:不跨策略不批量不推断,只 resolve 同 ref + # 未决);resolved 单列不进误报率分母(校准口径 human-only 不变)。 + try: + from pending import resolve_by_ref + _n = resolve_by_ref(manifest_id, outcome="resolved") + if _n: + print(f"REGRESS-GUARD: 待决自动回流 {_n} 笔" + f"(同清单 {manifest_id} 过门禁 → 闭环)", file=sys.stderr) + except Exception: + pass # 回流是增强不是依赖 + cov_note = f",覆盖率 {result['coverage_pct']}%" if result.get("coverage_pct") is not None else "" + print(f"REGRESS-GUARD: ✅ 测试通过 ({passed}){cov_note},清单已标记 done", file=sys.stderr) + _mark_expected(regress_dir, "gated") + emit_pass() + + elif status == "skip": + # 无测试运行器 → 活跃清单存在但缺 runner:这仍需人工确认,阻断 + mstatus = get_manifest_status(manifest) + # quick 豁免(v1.37,P1#19):mode: quick = 机器判据达标的小改动 + # (≤3文件/纯内部/无环境变更),runner 缺失 warn 放行留痕—— + # 塌方曲线的修复方向是"豁免留痕可审计",不是"伪装 full 被绕过" + # 顾问防线:豁免本身过机器校验——清单实际文件数 >3 则豁免不成立 + _mp = parse_frontmatter(manifest) or {} + _quick_files = len(get_all_changed_files(manifest)) + if str(_mp.get("mode") or "") == "quick" and _quick_files <= 3: + record(regress_dir, "commit_passed", manifest_id, + runner=runner, passed=0, total=0, + note="quick_no_runner_exempt", files=_quick_files) + _mark_expected(regress_dir, "gated") + emit_warn(f"quick 模式清单无测试运行器(mode: quick 豁免,已留痕 history)。") + # 注:能走到这里说明清单是明确活跃的(planning/in-progress/verifying), + # 否则 main() 早就以 no_active_manifest 放行了 + # (P1#11 去重:旧行为连记两条同毫秒 record——stats 的比率全体翻倍) + record(regress_dir, "commit_blocked", manifest_id, + reason="no_test_runner", runner=runner, manifest_status=mstatus) + msg = ( + f"未检测到测试运行器({runner}),无法自动验证测试。\n" + f"清单状态为 {mstatus}(需改为 done/completed 才能放行)。\n\n" + "解决方法:\n" + " - Java 项目:确保 mvn 在 PATH(brew install maven / apt install maven)\n" + " - Node 项目:npm i -D jest\n" + " - Python 项目:pip install pytest\n" + " - 或在清单中标记 status: done(手动确认无需测试)" + ) + if strict: + emit_block(msg) + else: + emit_warn(msg) + + else: + # 测试失败 → 阻断 + failures = result.get("failures", [])[:5] + lines = [] + for f in failures: + lines.append(f" ❌ {f.get('test', '?')}") + if f.get("message"): + lines.append(f" {f['message'][:120]}") + lines.append(f"\n 共 {result.get('failed', 0)} 个失败 / {result.get('passed', 0)} 个通过") + fail_str = "\n".join(lines) if lines else " (详情见测试输出)" + # 记录每个失败用例 + for f in failures: + record(regress_dir, "test_failed", manifest_id, + runner=runner, test_name=f.get("test", "?")) + record(regress_dir, "commit_blocked", manifest_id, + reason="test_failed", runner=runner, + failed=result.get("failed", 0), passed=result.get("passed", 0)) + emit_block( + f"commit 被阻断。测试未通过(runner: {runner}):\n\n{fail_str}\n\n" + "请修复失败用例后重新 commit。测试通过后 hook 会自动放行。", + reason_key="test_failed" + ) + + emit_pass() + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception: + # 门禁 fail-closed(评审批次一 P0-1a,2026-09-08 评审活体): + # 未预期异常 = 状态未知 = 阻断。旧行为 exit 1 会被钩子框架当 + # "非阻断错误"处理 → fail-safe 门禁翻成 fail-open 静默放行 + # (触发例:非 UTF-8 清单的 UnicodeDecodeError 穿透无保护循环) + print(f"REGRESS-GUARD: 门禁未预期异常,fail-safe 阻断(修好后重试):\n" + f"{traceback.format_exc()[-600:]}", file=sys.stderr) + sys.exit(2) diff --git a/plugins/regress-guard/hooks/scripts/prompt_intercept.py b/plugins/regress-guard/hooks/scripts/prompt_intercept.py new file mode 100755 index 0000000..4686040 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/prompt_intercept.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit hook:用户提交输入时自动检测需求特征。 + +不阻断(exit 0),通过 additionalContext 注入提醒: + - 需求太短(<20字)→ 提醒先解析 + - 含模糊词 → 提醒歧义检测 + - 像"改/加/修"类变更 → 提醒先 /regress:plan + - 重复提交(和上一条一样)→ 提醒可能重复 + +这是"需求进来时的第一道关口"。 +""" +import sys +import os +import json +import re +import tempfile + +# 考古地层(公理三):用户纠正是最稀缺的化石("自信地错"的唯一信号) +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) +try: + from journal import journal_append +except ImportError: # lib 缺失时地层降级关闭 + def journal_append(*a, **k): + return False + + +# 模糊词模式(触发歧义检测提醒) +AMBIGUOUS_PATTERNS = [ + r'(大概|差不多|那种|类似的|好像|应该|可能)', + r'(全部|所有|都)', + r'(优化|改进|完善|调整)', + r'(那个|这个|之前|上次)', + r'(弄一下|搞一下|处理一下|看看)', +] + +# 变更类动词(触发 plan 提醒) +CHANGE_VERBS = r'(改|加|修|删|重构|新增|去掉|替换|迁移)' + +# 最小需求长度 +MIN_REQUIREMENT_LEN = 15 + + +def _state_path(): + """状态文件路径——按项目隔离(避免跨项目污染重复检测)。""" + import hashlib + project_dir = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + key = hashlib.md5(project_dir.encode()).hexdigest()[:8] + return os.path.join(tempfile.gettempdir(), f"regress-guard-last-prompt-{key}.txt") + + +def load_last_prompt(): + """读取本项目上一条用户输入(检测重复提交)。""" + try: + with open(_state_path(), encoding="utf-8") as f: + return f.read().strip() + except (IOError, OSError): + return "" + + +def save_prompt(text): + """保存当前输入(供下次比对)。同时写全局接力文件(v1.32.3): + stop_notify 经它读最后输入——按项目哈希隔离在钩子进程间目录解析漂移时会 + 读空导致推送永远静默(2026-09-05 三报沉默根因),全局单文件最后写入者生效。 + v1.32.4:空文本也如实写入(推送决策已与文本解耦,空值让占位标题生效)。""" + try: + with open(_state_path(), "w") as f: + f.write(text[:500]) + with open(os.path.join(tempfile.gettempdir(), + "regress-guard-last-prompt.global.txt"), "w") as f: + f.write(text[:500]) + except (IOError, OSError): + pass + + +# ── 卡死自动触发("自己爬出来"的最后一环)── +# 用户连续无信息催促 = 当前路线无进展的信号。第 2 次即注入 scout 升级指令。 + +PROD_THRESHOLD = 2 + +def _prod_count_path(): + key = _state_path().replace("last-prompt", "prod-count") + return key + +def is_content_free_prod(text): + """无信息催促:归一化后 ≤12 字符且以继续类词开头(覆盖"继续攻克,自决策")。 + + 授权词豁免(病例:2026-09-03 活体误判——"继续,自决策"是批准语不是空催促, + 连续两次批准触发误报 scout 升级):含授权/委派语义的短语带实际指令,不算无信息。 + """ + t = re.sub(r"[\s,。!?,.!?]", "", text).lower() + if not t or len(t) > 12: + return False + if re.search(r"自决策|自决|你决定|自主决|自动做|直接做|放手做", t): + return False + return t.startswith(("继续", "接着", "go", "ok", "加油", "搞起", "弄完")) + +def bump_prod_count(is_prod): + """催促计数:prod 则 +1 并返回新值;实质输入则清零并返回 0。""" + path = _prod_count_path() + try: + n = int(open(path, encoding="utf-8").read().strip() or 0) + except (IOError, ValueError): + n = 0 + n = n + 1 if is_prod else 0 + try: + with open(path, "w", encoding="utf-8") as f: + f.write(str(n)) + except (IOError, OSError): + pass + return n + +def _scout_fire_path(): + return _state_path().replace("last-prompt", "scout-fires") + +def _bump_scout_fires(reset=False): + path = _scout_fire_path() + try: + n = int(open(path, encoding="utf-8").read().strip() or 0) + except (IOError, ValueError): + n = 0 + n = 0 if reset else n + 1 + try: + with open(path, "w", encoding="utf-8") as f: + f.write(str(n)) + except (IOError, OSError): + pass + return n + +def scout_escalation_reminder(n): + base = ( + f"🚨 卡死检测:用户已第 {n} 次无信息催促——当前路线大概率无进展。" + f"立即行动:调用 mcp__advisor__consult,参数 mode=\"scout\"、" + f"question=<当前核心问题一句话>、context=<已试方案与结果摘要>," + f"由带实时搜索的顾问调查后给出新方案,按其意见调整路线后再继续。" + f"(顾问工具不可用则自行判断并标注「未获第二意见」;" + f"禁止不改方法地重复原路线)" + ) + fires = _bump_scout_fires() + if fires >= 2: + base += ( + f"\n⚠️ 升级:scout 意见已第 {fires} 轮仍未破局——上下文可能已耗尽。" + f"先运行 /handoff 生成会话交接(含顾问意见与当前状态)," + f"建议用户新开会话读交接文件后继续,而不是在本会话继续堆上下文。" + ) + return base + + +# ── 纠错检测("自信地错"的唯一信号)── +# 流畅地走错方向不产生工具失败、不触发卡死计数,只有用户的纠正能暴露。 +# 负向后顾排除"报错了/出错了/崩错了"等描述性用法(那是工具报错,不是纠正)。 +CORRECTION_RE = re.compile( + r"(?<![报出崩闪])错了|不对|不是这样|理解错|方向错|搞反了|弄反了|又错|wrong" +) + + +def correction_reminder(text): + """检测到用户纠正 → 注入"先向顾问求证方向,再动手"。""" + m = CORRECTION_RE.search(text) + if not m: + return None + return ( + f"🔁 用户在纠正方向(检测到「{m.group(0)}」)——之前的方案可能\"自信地错\":" + f"这类错误没有失败信号,只有用户能察觉。先别急着按字面小修," + f"调用 mcp__advisor__consult,把【用户纠正原文+你刚才的方案与理由+" + f"关键代码位置】原样发给顾问,判断是方向性错误(推倒重来)还是细节偏差" + f"(局部修正),判断清楚再动手。" + f"(顾问工具不可用则自行判断并标注「未获第二意见」)" + ) + + +def analyze_prompt(text): + """分析用户输入,返回需要注入的提醒列表。""" + if not text: + return [] + + # 去掉命令前缀 + clean = re.sub(r'^/\S+\s*', '', text).strip() + reminders = [] + + # 0. 卡死自动触发(最高优先级,先于一切提醒) + if is_content_free_prod(clean): + n = bump_prod_count(True) + if n >= PROD_THRESHOLD: + reminders.append(scout_escalation_reminder(n)) + bump_prod_count(False) # 触发后重置,再积 2 次才重触(防每条催促都刷) + save_prompt(clean) + return reminders # 卡死信号独占注入,避免与其他提醒混杂 + else: + bump_prod_count(False) # 实质输入 → 清零 + _bump_scout_fires(reset=True) # 实质进展也重置 scout 未破局计数 + + # 0.5 纠错检测("自信地错"不产生失败信号,用户纠正词是唯一探测器) + cr = correction_reminder(clean) + if cr: + reminders.append(cr) + journal_append("user_correction", excerpt=clean[:200]) # 纠正化石入地层 + + # 1. 重复提交检测 + last = load_last_prompt() + if last and clean == last: + reminders.append( + "⚠️ 这条需求和上一条完全相同——如果是在等待 AI 响应," + "可能是模型慢导致的重复发送。AI 已经在处理了。" + ) + + # 2. 需求太短 + if len(clean) < MIN_REQUIREMENT_LEN and re.search(CHANGE_VERBS, clean): + reminders.append( + f"💡 需求描述较短({len(clean)}字)。" + f"建议先 /regress:plan 让 AI 解析需求、补全上下文后再动手。" + ) + + # 3. 模糊词检测 + found_ambiguous = [] + for pattern in AMBIGUOUS_PATTERNS: + m = re.search(pattern, clean) + if m: + found_ambiguous.append(m.group(0)) + if found_ambiguous: + reminders.append( + f"💡 需求含模糊词({', '.join(set(found_ambiguous))})——" + f"AI 会先解析歧义、做假设、只问关键分歧。" + f"如果你有明确预期,建议直接说出来能减少返工。" + ) + + # 4. 变更类需求 → 提醒 plan + if re.search(CHANGE_VERBS, clean) and len(clean) >= MIN_REQUIREMENT_LEN: + # 检查是否有 .regress/ + project_dir = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + # 向上查找 + has_regress = False + search_dir = project_dir + for _ in range(10): + if os.path.isdir(os.path.join(search_dir, ".regress")): + has_regress = True + break + parent = os.path.dirname(search_dir) + if parent == search_dir: + break + search_dir = parent + + if has_regress and "/regress:" not in text: + reminders.append( + "📋 检测到变更需求——AI 会先解析需求+分析改动点(/regress:plan 逻辑)," + "不需要你手动触发。" + ) + + save_prompt(clean) + return reminders + + + + +def _active_manifests(project_dir, limit=3): + """活跃清单行(v1.80,069):id+status+tier+边界文件数——治理上下文前置。""" + import glob + lines = [] + try: + for path in sorted(glob.glob( + os.path.join(project_dir, ".regress", "manifests", "*.md"))): + try: + with open(path, encoding="utf-8") as f: + head = f.read(4000) + except OSError: + continue + m = re.search(r"^status:\s*(\S+)", head, re.M) + if not m or m.group(1) not in ("planning", "in-progress"): + continue + mid = re.search(r"^id:\s*(\S+)", head, re.M) + tier = re.search(r"^tier:\s*(\S+)", head, re.M) + n_files = len(re.findall(r"^\s*-\s*id:\s*F\d+", head, re.M)) + lines.append(f"📋 {mid.group(1) if mid else path}({m.group(1)}" + f"{',' + tier.group(1) if tier else ''}," + f"边界 {n_files} 文件)") + if len(lines) >= limit: + break + except Exception: + pass + return lines + + +def _rule_recall_line(project_dir, prompt_text): + """规律召回 TOP-1(v1.80):match 现成地板 min_shared=3 兜噪声, + 再要求 top hits>=3 才出(顾问缓解:假阳性高就退边界单行)。""" + try: + from rules_ledger import match + hits = match(project_dir, prompt_text[:200], top=1) + if hits: + top = hits[0] + if (top.get("hits") or 0) >= 3: + sig = str(top.get("sig") or "")[:60] + return [f"📚 相似规律:{sig}(hits {top['hits']}——开工前看一眼历史教训)"] + except Exception: + pass + return [] + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.exit(0) + + # 会话中继(v1.34 会话作用域):本钩子进程带会话 env(Bash 工具进程不带—— + # agent 侧无法自证身份),每轮把「当前在本项目说话的会话」落中继文件, + # 供 plan_approve 盖 session 戳。早于 "/" 命令早退:命令轮也是本会话的轮。 + try: + from session_relay import write_relay + write_relay(os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") or os.getcwd()) + except Exception: + pass + + # UserPromptSubmit 的 match value 是 prompt 文本 + prompt_text = "" + if isinstance(data, dict): + prompt_text = data.get("prompt", data.get("text", data.get("message", ""))) + if not prompt_text: + # 可能直接是字符串 + prompt_text = str(data) + + if not isinstance(prompt_text, str): + prompt_text = str(prompt_text) + + # 跳过命令自身(/regress:xxx) + if prompt_text.strip().startswith("/"): + sys.exit(0) + + # 跳过系统消息 + if "<task-" in prompt_text or "<system" in prompt_text: + sys.exit(0) + + reminders = analyze_prompt(prompt_text) + gov_lines = [] + if os.environ.get("RG_PROMPT_CONTEXT", "").lower() not in ("off", "0", "false"): + # v1.80(069)治理上下文前置:活跃清单边界+规律召回——防线从拦截点前移到开工前 + pd = (os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") or os.getcwd()) + gov_lines = _active_manifests(pd) + _rule_recall_line(pd, prompt_text) + parts = [] + if gov_lines: + parts.append("【regress-guard 治理上下文】\n" + "\n".join(f" {l}" for l in gov_lines)) + if reminders: + parts.append("【regress-guard 需求入口检查】\n" + "\n".join(f" {r}" for r in reminders)) + if parts: + print(json.dumps({"additionalContext": "\n".join(parts)}, ensure_ascii=False)) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/read_before_edit_guard.py b/plugins/regress-guard/hooks/scripts/read_before_edit_guard.py new file mode 100755 index 0000000..e834637 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/read_before_edit_guard.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""read_before_edit_guard.py — "先读后改"门禁。 + +通过命令行参数区分模式: + python3 read_before_edit_guard.py post → PostToolUse 模式(记录 Read) + python3 read_before_edit_guard.py pre → PreToolUse 模式(拦截 Edit/Write) + +PostToolUse(Read) → 每次读文件,计数 +1 +PreToolUse(Edit/Write) → 检查本轮 Read 次数是否足够 + +状态文件:系统临时目录 regress-guard-read-counter.json(按 sessionId 隔离) + +规则: + - 默认比例 3:1(每改 1 个文件前至少读 3 个) + - 可在 .regress/config.json 用 read_before_edit_ratio 调整 + - 新文件创建(Write 不存在的文件)豁免 + - .regress/ 和配置文件修改豁免 + +退出码:0=放行,2=阻断 +""" +import sys +import os +import json +import tempfile + +DEFAULT_RATIO = 3 +from datetime import datetime + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) + +# 指纹哨兵:本会话自己刚改过的文件(改后到下次 Read 之间挂起指纹校验) +SELF_EDITED = -1 + + +def _fingerprint(fp): + """文件指纹 [mtime_ns, size]——"每一粒灰尘都必须对得上"(公理二)。 + + Read 时采集,Edit/Write 前复核;不一致 = 读后被外部修改(另一会话、 + git checkout、格式化进程……),盲改会基于过期认知,必须强制重读。 + """ + try: + st = os.stat(fp) + return [st.st_mtime_ns, st.st_size] + except OSError: + return None + + +def get_state_path(): + # P2#17:按会话分文件——全会话共享单文件在并行钩子/两会话下互相丢计数 + # (既有误锁也有漏拦),旧全局文件留给兼容读取 + sid = (os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") or "default") + import hashlib as _h + key = _h.md5(sid.encode()).hexdigest()[:8] + return os.path.join(tempfile.gettempdir(), + f"regress-guard-read-counter-{key}.json") + + +def load_state(): + try: + with open(get_state_path(), encoding="utf-8") as f: + return json.load(f) + except (IOError, json.JSONDecodeError): + return {} + + +def save_state(state): + try: + with open(get_state_path(), "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False) + except (IOError, OSError): + pass + + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else "pre" + + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.exit(0) + + tool_name = data.get("tool_name", "") + tool_input = data.get("tool_input", data) if isinstance(data, dict) else {} + if not isinstance(tool_input, dict): + tool_input = {} + + session_id = ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default" + ) + + # 读配置(支持 monorepo:从多个位置查找 .regress/) + project_dir = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + # 向上查找 .regress/config.json + config_path = None + search_dir = project_dir + for _ in range(10): # 最多向上 10 级 + candidate = os.path.join(search_dir, ".regress", "config.json") + if os.path.exists(candidate): + config_path = candidate + break + parent = os.path.dirname(search_dir) + if parent == search_dir: + break + search_dir = parent + + ratio = DEFAULT_RATIO # P2#25:单一常量(旧代码无 config 缺省 2、有 config 缺省 3、docstring 写 3——三处打架) + if config_path: + try: + with open(config_path, encoding="utf-8") as f: + ratio = json.load(f).get("read_before_edit_ratio", DEFAULT_RATIO) + except (IOError, json.JSONDecodeError): + pass + + # ratio=0 → 关闭此门禁 + if ratio <= 0: + sys.exit(0) + + state = load_state() + sess = state.get(session_id, { + "read_count": 0, "edit_count": 0, + "read_files": [], "last_reset": datetime.now().isoformat() + }) + + # ─── Post 模式:记录 Read ───────────────────────── + if mode == "post" and tool_name == "Read": + fp = tool_input.get("file_path", "") + if fp: + if fp not in sess["read_files"]: + sess["read_files"].append(fp) + fps = sess.get("read_fps") or {} + fp_val = _fingerprint(fp) + if fp_val: + fps[fp] = fp_val + sess["read_fps"] = fps + sess["read_count"] += 1 + state[session_id] = sess + save_state(state) + sys.exit(0) + + # ─── Pre 模式:拦截 Edit/Write ──────────────────── + if mode == "pre" and tool_name in ("Edit", "Write", "ApplyPatch"): + fp = tool_input.get("file_path", "") + + # 指纹复核(公理二):读后文件被外部改过 → 禁止盲改,强制重读 + fps = sess.get("read_fps") or {} + recorded = fps.get(fp) + if recorded not in (None, SELF_EDITED): + current = _fingerprint(fp) + if current is not None and current != recorded: + print( + f"REGRESS-GUARD: 🪞 文件指纹不匹配\n" + f" {fp}\n" + f" 最后一次读取后文件被外部修改(另一会话/git/格式化进程)。\n" + f" 你记忆里的内容已过期,禁止基于过期认知盲改。\n" + f" → 先重新 Read 该文件,确认现状后再改。", + file=sys.stderr + ) + sys.exit(2) + + # 豁免:新文件创建 + if tool_name == "Write" and fp and not os.path.exists(fp): + sess["edit_count"] += 1 + state[session_id] = sess + save_state(state) + sys.exit(0) + + # 豁免:框架自身文件 + if ".regress/" in fp or fp.endswith("AGENTS.md") or "regress-guard" in fp: + sys.exit(0) + + def _allow_edit(): + sess["edit_count"] += 1 + if fp: + # 自己改的:挂起指纹校验直到下次 Read(改后 mtime 必然变化) + sess.setdefault("read_fps", {})[fp] = SELF_EDITED + state[session_id] = sess + save_state(state) + sys.exit(0) + + # 豁免:目标文件本轮已读过(允许迭代修改同一文件) + if fp and fp in sess.get("read_files", []): + _allow_edit() + + # 检查:读次数 >= (改次数+1) * ratio + required = (sess["edit_count"] + 1) * ratio + if sess["read_count"] < required: + deficit = required - sess["read_count"] + print( + f"REGRESS-GUARD: ⚠️ 先读后改门禁\n" + f" 本轮已读 {sess['read_count']} 个文件,已改 {sess['edit_count']} 个。\n" + f" 规则:每改 1 个文件前至少读 {ratio} 个(当前需 {required},还差 {deficit})。\n" + f" 目标文件 {fp} 本轮尚未读取。\n\n" + f" 请先用 Read 读取目标文件及其依赖,理解上下文后再改。\n" + f" (关闭此门禁:.regress/config.json 设 read_before_edit_ratio: 0)", + file=sys.stderr + ) + sys.exit(2) + else: + _allow_edit() + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/reflection_check.py b/plugins/regress-guard/hooks/scripts/reflection_check.py new file mode 100755 index 0000000..8a5e912 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/reflection_check.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +"""Stop hook:AI 准备结束当前轮次时注入"反思提醒"。 + +不阻断(exit 0),只注入 additionalContext 提醒 AI 自查。 + +ZCode 的 Stop 事件在 AI 生成完回复准备结束时触发, +通过 additionalContext 注入反思清单,让 AI 自查后再输出最终回复。 +""" +import sys +import os +import json +import glob +import re +import tempfile +import time +import urllib.request + +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) +from manifest_fields import ACTIVE_STATUS_RE as _MF_ACTIVE_STATUS_RE # v1.20 单一来源 + + +def _git_changed_files(project_dir, cap=50): + """改动+未跟踪文件(git status --porcelain 一次拿全)。非 git 仓库返回空。""" + import subprocess + try: + proc = subprocess.run( + ["git", "status", "--porcelain", "-uall"], # -uall:未跟踪目录展开到文件 + capture_output=True, text=True, timeout=5, cwd=project_dir, + ) + except Exception: + return [] + out = [] + for ln in (proc.stdout or "").splitlines(): + path = ln[3:].strip() if len(ln) > 3 else "" + if " -> " in path: # 重命名取新路径 + path = path.split(" -> ")[-1] + path = path.strip('"').strip() + if path and not path.startswith(".regress/"): + out.append(path) + return out[:cap] + + +def _in_manifest(path, manifest_entries): + """路径是否落在清单条目里(支持相对/绝对差异与目录通配前缀)。""" + p = path.strip("/") + for ent in manifest_entries: + e = ent.strip().strip('"').strip("/") + if not e: + continue + if e.endswith("*"): + if p.startswith(e[:-1]): + return True + elif p == e or p.endswith("/" + e) or e.endswith("/" + p): + return True + return False + + +def _recent_consult(window_min=15): + """顾问审计(dsh 侧 audit.jsonl)近 window_min 分钟是否有咨询记录。""" + path = ( + os.environ.get("ADVISOR_AUDIT_PATH") + or os.path.join(os.path.expanduser("~"), ".dsh", "storages", "advisor", "audit.jsonl") + ) + try: + with open(path, encoding="utf-8") as f: + lines = f.readlines()[-50:] + except (IOError, OSError): + return False + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(minutes=window_min) + for ln in lines: + try: + if datetime.fromisoformat(json.loads(ln)["ts"][:19]) >= cutoff: + return True + except (ValueError, KeyError, IndexError, json.JSONDecodeError): + continue + return False + + +def _read_advisor_token(): + """本地 dsh 顾问 token:env 优先,fallback ~/.dsh/settings.yaml 的 advisor.token。""" + tok = os.environ.get("ADVISOR_DSH_TOKEN") + if tok: + return tok + try: + path = os.path.join(os.path.expanduser("~"), ".dsh", "settings.yaml") + with open(path, encoding="utf-8") as f: + text = f.read() + parts = re.split(r"^advisor:\s*$", text, flags=re.M) + if len(parts) < 2: + return None + seg = re.split(r"^[A-Za-z][\w-]*:\s*$", parts[1], flags=re.M)[0] + m = re.search(r"^\s{2,}token:\s*['\"]?([^'\"\s]+)", seg, re.M) + return m.group(1) if m else None + except (IOError, OSError): + return None + + +AUTO_CONSULT_COOLDOWN_S = 180 # Stop 钩子可能连续触发,3 分钟内不重复自动咨询 + +# v2.4 单次调用:服务端持有唯一权威超时(30 分钟,超时回 504), +# 客户端等 1805s 收下它——Stop 钩子注册闸已放宽到 1810s。 +# 服务端默认路由:deepseek-official/deepseek-v4-flash(model 可省略; +# 设 ADVISOR_LOCAL_MODEL="provider/model" 可显式覆盖) +AUTO_CONSULT_TIMEOUT_S = 1805 + + +def _consult_post_once(url, token, prompt, timeout_s): + """单次请求,返回 (answer, finish_reason) 或 None(网络层失败/空答)。""" + body = {"messages": [{"role": "user", "content": prompt}]} + override = os.environ.get("ADVISOR_LOCAL_MODEL") + if override: + body["model"] = override + req = urllib.request.Request( + url, data=json.dumps(body).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}) + try: + with urllib.request.urlopen(req, timeout=timeout_s) as r: + data = json.load(r) + except Exception: + return None + ch = (data.get("choices") or [{}])[0] + answer = ((ch.get("message") or {}).get("content") or "").strip() + return (answer, ch.get("finish_reason")) if answer else None + + +def _auto_consult(question, ferry_body): + """钩子直接向本地顾问求第二意见——"智商在线"的机器层。 + + 与主AI自行 consult 的区别:摆渡包是钩子采集的客观数据(失败清单/git 改动/ + 命令普查),未经主AI筛选——主AI的盲区恰好会漏掉最关键的那条,这层绕开它。 + 安全边界:仅本地 dsh 端点(上下文不出机器),绝不自动走云端/scout。 + 失败返回 None → 降级为指示主AI自行 consult(可 scout,可外发但那是主AI的显式决策)。 + """ + if os.environ.get("REGRESS_AUTO_CONSULT", "").lower() in ("off", "0", "false"): + return None + session = ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default" + ) + cool_path = os.path.join( + tempfile.gettempdir(), f"regress-guard-lastconsult-{session}") + now = time.time() + try: + if now - float(open(cool_path).read().strip()) < AUTO_CONSULT_COOLDOWN_S: + return None + except (IOError, ValueError): + pass + token = _read_advisor_token() + if not token: + return None + url = os.environ.get("ADVISOR_DSH_URL", "http://127.0.0.1:3080/v1/chat/completions") + prompt = ( + "你是主AI编码助手的第二意见顾问。主AI在任务中卡住;下面是治理钩子机器采集的" + "客观数据(失败清单/git改动/命令普查),未经主AI筛选。\n" + "请:1)诊断最可能根因 2)给出与已失败方法根本不同的推荐方案 3)注明确定程度。" + "诚实义务:无把握时第一句声明\"低把握\",禁止虚构API/参数。总长≤300字。\n\n" + f"【卡点】{question}\n\n【客观数据】\n{ferry_body}" + ) + answer, truncated = None, False + # P1#9 尝试戳先行:发起前就写冷却——顾问进程 hang 时**每个 Stop 不再重等 + # 1805s**(单次深度等待是设计,重复等待才是病;顾问预审定论:超时本身保留)。 + # 失败也进冷却 = 失败的自动咨询 3 分钟内不重试(Stops 连发场景不雪崩)。 + try: + with open(cool_path, "w", encoding="utf-8") as f: + f.write(str(now)) + except (IOError, OSError): + pass + got = _consult_post_once(url, token, prompt, AUTO_CONSULT_TIMEOUT_S) + if got is not None: + answer, finish = got + truncated = finish == "length" + if not answer or len(answer) < 10: + return None + if truncated: + answer += "\n(注:顾问意见疑似被 max_tokens 截断,仅采纳已完整表述的部分)" + return answer + + +def _git_diff_stat(project_dir, max_lines=15, cap=900): + """客观数据摆渡:未提交改动概览。非 git 仓库 / 超时 / 无改动返回空。""" + import subprocess + try: + proc = subprocess.run( + ["git", "diff", "--stat", "HEAD"], + capture_output=True, text=True, timeout=5, cwd=project_dir, + ) + except Exception: + return "" + stat = (proc.stdout or "").strip() + if not stat: + return "" + body = "\n".join(f" {ln}" for ln in stat.splitlines()[:max_lines]) + return body[:cap] + + +def _stable_failure_sigs(project_dir): + """考古地层里跨会话重复的失败签名(慢性已知,归 /regress:learn 管,不当急性卡死)。""" + try: + lib_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") + if lib_dir not in sys.path: + sys.path.insert(0, lib_dir) + from journal import journal_digest + return {d.get("sig") for d in journal_digest(project_dir)} + except Exception: + return set() + + +def check_context(project_dir): + """检查本轮工作状态,返回需要反思的点。""" + regress_dir = os.path.join(project_dir, ".regress") + if not os.path.isdir(regress_dir): + return None # 未接入 + + reminders = [] + + # 0. 轮内失败检测(用户无感层)—— v2 信号融合:3 次失败 ≠ 一定卡死。 + # 两个降级信号(任一命中即从"急性风暴"降为软提示,不烧顾问): + # ① 迭代中:失败签名在窗口内也有**成功**执行(TDD 红绿循环、修一步跑一步) + # ② 慢性已知:签名在考古地层里跨会话重复(稳定经验,归 /regress:learn 管) + # 只有"全新签名 + 零成功 + ≥3 次"才是急性卡死(强注入 + 机器摆渡咨询)。 + # 摆渡包自组装:失败清单原文 + git diff --stat 直接附在注入里—— + # 客观工件不经过 AI 的相关性过滤(自举问题)。 + try: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from fail_watch import recent_failures + from risk_watch import recent_usage + fails = recent_failures(10) + if len(fails) >= 3: + success_sigs = {u.get("sig") for u in recent_usage(15)} + stable_sigs = _stable_failure_sigs(project_dir) + acute = [f for f in fails + if f.get("sig", "?") not in success_sigs + and f.get("sig", "?") not in stable_sigs] + if len(acute) >= 3: + from collections import Counter + sig_counts = Counter(f.get("sig", "?") for f in acute) + fail_lines = "\n".join( + f" - {sig} ×{n}" for sig, n in sig_counts.most_common(5) + ) + lines = [ + f"⚙️ 轮内失败检测:近 10 分钟 {len(acute)} 次工具失败——" + f"当前方法大概率错误,禁止原样重试同一命令。", + " 失败清单(原文逐条摆渡,禁止自行过滤/压缩):", + fail_lines, + ] + diff_stat = _git_diff_stat(project_dir) + if diff_stat: + lines.append(" 未提交改动(git diff --stat):") + lines.append(diff_stat) + lines.append( + " → 立即调用 mcp__advisor__consult(mode=\"scout\"):" + "question=当前核心问题,context 必须原样携带上面全部条目+报错原文。" + "顾问看不到你的屏幕,摆渡包缺一条它就瞎一条;它不掌握的领域知识" + "(本仓库私有上下文、你正在用的工具/版本行为)全部依赖你摆渡。" + ) + reminders.append("\n".join(lines)) + # 机器层第二意见:客观数据直接送本地顾问,不经主AI筛选(绕开盲区) + ferry_body = fail_lines + (("\n" + diff_stat) if diff_stat else "") + opinion = _auto_consult( + f"近10分钟 {len(acute)} 次工具失败,当前方法大概率错误,禁止原样重试", + ferry_body, + ) + if opinion: + reminders.append( + "🧠 自动第二意见(本地顾问已收到机器摆渡包,未经你筛选):\n" + " 【顾问意见·仅供参考·决策权在你】\n" + opinion + + "\n 若不可行:mcp__advisor__consult(mode=\"scout\")带搜索重问;" + "采纳与否必须在回复中标注。" + ) + else: + mitigated = len(fails) - len(acute) + reasons = [] + if success_sigs & {f.get("sig", "?") for f in fails}: + reasons.append("失败签名窗口内也有成功执行(迭代/TDD 推进中)") + if stable_sigs & {f.get("sig", "?") for f in fails}: + reasons.append("属考古地层跨会话慢性失败(/regress:learn 的领域)") + reminders.append( + f"ℹ️ 失败信号 {len(fails)} 次已观察,但非急性卡死" + f"({'; '.join(reasons) or '证据不足'})——继续当前节奏推进;" + f"若真卡死会升级为急性警报(零成功+全新签名)。" + ) + except Exception: + pass # 探测器不可用不阻断反思 + + # 1. 检查是否有未提交的改动但没跑 track + # 活跃状态 = 封闭活跃集(planning/in-progress/verifying),与 manifest_parser 语义一致 + manifests_dir = os.path.join(regress_dir, "manifests") + active_manifests = [] + manifest_files = set() + if os.path.isdir(manifests_dir): + for f in sorted(glob.glob(os.path.join(manifests_dir, "*.md")), reverse=True): + # v1.23.2 长寿扫描:只读 frontmatter(done 堆积后 O(frontmatter); + # 正文引用状态词不再诈尸;file: 条目全在 frontmatter 的 planned/actual 里) + try: + from manifest_parser import read_frontmatter + content = read_frontmatter(f) + if _MF_ACTIVE_STATUS_RE.search(content): + active_manifests.append(os.path.basename(f)) + for m in re.finditer(r'file:\s*["\']?([^"\'\n#]+)', content): + manifest_files.add(m.group(1).strip()) + except (IOError, OSError): + pass + + if active_manifests: + reminders.append( + f"📋 有活跃的回归清单({', '.join(active_manifests[:3])})," + f"确认已跑 /regress:track 检查 F3 了吗?" + ) + + # 1.5 方向漂移自动检测(计划-实现对齐,无需人类开口) + # git 改动不在任何活跃清单的 planned/actual 里 = F3 嫌疑或需求漂移 + changed = _git_changed_files(project_dir) + drift = [c for c in changed if not _in_manifest(c, manifest_files)] + if drift: + reminders.append( + f"🧭 方向漂移检测:git 改动里有 {len(drift)} 个文件不在活跃清单的 " + f"planned/actual 中(如 {', '.join(drift[:4])})——要么是 F3" + f"(改了计划外的东西),要么需求已变。先 /regress:track 回写清单," + f"或调 mcp__advisor__consult 把【原始需求+漂移文件列表】发给顾问" + f"判断是否偏离需求,判断后再继续。" + ) + + # 2. 检查是否有 bypass 债务 + config_path = os.path.join(regress_dir, "config.json") + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + if config.get("bypass_until"): + reminders.append( + "⚠️ 当前处于 bypass 模式——本轮的改动未经测试验证," + "记得事后补 /regress:verify" + ) + except (IOError, json.JSONDecodeError): + pass + + # 3. 检查 bypass.log 是否有未还的债 + bypass_log = os.path.join(regress_dir, "bypass.log") + if os.path.exists(bypass_log): + try: + with open(bypass_log, encoding="utf-8") as f: + lines = f.readlines() + if len(lines) > 0: + reminders.append( + f"📝 bypass.log 有 {len(lines)} 条记录," + f"确认这些绕过的改动是否已补回归测试。" + ) + except (IOError, OSError): + pass + + # 4. 检查"没读代码就改代码" + # Stop hook 的 stdin 含 response preview,检查本轮是否 Read 过但改了代码 + # 用 read_before_edit_guard 的计数状态判断 + state_file = _get_read_counter_path() + session_id = os.environ.get("CLAUDE_SESSION_ID", os.environ.get("ZCODE_SESSION_ID", "default")) + try: + import json as _json + with open(state_file, encoding="utf-8") as f: + state = _json.load(f) + sess = state.get(session_id, {}) + read_count = sess.get("read_count", 0) + edit_count = sess.get("edit_count", 0) + # 如果改了代码但读的很少 + if edit_count > 0 and read_count < edit_count: + reminders.append( + f"📚 本轮改了 {edit_count} 个文件但只读了 {read_count} 个——" + f"确认你理解了被改代码的上下文吗?" + ) + + # 5. Pre-mortem:改了核心文件时触发经验预判 + if edit_count >= 3: + reminders.append( + f"🤔 Pre-mortem 检查:本轮改了 {edit_count} 个文件。" + f"假设这次提交上线后出bug,最可能是什么?花10秒预判一下。" + ) + except (IOError, _json.JSONDecodeError, KeyError): + pass + + # 6. 高风险动作无第二意见(决策点自动覆盖,无需人类开口) + # 破坏性/不可逆操作近 15 分钟有执行、顾问审计零记录 → 注入补审指令 + try: + from risk_watch import recent_risks + risks = recent_risks(15) + if risks and not _recent_consult(15): + details = ";".join(sorted({r.get("detail", "?") for r in risks})[:3]) + reminders.append( + f"⚠️ 高风险动作未过审:近 15 分钟执行了破坏性/不可逆操作({details})" + f"但顾问零咨询记录。补一次 mcp__advisor__consult:把【动作+理由+" + f"回滚方案】发给顾问确认必要性;若明确安全(如清单内清理)," + f"在回复中写明依据。" + ) + except Exception: + pass # 探测器不可用不阻断反思 + + # 7. 打转检测(成功但无进展的"变体重试硬闯",无需失败风暴触发) + # 同一命令 15 分钟 ≥4 次且其中 ≥2 次失败 = 在用小变体撞同一堵墙 + try: + from risk_watch import recent_usage + from fail_watch import recent_failures + usage = recent_usage(15) + if usage: + from collections import Counter + use_counts = Counter(u.get("sig", "?") for u in usage) + fail_counts = Counter(f.get("sig", "?") for f in recent_failures(15)) + for sig, n in use_counts.most_common(3): + fails_n = fail_counts.get(sig, 0) + if n >= 4 and fails_n >= 2: + reminders.append( + f"🔄 打转检测:命令「{sig}」15 分钟内执行 {n} 次、" + f"失败 {fails_n} 次——你在用小变体硬闯同一条路。" + f"立即 mcp__advisor__consult(mode=\"scout\"," + f"摆渡全部失败输出)换根本不同的方法," + f"禁止再产生第 {n + 1} 个变体。" + ) + churn_ferry = ( + f"重复命令: {sig} ×{n}(失败 {fails_n} 次)\n" + "同期失败签名: " + ", ".join(f"{s}×{c}" for s, c in fail_counts.most_common(5)) + ) + opinion = _auto_consult( + f"命令「{sig}」15分钟执行{n}次失败{fails_n}次,疑似变体硬闯同一条路", + churn_ferry, + ) + if opinion: + reminders.append( + "🧠 自动第二意见(本地顾问,机器摆渡包):\n" + " 【顾问意见·仅供参考·决策权在你】\n" + opinion + ) + break # 一条足够,避免注入刷屏 + if n >= 6 and fails_n == 0: + reminders.append( + f"🔄 打转自查:命令「{sig}」已成功执行 {n} 次——" + f"确认每次都在推进(轮询/等待属正常)," + f"否则同上换方法或 consult。" + ) + break + except Exception: + pass # 探测器不可用不阻断反思 + + # 8. 意图复述主动提示(半无感层:人类只需扫一眼,无需对话触发) + # "计划本身就理解错"会静默传播,机器无法替代人类意图——唯一能做的是 + # 让 AI 定期把理解亮出来供人扫视,把"做完才发现方向错"提前到"扫一眼就发现" + if active_manifests: + interval_s = os.environ.get("REGRESS_RESTATE_INTERVAL_S", "600") + try: + interval = float(interval_s) + except ValueError: + interval = 600.0 + if interval > 0: + newest = active_manifests[0] # 文件名倒序,第一个是最新清单 + try: + state_p = os.path.join( + tempfile.gettempdir(), + "regress-guard-lastrestate-{}.txt".format( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default"), + ) + now_s = time.time() + need = True + try: + prev = open(state_p, encoding="utf-8").read().strip().split("|") + if len(prev) == 2 and prev[0] == newest and now_s - float(prev[1]) < interval: + need = False + except (IOError, ValueError): + pass + if need: + reminders.append( + "🎯 意图复述(主动提示层):在回复末尾附三行块,供人类扫一眼纠偏:\n" + " 当前理解:<一句话复述任务目标,含边界(做什么/不做什么)>\n" + " 进度:<清单 F 项状态一行>\n" + " 下一步:<即将做的具体动作>\n" + " 若理解有偏差,人类只需回「不对」即触发顾问对质;" + "不要等做完才发现方向错。" + ) + with open(state_p, "w", encoding="utf-8") as f: + f.write(f"{newest}|{now_s}") + except Exception: + pass # 状态读写失败不阻断反思 + + # 9. 决策落盘提醒(公理二:决策链物质化——契约约定 AI 手写 decisions.md + # 全靠自觉,这里是机器层补口。两类最该刻进石头的决策点: + # ① 用户纠正(地层里有 user_correction 化石):错误方向必须留下尸体, + # 防未来会话把否决路线再走一遍 + # ② 顾问意见刚被消费(audit 近期有咨询):采纳标注的 durable 半边 + # ——audit.jsonl 里 adopted 是 null,落 decisions.md 才闭环) + try: + from datetime import datetime as _dt + lib_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") + if lib_dir not in sys.path: + sys.path.insert(0, lib_dir) + from journal import load_journal + from datetime import timedelta as _td + cutoff = _dt.now() - _td(minutes=10) + + def _ev_ts(e): + try: + return _dt.fromisoformat(e["ts"][:19]) + except (ValueError, KeyError, TypeError): + return None + recent_correction = any( + e.get("kind") == "user_correction" and (_ev_ts(e) or cutoff) >= cutoff + for e in load_journal(project_dir)[-20:] + ) + triggers = [] + if recent_correction: + triggers.append(( + "correction", + "✍️ 决策落盘(用户纠正):把【错误方向+修正后的方向+一句理由】" + "append 进 .regress/decisions.md(否决过的方案必须留下尸体," + "防未来会话重走);文件不存在按 init 模板创建。", + )) + if _recent_consult(10): + triggers.append(( + "consult", + "✍️ 决策落盘(顾问意见):把【已咨询→采纳/部分采纳/不采纳+一句依据】" + "append 进 .regress/decisions.md——审计闭环的 durable 半边" + "(audit.jsonl 的 adopted 字段仍是 null,聊天里的标注会随上下文蒸发)。", + )) + session = ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default" + ) + for kind, msg in triggers: + cool_p = os.path.join( + tempfile.gettempdir(), + f"regress-guard-lastdecision-{session}-{kind}") + try: + if time.time() - float(open(cool_p).read().strip()) < 1200: + continue # 同类 20 分钟冷却,防唠叨 + except (IOError, ValueError): + pass + reminders.append(msg) + with open(cool_p, "w", encoding="utf-8") as f: + f.write(str(time.time())) + except Exception: + pass # 决策提醒失败不阻断反思 + + return reminders if reminders else None + + +def _get_read_counter_path(): + import tempfile + return os.path.join(tempfile.gettempdir(), "regress-guard-read-counter.json") + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + + project_dir = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + + reminders = check_context(project_dir) + if reminders: + reflection_prompt = ( + "【regress-guard 反思检查】在结束本轮回复前,确认:\n" + + "\n".join(f" • {r}" for r in reminders) + + "\n\n如果以上有遗漏,先补做再结束。" + ) + print(json.dumps({"additionalContext": reflection_prompt})) + # exit 0 = 不阻断,可以继续(最多 3 次继续请求) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/risk_watch.py b/plugins/regress-guard/hooks/scripts/risk_watch.py new file mode 100644 index 0000000..2bcf1fa --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/risk_watch.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""risk_watch — PostToolUse 探测器(风险动作 + 命令重复普查,用户无感层)。 + +只写事实,不做判断(判断由 reflection_check 统一完成——探测器哑、评估器独裁): + 1. 破坏性/不可逆动作 → risk 事件(高精度模式,宁可漏报不误报) + 2. 成功执行的 Bash 命令 → usage 普查(供"原地打转"检测:变体重试硬闯) + 3. 清单编辑 → 审批化石(plan_created/refined/approved/cancelled 入地层) + +状态: /tmp/regress-guard-risk-<session>.jsonl {"ts","tool","sig","detail"} + /tmp/regress-guard-usage-<session>.jsonl {"ts","tool","sig"} +""" +import sys +import os +import json +import re +import tempfile +from datetime import datetime, timedelta + +# 考古地层(公理三):风险动作同时埋进项目内地层(/tmp 重启即失) +# 字段读取统一走 lib/manifest_fields(v1.20 单一来源) +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) +from manifest_fields import parse_core # noqa: E402 +try: + import journal + from journal import journal_append, load_journal +except ImportError: # lib 缺失时地层降级关闭 + def journal_append(*a, **k): + return False + + journal = None + load_journal = None + + +def session_id(): + return ( + os.environ.get("CLAUDE_SESSION_ID") + or os.environ.get("ZCODE_SESSION_ID") + or "default" + ) + + +def _state_path(kind): + return os.path.join( + tempfile.gettempdir(), f"regress-guard-{kind}-{session_id()}.jsonl" + ) + + +# 破坏性/不可逆模式(高精度优先:误报会让 AI 学会无视注入) +RISKY_BASH = [ + re.compile(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f"), # rm -rf / -fr / -ravf + re.compile(r"\brm\s+-[a-zA-Z]*f[a-zA-Z]*r"), + re.compile(r"\bgit\s+push\b.*(--force\b|-f\b)"), + re.compile(r"\bgit\s+reset\s+--hard"), + re.compile(r"\b(DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE)\b", re.I), + re.compile(r"\bchmod\s+-R\s+777"), + re.compile(r"\bmkfs\b|\bdd\s+if="), +] + + +def is_risky(tool, tool_input): + """返回风险明细(命中原因)或 None。""" + if not isinstance(tool_input, dict): + return None + if tool == "Bash": + cmd = (tool_input.get("command") or "").strip() + for pat in RISKY_BASH: + if pat.search(cmd): + return cmd[:120] + return None + fp = str(tool_input.get("file_path") or tool_input.get("path") or "") + # 秘密文件:仅精确 .env(.env.example 等模板豁免) + if os.path.basename(fp) == ".env": + return fp[:120] + for frag in ("credentials", "id_rsa", ".ssh/"): + if frag in fp: + return fp[:120] + return None + + +def normalize_sig(tool, tool_input): + """与 fail_watch 相同的归一化:Bash 取前两段;Edit/Write 取文件路径。""" + if not isinstance(tool_input, dict): + return "?" + if tool == "Bash": + cmd = (tool_input.get("command") or "").strip() + parts = cmd.split() + return " ".join(parts[:2])[:60] if parts else "?" + fp = tool_input.get("file_path") or tool_input.get("path") or "?" + return str(fp)[:120] + + +def _append(path, event): + try: + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + except (IOError, OSError): + pass + + +def _manifest_event(tool, tool_input): + """审批化石(v1.13/v1.18):agent 编辑清单时埋 创建/细化/批准/取消/受阻/临行 事件。 + + plan_approve.py 自己埋转写事件(脚本路径不经过本探测器);这里补 agent 的 + Edit/Write 路径。dedup:同类同清单只埋一次(plan_refined 除外——每次细化 + 都是一次真实的计划变更化石;task_blocked 按 episode 尾判)。 + 字段读取统一走 lib/manifest_fields(v1.20 单一来源)。 + """ + fp = str(tool_input.get("file_path") or tool_input.get("path") or "") + norm = fp.replace(os.sep, "/") + if "/.regress/manifests/" not in norm or not norm.endswith(".md"): + return + try: + with open(fp, encoding="utf-8") as f: + content = f.read() + except (IOError, OSError): + return + core = parse_core(content) + if not core: + return + + mid = core["id"] or os.path.basename(fp) + status = core["status"] or "in-progress" + approved_at = core["approved_at"] + provisional_at = core["provisional_at"] + + start = os.path.dirname(fp) + seen = set() + project_dir = None + if journal is not None: + project_dir = journal._find_project_dir(start) + if project_dir: + seen = {(e.get("kind"), e.get("manifest_id")) + for e in load_journal(project_dir)} + + if status == "cancelled": + if ("plan_cancelled", mid) not in seen: + journal_append("plan_cancelled", start_dir=start, manifest_id=mid) + elif status == "blocked": + # episode 去重:同一清单最近一次 task_blocked 之后没有 task_unblocked 才埋 + # (脚本路径 plan_approve.py 自己埋,这里补 agent 手工编辑路径) + timeline = [e for e in (load_journal(project_dir) if project_dir else []) + if e.get("manifest_id") == mid + and e.get("kind") in ("task_blocked", "task_unblocked")] + if not timeline or timeline[-1].get("kind") != "task_blocked": + journal_append("task_blocked", start_dir=start, manifest_id=mid, + note="agent编辑路径") + elif approved_at: + if ("plan_approved", mid) not in seen: + journal_append("plan_approved", start_dir=start, + manifest_id=mid, approved_at=approved_at) + elif provisional_at and status in ("in-progress", "verifying"): + # 临行(v1.18 伪全自动):agent 手工编辑路径的观察(脚本路径 plan_approve 自己埋) + if ("provisional_start", mid) not in seen: + journal_append("provisional_start", start_dir=start, + manifest_id=mid, note="agent编辑路径") + elif status == "planning": + if tool == "Write" and ("plan_created", mid) not in seen: + journal_append("plan_created", start_dir=start, manifest_id=mid) + elif tool == "Edit": + journal_append("plan_refined", start_dir=start, manifest_id=mid) + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + if not raw: + sys.exit(0) + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.exit(0) + + tool = data.get("tool_name", "?") + tool_input = data.get("tool_input", data if isinstance(data, dict) else {}) + + if tool in ("Edit", "Write"): + _manifest_event(tool, tool_input) + + detail = is_risky(tool, tool_input) + if detail: + _append(_state_path("risk"), { + "ts": datetime.now().isoformat(), + "tool": tool, + "sig": normalize_sig(tool, tool_input), + "detail": detail, + }) + # 化石入地层(usage 普查不入——高频低值,会撑爆地层) + journal_append("risk_action", tool=tool, + sig=normalize_sig(tool, tool_input), detail=detail) + + # usage 普查只记 Bash(Edit/Write 同文件迭代是正常工作流) + if tool == "Bash": + _append(_state_path("usage"), { + "ts": datetime.now().isoformat(), + "tool": tool, + "sig": normalize_sig(tool, tool_input), + }) + sys.exit(0) + + +def _recent(path, window_min): + events = [] + try: + with open(path, encoding="utf-8") as f: + for line in f: + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except (IOError, OSError): + return [] + cutoff = datetime.now() - timedelta(minutes=window_min) + recent = [e for e in events if _within(e, cutoff)] + if len(recent) < len(events): # 顺手截掉过期,防无限增长 + # P2#22:temp+os.replace 原子截断(与 fail_watch 同病同修) + try: + tmp = path + ".trim.tmp" + with open(tmp, "w", encoding="utf-8") as f: + for e in recent: + f.write(json.dumps(e, ensure_ascii=False) + "\n") + os.replace(tmp, path) + except (IOError, OSError): + pass + return recent + + +def _within(event, cutoff): + """容错比较:统一剥时区(aware/naive 混比会 TypeError,静默杀探测器)。""" + try: + dt = datetime.fromisoformat(event["ts"]) + if dt.tzinfo: + dt = dt.replace(tzinfo=None) + return dt >= cutoff + except (ValueError, KeyError, TypeError): + return False + + +def recent_risks(window_min=15): + """近 window_min 分钟的破坏性动作(供 reflection_check 调用)。""" + return _recent(_state_path("risk"), window_min) + + +def recent_usage(window_min=15): + """近 window_min 分钟的 Bash 命令普查(供打转检测)。""" + return _recent(_state_path("usage"), window_min) + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/self_heal.py b/plugins/regress-guard/hooks/scripts/self_heal.py new file mode 100755 index 0000000..0c6cd83 --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/self_heal.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 +"""SessionStart 自愈脚本——每次 ZCode 启动时检查 regress-guard 文件完整性。 + +检查项: + 1. ~/.zcode/regress-guard-hooks/launcher.js 是否存在 + 2. ~/.zcode/regress-guard-hooks/pre_commit_guard.py 是否存在 + 3. ~/.zcode/regress-guard-hooks/lib/*.py 是否完整 + 4. ~/.zcode/skills/ 下的 3 个 skill 是否存在 + 5. config.json 中 hook 是否注册 + +如果发现缺失,尝试从源目录自动修复。 +如果有字段缺失但无法修复,输出警告(通过 additionalContext 注入对话)。 + +被 SessionStart hook 调用,输出 JSON 到 stdout。 +""" +import sys +import os +import re +import json +import glob + + +def _ver_gt(a, b): + """比较语义版本 a > b(如 '0.6.0' > '0.5.0')。""" + def parse(v): + try: + return tuple(int(x) for x in v.split(".")) + except (ValueError, AttributeError): + return (0, 0, 0) + return parse(a) > parse(b) + + +ZCODE_HOME = os.path.expanduser("~/.zcode") +HOOK_HOME = os.path.join(ZCODE_HOME, "regress-guard-hooks") +SKILLS_DIR = os.path.join(ZCODE_HOME, "skills") +COMMANDS_DIR = os.path.join(ZCODE_HOME, "commands") +CONFIG_FILE = os.path.join(ZCODE_HOME, "cli", "config.json") + +# 源目录(尝试多个位置) +SOURCE_CANDIDATES = [ + os.path.join(ZCODE_HOME, "workspace", "default", "regress-guard"), + os.path.join(os.getcwd(), "regress-guard"), + # 从 hook 脚本自身位置反推(hook 脚本在 regress-guard/hooks/scripts/lib/ 下) + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), + # 常见开发路径 + os.path.join(os.path.expanduser("~"), "regress-guard"), + os.path.join(os.path.expanduser("~"), "projects", "regress-guard"), +] + +# 完整契约清单(v1.26.1 补全;与源目录的漂移由 check_docs 守卫拦——病例:本次审计发现 +# 清单停在 v1.19,boundary_guard 等 5 脚本与 4 个 lib 不在清单,do_upgrade 半量拷贝) +REQUIRED_HOOK_FILES = ["launcher.js", "pre_commit_guard.py", "read_before_edit_guard.py", + "prompt_intercept.py", "reflection_check.py", "fail_watch.py", + "risk_watch.py", "compact_notice.py", "execution_valve.py", + "boundary_guard.py", "stop_notify.py", "plan_bridge.py"] +REQUIRED_LIB_FILES = [ + "manifest_parser.py", "git_diff_analyzer.py", + "test_runner.py", "history.py", "filelock.py", "self_heal.py", + "cochange_rules.py", + "journal.py", "plan_approve.py", "manifest_fields.py", "rules_ledger.py", + "notify.py", "wecom_notify.py", "facts.py", + "pending.py", "session_relay.py", "sentinel.py", "scan_check.py", + "secret_scan.py", "test_cache.py" +] +REQUIRED_COMMANDS = [ + "regress:init", "regress:plan", "regress:track", "regress:verify", + "regress:quick", "regress:bypass", "regress:learn", "regress:evolve", + "regress:trace", "regress:resume", "regress:finish", "regress:stats", + "regress:characterize", + "regress:install", "regress:uninstall", "regress:update" +] + +README_TEMPLATE_NAME = "regress-dir-readme.md" + + +def find_source(): + """找到插件源目录。""" + for path in SOURCE_CANDIDATES: + if os.path.isfile(os.path.join(path, "install.sh")): + return os.path.abspath(path) + return None + + +def get_installed_version(): + """读取已安装版本号。""" + meta_path = os.path.join(HOOK_HOME, ".source") + if os.path.exists(meta_path): + try: + with open(meta_path) as f: + for line in f: + if line.startswith("source_version="): + return line.strip().split("=", 1)[1] + except (IOError, OSError): + pass + return "0.0.0" + + +def get_source_version(source): + """读取源目录版本号。""" + pj = os.path.join(source, ".zcode-plugin", "plugin.json") + try: + with open(pj) as f: + import json + return json.load(f).get("version", "0.0.0") + except (IOError, json.JSONDecodeError): + return "0.0.0" + + +def do_upgrade(source): + """从源目录全量覆盖升级(commands/hooks/lib/scripts)。""" + import shutil + upgraded = [] + + # 清理旧版 skills(v1.1 砍掉了 skills 目录) + skills_dir_source = os.path.join(source, "skills") + if not os.path.isdir(skills_dir_source): + # 源已无 skills → 清理安装目录的旧 skills + if os.path.isdir(SKILLS_DIR): + for old_skill in ("regression-planning", "characterization-testing", + "change-impact-analysis", "requirement-parsing", + "adaptive-thinking", "adaptive-learning"): + old_path = os.path.join(SKILLS_DIR, old_skill) + if os.path.isdir(old_path): + shutil.rmtree(old_path, ignore_errors=True) + upgraded.append(f"清理旧skill:{old_skill}") + + # 清理旧版 evolution.py(v1.1 砍掉了) + old_evolution = os.path.join(HOOK_HOME, "lib", "evolution.py") + if os.path.exists(old_evolution) and not os.path.exists(os.path.join(source, "hooks", "scripts", "lib", "evolution.py")): + os.remove(old_evolution) + upgraded.append("清理旧lib:evolution.py") + + # commands + for cmd_file in os.listdir(os.path.join(source, "commands")): + if cmd_file.endswith(".md"): + shutil.copy2(os.path.join(source, "commands", cmd_file), COMMANDS_DIR) + upgraded.append(f"cmd:{cmd_file}") + + # hook scripts + lib:全量拷贝(v1.26.1——目录本身是单一来源,清单只做缺失检测; + # 旧实现按过时清单半量拷贝,升级机拿到陈旧 boundary_guard/缺失 rules_ledger) + # P2#23:原子替换(copy→tmp+os.replace)——就地截断写在升级窗口内可能让 + # 新起的钩子进程读到半截源码 SyntaxError→门禁崩溃即放行 + def _atomic_copy(src, dst): + tmp = dst + ".heal.tmp" + shutil.copy2(src, tmp) + os.replace(tmp, dst) + + scripts_dir = os.path.join(source, "hooks", "scripts") + for f in sorted(os.listdir(scripts_dir)): + if f.endswith((".py", ".js")) and f != "self_heal.py": + _atomic_copy(os.path.join(scripts_dir, f), os.path.join(HOOK_HOME, f)) + upgraded.append(f"hook:{f}") + lib_dir = os.path.join(scripts_dir, "lib") + for f in sorted(os.listdir(lib_dir)): + if f.endswith(".py"): + _atomic_copy(os.path.join(lib_dir, f), os.path.join(HOOK_HOME, "lib", f)) + upgraded.append(f"lib:{f}") + + # templates:全部部署(init 的 cp 引用 <插件路径>/templates/,缺文件即断链) + tpl_src = os.path.join(source, "templates") + tpl_dst = os.path.join(HOOK_HOME, "templates") + os.makedirs(tpl_dst, exist_ok=True) + for f in sorted(os.listdir(tpl_src)): + if f.endswith(".md"): + shutil.copy2(os.path.join(tpl_src, f), os.path.join(tpl_dst, f)) + upgraded.append(f"tpl:{f}") + + # self_heal 本身 + src = os.path.join(source, "hooks", "scripts", "self_heal.py") + if os.path.exists(src): + shutil.copy2(src, os.path.join(HOOK_HOME, "lib", "self_heal.py")) + + # 更新版本标记 + meta_path = os.path.join(HOOK_HOME, ".source") + with open(meta_path, "w") as f: + f.write(f"source_path={source}\n") + f.write(f"source_version={get_source_version(source)}\n") + import datetime + f.write(f"upgraded_at={datetime.datetime.now().isoformat()}\n") + + return upgraded + + +def _current_regress_dir(): + """定位当前项目的 .regress/(向上最多 10 级)。""" + d = ( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd() + ) + d = os.path.abspath(d) + for _ in range(10): + cand = os.path.join(d, ".regress") + if os.path.isdir(cand): + return cand + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + return None + + +def find_readme_template(): + """零号入口模板:安装目录 templates/ 或源目录 templates/。""" + cands = [os.path.join(HOOK_HOME, "templates", README_TEMPLATE_NAME)] + src = find_source() + if src: + cands.append(os.path.join(src, "templates", README_TEMPLATE_NAME)) + for c in cands: + if os.path.isfile(c): + return c + return None + + +_README_MARKER = re.compile(r"generated-by:\s*regress-guard\s*v(\d[\d.]*)") + + +def _tpl_version(tpl_path): + try: + with open(tpl_path, encoding="utf-8") as f: + m = _README_MARKER.search(f.read()) + return m.group(1) if m else "" + except (IOError, OSError): + return "" + + +def backfill_project_readme(regress_dir): + """老项目自动升级(v1.16 补 / v1.21 刷新):.regress/ 的零号 README。 + + 三分法:缺失→补;带 generated-by 标记且版本旧→刷新(机器写的,安全); + 无标记(人类定制)或版本不旧→**永不动**。 + """ + readme = os.path.join(regress_dir, "README.md") + tpl = find_readme_template() + if not tpl: + return None + if not os.path.exists(readme): + import shutil + shutil.copy2(tpl, readme) + return f"老项目升级·补零号入口: {readme}" + try: + with open(readme, encoding="utf-8") as f: + existing = f.read() + except (IOError, OSError): + return None + m = _README_MARKER.search(existing) + if not m: + return None # 人类定制(或无标记)——永不覆盖 + tpl_ver = _tpl_version(tpl) + if tpl_ver and _ver_gt(tpl_ver, m.group(1)): + import shutil + shutil.copy2(tpl, readme) + return f"老项目升级·刷新机器生成的 README v{m.group(1)}→v{tpl_ver}: {readme}" + return None + + +def _active_manifest_sentinel(): + """活跃清单哨兵(v1.17):新会话第一眼知道有任务在进行、续作走哪条路。 + + 失忆读者层的命令召唤侧——产物层建了自足性,还得有人在会话开场指路。 + SessionStart(startup) 每会话只发一次,无需防重复门。 + 字段读取统一走 lib/manifest_fields(v1.20 单一来源)。 + """ + regress_dir = _current_regress_dir() + if not regress_dir: + return None + base = os.path.dirname(os.path.abspath(__file__)) + for p in (base, os.path.join(base, "lib")): + if p not in sys.path: + sys.path.insert(0, p) + from manifest_fields import parse_core, ACTIVE_STATUSES, field + from manifest_parser import read_frontmatter + + def _stale_suffix(mf, content, status): + """长寿可见化(v1.23.2):planning/verifying 搁置 >30 天标 ⏰。 + + 遗忘的清单不会过期(设计如此——清单是人类的决策物),但僵尸的默认 + 状态不该是"神秘禁编",该是"一眼可见"。只提示,不自动处置。 + 年龄信号:created_at 优先;缺失/不可解析则 mtime 兜底 + (真实项目实证:旧模板清单普遍无 created_at,如 lqgd 全部 5 个)。 + """ + if status not in ("planning", "verifying"): + return "" + days = None + created = (field(content, "created_at") or "")[:10] + if created: + try: + from datetime import date + days = (date.today() - date.fromisoformat(created)).days + except ValueError: + days = None + if days is None: + try: + import time as _time + days = int((_time.time() - os.path.getmtime(mf)) // 86400) + except (OSError, ValueError): + return "" + return f" · ⏰ 已搁置{days}天" if days > 30 else "" + + lines = [] + mdir = os.path.join(regress_dir, "manifests") + if not os.path.isdir(mdir): + return None + import glob as _glob + for mf in sorted(_glob.glob(os.path.join(mdir, "*.md")), reverse=True): + try: + content = read_frontmatter(mf) + except (IOError, OSError): + continue + core = parse_core(content) + status = core.get("status", "") + if status not in ACTIVE_STATUSES: + continue + name = core.get("id") or os.path.basename(mf) + if status == "blocked": + need = core.get("blocked_need", "") or "见 blocked 块" + lines.append(f"🛑 {name}(blocked · 受阻,需要人类:{need[:40]})") + elif status == "in-progress" and core.get("provisional_at"): + # 临行(伪全自动):否决窗内——执行授权来自人类事前预授权,此刻仍可 --cancel + lines.append(f"🚀 {name}(临行中 · 顾问预审通过 · 否决窗内,--cancel 可停)") + else: + extra = {"planning": "待人类批准", + "verifying": "验证中"}.get(status, f"{core.get('open_fragiles', 0)} 个脆弱点未锁") + icon = {"planning": "⏸", "in-progress": "🎯", "verifying": "🔍"}[status] + lines.append(f"{icon} {name}({status} · {extra}{_stale_suffix(mf, content, status)})") + if len(lines) >= 3: + break + if not lines: + return None + return ("【进行中任务】\n" + "\n".join(lines) + + "\n断点续作:/regress:resume(从 .regress/ 产物层单侧重建现场)") + + +def backfill_lock_gitignore(regress_dir): + """长寿(v1.23.3):manifests/.gitignore 缺失即补(幂等,永不覆盖已有内容)。 + + 病例:windwos 项目 4 个 .lock 残留——filelock 的 sidecar 空文件无害, + 但下次 git add .regress 就会进仓库。写在 manifests/ 内部,不碰用户的 .gitignore。 + """ + gi = os.path.join(regress_dir, "manifests", ".gitignore") + if not os.path.isdir(os.path.dirname(gi)) or os.path.exists(gi): + return None + try: + with open(gi, "w", encoding="utf-8") as f: + f.write(".*.lock\n") + return "manifests/.gitignore(锁残留不入库)" + except (IOError, OSError): + return None + + +def check_and_heal(): + """检查文件完整性 + 版本升级。返回 (issues, healed)。""" + issues = [] + healed = [] + source = find_source() + + # ─── 0. 版本检测:源版本更高则自动升级 ─────────── + if source: + installed_ver = get_installed_version() + source_ver = get_source_version(source) + if _ver_gt(source_ver, installed_ver): + upgraded = do_upgrade(source) + healed.append(f"自动升级 v{installed_ver} → v{source_ver}({len(upgraded)} 个文件)") + # 升级后不需要再检查文件完整性(刚全量覆盖) + return issues, healed + + # ─── 1. hook 脚本 ───────────────────────────────── + + # ─── 1. hook 脚本 ───────────────────────────────── + for f in REQUIRED_HOOK_FILES: + path = os.path.join(HOOK_HOME, f) + if not os.path.exists(path): + if source: + src = os.path.join(source, "hooks", "scripts", f) + if os.path.exists(src): + os.makedirs(HOOK_HOME, exist_ok=True) + import shutil + shutil.copy2(src, path) + healed.append(f"恢复 hook: {f}") + else: + issues.append(f"hook 脚本缺失且无法恢复: {f}") + else: + issues.append(f"hook 脚本缺失: {f}") + + # ─── 2. lib 文件 ────────────────────────────────── + lib_dir = os.path.join(HOOK_HOME, "lib") + for f in REQUIRED_LIB_FILES: + path = os.path.join(lib_dir, f) + if not os.path.exists(path): + if source: + src = os.path.join(source, "hooks", "scripts", "lib", f) + if os.path.exists(src): + os.makedirs(lib_dir, exist_ok=True) + import shutil + shutil.copy2(src, path) + healed.append(f"恢复 lib: {f}") + else: + issues.append(f"lib 缺失且无法恢复: {f}") + else: + issues.append(f"lib 缺失: {f}") + + # ─── 3. commands ────────────────────────────────── + for cmd in REQUIRED_COMMANDS: + cmd_path = os.path.join(COMMANDS_DIR, f"{cmd}.md") + if not os.path.exists(cmd_path): + if source: + src = os.path.join(source, "commands", f"{cmd}.md") + if os.path.exists(src): + os.makedirs(COMMANDS_DIR, exist_ok=True) + import shutil + shutil.copy2(src, cmd_path) + healed.append(f"恢复命令: {cmd}") + else: + issues.append(f"命令缺失且无法恢复: {cmd}") + else: + issues.append(f"命令缺失: {cmd}") + + return issues, healed + + +def _gc_tmp_state(max_age_days=7): + """P2#27:/tmp 状态文件 GC——评审实测 5 天积 238 个(last-prompt×80+、 + stop-notify 戳×58、read-counter 等)。死会话的状态文件永不清理。 + SessionStart 顺手扫,>7 天即删;失败静默(GC 是卫生不是依赖)。""" + import tempfile, time, glob as _g + try: + cutoff = time.time() - max_age_days * 86400 + base = tempfile.gettempdir() + n = 0 + for p in _g.glob(os.path.join(base, "regress-guard-*")): + try: + if os.path.getmtime(p) < cutoff: + os.remove(p) + n += 1 + except OSError: + pass + if n: + return f"gc:{n}" + except Exception: + pass + return None + + +def check_bridge_registration(): + """v1.39 注册漂移警示(REGRESS-2026-028):plan_bridge.py 文件在而 + config.json 无桥注册——自愈只搬脚本不改编用户配置(红线),漂移只警示, + 修复出口是重跑 install.sh。警示走 stderr(ZCode log 可见),不打断启动。""" + try: + if not os.path.exists(os.path.join(HOOK_HOME, "plan_bridge.py")): + return + with open(CONFIG_FILE, encoding="utf-8") as f: + cfg = json.load(f) + blob = json.dumps(cfg.get("hooks", {}).get("events", {})) + if "plan_bridge" not in blob: + print("REGRESS-GUARD: ⚠️ 计划桥脚本已就位但 config.json 未注册——" + "重跑 install.sh 激活原生计划模式转录", file=sys.stderr) + except Exception: + pass # 警示是增强不是依赖 + + +def _drift_pair(): + """(已装, 源仓) 版本对;任一缺失返回 None。v1.78 拆出供 Stop 级警示复用。""" + try: + installed = "" + src_meta = os.path.join(HOOK_HOME, ".source") + if os.path.isfile(src_meta): + with open(src_meta, encoding="utf-8") as f: + for line in f: + if line.startswith("source_version="): + installed = line.split("=", 1)[1].strip() + if not installed: + return None + for cand in SOURCE_CANDIDATES: + pj = os.path.join(cand, ".zcode-plugin", "plugin.json") + if os.path.isfile(pj): + with open(pj, encoding="utf-8") as f: + source_v = str(json.load(f).get("version") or "") + if source_v: + return (installed, source_v) + except Exception: + pass + return None + + +def _ver_key(v): + """semver 规范化比较键(顾问精化③):x.y.z 取整数元组,非数字段原样垫后。""" + body = str(v).lstrip("v").split("+")[0] + parts = body.split(".") + key = [] + for p in parts[:3]: + key.append(int(p) if p.isdigit() else 0) + while len(key) < 3: + key.append(0) + key.append("-".join(parts[3:]) or ("-" in parts[-1] and not parts[-1].isdigit() and parts[-1] or "")) + return tuple(key) + + +def check_version_drift(): + """v1.59 版本漂移警示(044 病例机制化):源仓 plugin.json 新于已装副本 + → 会话启动自愈本会同步,但热会话里的钩子仍是旧版在把关——警示一眼可见。 + 已装版本读 install.sh 落的 .source 戳(source_version= 行)。 + 只警示不自动改(升级走既有轨道:会话启动自愈 / install.sh)。""" + pair = _drift_pair() + if not pair: + return + installed, source_v = pair + if _ver_key(source_v) != _ver_key(installed): + print(f"REGRESS-GUARD: ⚠️ 已装副本 v{installed} 与源仓 v{source_v} " + f"版本漂移——重启会话(启动自愈会同步)或重跑 install.sh 激活," + f"期间钩子按旧版把关(事件已盖 guard_version 可查)", + file=sys.stderr) + + +def main(): + # 快速检查:如果连 hook_home 都不存在,说明根本没装过,跳过 + if not os.path.isdir(HOOK_HOME): + # 未安装状态,不干预 + print(json.dumps({"status": "not_installed"})) + return + + issues, healed = check_and_heal() + gc_note = _gc_tmp_state() + if gc_note: + healed.append(gc_note) + check_bridge_registration() + check_version_drift() + + # 老项目自动升级:零号入口缺失即补(幂等,永不覆盖已定制内容) + rg = _current_regress_dir() + if rg: + note = backfill_project_readme(rg) + if note: + healed.append(note) + note = backfill_lock_gitignore(rg) + if note: + healed.append(note) + + # 项目经验注入(越用越聪明的 ambient 层):摘要有变化才注入,避免每次启动刷屏 + # 活跃清单哨兵:有任务在进行则必注入(每会话一次,指路 /regress:resume) + sentinel = _active_manifest_sentinel() + digest = _project_digest() + context = "\n\n".join(p for p in (sentinel, digest) if p) + if healed: + msg = "regress-guard 自愈:恢复了 " + ", ".join(healed) + if context: + msg += "\n\n" + context + print(json.dumps({"status": "healed", "additionalContext": msg})) + elif issues: + msg = "regress-guard 警告:以下文件缺失且无法自动恢复:\n" + "\n".join(f" - {i}" for i in issues) + msg += "\n\n建议重新运行 /regress:install" + print(json.dumps({"status": "warning", "additionalContext": msg})) + elif context: + # 哨兵(有活跃任务时)或摘要变化时注入 + print(json.dumps({"status": "ok", "additionalContext": context})) + else: + print(json.dumps({"status": "ok"})) + + +def _project_digest(max_lines=5): + """从当前项目的 .regress 生成经验摘要;无有效数据或未变化时返回 None。 + + 防重复门:摘要 hash 存 .regress/.last-digest,相同则不注入。 + """ + import hashlib + try: + base = os.path.dirname(os.path.abspath(__file__)) + for p in (base, os.path.join(base, "lib")): + if p not in sys.path: + sys.path.insert(0, p) + from history import summarize + except ImportError: + return None + + # 定位当前项目的 .regress + regress_dir = _current_regress_dir() + if not regress_dir: + return None + + try: + s = summarize(regress_dir) + except Exception: + return None + + lines = [] + f3s = s.get("top_f3_files") or [] + if f3s: + files = ", ".join(f"{f}({c})" for f, c in f3s[:2]) + lines.append(f"📋 本项目规律:改这些文件时容易漏改 {files}") + debt = s.get("tech_debt", 0) + if debt > 0: + lines.append(f"💳 有 {debt} 笔 bypass 未补回归") + cov = s.get("avg_coverage_pct") + if cov is not None: + lines.append(f"📊 平均测试覆盖率 {cov}%") + outs = s.get("outside_gate_commits", 0) + if outs > 0: + lines.append(f"📤 {outs} 次提交未走门禁(IDE/终端直提)") + if not lines: + return None + + digest = "【regress-guard 项目经验】\n" + "\n".join(lines[:max_lines]) + marker = os.path.join(regress_dir, ".last-digest") + h = hashlib.md5(digest.encode()).hexdigest()[:10] + prev = "" + try: + prev = open(marker).read().strip() + except (IOError, OSError): + pass + if prev == h: + return None # 摘要未变化,不重复注入 + try: + open(marker, "w").write(h) + except (IOError, OSError): + pass + return digest + + +if __name__ == "__main__": + main() diff --git a/plugins/regress-guard/hooks/scripts/stop_notify.py b/plugins/regress-guard/hooks/scripts/stop_notify.py new file mode 100644 index 0000000..3199efb --- /dev/null +++ b/plugins/regress-guard/hooks/scripts/stop_notify.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# v1.32.5 金丝雀:任何调用尝试都先落痕(连 import 崩溃也可见)——定位客户端是否真的调了本钩子 +try: + import os as _os, sys as _sys, time as _time, tempfile as _tf + with open(_os.path.join(_tf.gettempdir(), "regress-guard-stop-notify.log"), "a") as _f: + _f.write(f"{_time.strftime('%m-%d %H:%M:%S')} CANARY argv={_sys.argv[1:3]}\n") +except Exception: + pass +"""stop_notify — 轮末推送(v1.32.2:正常对话也推,用户令)。 + +病:done 事件只接在 /regress:finish(清单任务收尾),问答/分析/长自主轮不经过 +任何推送点——用户离场等待时三度沉默(通道本身健康,双证据复验)。 +v1.32.2:018 的"正常对话静音"设计被用户推翻——离场人类的任何轮次结束都是 +"回来收货"信号。活跃对话的密集轮次由 90s 冷却天然吸收(隔 >90s 才再响)。 + +设计:UserPromptSubmit 已把最后一条用户输入存入状态文件 +(prompt_intercept.load_last_prompt),Stop 时读它: +- 含授权词(自决策|自决|你决定|自主决|自动做|直接做|放手做)→ + chat「🏁 阶段完成:<指令摘要>」 +- 不含 → chat「💬 回复完成:<指令摘要>」(v1.38 起独立 chat 事件, + 曾冒充 done 把发送台账刷成 done×375——真 done 只有 3 次) +- 90s 冷却(标记文件)防 finish 仪式推送后立即双响 + +Stop 钩子无 matcher(v1.27.1 空 matcher 掀翻整机的教训)。stdin 未用但保持读空。 +""" +import os +import re +import sys +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LIB_DIR = os.path.join(_HERE, "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +from notify import notify # noqa: E402 + +AUTONOMY_RE = re.compile(r"自决策|自决|你决定|自主决|自动做|直接做|放手做") +COOLDOWN_S = 90 + + +def _project_dir(): + return (os.environ.get("CLAUDE_PROJECT_DIR") + or os.environ.get("ZCODE_PROJECT_DIR") + or os.getcwd()) + + +def _last_prompt(): + """v1.32.3:优先读全局接力文件——按项目哈希的路径在钩子进程间目录解析漂移时 + 会读空(三报沉默根因),全局单文件最后写入者生效;回落旧路径保兼容。""" + import tempfile + g = os.path.join(tempfile.gettempdir(), "regress-guard-last-prompt.global.txt") + try: + with open(g, encoding="utf-8") as f: + t = f.read().strip() + if t: + return t + except OSError: + pass + sys.path.insert(0, _HERE) + from prompt_intercept import load_last_prompt + return load_last_prompt() + + +def _log(decision, extra=""): + """决策日志(append-only):下次沉默报告一眼定位断点。""" + import tempfile + try: + with open(os.path.join(tempfile.gettempdir(), "regress-guard-stop-notify.log"), "a") as f: + f.write(f"{time.strftime('%m-%d %H:%M:%S')} pd={_project_dir()[-24:]} " + f"cooled={_cooled()} {decision} {extra}\n") + except OSError: + pass + + +def _marker_path(): + import hashlib + key = hashlib.md5(_project_dir().encode()).hexdigest()[:8] + import tempfile + return os.path.join(tempfile.gettempdir(), + f"regress-guard-stop-notify-{key}.ts") + + +def _cooled(): + try: + age = time.time() - os.path.getmtime(_marker_path()) + return age > COOLDOWN_S + except OSError: + return True + + +def _mark(): + try: + with open(_marker_path(), "w") as f: + f.write(str(time.time())) + except OSError: + pass + + +def should_notify(last_prompt, cooled=True): + """v1.32.4 根治:推送与消息文本彻底解耦(图片/空文本轮也响——用户令"根治")。 + 冷却是唯一节流;last_prompt 仅作摘要装饰,不参与决策。""" + return bool(cooled) + + + + +def _drift_notice_once(): + """v1.78(067):Stop 级版本漂移警示——SessionStart 每会话只跑一次(宿主 + 源码证实 resume 不重发),热会话全程旧版把关无人知(2026-09-20 夜宿主 + 3.14.0→3.14.1 漂移,次晨巡检才发现)。每对版本只警一次:stderr 当轮可见 + (顾问精化④),chat 一条滞后可见;状态 tmp+rename 原子写(精化②); + 版本比较规范化(精化③,self_heal._ver_key)。全程 best-effort(精化①)。""" + try: + sys.path.insert(0, _HERE) + from self_heal import _drift_pair, _ver_key + pair = _drift_pair() + if not pair or _ver_key(pair[0]) == _ver_key(pair[1]): + return + installed, source_v = pair + import json as _json + import tempfile + state = os.path.join(tempfile.gettempdir(), "regress-drift-noticed.json") + key = f"{installed}->{source_v}" + try: + with open(state, encoding="utf-8") as f: + if _json.load(f).get("pair") == key: + return # 同对已警过:静默(零噪音) + except (OSError, ValueError): + pass + print(f"REGRESS-GUARD: ⚠️ 版本漂移(轮末巡检):已装 v{installed} vs 源仓 " + f"v{source_v}——重启会话或跑 bash install.sh 激活;" + f"消除漂移前本轮警示不重复", + file=sys.stderr) + try: + tmp = state + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + _json.dump({"pair": key, "ts": time.strftime("%F %T")}, f) + os.replace(tmp, state) # 原子写防并发会话撕裂 + except OSError: + pass + try: + notify(_project_dir(), "chat", "⚠️ 插件版本漂移", + f"已装 v{installed} 源仓 v{source_v}——重启会话或 install.sh 激活") + except Exception: + pass # chat 滞后可见也行(stderr 已当轮可见) + except Exception: + pass # 警示是增强不是依赖:不改退出码不阻塞 + + +def main(): + try: + _ = sys.stdin.read() + except Exception: + pass + _drift_notice_once() # v1.78:轮末漂移警示(自带节流,先于冷却判断) + lp = _last_prompt() + if not should_notify(lp, _cooled()): + _log("SKIP", f"prompt={'有' if lp else '空'}") + sys.exit(0) + pd = _project_dir() + excerpt = (lp or "").strip()[:24] + if not excerpt: + excerpt = "[图片或无文本消息]" + if AUTONOMY_RE.search(lp or ""): + title, body = f"🏁 阶段完成:{excerpt}", "授权轮已收尾,可下发下一步或回来验收" + else: + title, body = f"💬 回复完成:{excerpt}", "本轮对话已收尾,可继续追问或离场" + try: + notify(pd, "chat", title, body) # v1.38:独立 chat 事件,不再冒充 done 污染统计 + _mark() + _log("PUSH", title[:40]) + except Exception as e: # 推送是增强不是依赖 + _log("ERROR", str(e)[:60]) + print(f"stop_notify: 推送失败(忽略): {e}", file=sys.stderr) + sys.exit(0) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/regress-guard/install.sh b/plugins/regress-guard/install.sh new file mode 100755 index 0000000..d9df2d1 --- /dev/null +++ b/plugins/regress-guard/install.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# regress-guard 一键安装器 +# +# 用法:bash install.sh +# +# 做的事: +# 1. 把 3 个 skill 复制到 ~/.zcode/skills/ +# 2. 把所有命令复制到 ~/.zcode/commands/ +# 3. 把 hook 脚本复制到 ~/.zcode/regress-guard-hooks/ +# 4. 在 ~/.zcode/cli/config.json 注册 PreToolUse hook +# 5. 在 ~/.zcode/AGENTS.md 注入回归契约 +# +# 幂等:重复运行不会重复添加,只会更新。 + +set -euo pipefail + +# ─── 定位插件根目录 ─────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="${SCRIPT_DIR}" + +ZCODE_HOME="${HOME}/.zcode" +SKILLS_DIR="${ZCODE_HOME}/skills" +COMMANDS_DIR="${ZCODE_HOME}/commands" +HOOK_HOME="${ZCODE_HOME}/regress-guard-hooks" +CONFIG_FILE="${ZCODE_HOME}/cli/config.json" +AGENTS_FILE="${ZCODE_HOME}/AGENTS.md" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${GREEN}✅${NC} $1"; } +warn() { echo -e "${YELLOW}⚠️${NC} $1"; } +error() { echo -e "${RED}❌${NC} $1"; } + +echo "════════════════════════════════════════════" +echo " regress-guard 一键安装" +echo "════════════════════════════════════════════" +echo "" + +# ─── 前置检查 ───────────────────────────────────────── +if [ ! -d "${ZCODE_HOME}" ]; then + error "未找到 ~/.zcode/ 目录。请确认已安装 ZCode。" + exit 1 +fi + +# 检查 node 和 python3 +if ! command -v node >/dev/null 2>&1; then + error "未找到 node。ZCode 需要 Node.js 运行时。" + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + warn "未找到 python3,hook 将无法运行。请安装 Python 3.6+。" +fi + +echo "插件源: ${PLUGIN_ROOT}" +echo "目标: ${ZCODE_HOME}" +echo "" + +# ─── 1. 复制 commands ───────────────────────────────── +mkdir -p "${COMMANDS_DIR}" +for cmd_file in "${PLUGIN_ROOT}"/commands/*.md; do + [ -f "$cmd_file" ] || continue + cp "$cmd_file" "${COMMANDS_DIR}/" + info "command: $(basename "$cmd_file" .md)" +done + +# ─── 3. 复制 hook 脚本 ──────────────────────────────── +# 放到固定位置(不走插件根,因为用户级 config.json 不支持 ${ZCODE_PLUGIN_ROOT}) +mkdir -p "${HOOK_HOME}/lib" +cp "${PLUGIN_ROOT}/hooks/scripts/launcher.js" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/pre_commit_guard.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/read_before_edit_guard.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/prompt_intercept.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/reflection_check.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/fail_watch.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/risk_watch.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/compact_notice.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/execution_valve.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/boundary_guard.py" "${HOOK_HOME}/" +cp "${PLUGIN_ROOT}/hooks/scripts/stop_notify.py" "${HOOK_HOME}/" # P0-2(评审2026-023):注册了却从不拷贝——安装到自愈前每轮 Stop 报文件不存在 +cp "${PLUGIN_ROOT}/hooks/scripts/plan_bridge.py" "${HOOK_HOME}/" # v1.39 原生计划桥(同族教训预防) +cp "${PLUGIN_ROOT}/hooks/scripts/self_heal.py" "${HOOK_HOME}/lib/" +cp "${PLUGIN_ROOT}/hooks/scripts/lib/"*.py "${HOOK_HOME}/lib/" +mkdir -p "${HOOK_HOME}/templates" +cp "${PLUGIN_ROOT}/templates/"*.md "${HOOK_HOME}/templates/" # v1.26.1:全部模板(product-context 缺部署会断 init 链) +chmod +x "${HOOK_HOME}/pre_commit_guard.py" "${HOOK_HOME}/read_before_edit_guard.py" "${HOOK_HOME}/risk_watch.py" "${HOOK_HOME}/compact_notice.py" "${HOOK_HOME}/execution_valve.py" "${HOOK_HOME}/boundary_guard.py" "${HOOK_HOME}/lib/"*.py 2>/dev/null || true +info "hook 脚本 → ${HOOK_HOME}" + +# ─── 4. 注册 hook 到 config.json ────────────────────── +# 用户级 config.json 的 hooks 格式(注意 events wrapper + enabled: true) +mkdir -p "$(dirname "${CONFIG_FILE}")" + +# 用 python3 安全地合并 JSON(避免 jq 依赖) +# 用引号包裹 PYEOF 防止 bash 展开变量,用环境变量传值 +CONFIG_FILE="${CONFIG_FILE}" HOOK_HOME="${HOOK_HOME}" python3 << 'PYEOF' +import json, os, sys + +config_path = os.environ["CONFIG_FILE"] +hook_home = os.environ["HOOK_HOME"] + +# hook 配置(用户级,用绝对路径) +hook_entry = { + "type": "process", + "command": "node", + "args": [os.path.join(hook_home, "launcher.js")], + "timeoutMs": 180000, + "statusMessage": "regress-guard: 运行测试验证..." +} + +new_hooks_block = { + "enabled": True, + "events": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [hook_entry] + } + ] + } +} + +# 读取现有配置(如果有) +config = {} +if os.path.exists(config_path): + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + if not isinstance(config, dict): + config = {} + except Exception: + config = {} + +# 确保 hooks.enabled = True +if "hooks" not in config: + config["hooks"] = {} +config["hooks"]["enabled"] = True + +# 合并 events(不覆盖其他 hook) +if "events" not in config["hooks"]: + config["hooks"]["events"] = {} + +events = config["hooks"]["events"] + +# 检查是否已有 regress-guard 的 hook(避免重复) +pretool = events.get("PreToolUse", []) +already_installed = False +for entry in pretool: + for h in entry.get("hooks", []): + args = h.get("args", []) + if any("launcher.js" in str(a) and "regress-guard" in str(a) for a in args): + already_installed = True + break + +if not already_installed: + # 先移除旧的 regress-guard 条目(幂等更新) + cleaned = [] + for entry in pretool: + hooks = entry.get("hooks", []) + filtered = [h for h in hooks + if not any("regress-guard" in str(a) for a in h.get("args", []))] + if filtered: + cleaned.append({**entry, "hooks": filtered}) + cleaned.append({"matcher": "Bash", "hooks": [hook_entry]}) + events["PreToolUse"] = cleaned + +# SessionStart 自愈 hook(每次启动检查文件完整性) +selfheal_entry = { + "type": "command", + "command": f'python3 "{os.path.join(hook_home, "lib", "self_heal.py")}"', + "timeout": 5, + "statusMessage": "regress-guard: 检查完整性..." +} +session_hooks = events.get("SessionStart", []) +session_cleaned = [h for h in session_hooks + if "self_heal" not in json.dumps(h)] +session_cleaned.append({"matcher": "startup", "hooks": [selfheal_entry]}) +events["SessionStart"] = session_cleaned + +# 先读后改门禁(PreToolUse 拦 Edit/Write + PostToolUse 记 Read) +rbe_path = os.path.join(hook_home, "read_before_edit_guard.py") +pre_edit_entry = { + "type": "command", + "command": f'python3 "{rbe_path}" pre', + "timeout": 5, + "statusMessage": "regress-guard: 先读后改检查..." +} +post_read_entry = { + "type": "command", + "command": f'python3 "{rbe_path}" post', + "timeout": 5, + "statusMessage": "regress-guard: 记录读取..." +} +# 加到 PreToolUse(匹配 Edit|Write|ApplyPatch) +pretool2 = events.get("PreToolUse", []) +pretool2 = [e for e in pretool2 if "read_before_edit" not in json.dumps(e)] +pretool2.append({"matcher": "Edit|Write|ApplyPatch", "hooks": [pre_edit_entry]}) +events["PreToolUse"] = pretool2 +# 加 PostToolUse(匹配 Read) +posttool = events.get("PostToolUse", []) +posttool = [e for e in posttool if "read_before_edit" not in json.dumps(e)] +posttool.append({"matcher": "Read", "hooks": [post_read_entry]}) +events["PostToolUse"] = posttool + +# UserPromptSubmit hook:需求入口检查 +prompt_path = os.path.join(hook_home, "prompt_intercept.py") +prompt_entry = { + "type": "command", + "command": f'python3 "{prompt_path}"', + "timeout": 5, + "statusMessage": "regress-guard: 需求入口检查..." +} +submit_hooks = events.get("UserPromptSubmit", []) +submit_hooks = [e for e in submit_hooks if "prompt_intercept" not in json.dumps(e)] +submit_hooks.append({"hooks": [prompt_entry]}) # 全匹配=省略 matcher(空串违反 schema ≥1 字符,会致整份 config 被丢弃) +events["UserPromptSubmit"] = submit_hooks + +# PostToolUseFailure: 失败信号采集 +failwatch_path = os.path.join(hook_home, "fail_watch.py") +fail_entry = {"type": "command", "command": f'python3 "{failwatch_path}"', + "timeout": 5, "statusMessage": "regress-guard: 失败信号采集..."} +fw_hooks = events.get("PostToolUseFailure", []) +fw_hooks = [e for e in fw_hooks if "fail_watch" not in json.dumps(e)] +fw_hooks.append({"matcher": "Bash|Edit|Write", "hooks": [fail_entry]}) +events["PostToolUseFailure"] = fw_hooks + +# PostToolUse: 风险与重复信号采集(成功执行后:破坏性动作 + Bash 命令普查) +riskwatch_path = os.path.join(hook_home, "risk_watch.py") +risk_entry = {"type": "command", "command": f'python3 "{riskwatch_path}"', + "timeout": 5, "statusMessage": "regress-guard: 风险与重复信号采集..."} +rw_hooks = events.get("PostToolUse", []) +rw_hooks = [e for e in rw_hooks if "risk_watch" not in json.dumps(e)] +rw_hooks.append({"matcher": "Bash|Edit|Write", "hooks": [risk_entry]}) +events["PostToolUse"] = rw_hooks + +# SessionStart(compact): 压缩记忆降级警告 +compact_path = os.path.join(hook_home, "compact_notice.py") +compact_entry = {"type": "command", "command": f'python3 "{compact_path}"', + "timeout": 5, "statusMessage": "regress-guard: 压缩检查..."} +sc_hooks = events.get("SessionStart", []) +sc_hooks = [h for h in sc_hooks if "compact_notice" not in json.dumps(h)] +sc_hooks.append({"matcher": "compact", "hooks": [compact_entry]}) +events["SessionStart"] = sc_hooks + +# Stop: 反思检查 + 钩子层自动第二意见(本地顾问,卡死时客观数据直送, +# 未经主AI筛选;超时 1810s 是为自动咨询留余量——正常路径 <1s 返回) +reflect_path = os.path.join(hook_home, "reflection_check.py") +reflect_entry = {"type": "command", "command": f'python3 "{reflect_path}"', + "timeout": 90, "statusMessage": "regress-guard: 反思检查..."} +# v1.32 授权门控轮末推送:授权语(自决策…)触发的自主轮收尾推手机"阶段完成" +stopn_path = os.path.join(hook_home, "stop_notify.py") +stopn_entry = {"type": "command", "command": f'python3 "{stopn_path}"', + "timeout": 8, "statusMessage": "regress-guard: 阶段完成推送(授权轮)..."} +stop_hooks = events.get("Stop", []) +stop_hooks = [h for h in stop_hooks if "reflection_check" not in json.dumps(h)] +stop_hooks.append({"hooks": [reflect_entry, stopn_entry]}) # 同上:v1.27.1 修复——空 matcher 曾致全机钩子零装载 +events["Stop"] = stop_hooks + +# PreToolUse(Edit|Write|ApplyPatch): 开发边界守卫(检测→拦截:越界编辑在发生前阻断) +boundary_path = os.path.join(hook_home, "boundary_guard.py") +boundary_entry = {"type": "command", "command": f'python3 "{boundary_path}"', + "timeout": 5, "statusMessage": "regress-guard: 边界检查..."} +bg_hooks = events.get("PreToolUse", []) +for e in bg_hooks: + if e.get("matcher") == "Edit|Write|ApplyPatch": + kept = [h for h in e.get("hooks", []) if "boundary_guard" not in json.dumps(h)] + e["hooks"] = kept + [boundary_entry] + break +else: + bg_hooks.append({"matcher": "Edit|Write|ApplyPatch", "hooks": [boundary_entry]}) +# v1.29 旁路收口:Bash 写目标(rm/mv/tee/重定向/sed -i)同样过边界判定 +for e in bg_hooks: + if e.get("matcher") == "Bash": + kept = [h for h in e.get("hooks", []) if "boundary_guard" not in json.dumps(h)] + e["hooks"] = kept + [dict(boundary_entry, + statusMessage="regress-guard: 边界检查(Bash)...")] + break +else: + bg_hooks.append({"matcher": "Bash", "hooks": [boundary_entry]}) +events["PreToolUse"] = bg_hooks + +# PreToolUse(Bash): 执行阀(公理四——不可逆命令需显式令牌 REGRESS_CONFIRM=YES) +valve_path = os.path.join(hook_home, "execution_valve.py") +valve_entry = {"type": "command", "command": f'python3 "{valve_path}"', + "timeout": 5, "statusMessage": "regress-guard: 执行阀检查..."} +pt_hooks = events.get("PreToolUse", []) +# 幂等:逐条目摘除旧阀(保留条目内其他 hook,如 launcher/pre_commit_guard), +# 再把新阀挂进 Bash matcher 的条目(无则新建)——不能按条目整删,会连带丢测试卡点 +for e in pt_hooks: + e["hooks"] = [h for h in e.get("hooks", []) + if "execution_valve" not in json.dumps(h)] +for e in pt_hooks: + if e.get("matcher") == "Bash" and e.get("hooks"): + e["hooks"].append(valve_entry) + break +else: + pt_hooks.append({"matcher": "Bash", "hooks": [valve_entry]}) +events["PreToolUse"] = pt_hooks + +# v1.39 原生计划模式桥(REGRESS-2026-028):ExitPlanMode 批准转录 / 拒绝化石。 +# 单钩子原子;注册漂移由 self_heal 警示(不自动改用户 config)。 +bridge_path = os.path.join(hook_home, "plan_bridge.py") +bridge_post = {"type": "command", "command": f'python3 "{bridge_path}" post', + "timeout": 5, "statusMessage": "regress-guard: 原生计划转录..."} +bridge_fail = {"type": "command", "command": f'python3 "{bridge_path}" fail', + "timeout": 5, "statusMessage": "regress-guard: 拒绝计划化石..."} +pb_hooks = events.get("PostToolUse", []) +pb_hooks = [e for e in pb_hooks if "plan_bridge" not in json.dumps(e)] +pb_hooks.append({"matcher": "ExitPlanMode", "hooks": [bridge_post]}) +events["PostToolUse"] = pb_hooks +fb_hooks = events.get("PostToolUseFailure", []) +fb_hooks = [e for e in fb_hooks if "plan_bridge" not in json.dumps(e)] +fb_hooks.append({"matcher": "ExitPlanMode", "hooks": [bridge_fail]}) +events["PostToolUseFailure"] = fb_hooks + +config["hooks"]["events"] = events + +# 写回 +with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + +print("hook 已注册到 " + config_path) +PYEOF + +info "hook 注册 → ${CONFIG_FILE}" + +# ─── 5. 注入 AGENTS.md 回归契约 + 灵魂 ──────────────── +AGENTS_FILE="${AGENTS_FILE}" python3 << 'PYEOF' +import os, re + +agents_path = os.environ["AGENTS_FILE"] +SOUL_START = "<!-- regress-guard soul start -->" +SOUL_END = "<!-- regress-guard soul end -->" +CONTRACT_START = "<!-- regress-guard start -->" +CONTRACT_END = "<!-- regress-guard end -->" + +SOUL = f"""{SOUL_START} +## 工作原则 + +1. **能推断就别问**——代码/日志/上下文里有答案,直接用 +2. **有默认就别选**——给用户"否决权"而非"选择题" +3. **做完了汇报**——别中途请示,做完说结果 +4. **自己犯的错自己修**——测试失败自己改,别甩给用户 +5. **只问改变交付性质的事**——"做什么"问人,"怎么做"自己定 +{SOUL_END}""" + +# 契约长度冻结(v1.20 决策):13 条封顶——每加一条必须并掉一条;长契约会被摘要,被摘要的规则等于没写 +CONTRACT = f"""{CONTRACT_START} +## 回归契约 + +0. **先读后改**:改代码前至少读 2 个相关文件(hook 强制;读后被外部改过的文件指纹不匹配,hook 会拦) +1. **需求解析优先**:从代码推断上下文、检测歧义、自决消歧;计划(planning 状态)须人类批准后才实施——批准前边界守卫拦编辑,不认同就继续对话完善;批准走 lib/plan_approve.py(approved 落产物+漂移检查),人类也可直接填清单 approved.at(产物直通);**原生计划模式批准由 plan_bridge 钩子自动转录(钩子未生效时 AI 补转录,幂等)**;断点续作用 /regress:resume 从产物层重建现场 +2. 开发完跑 /regress:track 发现 F3,直接回写 +3. 提交时 hook 自跑测试,被拦后自己修;**脆弱点 open 状态的清单禁止提交**(先 verify 拿证据转 locked,或显式 flagged 挂牌) +4. 紧急用 /regress:bypass <分钟> 临时绕过 +5. 改 >=3 个文件时 hook 会触发 pre-mortem:假设出bug最可能是什么? +6. **不可逆命令要令牌**:mkfs/dd if=/force push/DROP/TRUNCATE/项目外 rm -rf 会被执行阀拦,确要执行须显式加 REGRESS_CONFIRM=YES 前缀(禁止习惯性携带) +7. **失败/风险/用户纠正自动入地层**(.regress/journal/,append-only)——别删,那是未来会话的考古资产;重大决策(含否决过的方案)写 .regress/decisions.md +8. **开发边界**:活跃清单外/边界外的文件 Edit/Write 会被 hook **事前拦截**——别绕,扩界的唯一出口是 /regress:track 回写(留痕);边界不是拖慢,是把 token 和质量都省下来(6B→1B 实证) +9. **受阻是一等状态**:卡住别硬磨别绕过——`plan_approve.py <清单> --block --reason --need` 四问落产物并转达人类(受阻期间边界守卫拦编辑);解阻后 `--unblock` +10. **假设被证伪别悄悄改**:verify 实测推翻清单推测时,追加「假设失效记录」(was→reality→evidence)+ `journal.py . add assumption_broken`——失效过程比结论值钱 +11. **顾问预审(伪全自动)**:计划卡片送顾问预审——顾问有一票否决权(有方向性异议必等人,即使已预授权)无一票批准权;预授权任务无异议才 `--provisional --advisor` 临行(否决窗内 `--cancel` 可停);知识型受阻先问顾问再 --block;预审意见必须来自真实 consult(audit 可查),不得代笔 +12. **验证主权(AVS)**:测试替身的宽松断言(oracle:模拟器不校验什么/mock 不查什么)入脆弱点拓扑单列——单测假阴性之源;感官终验问人只收通过/不通过并落 human_check 化石(verify=human_check:<vid>,门禁验化石存在性不复跑感官);环境脆弱点落启动预检(含跨服务契约往返断言),禁止"请确保 Redis 已启动"式人读指令;真机联调自主完成,仅感官终验时叫人("你自己弄!成功后再叫人类!") + +脚本路径(命令文档中 `<插件路径>` 的解析):lib 脚本一律用已安装路径 +`~/.zcode/regress-guard-hooks/lib/`(journal.py / plan_approve.py / history.py 都在), +`<插件路径>/hooks/scripts/lib/` 是源码等价路径。 + +可用命令:/regress:init, /regress:plan, /regress:track, /regress:verify, +/regress:quick, /regress:bypass, /regress:learn, /regress:evolve, +/regress:trace, /regress:resume, /regress:finish, +/regress:install, /regress:uninstall, /regress:update +{CONTRACT_END}""" + +FULL_BLOCK = SOUL + "\n\n" + CONTRACT + +if os.path.exists(agents_path): + content = open(agents_path, encoding="utf-8").read() + # 先清理旧版标记块(兼容升级) + for marker_s, marker_e in [(SOUL_START, SOUL_END), (CONTRACT_START, CONTRACT_END)]: + pattern = re.escape(marker_s) + r".*?" + re.escape(marker_e) + r"\n*" + content = re.sub(pattern, '', content, flags=re.DOTALL) + new = content.rstrip() + "\n\n" + FULL_BLOCK + "\n" +else: + new = "# 用户级指令\n\n" + FULL_BLOCK + "\n" + +with open(agents_path, "w", encoding="utf-8") as f: + f.write(new) +print("AGENTS.md 已注入(含灵魂原则)") +PYEOF + +info "AGENTS.md 回归契约已注入" + +# ─── 6. 复制 check_docs.py 和 小白指南 ──────────────── +mkdir -p "${ZCODE_HOME}/regress-guard-docs" +cp "${PLUGIN_ROOT}/scripts/check_docs.py" "${ZCODE_HOME}/regress-guard-docs/" 2>/dev/null || true +cp "${PLUGIN_ROOT}/docs/小白指南.md" "${ZCODE_HOME}/regress-guard-docs/" 2>/dev/null || true +cp "${PLUGIN_ROOT}/docs/WORKFLOW.md" "${ZCODE_HOME}/regress-guard-docs/" 2>/dev/null || true + +# ─── 7. 记录源路径和版本(供自动升级用)──────────────── +SOURCE_VERSION="$(python3 -c "import json; print(json.load(open('${PLUGIN_ROOT}/.zcode-plugin/plugin.json')).get('version','0.0.0'))" 2>/dev/null || echo "unknown")" +cat > "${HOOK_HOME}/.source" << META +source_path=${PLUGIN_ROOT} +source_version=${SOURCE_VERSION} +installed_at=$(date -Iseconds) +META +info "版本标记: v${SOURCE_VERSION}(源: ${PLUGIN_ROOT})" + +# ─── v1.76 安装后自检:三查一屏,红即大声失败(装坏当场知) ── +echo "" +if ! python3 "${PLUGIN_ROOT}/scripts/post_install_check.py" \ + "${PLUGIN_ROOT}" "${HOME}/.zcode/regress-guard-hooks"; then + echo "" + echo -e "${RED}❌ 安装后自检未过——按上方指引修复后再使用${NC}" + exit 1 +fi + +# ─── 完成 ──────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════" +echo -e "${GREEN} ✅ regress-guard 安装完成!${NC}" +echo "════════════════════════════════════════════" +echo "" +echo "已安装:" +echo " • 11 个命令(/regress:init 等)" +echo " • commit 卡点 hook(自动跑测试)" +echo " • 先读后改门禁 + 文件指纹(读后被外部改过 → 强制重读)" +echo " • pre-mortem 反思(改≥3文件时触发)" +echo " • SessionStart 自愈(自动修复缺失文件)" +echo " • 全自动无感层:失败风暴→scout / 破坏性动作未过审→补审 / 打转检测 / 方向漂移(清单外改动)" +echo " • SessionStart(compact) 压缩记忆降级警告" +echo " • 四公理机制:" +echo " 公理一 脆弱点挂牌(fragile_points open 禁提交,verify 拿证据转 locked)" +echo " 公理二 认知物质化(决策日志 decisions.md + 文件指纹校验)" +echo " 公理三 考古地层(journal/events.jsonl,失败/风险/纠正化石随 git 入库)" +echo " 公理四 执行阀(不可逆命令需显式令牌 REGRESS_CONFIRM=YES)" + echo " • 开发边界守卫(越界编辑事前拦截,track 回写即扩界——6B→1B 的关键机制)" +echo " • AGENTS.md 回归契约" +echo "" +echo "现在在任意项目中对 AI 说:" +echo " /regress:init ← 初始化项目" +echo "" +echo "卸载:bash ${PLUGIN_ROOT}/uninstall.sh" diff --git a/plugins/regress-guard/scripts/post_install_check.py b/plugins/regress-guard/scripts/post_install_check.py new file mode 100644 index 0000000..6b5dbc6 --- /dev/null +++ b/plugins/regress-guard/scripts/post_install_check.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""安装后自检(v1.76,065):三查一屏——公网用户装完即知好坏。 + +①语法面:已装目录全部 *.py py_compile 零错(半拷贝/截断文件当场现形) +②注册面:hooks.json 引用的每个脚本文件在已装目录存在(注册了却缺文件=首次 + 使用才炸的最阴形态) +③版本面:.source 戳 source_version 与源仓 plugin.json 一致(装旧不知旧) +任一红 → exit 1 + 修复指引——装坏要大声失败(顾问"勿砍"项)。 + +用法:post_install_check.py [源仓根] [已装目录] +缺省:源仓=脚本上两级;已装=~/.zcode/regress-guard-hooks +""" +import json +import os +import sys + + +def _referenced_scripts(src_root): + """hooks.json 里 command 串引用到的脚本文件名集合(递归遍历嵌套结构)。""" + names = set() + + def scan(node): + if isinstance(node, dict): + for k, v in node.items(): + if k == "command" and isinstance(v, str): + for tok in v.replace('"', " ").split(): + if tok.endswith((".py", ".js")) and "/" in tok: + names.add(os.path.basename(tok)) + else: + scan(v) + elif isinstance(node, list): + for item in node: + scan(item) + + try: + with open(os.path.join(src_root, "hooks", "hooks.json"), + encoding="utf-8") as f: + scan(json.load(f)) + except (OSError, json.JSONDecodeError): + pass + return names + + +def main(src_root=None, installed=None): + src_root = src_root or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..")) + installed = installed or os.path.join( + os.path.expanduser("~/.zcode"), "regress-guard-hooks") + fails = [] + + # ① 语法面 + py_files = [] + for base, _dirs, files in os.walk(installed): + if "__pycache__" in base: + continue + py_files += [os.path.join(base, f) for f in files if f.endswith(".py")] + syntax_bad = [] + for p in py_files: + try: + with open(p, encoding="utf-8") as f: + compile(f.read(), p, "exec") # 纯语法检,不写 pyc + except Exception as e: + syntax_bad.append(f"{os.path.relpath(p, installed)}: {e}") + if syntax_bad: + fails.append("语法面") + print(f"❌ 语法面:{len(syntax_bad)} 个文件编译失败") + for b in syntax_bad[:5]: + print(f" · {b}") + else: + print(f"✅ 语法面:{len(py_files)} 个 .py 全部编译通过") + + # ② 注册面 + missing = [n for n in sorted(_referenced_scripts(src_root)) + if not os.path.exists(os.path.join(installed, n))] + if missing: + fails.append("注册面") + print(f"❌ 注册面:hooks.json 引用但已装目录缺失 {len(missing)} 个:") + for n in missing[:5]: + print(f" · {n}") + else: + refs = _referenced_scripts(src_root) + print(f"✅ 注册面:hooks.json 引用的 {len(refs)} 个脚本全部在位") + + # ③ 版本面 + try: + src_ver = json.load(open( + os.path.join(src_root, ".zcode-plugin", "plugin.json"), + encoding="utf-8"))["version"] + except Exception as e: + src_ver = None + fails.append("版本面") + print(f"❌ 版本面:源仓 plugin.json 读不了:{e}") + stamp_ver = None + try: + for line in open(os.path.join(installed, ".source"), encoding="utf-8"): + if line.startswith("source_version="): + stamp_ver = line.split("=", 1)[1].strip() + except OSError: + pass + form = ".source 戳" + if stamp_ver is None: + # 插件形态(v1.84,市场源安装无戳):比已装 plugin.json(顾问缓: + # 探测不到软提示不计红) + try: + stamp_ver = json.load(open( + os.path.join(installed, ".zcode-plugin", "plugin.json"), + encoding="utf-8"))["version"] + form = "plugin.json" + except Exception: + if src_ver: + print("⚠️ 版本面:无 .source 戳也无已装 plugin.json" + "(非本插件安装形态?)——软提示不计红") + if src_ver and stamp_ver == src_ver: + tag = f"({form})" if stamp_ver else "" + print(f"✅ 版本面:已装 v{stamp_ver} == 源仓 v{src_ver}{tag}") + elif src_ver and stamp_ver: + fails.append("版本面") + print(f"❌ 版本面:已装 v{stamp_ver}({form}) != 源仓 v{src_ver}") + + if fails: + print(f"\n自检未过({'/'.join(fails)})——请重跑 bash " + f"{os.path.join(src_root, 'install.sh')};仍红则 " + f"bash {os.path.join(src_root, 'uninstall.sh')} 后重装", file=sys.stderr) + return 1 + print("自检三查全绿") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else None, + sys.argv[2] if len(sys.argv) > 2 else None)) diff --git a/plugins/regress-guard/templates/adr.md b/plugins/regress-guard/templates/adr.md new file mode 100644 index 0000000..d309003 --- /dev/null +++ b/plugins/regress-guard/templates/adr.md @@ -0,0 +1,37 @@ +# ADR-{{NNN}}:{{标题=决策一句话}} + +- **日期**:{{DATE}} +- **状态**:{{proposed | accepted(含生效条件) | superseded by ADR-NNN}} +- **关联清单**:{{REGRESS-xxxx,无则空}} + +## 背景(context) + +{{为什么现在要做这个决策——触发性事件/反复出现的痛/新约束。写事实,不写愿景。}} + +## 备选(options) + +| 方案 | 一句描述 | 主要代价/风险 | +|------|---------|--------------| +| A(采纳) | {{}} | {{}} | +| B | {{}} | {{否决因一句}} | +| C | {{}} | {{否决因一句}} | + +> 没有备选的决策不是决策,是默认(/regress:plan 4.8 同律)。 + +## 决策(decision) + +{{选了什么,一段话。可执行的表述:以后遇到 X 就 Y。}} + +## 后果(consequences) + +- **正向**:{{得到什么}} +- **代价**:{{放弃了什么、增加了什么负担}} +- **红线**:{{此决策立下的不许碰项(如:A 模块永不 import B;数据流只进不出)—— + 可写成 fragile_points kind=arch 的 verify 命令进清单,机器强制}} + +--- + +> 文件名:`.regress/adr/{{NNN}}-{{短标题}}.md`,NNN 递增不复用;superseded 不删改, +> 新开 ADR 引用它(日落凝固,淘天 sunset 同款)。 +> 何时必须落 ADR(/regress:plan 4.10):引入新依赖 / 新服务 / 改数据流 / +> 破模块边界 / 推翻旧 ADR。小改动免仪式。 diff --git a/plugins/regress-guard/templates/product-arch.md b/plugins/regress-guard/templates/product-arch.md new file mode 100644 index 0000000..ca34045 --- /dev/null +++ b/plugins/regress-guard/templates/product-arch.md @@ -0,0 +1,26 @@ +# 产品·架构地图(模块卡片 · v1.35) + +> 产品存量与架构连接点同卡共写(顾问修正:两图合一防信息重叠)。 +> 每模块一卡:不知道已有什么,就永远不知道缺什么;不知道怎么连的, +> 就不知道改哪里会塌。init 生成骨架,/regress:finish 代谢位回写, +> /regress:plan 步骤 2a 与产品上下文卡同读。 + +**使用规则**: +- 一个模块一张卡,模块拆分/合并时卡片跟着动(卡片即模块的活档案) +- 「依赖」写方向(本模块 → 依赖谁),红线(不许依赖谁)写进 ADR 并在此引用 +- 「验证路径」= 用一个真实用户故事走通本模块的操作步骤(顾问补强: + 有路径才有活卡,没有路径的卡是装饰) +- 卡片会过期——finish 回写时顺手校对;连续两个任务没碰的模块卡标 🧊 待验证 + +--- + +## 模块:{{模块名}} + +- **完成度**:{{sketch |骨架可用 | 功能完整 | 打磨过 | 稳定}}(一档一词,别写百分比) +- **已具备能力**:{{用户视角的能力清单——"能做什么",不是"有哪些文件"}} +- **已知缺口**:{{伴侣功能缺失/状态缺失/已知粗糙点——/regress:plan 4.9 的产出落这里}} +- **依赖(方向)**:{{本模块 → X/Y/Z;对外契约:暴露给谁什么接口/事件/数据}} +- **验证路径**:{{一个用户故事走通的全步骤,如:用户登录→点设置→改一项→保存→重进看到新值}} +- **关联 ADR**:{{ADR-003 等,无则空}} + +<!-- 复制上一块卡新增模块;模块下线时整卡删除前先把"为什么下线"落一条 ADR --> diff --git a/plugins/regress-guard/templates/product-context.md b/plugins/regress-guard/templates/product-context.md new file mode 100644 index 0000000..911d128 --- /dev/null +++ b/plugins/regress-guard/templates/product-context.md @@ -0,0 +1,22 @@ +# 产品上下文(「资深·懂行业·贴近用户」那部分知识的住所 · v1.26) + +> 老板/产品负责人首填「用户」与「价值观」段(只有人类知道);「行业惯例」段可由 +> 顾问带搜索起草(mode=scout,注明来源与日期),人类修订。AI 维护更新,**永不删人类写的段**。 +> /regress:plan 2a:用户可见的产品需求必读本卡;卡缺且需求是产品功能 → 计划卡片一次性问全再建卡。 + +## 用户是谁 + +- {{人群/年龄/使用场景/设备与网络条件——例:70+ 岁老人独居,4G 弱网,子女端 App 接听}} + +## 产品价值观(不可牺牲的) + +- {{例:延迟不可牺牲;老人侧任何操作不超过一步;误报的代价远小于漏报}} + +## 行业惯例(顾问带搜索起草,逐条注来源+日期) + +- {{例:主流医疗警报器 = 一键触发→自动免提全双工,PTT 属对讲机品类惯例而非 SOS 品类 + (顾问 scout 2026-09-03,来源:PERS 研究/多嘴猫/爱牵挂)}} + +## 设计否决记录(学费——跨任务重复的产品否决会升格为上面的段) + +- {{例:2026-09-03 对讲交互设计被否——理由归类:行业惯例(应免持非按住)}} diff --git a/plugins/regress-guard/templates/regress-dir-readme.md b/plugins/regress-guard/templates/regress-dir-readme.md new file mode 100644 index 0000000..4c300f9 --- /dev/null +++ b/plugins/regress-guard/templates/regress-dir-readme.md @@ -0,0 +1,34 @@ +# .regress/ — 回归治理数据目录(先读我) + +> 写给失忆的读者(新会话/新同事/接手者):工作现场全在本目录,不依赖任何人的记忆。 + +## 三步上手 + +0. **产品需求**:先读 `product-context.md`(产品上下文卡:用户/价值观/行业惯例——如果存在) +1. **看现场**:`manifests/` 里 status ∈ planning/in-progress/verifying/blocked 的清单—— + planning=等人类批准;blocked=受阻(读清单 blocked 四问,need 写着需要你提供什么) +2. **跑环境**:清单正文「环境准备(必读)」段——版本/连接命令/唯一正确的启动入口 +3. **干活**:按清单正文「实施顺序(一步一响)」逐步做,每步达成表里的可观察里程碑 + +## 什么时候算做完(不由感觉定义) + +- 验收标准表逐条 locked(判据拿到证据)+ 脆弱点全部 locked/flagged——open 禁止提交 +- 实际改动全部回写清单(F3 清零);提交时 hook 自跑测试通过 +- 清单 status → done + +## 出错了怎么办 + +- verify 失败 → 先查清单「报错自救」表和脆弱点 rescue 字段 +- 修复 3 次仍败/需要人类输入 → 受阻(`~/.zcode/regress-guard-hooks/lib/plan_approve.py <清单> --block`), + 转达 need——受阻是合法停止 +- 假设被实测推翻 → 清单「假设失效记录」追加 was→reality→evidence,别悄悄改写 +- 排障改了仍不好 → 这是**假设被证伪**不是防御不够:回「假设账本」新增一行重新取证, + 禁止直接叠防御层 + +## AI 会话 + +断点续作直接跑 `/regress:resume`(从本目录产物单侧重建现场)。 +其他:decisions.md=决策史(否决过的方案别重走);journal/=考古地层;history.jsonl=门禁决策史。 + +--- +<!-- generated-by: regress-guard v1.26.1 · 机器生成;人工定制请删除本行(删除后永不被自动覆盖/刷新) --> diff --git a/plugins/regress-guard/templates/regress-manifest.md b/plugins/regress-guard/templates/regress-manifest.md new file mode 100644 index 0000000..0db34c2 --- /dev/null +++ b/plugins/regress-guard/templates/regress-manifest.md @@ -0,0 +1,234 @@ +--- +id: REGRESS-{{YEAR}}-{{SEQ}} +requirement: "{{RAW_REQUIREMENT}}" +understood_intent: # 对齐三件(v1.40 升格:复述=行业对齐标配 restatement) + 复述: "{{AI_PARSED_FULL_DESCRIPTION}}" + 边界: "{{做什么/不做什么}}" + 判据: "{{怎么算成:引用验收标准节第几条}}" +scan: # 全貌三行(v1.40 规则A:M/L 缺任一行门禁拦——理解是强制产物) + entry: "{{入口在哪:一句话}}" + test: "{{测试怎么跑:一条命令}}" + card: "{{动的是哪张模块卡:模块名/新模块;card_sync: false=显式豁免结构同步}}" +rollback: "git revert 即回滚" # 能力断言+引信(v1.41 全档必填)——触及迁移/schema/破坏性SQL时默认失效,须写具体回滚路径(数据怎么回/迁移怎么退) +self_review: # 自审 diff(v1.41 触发表激活:不适用=键不出现,「无」=查过没有;门禁按触发拦缺失键) + 计划外: "{{actual_changes 非空时必在:逐文件一句或 无}}" + 调试残留: "{{diff 命中调试模式且非 tests/ 时必在:条目或 无}}" +assumptions: + - "{{ASSUMPTION_1}}" + - "{{ASSUMPTION_2}}" +confirmed: [] +ambiguities_resolved: + - point: "{{AMBIGUITY_POINT}}" + ai_choice: "{{AI_DECISION}}" + basis: "{{CODE_OR_CONVENTION_BASIS}}" +status: planning # planning=待批准(拦编辑)→批准后 in-progress;blocked=受阻(拦编辑,四问见 blocked 块);cancelled=取消归档;done=收尾 +base_head: "{{GIT_HEAD_SHORT}}" # 创建时 git HEAD 短sha(非 git 项目留空)——批准时校验计划是否漂移 +approved: # 批准落产物(v1.13):批准词由 AI 转写(plan_approve.py),或人类直接填 at(产物直通) + at: "" # 批准时间(空=未批准;非空=边界守卫视同已批准) + note: "" # 批准附言/修改要求 +blocked: # 受阻记录(v1.14):卡住别硬磨别绕过——plan_approve.py --block 写入四问 + reason: "" # 阻塞在哪(具体命令/代码位置/环境) + tried: "" # 已尝试的方法 + unsafe_why: "" # 为什么不能安全继续 + need: "" # 需要人类提供什么(信息/权限/决策) + at: "" +provisional: # 临行(v1.18 伪全自动):预授权任务经顾问预审无方向性异议后临行;否决=--cancel + at: "" # 临行时间(空=非临行);哨兵显示 🚀 否决窗内 + advisor: "" # 预审一句话结论——顾问有一票否决权,没有一票批准权 +planned_changes: + - id: F1 + file: "{{FILE_PATH}}" + type: method-logic + reason: "{{REASON}}" + tests_required: [unit, smoke] + characterization_needed: false +actual_changes: [] +boundary: # 开发边界(hook 事前拦截越界 Edit/Write;track 回写即扩界) + include: + - "src/**" # 改动所在目录 + - "tests/**" # 配套测试 +fragile_points: # 公理一:穷举致使失败的关键脆弱点,逐个挂牌 + - id: V1 + kind: env # env | dependency | resource | data | concurrency | api | oracle | sensory | product | arch + description: "{{FRAGILE_DESCRIPTION}}" + verify: "{{VERIFY_COMMAND}}" # 可执行命令,/regress:verify 跑它拿证据 + rescue: "{{RESCUE_COMMAND}}" # 可选:verify 失败/见典型报错时先敲这个(自救) + # status 行禁带行内注释(P1#12 病例:行尾 # 让 open 检测恒不中=公理一静默失效) + # 流转:open=未挂牌(禁提交) / locked=verify已通过 / flagged=带病挂牌 + status: open +adr: "" # 关联 ADR(v1.35):重大选型必填 .regress/adr/NNN-标题.md 路径,小改动留空 +test_results: {} +created_at: "{{DATE}}" +--- + +# 回归清单:{{REQUIREMENT}} + +## 需求理解 + +**原始需求**:{{RAW_REQUIREMENT}} + +**AI 理解**:{{UNDERSTOOD_INTENT}} + +**设计取舍**(v1.25):{{被否备选方案}}——否决因:{{一句理由}}(没有备选的设计不是设计) + +**用户所见**(v1.26 草图先行,用户可见功能必备):{{界面示意 / 三步交互流 / API 示例——否决发生在草图,不在实现后}} + +**假设**(如有不符请指出): +1. {{ASSUMPTION_1}} +2. {{ASSUMPTION_2}} + +## 验收标准(做到什么算完 · v1.25 证据律 · v1.40 EARS-lite) + +> 每条必须可检验:布尔判据或可观察行为。"根治/优化/更好"这类词必须挂在下面 +> 某条判据上,指不回去的词不许出现在汇报里。非功能底线(延迟/安全/兼容)在 +> 这里定型——它们在需求阶段缺席,设计就会朝反方向跑 +> (病例:REGRESS-005 jitter buffer,产品价值观在两轮过度设计后才抵达)。 +> 与脆弱点互补分层:验收=需求侧 done 定义(功能对不对),脆弱点=风险侧(周边会不会坏)。 +> **EARS-lite 行格式(v1.40,M/L 用)**:`When <条件>,则 <可观察结果>(验:<命令>)` +> ——条件/结果/验命令三件齐才算一条完整判据(对标 Kiro EARS 的机器可解析性, +> 验命令复用脆弱点 verify 惯例,finish/verify 可直接跑)。 +> **验收入环(v1.55,M/L)**:验证过一行就在行尾加 `✅`——done 盖章时门禁校验 +> 全勾(缺节/未勾/占位都拦,S 档/quick 豁免);表格式则看状态列(pass/done/locked)。 + +| # | 判据(EARS-lite:When…则…验:…) | 证据(verify 命令 / human_check) | 状态 | +|---|--------------|-----------------------------------|------| +| A1 | {{例:When 发起对讲,则 3s 内建流且全程无断续}} | {{真机听感 human_check:V8}} | open | + +## 广度矩阵(用户可见功能必填 · v1.35:治"做完才发现少一半") + +> 三个子节缺一不可(/regress:plan 4.9 的产出落这里)。**每行配验证路径**—— +> 用户故事反推一条可操作验证语句,防矩阵沦为条款清单(顾问补强)。 + +### 角色 × 任务 + +| 角色 | 任务(jobs-to-be-done) | 验证路径(一条可操作的走查语句) | +|------|------------------------|--------------------------------| +| {{管理员}} | {{配一次设备并验证生效}} | {{登录→设备页→添加→看到在线→下发配置→读回确认}} | + +### 伴侣功能清单(UI 类逐项过;写"不做"也算答,留空=没想过) + +| 伴侣 | 本期做吗 | 说明 | +|------|---------|------| +| 设置入口 | {{做/不做}} | {{}} | +| 错误态(失败提示/重试) | {{}} | {{}} | +| 空态(首次无数据) | {{}} | {{}} | +| 加载态(等待反馈) | {{}} | {{}} | +| 撤销/二次确认(破坏性操作) | {{}} | {{}} | +| 权限(谁能用) | {{}} | {{}} | +| 移动端/窄屏 | {{}} | {{}} | +| 数据导出 | {{}} | {{}} | +| i18n / 文案一致性 | {{}} | {{}} | +| 无障碍(键盘/对比度) | {{}} | {{}} | + +### 竞品对照(有竞品时必填,无竞品写"无") + +| 能力 | 我们 | 竞品A | 差距动作 | +|------|------|-------|---------| +| {{}} | {{有/无/半}} | {{}} | {{补/不补+理由}} | + +## 架构影响(跨模块/结构性改动必填 · v1.35) + +> 读 `.regress/product-arch.md` 模块卡片后回答(卡不存在 → /regress:init 补)。 +> 单文件内部改动可写"无结构性影响"一句话了事。 + +- **动了哪些模块**:{{模块卡片里的名字,不是文件路径}} +- **依赖方向变化**:{{新增/删除了谁的依赖;与卡片「依赖」栏对照}} +- **新增耦合**:{{本改动让哪两个原本独立的东西绑在一起了}} +- **触发 ADR**:{{新依赖/新服务/改数据流/破边界 → 是则填 adr: 引用并落 ADR 文件;否→"否"}} + +## DoD 状态完备性(UI 功能必填 · v1.35:跑通≠完成) + +> 每个用户可见状态都要**真渲染看过**(browser-use 截图为证)才算 done—— +> 落 fragile_points kind=product 搭既有机器强制(open 挡提交)。 +> 代码审查发现不了空态尴尬——只有真眼睛能。 + +| 状态 | 看过吗 | 证据 | +|------|--------|------| +| 正常态 | {{}} | {{截图/human_check:Vn}} | +| 空态 | {{}} | {{}} | +| 错误态 | {{}} | {{}} | +| 加载态(慢网/大列表) | {{}} | {{}} | + +## 环境准备(必读 · 写给失忆的读者) + +> 写给失忆的读者:只凭这段把环境跑起来,不求助、不迷路。(写作指导 /regress:plan 4.7) + +- **运行时**:{{JDK/Node/Python 版本及原因;有 --add-opens 之类的特殊参数直接给全命令}} +- **外部依赖**:{{Redis/TDengine/…:本地装还是隧道连?给出 ssh -L 等现成命令}} +- **启动入口**:{{唯一正确的启动脚本;若"别点 IDE 的 Run"必须写明}} + +## 实施顺序(一步一响) + +> planned_changes 是集合,这里是序列:每步挂**可观察里程碑**,做完一步见到一步的响。 + +| 步 | 做什么 | 里程碑(做到什么算过) | +|---|--------|----------------------| +| 1 | {{基石:无网络依赖的纯逻辑 + 单测}} | {{对应单测绿}} | +| 2 | {{骨架:服务/端口能起}} | {{控制台打印出约定日志}} | +| 3 | {{协议/联动}} | {{可观察的输出}} | + +## 预期改动点 + +### F1: {{FILE_PATH}} — {{TYPE}} +- **改动原因**:{{REASON}} +- **测试要求**:unit + smoke +- **影响范围**:{{AFFECTED}} + +## 脆弱点拓扑(公理一) + +成功不是代码跑通,而是所有已知脆弱点被锁死或显式挂牌。 +**未列出的脆弱点才是真正的未知风险**——动手前穷举:谁帮我设了环境变量? +依赖库会不会报错?资源会不会被抢(PID/端口/GPU)?数据会丢吗? + +| ID | 类型 | 脆弱点 | 验证命令 | 状态 | +|----|------|--------|----------|------| +| V1 | env | {{FRAGILE_DESCRIPTION}} | `{{VERIFY_COMMAND}}` | open | + +> status 流转:open →(verify 命令实测通过)→ locked; +> 或 open →(明知风险但暂不处理,写明原因)→ flagged。open 状态禁止提交。 +> +> sensory 类(人的感官是唯一判据,如对讲声音清晰):问人只收**通过/不通过**, +> verify 写 `human_check:<vid>`,结果落化石(`journal.py . add human_check`)—— +> 门禁验"人确认过"这个事实的存在,不复跑感官。 + +## 报错自救(What if broken) + +> verify 失败先查这张表再动手:左列报错关键词、右列自救命令(与 rescue 字段同源; +> 踩坑修复后回填,经 /regress:learn 沉淀为项目规律)。 + +| 报错关键词 | 自救 | +|-----------|------| +| {{UnsatisfiedLinkError taos}} | {{用 run-local.sh 启动,别用 IDE Run}} | + +## 假设账本(排障任务必填 · v1.23:先检验后动手) + +> 病例 REGRESS-005:三轮"根治"全上,真凶是自己上一轮写的参数(全文 docs/PHILOSOPHY.md §4)。 + +每个假设先过检验关,裁决通过才写修复代码——**写不出当场能跑的检验命令的, +不是假设,是猜测。** + +| # | 假设 | 检验命令(当场可跑) | 结果 | 裁决 | +|---|------|---------------------|------|------| +| H1 | {{例:前端超发导致丢帧}} | {{grep 日志统计实际到达帧率}} | {{实测 50fps}} | 证伪 | + +裁决规则: +- **第一嫌疑人 = 自己最近的 diff**——症状出现在某次修复之后,先 `git log` 审那次 + 修复,再向外搜(前端/固件/网络) +- 检验通过 → 才允许写修复代码;修复后 oracle 仍报问题 = **假设被证伪**, + 不是"防御不够"——回本表新增一行重新取证,禁止直接叠防御层(叠层须出示新证据) +- **旧解释不复用**——上个根因对这个症状无效,上次为真 ≠ 这次为真,复用必须重测 + +## 假设失效记录(v1.14:失效过程比结论值钱) + +假设被实测证伪时**不要悄悄改写**——追加一行,把 was→reality→evidence 留在产物里: + +| 脆弱点 | 原假设(was) | 实际(reality) | 证据(evidence) | +|--------|--------------|----------------|-----------------| +| V? | (推测:按 1601/1603 格式) | (实测:Cmd=7203 但 Ack=1) | `node mock-device.js --self-test` 输出 | + +同时埋一条地层化石(机器可读): + +```bash +python3 "<插件路径>/hooks/scripts/lib/journal.py" . add assumption_broken \ + '{"manifest_id":"<ID>","vid":"V5","was":"<原假设>","reality":"<实际>","evidence":"<命令>"}' +``` diff --git a/plugins/regress-guard/uninstall.sh b/plugins/regress-guard/uninstall.sh new file mode 100755 index 0000000..0781f17 --- /dev/null +++ b/plugins/regress-guard/uninstall.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# regress-guard 卸载器 +# +# 用法:bash uninstall.sh +# +# 清理: +# 1. 删除 ~/.zcode/skills/ 下的 3 个 skill +# 2. 删除 ~/.zcode/commands/ 下的 7 个命令 +# 3. 删除 ~/.zcode/regress-guard-hooks/ +# 4. 从 config.json 移除 hook 注册 +# 5. 从 AGENTS.md 移除回归契约块 +# +# 安全:不会删除用户的其他 skill/命令/config 字段。 + +set -euo pipefail + +ZCODE_HOME="${HOME}/.zcode" +HOOK_HOME="${ZCODE_HOME}/regress-guard-hooks" +CONFIG_FILE="${ZCODE_HOME}/cli/config.json" +AGENTS_FILE="${ZCODE_HOME}/AGENTS.md" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' +info() { echo -e "${GREEN}✅${NC} $1"; } +warn() { echo -e "${YELLOW}⚠️${NC} $1"; } + +echo "════════════════════════════════════════════" +echo " regress-guard 卸载" +echo "════════════════════════════════════════════" +echo "" + +# ─── 1. 删除旧版 skills(兼容清理)────────────────── +for skill in regression-planning characterization-testing change-impact-analysis requirement-parsing adaptive-thinking adaptive-learning; do + if [ -d "${ZCODE_HOME}/skills/${skill}" ]; then + rm -rf "${ZCODE_HOME}/skills/${skill}" + info "清理旧 skill: ${skill}" + fi +done + +# ─── 2. 删除 commands(与 self_heal REQUIRED_COMMANDS 同源,P1#6 补 resume/finish/stats)─── +for cmd in regress:init regress:plan regress:track regress:verify regress:quick regress:bypass regress:learn regress:evolve regress:trace regress:resume regress:finish regress:stats regress:characterize regress:install regress:uninstall regress:update; do + f="${ZCODE_HOME}/commands/${cmd}.md" + if [ -f "$f" ]; then + rm "$f" + info "删除命令: ${cmd}" + fi +done + +# ─── 3. 删除 hook 脚本 ──────────────────────────────── +if [ -d "${HOOK_HOME}" ]; then + rm -rf "${HOOK_HOME}" + info "删除 hook 脚本: ${HOOK_HOME}" +fi + +# ─── 4. 从 config.json 移除 hook ────────────────────── +if [ -f "${CONFIG_FILE}" ]; then + python3 << 'PYEOF' +import json, os, re + +config_path = os.path.expanduser("~/.zcode/cli/config.json") +try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) +except Exception: + exit(0) + +hooks = config.get("hooks", {}) +events = hooks.get("events", {}) + +# P1#6 统一清理:所有事件、所有条目——凡 command/args 里带 regress-guard 痕迹的 +# hook 一律摘除(旧实现按脚本名逐事件过滤:Stop 只滤 reflection_check 漏掉 +# stop_notify、PreToolUse 漏 boundary_guard/execution_valve、PostToolUseFailure +# 与 SessionStart(compact) 完全不清——卸载后 6 条死钩子指已删目录,每次工具调用报错) +def _is_ours(h): + blob = str(h.get("command", "")) + " " + str(h.get("args", [])) + return ("regress-guard" in blob or "launcher.js" in blob + or any(s in blob for s in ( + "read_before_edit", "prompt_intercept", "reflection_check", + "self_heal", "boundary_guard", "execution_valve", + "fail_watch", "risk_watch", "compact_notice", "stop_notify", + "pre_commit_guard", "plan_bridge")) + +for ev in list(events): + cleaned = [] + for entry in events[ev]: + filtered = [h for h in entry.get("hooks", []) if not _is_ours(h)] + if filtered: + cleaned.append({**entry, "hooks": filtered}) + if cleaned: + events[ev] = cleaned + else: + events.pop(ev, None) + +if not events: + hooks.pop("events", None) + # 如果 hooks 空了,保留 enabled(不影响其他东西) +config["hooks"] = hooks + +with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + +print("已从 config.json 移除 hook 注册") +PYEOF + info "hook 注册已移除" +fi + +# ─── 5. 从 AGENTS.md 移除回归契约 ──────────────────── +if [ -f "${AGENTS_FILE}" ]; then + python3 << 'PYEOF' +import re, os +path = os.path.expanduser("~/.zcode/AGENTS.md") +content = open(path, encoding="utf-8").read() +pattern = r'<!-- regress-guard start -->.*?<!-- regress-guard end -->\n*' +new = re.sub(pattern, '', content, flags=re.DOTALL).rstrip() + '\n' +open(path, "w", encoding="utf-8").write(new) +print("已从 AGENTS.md 移除回归契约") +PYEOF + info "AGENTS.md 回归契约已移除" +fi + +# ─── 6. 清理文档(可选)────────────────────────────── +if [ -d "${ZCODE_HOME}/regress-guard-docs" ]; then + rm -rf "${ZCODE_HOME}/regress-guard-docs" + info "删除文档" +fi + +echo "" +echo "════════════════════════════════════════════" +echo -e "${GREEN} ✅ regress-guard 已卸载${NC}" +echo "════════════════════════════════════════════" +echo "" +echo "项目的 .regress/ 目录未删除(保留历史数据)。" +echo "如需彻底清理项目,手动删除项目中的 .regress/ 文件夹。"