agentic-project-kit 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentic_project_kit/__init__.py +1 -0
- agentic_project_kit/absolute_path_portability_audit.py +241 -0
- agentic_project_kit/access_level_policy.py +14 -0
- agentic_project_kit/access_levels.py +22 -0
- agentic_project_kit/action_registry.py +195 -0
- agentic_project_kit/action_specs.py +286 -0
- agentic_project_kit/agent_command_runner.py +516 -0
- agentic_project_kit/audit_test_matrix.py +36 -0
- agentic_project_kit/audit_test_matrix_cli.py +43 -0
- agentic_project_kit/chat_bootloader.py +257 -0
- agentic_project_kit/chat_entrypoint_contract.py +297 -0
- agentic_project_kit/checks.py +511 -0
- agentic_project_kit/ci_readiness.py +211 -0
- agentic_project_kit/cli.py +147 -0
- agentic_project_kit/cli_commands/__init__.py +1 -0
- agentic_project_kit/cli_commands/absolute_path_audit.py +25 -0
- agentic_project_kit/cli_commands/actions.py +21 -0
- agentic_project_kit/cli_commands/artifact_gc.py +240 -0
- agentic_project_kit/cli_commands/boot.py +55 -0
- agentic_project_kit/cli_commands/chat.py +34 -0
- agentic_project_kit/cli_commands/checks.py +305 -0
- agentic_project_kit/cli_commands/cockpit.py +103 -0
- agentic_project_kit/cli_commands/command_taxonomy.py +25 -0
- agentic_project_kit/cli_commands/commands.py +129 -0
- agentic_project_kit/cli_commands/dev.py +23 -0
- agentic_project_kit/cli_commands/direction.py +92 -0
- agentic_project_kit/cli_commands/doc_currency_audit.py +25 -0
- agentic_project_kit/cli_commands/doc_registry.py +126 -0
- agentic_project_kit/cli_commands/docs.py +223 -0
- agentic_project_kit/cli_commands/dpa.py +1338 -0
- agentic_project_kit/cli_commands/evidence.py +251 -0
- agentic_project_kit/cli_commands/github.py +34 -0
- agentic_project_kit/cli_commands/governance.py +14 -0
- agentic_project_kit/cli_commands/gui.py +23 -0
- agentic_project_kit/cli_commands/gui_readiness_gate.py +27 -0
- agentic_project_kit/cli_commands/handoff.py +183 -0
- agentic_project_kit/cli_commands/human_workflows.py +430 -0
- agentic_project_kit/cli_commands/init.py +132 -0
- agentic_project_kit/cli_commands/instruction.py +75 -0
- agentic_project_kit/cli_commands/mutation_lock_audit.py +24 -0
- agentic_project_kit/cli_commands/ns_legacy_audit.py +25 -0
- agentic_project_kit/cli_commands/pass_already_done.py +66 -0
- agentic_project_kit/cli_commands/patch_failure_discipline_audit.py +26 -0
- agentic_project_kit/cli_commands/patch_preflight.py +37 -0
- agentic_project_kit/cli_commands/path_literal_audit.py +39 -0
- agentic_project_kit/cli_commands/patterns.py +40 -0
- agentic_project_kit/cli_commands/planning_docs_audit.py +25 -0
- agentic_project_kit/cli_commands/pr.py +133 -0
- agentic_project_kit/cli_commands/pr_hygiene.py +27 -0
- agentic_project_kit/cli_commands/profiles.py +26 -0
- agentic_project_kit/cli_commands/program_redundancy_audit.py +25 -0
- agentic_project_kit/cli_commands/project_direction.py +25 -0
- agentic_project_kit/cli_commands/release.py +392 -0
- agentic_project_kit/cli_commands/release_publish_orchestration.py +36 -0
- agentic_project_kit/cli_commands/remote_branch_hygiene.py +152 -0
- agentic_project_kit/cli_commands/remote_next.py +57 -0
- agentic_project_kit/cli_commands/removed_ns_commands.py +65 -0
- agentic_project_kit/cli_commands/rule_registry.py +129 -0
- agentic_project_kit/cli_commands/rules.py +222 -0
- agentic_project_kit/cli_commands/scaffold.py +46 -0
- agentic_project_kit/cli_commands/slice.py +21 -0
- agentic_project_kit/cli_commands/standard_gates_audit_suite.py +27 -0
- agentic_project_kit/cli_commands/state.py +51 -0
- agentic_project_kit/cli_commands/status_current_state_audit.py +30 -0
- agentic_project_kit/cli_commands/todo.py +53 -0
- agentic_project_kit/cli_commands/transfer.py +19 -0
- agentic_project_kit/cli_commands/transfer_context_flow.py +398 -0
- agentic_project_kit/cli_commands/transfer_context_helpers.py +398 -0
- agentic_project_kit/cli_commands/transfer_diagnostics.py +767 -0
- agentic_project_kit/cli_commands/transfer_evidence_flow.py +368 -0
- agentic_project_kit/cli_commands/transfer_handoff_flow.py +594 -0
- agentic_project_kit/cli_commands/transfer_order_contract.py +74 -0
- agentic_project_kit/cli_commands/transfer_post_merge_complete.py +442 -0
- agentic_project_kit/cli_commands/transfer_post_merge_settle.py +171 -0
- agentic_project_kit/cli_commands/transfer_pr_closeout_complete.py +124 -0
- agentic_project_kit/cli_commands/transfer_pr_create_flow.py +743 -0
- agentic_project_kit/cli_commands/transfer_pr_merge_flow.py +547 -0
- agentic_project_kit/cli_commands/transfer_repo_after_pr.py +160 -0
- agentic_project_kit/cli_commands/transfer_repo_pre_pr.py +664 -0
- agentic_project_kit/cli_commands/transfer_runner_flow.py +131 -0
- agentic_project_kit/cli_commands/transfer_runtime_initial.py +194 -0
- agentic_project_kit/cli_commands/transfer_shared.py +274 -0
- agentic_project_kit/cli_commands/validation.py +200 -0
- agentic_project_kit/cli_commands/version.py +27 -0
- agentic_project_kit/cli_commands/work_orders.py +219 -0
- agentic_project_kit/cli_commands/workflow.py +471 -0
- agentic_project_kit/cli_commands/workflow_guard.py +26 -0
- agentic_project_kit/cli_commands/workspace.py +252 -0
- agentic_project_kit/cockpit.py +608 -0
- agentic_project_kit/cockpit_readiness.py +67 -0
- agentic_project_kit/command_authority_audit.py +192 -0
- agentic_project_kit/command_inbox_check.py +103 -0
- agentic_project_kit/command_manifest.py +591 -0
- agentic_project_kit/command_queue_contract.py +161 -0
- agentic_project_kit/command_selector.py +245 -0
- agentic_project_kit/command_taxonomy.py +264 -0
- agentic_project_kit/commit_guard.py +67 -0
- agentic_project_kit/communication_artifact_gc.py +495 -0
- agentic_project_kit/communication_rule_context.py +437 -0
- agentic_project_kit/communication_state.py +115 -0
- agentic_project_kit/composed_wrapper_invariants.py +97 -0
- agentic_project_kit/composite_short_circuit.py +140 -0
- agentic_project_kit/contract.py +251 -0
- agentic_project_kit/control_state.py +83 -0
- agentic_project_kit/doc_currency_audit.py +536 -0
- agentic_project_kit/doc_lifecycle.py +1179 -0
- agentic_project_kit/doc_lifecycle_signals.py +446 -0
- agentic_project_kit/doc_lifecycle_sweep.py +639 -0
- agentic_project_kit/doc_mesh.py +491 -0
- agentic_project_kit/doctor.py +379 -0
- agentic_project_kit/documentation_registry.py +1122 -0
- agentic_project_kit/documentation_system_audit.py +310 -0
- agentic_project_kit/dpa_current_handoff_lifecycle.py +549 -0
- agentic_project_kit/dpa_dp2_decision_readiness.py +362 -0
- agentic_project_kit/dpa_dp3_dp4_adjudication.py +604 -0
- agentic_project_kit/dpa_dp5_block_new_gate.py +294 -0
- agentic_project_kit/dpa_dp5_stage_adoption.py +615 -0
- agentic_project_kit/dpa_dp5_strict_gate.py +187 -0
- agentic_project_kit/dpa_final_closeout.py +627 -0
- agentic_project_kit/dpa_fixture_evidence.py +680 -0
- agentic_project_kit/dpa_maintainer_record_check.py +711 -0
- agentic_project_kit/dpa_post_dp2_scope_assessment.py +734 -0
- agentic_project_kit/dpa_probe_002_readiness.py +509 -0
- agentic_project_kit/dpa_probe_003_readiness.py +417 -0
- agentic_project_kit/dpa_probe_004_readiness.py +437 -0
- agentic_project_kit/dpa_readiness.py +505 -0
- agentic_project_kit/dpa_readonly_probe_execution.py +500 -0
- agentic_project_kit/dpa_registry_contracts.py +312 -0
- agentic_project_kit/dpa_renderer_readiness.py +407 -0
- agentic_project_kit/dpa_repo_adoption_assessment.py +574 -0
- agentic_project_kit/dpa_stable_readiness.py +743 -0
- agentic_project_kit/dpa_successor_projection.py +168 -0
- agentic_project_kit/dpa_workspace_init_projection.py +93 -0
- agentic_project_kit/dpa_wrt_ch001_evidence.py +327 -0
- agentic_project_kit/entrypoint_slice_runner.py +153 -0
- agentic_project_kit/evidence_clean.py +152 -0
- agentic_project_kit/evidence_commit_paths.py +214 -0
- agentic_project_kit/evidence_finalize_log.py +204 -0
- agentic_project_kit/evidence_guard.py +114 -0
- agentic_project_kit/evidence_inspector.py +507 -0
- agentic_project_kit/evidence_state_contract.py +99 -0
- agentic_project_kit/execution_mode_state.py +148 -0
- agentic_project_kit/final_summary_contract.py +85 -0
- agentic_project_kit/finalize_guard.py +139 -0
- agentic_project_kit/gatekeeper_core.py +232 -0
- agentic_project_kit/github.py +25 -0
- agentic_project_kit/governance.py +59 -0
- agentic_project_kit/gui_action_execution.py +105 -0
- agentic_project_kit/gui_action_renderer.py +81 -0
- agentic_project_kit/gui_action_run_intent.py +98 -0
- agentic_project_kit/gui_action_views.py +195 -0
- agentic_project_kit/gui_activity_log.py +76 -0
- agentic_project_kit/gui_button_catalog.py +605 -0
- agentic_project_kit/gui_cockpit.py +365 -0
- agentic_project_kit/gui_cockpit_actions.py +695 -0
- agentic_project_kit/gui_cockpit_common.py +172 -0
- agentic_project_kit/gui_cockpit_header.py +501 -0
- agentic_project_kit/gui_cockpit_sidebar.py +387 -0
- agentic_project_kit/gui_cockpit_task.py +748 -0
- agentic_project_kit/gui_command_projection.py +355 -0
- agentic_project_kit/gui_communication_modes.py +130 -0
- agentic_project_kit/gui_dry_run.py +219 -0
- agentic_project_kit/gui_gatekeeper_status.py +259 -0
- agentic_project_kit/gui_layout_plan.py +148 -0
- agentic_project_kit/gui_open_folder.py +18 -0
- agentic_project_kit/gui_output_status_panel.py +61 -0
- agentic_project_kit/gui_panel_state.py +47 -0
- agentic_project_kit/gui_presenter.py +62 -0
- agentic_project_kit/gui_readiness_gate.py +206 -0
- agentic_project_kit/gui_release_flow.py +59 -0
- agentic_project_kit/gui_task_editor.py +1077 -0
- agentic_project_kit/gui_tk_widgets.py +177 -0
- agentic_project_kit/gui_tkinter_renderer.py +87 -0
- agentic_project_kit/gui_tkinter_shell.py +1176 -0
- agentic_project_kit/gui_transfer_contract.py +28 -0
- agentic_project_kit/gui_viewmodel.py +698 -0
- agentic_project_kit/gui_window_guard.py +46 -0
- agentic_project_kit/handoff_freshness.py +390 -0
- agentic_project_kit/handoff_prompt.py +180 -0
- agentic_project_kit/handoff_state.py +254 -0
- agentic_project_kit/instruction_lint.py +455 -0
- agentic_project_kit/llm_context_carriers.py +66 -0
- agentic_project_kit/llm_execution_context.py +285 -0
- agentic_project_kit/local_command_stack.py +133 -0
- agentic_project_kit/local_feature_gate.py +82 -0
- agentic_project_kit/local_garbage_collector.py +269 -0
- agentic_project_kit/models.py +18 -0
- agentic_project_kit/mutation_lock_audit.py +421 -0
- agentic_project_kit/next_turn_evidence.py +253 -0
- agentic_project_kit/next_turn_merge_if_green.py +459 -0
- agentic_project_kit/next_turn_pr_status.py +255 -0
- agentic_project_kit/next_turn_result.py +183 -0
- agentic_project_kit/next_turn_runner.py +159 -0
- agentic_project_kit/next_turn_slot.py +107 -0
- agentic_project_kit/next_turn_status.py +274 -0
- agentic_project_kit/ns_legacy_reference_audit.py +258 -0
- agentic_project_kit/ns_slice_runner.py +16 -0
- agentic_project_kit/operational_handoff_projection.py +124 -0
- agentic_project_kit/output_contract.py +80 -0
- agentic_project_kit/output_repair.py +61 -0
- agentic_project_kit/pass_already_done.py +216 -0
- agentic_project_kit/patch_artifact_preflight.py +147 -0
- agentic_project_kit/patch_cycle_workflow.py +440 -0
- agentic_project_kit/patch_failure_discipline_audit.py +223 -0
- agentic_project_kit/patch_preflight.py +215 -0
- agentic_project_kit/path_literal_audit.py +612 -0
- agentic_project_kit/patterns.py +99 -0
- agentic_project_kit/planning_docs_consolidation_audit.py +313 -0
- agentic_project_kit/portability_shell_gate.py +40 -0
- agentic_project_kit/post_merge_handoff_refresh.py +56 -0
- agentic_project_kit/post_release.py +252 -0
- agentic_project_kit/post_release_closeout.py +616 -0
- agentic_project_kit/pr_cleanup.py +172 -0
- agentic_project_kit/pr_closeout.py +50 -0
- agentic_project_kit/pr_closeout_complete.py +344 -0
- agentic_project_kit/pr_create_or_skip.py +71 -0
- agentic_project_kit/pr_hygiene.py +210 -0
- agentic_project_kit/program_redundancy_audit.py +251 -0
- agentic_project_kit/project_direction.py +840 -0
- agentic_project_kit/protected_change_planner.py +194 -0
- agentic_project_kit/release.py +401 -0
- agentic_project_kit/release_doi_safety.py +24 -0
- agentic_project_kit/release_gate_core.py +174 -0
- agentic_project_kit/release_metadata_authority_gate.py +328 -0
- agentic_project_kit/release_metadata_prep.py +43 -0
- agentic_project_kit/release_notes.py +823 -0
- agentic_project_kit/release_prep_core.py +204 -0
- agentic_project_kit/release_prepare.py +369 -0
- agentic_project_kit/release_process_guardrails.py +247 -0
- agentic_project_kit/release_publish_core.py +114 -0
- agentic_project_kit/release_publish_orchestration.py +469 -0
- agentic_project_kit/release_state.py +552 -0
- agentic_project_kit/release_verify_core.py +175 -0
- agentic_project_kit/remote_branch_hygiene.py +223 -0
- agentic_project_kit/remote_next.py +89 -0
- agentic_project_kit/remote_next_closeout.py +101 -0
- agentic_project_kit/remote_next_order_contract.py +391 -0
- agentic_project_kit/removed_ns_commands.py +458 -0
- agentic_project_kit/removed_source_audit.py +225 -0
- agentic_project_kit/rendering.py +13 -0
- agentic_project_kit/repair_report.py +79 -0
- agentic_project_kit/repo_identity.py +59 -0
- agentic_project_kit/result_report_classifier.py +57 -0
- agentic_project_kit/rule_ack.py +105 -0
- agentic_project_kit/rule_preservation.py +58 -0
- agentic_project_kit/rule_refresh.py +174 -0
- agentic_project_kit/rule_registry_registration.py +322 -0
- agentic_project_kit/rule_registry_report.py +206 -0
- agentic_project_kit/rule_registry_validator.py +704 -0
- agentic_project_kit/rule_snapshot.py +98 -0
- agentic_project_kit/rule_source_validator.py +133 -0
- agentic_project_kit/run_summary.py +68 -0
- agentic_project_kit/run_summary_renderer.py +238 -0
- agentic_project_kit/runtime_validator.py +68 -0
- agentic_project_kit/safe_file_replace.py +35 -0
- agentic_project_kit/safe_patch.py +47 -0
- agentic_project_kit/safe_push.py +218 -0
- agentic_project_kit/safe_remove_diagnostic.py +51 -0
- agentic_project_kit/scaffold.py +69 -0
- agentic_project_kit/site_claims.py +444 -0
- agentic_project_kit/site_generator.py +1140 -0
- agentic_project_kit/slice_gate.py +225 -0
- agentic_project_kit/slice_runner.py +75 -0
- agentic_project_kit/standard_gates_audit_suite.py +167 -0
- agentic_project_kit/state_freshness.py +135 -0
- agentic_project_kit/status_current_state_audit.py +762 -0
- agentic_project_kit/successor_handoff_package.py +1763 -0
- agentic_project_kit/templates.py +292 -0
- agentic_project_kit/terminal_block_guard.py +166 -0
- agentic_project_kit/terminal_logging.py +275 -0
- agentic_project_kit/todo.py +97 -0
- agentic_project_kit/transfer_closeout.py +136 -0
- agentic_project_kit/transfer_continue.py +215 -0
- agentic_project_kit/transfer_local_runner.py +101 -0
- agentic_project_kit/transfer_operation_monitor.py +236 -0
- agentic_project_kit/transfer_post_merge_lifecycle.py +356 -0
- agentic_project_kit/transfer_post_merge_settle.py +383 -0
- agentic_project_kit/transfer_pr_actions.py +164 -0
- agentic_project_kit/transfer_remote_next.py +959 -0
- agentic_project_kit/transfer_repo_actions.py +2253 -0
- agentic_project_kit/transfer_runner.py +347 -0
- agentic_project_kit/transfer_safety_context.py +291 -0
- agentic_project_kit/transfer_state.py +483 -0
- agentic_project_kit/transfer_uplink.py +471 -0
- agentic_project_kit/transfer_workflow_next.py +148 -0
- agentic_project_kit/typed_work_order_evidence.py +58 -0
- agentic_project_kit/typed_work_order_queue.py +166 -0
- agentic_project_kit/typed_work_order_runner.py +168 -0
- agentic_project_kit/work_cycle.py +317 -0
- agentic_project_kit/work_discard_changes.py +129 -0
- agentic_project_kit/work_order_patch.py +199 -0
- agentic_project_kit/work_order_runner.py +120 -0
- agentic_project_kit/work_order_uploader.py +220 -0
- agentic_project_kit/work_order_validator.py +185 -0
- agentic_project_kit/work_orders.py +246 -0
- agentic_project_kit/workflow_guard.py +262 -0
- agentic_project_kit/workflow_summary_runner.py +124 -0
- agentic_project_kit/workspace.py +665 -0
- agentic_project_kit/workspace_adopt.py +471 -0
- agentic_project_kit/workspace_dpa_intake.py +406 -0
- agentic_project_kit/workspace_init.py +487 -0
- agentic_project_kit/workspace_lock.py +129 -0
- agentic_project_kit/workspace_remove.py +381 -0
- agentic_project_kit/workspace_upgrade.py +324 -0
- agentic_project_kit/wrapper_live_status.py +104 -0
- agentic_project_kit-1.0.1.dist-info/METADATA +724 -0
- agentic_project_kit-1.0.1.dist-info/RECORD +310 -0
- agentic_project_kit-1.0.1.dist-info/WHEEL +4 -0
- agentic_project_kit-1.0.1.dist-info/entry_points.txt +3 -0
- agentic_project_kit-1.0.1.dist-info/licenses/LICENSE +10 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from agentic_project_kit.rule_refresh import COMMUNICATION_RULES_OUTPUT, RuleRefreshResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PENDING_STATE_PATH = Path(".agentic/rule_ack/communication_refresh_pending.json")
|
|
15
|
+
ACK_STATE_PATH = Path(".agentic/rule_ack/communication_refresh_ack.json")
|
|
16
|
+
COMMUNICATION_RULES_REMOTE_REF = "main"
|
|
17
|
+
|
|
18
|
+
REQUIRED_LOADED_SECTIONS = (
|
|
19
|
+
"NO_COPY_TERMINAL_EVIDENCE",
|
|
20
|
+
"CHAT_COMMUNICATION_CONTRACT",
|
|
21
|
+
"PORTABLE_CHAT_EXECUTION_CONTRACT",
|
|
22
|
+
"CHAT_BOOTSTRAP_AND_DRIFT_CONTRACT",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CommunicationRuleCapsule:
|
|
28
|
+
result_status: str
|
|
29
|
+
kind: str
|
|
30
|
+
next_reply: str
|
|
31
|
+
local_path: str
|
|
32
|
+
remote_path: str
|
|
33
|
+
commit: str
|
|
34
|
+
blob_sha: str
|
|
35
|
+
generated_at: str
|
|
36
|
+
must_read_before_continue: bool
|
|
37
|
+
local_only: bool
|
|
38
|
+
remote_readable: bool
|
|
39
|
+
pending_state_path: str | None = None
|
|
40
|
+
next_action: str = ""
|
|
41
|
+
remote_ref: str = COMMUNICATION_RULES_REMOTE_REF
|
|
42
|
+
|
|
43
|
+
def as_json_data(self) -> dict[str, object]:
|
|
44
|
+
return {
|
|
45
|
+
"schema_version": 1,
|
|
46
|
+
"result_status": self.result_status,
|
|
47
|
+
"kind": self.kind,
|
|
48
|
+
"next_reply": self.next_reply,
|
|
49
|
+
"local_path": self.local_path,
|
|
50
|
+
"remote_path": self.remote_path,
|
|
51
|
+
"commit": self.commit,
|
|
52
|
+
"blob_sha": self.blob_sha,
|
|
53
|
+
"generated_at": self.generated_at,
|
|
54
|
+
"must_read_before_continue": self.must_read_before_continue,
|
|
55
|
+
"local_only": self.local_only,
|
|
56
|
+
"remote_readable": self.remote_readable,
|
|
57
|
+
"pending_state_path": self.pending_state_path,
|
|
58
|
+
"next_action": self.next_action,
|
|
59
|
+
"remote_ref": self.remote_ref,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class CommunicationContextGateResult:
|
|
65
|
+
result_status: str
|
|
66
|
+
reason: str
|
|
67
|
+
communication_context_fresh: bool
|
|
68
|
+
required_next_reply: str | None
|
|
69
|
+
remote_path: str
|
|
70
|
+
remote_ref: str
|
|
71
|
+
expected_blob_sha: str | None
|
|
72
|
+
generated_at: str | None
|
|
73
|
+
ack_source: str | None = None
|
|
74
|
+
ack_blob_sha: str | None = None
|
|
75
|
+
next_action: str = ""
|
|
76
|
+
|
|
77
|
+
def as_json_data(self) -> dict[str, object]:
|
|
78
|
+
return {
|
|
79
|
+
"schema_version": 1,
|
|
80
|
+
"kind": "communication_context_gate",
|
|
81
|
+
"result_status": self.result_status,
|
|
82
|
+
"reason": self.reason,
|
|
83
|
+
"communication_context_fresh": self.communication_context_fresh,
|
|
84
|
+
"required_next_reply": self.required_next_reply,
|
|
85
|
+
"remote_path": self.remote_path,
|
|
86
|
+
"remote_ref": self.remote_ref,
|
|
87
|
+
"expected_blob_sha": self.expected_blob_sha,
|
|
88
|
+
"generated_at": self.generated_at,
|
|
89
|
+
"ack_source": self.ack_source,
|
|
90
|
+
"ack_blob_sha": self.ack_blob_sha,
|
|
91
|
+
"next_action": self.next_action,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class CommunicationRefreshAckResult:
|
|
97
|
+
result_status: str
|
|
98
|
+
reason: str
|
|
99
|
+
ack_path: str
|
|
100
|
+
source: str | None
|
|
101
|
+
blob_sha: str | None
|
|
102
|
+
generated_at: str | None
|
|
103
|
+
loaded_sections: tuple[str, ...]
|
|
104
|
+
rules_loaded: bool
|
|
105
|
+
written_state_path: str | None = None
|
|
106
|
+
next_action: str = ""
|
|
107
|
+
|
|
108
|
+
def as_json_data(self) -> dict[str, object]:
|
|
109
|
+
return {
|
|
110
|
+
"schema_version": 1,
|
|
111
|
+
"kind": "communication_rule_refresh_ack_result",
|
|
112
|
+
"result_status": self.result_status,
|
|
113
|
+
"reason": self.reason,
|
|
114
|
+
"ack_path": self.ack_path,
|
|
115
|
+
"source": self.source,
|
|
116
|
+
"blob_sha": self.blob_sha,
|
|
117
|
+
"generated_at": self.generated_at,
|
|
118
|
+
"loaded_sections": list(self.loaded_sections),
|
|
119
|
+
"rules_loaded": self.rules_loaded,
|
|
120
|
+
"written_state_path": self.written_state_path,
|
|
121
|
+
"next_action": self.next_action,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_communication_rule_capsule(
|
|
126
|
+
project_root: Path | str,
|
|
127
|
+
result: RuleRefreshResult,
|
|
128
|
+
*,
|
|
129
|
+
publish: bool,
|
|
130
|
+
write_pending: bool = False,
|
|
131
|
+
) -> CommunicationRuleCapsule:
|
|
132
|
+
root = Path(project_root).resolve()
|
|
133
|
+
local_path = Path(result.output_path)
|
|
134
|
+
absolute = root / local_path
|
|
135
|
+
data = absolute.read_bytes()
|
|
136
|
+
blob_sha = git_blob_sha(data)
|
|
137
|
+
commit = _git_output(root, "rev-parse", "HEAD") or "UNKNOWN"
|
|
138
|
+
tracked_blob_sha = _git_output(root, "rev-parse", f"HEAD:{local_path.as_posix()}")
|
|
139
|
+
path_dirty = bool(_git_output(root, "status", "--porcelain", "--", local_path.as_posix()))
|
|
140
|
+
remote_readable = bool(publish and tracked_blob_sha == blob_sha and not path_dirty)
|
|
141
|
+
generated_at = _extract_generated_at(absolute)
|
|
142
|
+
pending_path: str | None = None
|
|
143
|
+
next_action = "Rule capsule generated locally."
|
|
144
|
+
if publish:
|
|
145
|
+
next_action = (
|
|
146
|
+
"Send d2 only after the rule capsule is committed and remote-readable."
|
|
147
|
+
if not remote_readable
|
|
148
|
+
else "Send d2; assistant must read the remote rule capsule and ACK it before mutation."
|
|
149
|
+
)
|
|
150
|
+
if write_pending:
|
|
151
|
+
pending = pending_state_from_capsule(
|
|
152
|
+
CommunicationRuleCapsule(
|
|
153
|
+
result_status="PASS",
|
|
154
|
+
kind="communication_rule_refresh",
|
|
155
|
+
next_reply=result.next_reply,
|
|
156
|
+
local_path=result.output_path,
|
|
157
|
+
remote_path=local_path.as_posix(),
|
|
158
|
+
commit=commit,
|
|
159
|
+
blob_sha=blob_sha,
|
|
160
|
+
generated_at=generated_at,
|
|
161
|
+
must_read_before_continue=publish,
|
|
162
|
+
local_only=not publish,
|
|
163
|
+
remote_readable=remote_readable,
|
|
164
|
+
next_action=next_action,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
target = root / PENDING_STATE_PATH
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
target.write_text(json.dumps(pending, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
170
|
+
pending_path = PENDING_STATE_PATH.as_posix()
|
|
171
|
+
return CommunicationRuleCapsule(
|
|
172
|
+
result_status="PASS",
|
|
173
|
+
kind="communication_rule_refresh",
|
|
174
|
+
next_reply=result.next_reply,
|
|
175
|
+
local_path=result.output_path,
|
|
176
|
+
remote_path=local_path.as_posix(),
|
|
177
|
+
commit=commit,
|
|
178
|
+
blob_sha=blob_sha,
|
|
179
|
+
generated_at=generated_at,
|
|
180
|
+
must_read_before_continue=publish,
|
|
181
|
+
local_only=not publish,
|
|
182
|
+
remote_readable=remote_readable,
|
|
183
|
+
pending_state_path=pending_path,
|
|
184
|
+
next_action=next_action,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def pending_state_from_capsule(capsule: CommunicationRuleCapsule) -> dict[str, object]:
|
|
189
|
+
return {
|
|
190
|
+
"schema_version": 1,
|
|
191
|
+
"required_next_reply": "d2",
|
|
192
|
+
"required_action": "read_remote_communication_rules",
|
|
193
|
+
"remote_path": capsule.remote_path,
|
|
194
|
+
"remote_ref": capsule.remote_ref,
|
|
195
|
+
"local_path": capsule.local_path,
|
|
196
|
+
"expected_blob_sha": capsule.blob_sha,
|
|
197
|
+
"generated_at": capsule.generated_at,
|
|
198
|
+
"commit": capsule.commit,
|
|
199
|
+
"blocks_normal_go": True,
|
|
200
|
+
"local_only": capsule.local_only,
|
|
201
|
+
"remote_readable": capsule.remote_readable,
|
|
202
|
+
"next_action": capsule.next_action,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def require_current_communication_context(
|
|
207
|
+
project_root: Path | str = ".",
|
|
208
|
+
) -> CommunicationContextGateResult:
|
|
209
|
+
root = Path(project_root).resolve()
|
|
210
|
+
pending = _read_json(root / PENDING_STATE_PATH)
|
|
211
|
+
if not pending:
|
|
212
|
+
ack = _read_json(root / ACK_STATE_PATH)
|
|
213
|
+
return CommunicationContextGateResult(
|
|
214
|
+
result_status="PASS",
|
|
215
|
+
reason="communication_context_current",
|
|
216
|
+
communication_context_fresh=True,
|
|
217
|
+
required_next_reply=None,
|
|
218
|
+
remote_path=str(ack.get("source", COMMUNICATION_RULES_OUTPUT.as_posix())) if ack else COMMUNICATION_RULES_OUTPUT.as_posix(),
|
|
219
|
+
remote_ref=str(ack.get("remote", COMMUNICATION_RULES_REMOTE_REF)) if ack else COMMUNICATION_RULES_REMOTE_REF,
|
|
220
|
+
expected_blob_sha=None,
|
|
221
|
+
generated_at=str(ack.get("generated_at")) if ack else None,
|
|
222
|
+
ack_source=str(ack.get("source")) if ack else None,
|
|
223
|
+
ack_blob_sha=str(ack.get("blob_sha")) if ack else None,
|
|
224
|
+
next_action="Communication context is current; mutation may continue.",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
remote_path = str(pending.get("remote_path", COMMUNICATION_RULES_OUTPUT.as_posix()))
|
|
228
|
+
remote_ref = str(pending.get("remote_ref", COMMUNICATION_RULES_REMOTE_REF))
|
|
229
|
+
expected_blob_sha = str(pending.get("expected_blob_sha", ""))
|
|
230
|
+
generated_at = str(pending.get("generated_at", ""))
|
|
231
|
+
remote_readable = bool(pending.get("remote_readable", False)) or _pending_capsule_is_remote_readable(
|
|
232
|
+
root,
|
|
233
|
+
remote_path=remote_path,
|
|
234
|
+
remote_ref=remote_ref,
|
|
235
|
+
expected_blob_sha=expected_blob_sha,
|
|
236
|
+
)
|
|
237
|
+
if not remote_readable:
|
|
238
|
+
return CommunicationContextGateResult(
|
|
239
|
+
result_status="BLOCK",
|
|
240
|
+
reason="communication_rule_refresh_not_remote_readable",
|
|
241
|
+
communication_context_fresh=False,
|
|
242
|
+
required_next_reply="d2",
|
|
243
|
+
remote_path=remote_path,
|
|
244
|
+
remote_ref=remote_ref,
|
|
245
|
+
expected_blob_sha=expected_blob_sha,
|
|
246
|
+
generated_at=generated_at,
|
|
247
|
+
next_action="Commit and publish the rule capsule before sending d2.",
|
|
248
|
+
)
|
|
249
|
+
return CommunicationContextGateResult(
|
|
250
|
+
result_status="BLOCK",
|
|
251
|
+
reason="communication_rule_refresh_pending",
|
|
252
|
+
communication_context_fresh=False,
|
|
253
|
+
required_next_reply="d2",
|
|
254
|
+
remote_path=remote_path,
|
|
255
|
+
remote_ref=remote_ref,
|
|
256
|
+
expected_blob_sha=expected_blob_sha,
|
|
257
|
+
generated_at=generated_at,
|
|
258
|
+
next_action="Assistant must read the remote rule capsule and provide RULE_REFRESH_ACK before mutation.",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _pending_capsule_is_remote_readable(
|
|
263
|
+
root: Path,
|
|
264
|
+
*,
|
|
265
|
+
remote_path: str,
|
|
266
|
+
remote_ref: str,
|
|
267
|
+
expected_blob_sha: str,
|
|
268
|
+
) -> bool:
|
|
269
|
+
if not remote_path or not expected_blob_sha:
|
|
270
|
+
return False
|
|
271
|
+
if _git_output(root, "status", "--porcelain", "--", remote_path):
|
|
272
|
+
return False
|
|
273
|
+
candidates = [_remote_tracking_ref(remote_ref)]
|
|
274
|
+
upstream = _git_output(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
275
|
+
if upstream:
|
|
276
|
+
candidates.insert(0, upstream)
|
|
277
|
+
for ref in dict.fromkeys(candidates):
|
|
278
|
+
if _git_output(root, "rev-parse", f"{ref}:{remote_path}") == expected_blob_sha:
|
|
279
|
+
return True
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _remote_tracking_ref(ref: str) -> str:
|
|
284
|
+
normalized = ref.strip() or COMMUNICATION_RULES_REMOTE_REF
|
|
285
|
+
if normalized.startswith(("origin/", "refs/")):
|
|
286
|
+
return normalized
|
|
287
|
+
return f"origin/{normalized}"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def acknowledge_communication_refresh(
|
|
291
|
+
project_root: Path | str,
|
|
292
|
+
ack_file: Path | str,
|
|
293
|
+
) -> CommunicationRefreshAckResult:
|
|
294
|
+
root = Path(project_root).resolve()
|
|
295
|
+
ack_path = Path(ack_file)
|
|
296
|
+
if not ack_path.is_absolute():
|
|
297
|
+
ack_path = root / ack_path
|
|
298
|
+
try:
|
|
299
|
+
ack = parse_ack_text(ack_path.read_text(encoding="utf-8"))
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
return _ack_block(str(ack_path), "ack_file_missing", str(exc))
|
|
302
|
+
pending = _read_json(root / PENDING_STATE_PATH)
|
|
303
|
+
if not pending:
|
|
304
|
+
return _ack_block(str(ack_path), "pending_state_missing", "No communication refresh pending state exists.")
|
|
305
|
+
|
|
306
|
+
problems = _ack_problems(ack, pending)
|
|
307
|
+
if problems:
|
|
308
|
+
return _ack_block(str(ack_path), ",".join(problems), "ACK does not match pending communication refresh.", ack=ack)
|
|
309
|
+
|
|
310
|
+
state = {
|
|
311
|
+
"schema_version": 1,
|
|
312
|
+
"kind": "communication_rule_refresh_ack",
|
|
313
|
+
"result_status": "PASS",
|
|
314
|
+
"source": ack["source"],
|
|
315
|
+
"remote": ack.get("remote", pending.get("remote_ref", COMMUNICATION_RULES_REMOTE_REF)),
|
|
316
|
+
"blob_sha": ack["blob_sha"],
|
|
317
|
+
"generated_at": ack["generated_at"],
|
|
318
|
+
"loaded_sections": list(ack["loaded_sections"]),
|
|
319
|
+
"rules_loaded": True,
|
|
320
|
+
"acknowledged_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
321
|
+
"communication_context_fresh": True,
|
|
322
|
+
}
|
|
323
|
+
target = root / ACK_STATE_PATH
|
|
324
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
target.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
326
|
+
(root / PENDING_STATE_PATH).unlink(missing_ok=True)
|
|
327
|
+
return CommunicationRefreshAckResult(
|
|
328
|
+
result_status="PASS",
|
|
329
|
+
reason="communication_rule_refresh_acknowledged",
|
|
330
|
+
ack_path=str(ack_path),
|
|
331
|
+
source=str(ack["source"]),
|
|
332
|
+
blob_sha=str(ack["blob_sha"]),
|
|
333
|
+
generated_at=str(ack["generated_at"]),
|
|
334
|
+
loaded_sections=tuple(str(item) for item in ack["loaded_sections"]),
|
|
335
|
+
rules_loaded=True,
|
|
336
|
+
written_state_path=ACK_STATE_PATH.as_posix(),
|
|
337
|
+
next_action="Communication context ACK accepted; mutation may continue.",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def parse_ack_text(text: str) -> dict[str, Any]:
|
|
342
|
+
stripped = text.strip()
|
|
343
|
+
if stripped.startswith("{"):
|
|
344
|
+
data = json.loads(stripped)
|
|
345
|
+
else:
|
|
346
|
+
data: dict[str, Any] = {}
|
|
347
|
+
for line in stripped.splitlines():
|
|
348
|
+
if not line or line == "RULE_REFRESH_ACK" or "=" not in line:
|
|
349
|
+
continue
|
|
350
|
+
key, value = line.split("=", 1)
|
|
351
|
+
data[key.strip()] = value.strip()
|
|
352
|
+
loaded = data.get("loaded_sections", [])
|
|
353
|
+
if isinstance(loaded, str):
|
|
354
|
+
data["loaded_sections"] = [item.strip() for item in loaded.split(",") if item.strip()]
|
|
355
|
+
data["rules_loaded"] = str(data.get("rules_loaded", "")).lower() == "true" or data.get("rules_loaded") is True
|
|
356
|
+
return data
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def git_blob_sha(data: bytes) -> str:
|
|
360
|
+
return hashlib.sha1(b"blob " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _ack_problems(ack: dict[str, Any], pending: dict[str, object]) -> list[str]:
|
|
364
|
+
problems: list[str] = []
|
|
365
|
+
if ack.get("source") != pending.get("remote_path"):
|
|
366
|
+
problems.append("source_mismatch")
|
|
367
|
+
expected_remote = str(pending.get("remote_ref", COMMUNICATION_RULES_REMOTE_REF))
|
|
368
|
+
if str(ack.get("remote", expected_remote)) != expected_remote:
|
|
369
|
+
problems.append("remote_ref_mismatch")
|
|
370
|
+
if ack.get("blob_sha") != pending.get("expected_blob_sha"):
|
|
371
|
+
problems.append("blob_sha_mismatch")
|
|
372
|
+
if ack.get("generated_at") != pending.get("generated_at"):
|
|
373
|
+
problems.append("generated_at_mismatch")
|
|
374
|
+
if not ack.get("rules_loaded"):
|
|
375
|
+
problems.append("rules_not_loaded")
|
|
376
|
+
loaded = set(str(item) for item in ack.get("loaded_sections", []))
|
|
377
|
+
missing_sections = [section for section in REQUIRED_LOADED_SECTIONS if section not in loaded]
|
|
378
|
+
if missing_sections:
|
|
379
|
+
problems.append("missing_loaded_sections")
|
|
380
|
+
if ack.get("result_status") != "PASS":
|
|
381
|
+
problems.append("result_status_not_pass")
|
|
382
|
+
if ack.get("kind") not in {None, "communication_rule_refresh_ack"}:
|
|
383
|
+
problems.append("kind_mismatch")
|
|
384
|
+
return problems
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _ack_block(
|
|
388
|
+
ack_path: str,
|
|
389
|
+
reason: str,
|
|
390
|
+
next_action: str,
|
|
391
|
+
*,
|
|
392
|
+
ack: dict[str, Any] | None = None,
|
|
393
|
+
) -> CommunicationRefreshAckResult:
|
|
394
|
+
ack = ack or {}
|
|
395
|
+
loaded = ack.get("loaded_sections", ())
|
|
396
|
+
if isinstance(loaded, str):
|
|
397
|
+
loaded = tuple(item.strip() for item in loaded.split(",") if item.strip())
|
|
398
|
+
return CommunicationRefreshAckResult(
|
|
399
|
+
result_status="BLOCK",
|
|
400
|
+
reason=reason,
|
|
401
|
+
ack_path=ack_path,
|
|
402
|
+
source=str(ack.get("source")) if ack.get("source") is not None else None,
|
|
403
|
+
blob_sha=str(ack.get("blob_sha")) if ack.get("blob_sha") is not None else None,
|
|
404
|
+
generated_at=str(ack.get("generated_at")) if ack.get("generated_at") is not None else None,
|
|
405
|
+
loaded_sections=tuple(str(item) for item in loaded),
|
|
406
|
+
rules_loaded=bool(ack.get("rules_loaded", False)),
|
|
407
|
+
next_action=next_action,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _extract_generated_at(path: Path) -> str:
|
|
412
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
413
|
+
if line.startswith("Generated at:"):
|
|
414
|
+
return line.split(":", 1)[1].strip()
|
|
415
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _git_output(root: Path, *args: str) -> str:
|
|
419
|
+
completed = subprocess.run(
|
|
420
|
+
["git", *args],
|
|
421
|
+
cwd=root,
|
|
422
|
+
text=True,
|
|
423
|
+
stdout=subprocess.PIPE,
|
|
424
|
+
stderr=subprocess.PIPE,
|
|
425
|
+
check=False,
|
|
426
|
+
)
|
|
427
|
+
if completed.returncode != 0:
|
|
428
|
+
return ""
|
|
429
|
+
return completed.stdout.strip()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
433
|
+
try:
|
|
434
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
435
|
+
except (OSError, json.JSONDecodeError):
|
|
436
|
+
return {}
|
|
437
|
+
return data if isinstance(data, dict) else {}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_STATE_PATH = Path(".agentic/communication_state.json")
|
|
13
|
+
VALID_ORIGINS = {"local", "remote", "mixed"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CommunicationStateCheck:
|
|
18
|
+
ok: bool
|
|
19
|
+
findings: tuple[str, ...]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_summary_id(value: int) -> str:
|
|
23
|
+
if value < 0:
|
|
24
|
+
raise ValueError("summary id must be non-negative")
|
|
25
|
+
return f"COMM-{value:05d}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def current_branch() -> str:
|
|
29
|
+
result = subprocess.run(["git", "branch", "--show-current"], text=True, capture_output=True, check=False)
|
|
30
|
+
return result.stdout.strip() or "unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_state(path: Path = DEFAULT_STATE_PATH) -> dict[str, Any]:
|
|
34
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_state(state: dict[str, Any], path: Path = DEFAULT_STATE_PATH) -> None:
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_state(path: Path = DEFAULT_STATE_PATH) -> CommunicationStateCheck:
|
|
43
|
+
findings: list[str] = []
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return CommunicationStateCheck(False, ("missing_state_file",))
|
|
46
|
+
try:
|
|
47
|
+
state = load_state(path)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
return CommunicationStateCheck(False, ("invalid_json",))
|
|
50
|
+
if state.get("schema_version") != 1:
|
|
51
|
+
findings.append("invalid_schema_version")
|
|
52
|
+
summary_id = state.get("last_summary_id")
|
|
53
|
+
if not isinstance(summary_id, int) or summary_id < 0:
|
|
54
|
+
findings.append("invalid_last_summary_id")
|
|
55
|
+
origin = state.get("last_origin")
|
|
56
|
+
if origin is not None and origin not in VALID_ORIGINS:
|
|
57
|
+
findings.append("invalid_last_origin")
|
|
58
|
+
return CommunicationStateCheck(not findings, tuple(findings) if findings else ("none",))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def next_summary_header(
|
|
62
|
+
path: Path = DEFAULT_STATE_PATH,
|
|
63
|
+
origin: str = "local",
|
|
64
|
+
branch: str | None = None,
|
|
65
|
+
slice_name: str = "",
|
|
66
|
+
write: bool = True,
|
|
67
|
+
) -> str:
|
|
68
|
+
if origin not in VALID_ORIGINS:
|
|
69
|
+
raise ValueError(f"invalid origin: {origin}")
|
|
70
|
+
if write and not slice_name.strip():
|
|
71
|
+
raise ValueError("slice_name must not be empty when writing communication summary state")
|
|
72
|
+
state = load_state(path)
|
|
73
|
+
next_id = int(state.get("last_summary_id", 0)) + 1
|
|
74
|
+
now = datetime.now().astimezone()
|
|
75
|
+
branch_name = branch or current_branch()
|
|
76
|
+
header = f"SUMMARY {format_summary_id(next_id)} | {now:%Y-%m-%d %H:%M:%S %z}"
|
|
77
|
+
if write:
|
|
78
|
+
state["last_summary_id"] = next_id
|
|
79
|
+
state["updated_at"] = now.isoformat(timespec="seconds")
|
|
80
|
+
state["last_origin"] = origin
|
|
81
|
+
state["last_branch"] = branch_name
|
|
82
|
+
state["last_slice"] = slice_name
|
|
83
|
+
write_state(state, path)
|
|
84
|
+
return header
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
parser = argparse.ArgumentParser(prog="communication-state")
|
|
89
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
90
|
+
check_parser = sub.add_parser("check")
|
|
91
|
+
check_parser.add_argument("--state-path", default=str(DEFAULT_STATE_PATH))
|
|
92
|
+
next_parser = sub.add_parser("next-summary")
|
|
93
|
+
next_parser.add_argument("--state-path", default=str(DEFAULT_STATE_PATH))
|
|
94
|
+
next_parser.add_argument("--origin", choices=sorted(VALID_ORIGINS), default="local")
|
|
95
|
+
next_parser.add_argument("--branch", default=None)
|
|
96
|
+
next_parser.add_argument("--slice", default="")
|
|
97
|
+
next_parser.add_argument("--no-write", action="store_true")
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
path = Path(args.state_path)
|
|
100
|
+
if args.command == "check":
|
|
101
|
+
result = check_state(path)
|
|
102
|
+
print("COMMUNICATION STATE CHECK")
|
|
103
|
+
print(f"state_path={path}")
|
|
104
|
+
print(f"state={'PASS' if result.ok else 'FAIL'}")
|
|
105
|
+
print("findings:")
|
|
106
|
+
for finding in result.findings:
|
|
107
|
+
print(f" - {finding}")
|
|
108
|
+
print(f"### RESULT: {'PASS' if result.ok else 'FAIL'} ###")
|
|
109
|
+
return 0 if result.ok else 1
|
|
110
|
+
print(next_summary_header(path, args.origin, args.branch, args.slice, not args.no_write))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StepLike(Protocol):
|
|
8
|
+
name: str
|
|
9
|
+
result: object
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class WrapperInvariantViolation:
|
|
14
|
+
wrapper: str
|
|
15
|
+
step_name: str
|
|
16
|
+
step_status: str
|
|
17
|
+
wrapper_status: str
|
|
18
|
+
allowed_recovery_states: tuple[str, ...]
|
|
19
|
+
message: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _status_of(result: object) -> str:
|
|
23
|
+
value = getattr(result, "result_status", "")
|
|
24
|
+
return str(value or "").upper()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _state_of(result: object) -> str:
|
|
28
|
+
value = getattr(result, "lifecycle_state", "")
|
|
29
|
+
if not value:
|
|
30
|
+
value = getattr(result, "state", "")
|
|
31
|
+
return str(value or "").upper()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_no_failed_required_step_promoted_to_pass(
|
|
35
|
+
*,
|
|
36
|
+
wrapper: str,
|
|
37
|
+
wrapper_status: str,
|
|
38
|
+
steps: Iterable[StepLike],
|
|
39
|
+
allowed_recovery_states: Iterable[str] = (),
|
|
40
|
+
) -> list[WrapperInvariantViolation]:
|
|
41
|
+
"""Validate the central composed-wrapper safety invariant.
|
|
42
|
+
|
|
43
|
+
A composed wrapper must not report PASS when a required substep reported
|
|
44
|
+
FAIL/BLOCKED unless that substep state is explicitly allowlisted as a
|
|
45
|
+
recovery state by the wrapper.
|
|
46
|
+
|
|
47
|
+
The helper is intentionally small and pure so lifecycle tests can use it
|
|
48
|
+
without shelling out or creating repository state.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
normalized_wrapper_status = str(wrapper_status or "").upper()
|
|
52
|
+
if normalized_wrapper_status != "PASS":
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
allowed = tuple(str(item or "").upper() for item in allowed_recovery_states)
|
|
56
|
+
violations: list[WrapperInvariantViolation] = []
|
|
57
|
+
|
|
58
|
+
for step in steps:
|
|
59
|
+
result = getattr(step, "result", None)
|
|
60
|
+
step_status = _status_of(result)
|
|
61
|
+
step_state = _state_of(result)
|
|
62
|
+
if step_status not in {"FAIL", "BLOCKED"}:
|
|
63
|
+
continue
|
|
64
|
+
if step_state and step_state in allowed:
|
|
65
|
+
continue
|
|
66
|
+
violations.append(
|
|
67
|
+
WrapperInvariantViolation(
|
|
68
|
+
wrapper=wrapper,
|
|
69
|
+
step_name=str(getattr(step, "name", "")),
|
|
70
|
+
step_status=step_status,
|
|
71
|
+
wrapper_status=normalized_wrapper_status,
|
|
72
|
+
allowed_recovery_states=allowed,
|
|
73
|
+
message=(
|
|
74
|
+
f"{wrapper} returned PASS although required step "
|
|
75
|
+
f"{getattr(step, 'name', '')!s} returned {step_status}."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return violations
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assert_no_failed_required_step_promoted_to_pass(
|
|
84
|
+
*,
|
|
85
|
+
wrapper: str,
|
|
86
|
+
wrapper_status: str,
|
|
87
|
+
steps: Iterable[StepLike],
|
|
88
|
+
allowed_recovery_states: Iterable[str] = (),
|
|
89
|
+
) -> None:
|
|
90
|
+
violations = validate_no_failed_required_step_promoted_to_pass(
|
|
91
|
+
wrapper=wrapper,
|
|
92
|
+
wrapper_status=wrapper_status,
|
|
93
|
+
steps=steps,
|
|
94
|
+
allowed_recovery_states=allowed_recovery_states,
|
|
95
|
+
)
|
|
96
|
+
if violations:
|
|
97
|
+
raise AssertionError("; ".join(item.message for item in violations))
|