phase-loop-runtime 0.2.0__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.
- phase_loop_runtime/__init__.py +20 -0
- phase_loop_runtime/_contract_docs/phase-loop/extraction-readiness.md +90 -0
- phase_loop_runtime/_contract_docs/phase-loop/granular-execution-policy.md +137 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-capability-matrix.md +510 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-skill-matrix.md +125 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-substrate-manifest.md +232 -0
- phase_loop_runtime/_contract_docs/phase-loop/pi-loop-control.md +247 -0
- phase_loop_runtime/_contract_docs/phase-loop/protocol.md +2759 -0
- phase_loop_runtime/_contract_docs/phase-loop/runtime-boundary.md +289 -0
- phase_loop_runtime/_contract_docs/phase-loop/spec-discovery-roots.md +47 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-governed-pipeline.md +42 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-portal-projection.md +34 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-regenesis.md +29 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-report.md +32 -0
- phase_loop_runtime/_contract_docs/runtime/verification-evidence-contract.md +52 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/commands.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/mixed.json +5 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/timeout-note.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/redacted-summary.json +6 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/sample.json +7 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/edge.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-a.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-b.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-c.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/claimed-results.json +5 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/metrics.json +9 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/findings.md +10 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/scores.json +4 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/summary.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/bundle-check.json +8 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/operator-note.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/events-sample.jsonl +3 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/receipt.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/evidence.json +13 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/summary.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/matrix.json +9 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/notes.md +4 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/console-summary.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/screenshot-standin.ppm +5 -0
- phase_loop_runtime/adoption_bundle.py +273 -0
- phase_loop_runtime/advisor_board/CONTRACTS.md +257 -0
- phase_loop_runtime/advisor_board/__init__.py +288 -0
- phase_loop_runtime/advisor_board/backing.py +137 -0
- phase_loop_runtime/advisor_board/backing_omnigent.py +498 -0
- phase_loop_runtime/advisor_board/config.py +225 -0
- phase_loop_runtime/advisor_board/events.py +115 -0
- phase_loop_runtime/advisor_board/fixtures/advisor-boards.example.toml +59 -0
- phase_loop_runtime/advisor_board/fixtures.py +91 -0
- phase_loop_runtime/advisor_board/harness_mapping.py +125 -0
- phase_loop_runtime/advisor_board/matrix.py +146 -0
- phase_loop_runtime/advisor_board/observability.py +481 -0
- phase_loop_runtime/advisor_board/presets.py +188 -0
- phase_loop_runtime/advisor_board/registries.py +309 -0
- phase_loop_runtime/advisor_board/resolver.py +253 -0
- phase_loop_runtime/advisor_board/schema.py +269 -0
- phase_loop_runtime/advisor_board/standin.py +166 -0
- phase_loop_runtime/advisor_board/validation.py +159 -0
- phase_loop_runtime/agent_runtime_provider.py +676 -0
- phase_loop_runtime/baml_modular.py +656 -0
- phase_loop_runtime/baml_src/dotfiles_adoption_manifest.baml +37 -0
- phase_loop_runtime/baml_src/dotfiles_c4_document.baml +14 -0
- phase_loop_runtime/baml_src/dotfiles_plan_manifest.baml +50 -0
- phase_loop_runtime/baml_src/dotfiles_runtime_projection.baml +16 -0
- phase_loop_runtime/baml_src/dotfiles_task_catalog.baml +20 -0
- phase_loop_runtime/baml_src/emit_phase_closeout.baml +86 -0
- phase_loop_runtime/baml_src/evaluate_suspected_fake_evidence.baml +36 -0
- phase_loop_runtime/baml_src/verification_evidence.baml +40 -0
- phase_loop_runtime/broker.py +181 -0
- phase_loop_runtime/build_bundle.py +294 -0
- phase_loop_runtime/capability_registry.py +835 -0
- phase_loop_runtime/classifier.py +87 -0
- phase_loop_runtime/claude_agent_view.py +625 -0
- phase_loop_runtime/claude_channel_sidecar.py +1086 -0
- phase_loop_runtime/cli.py +2599 -0
- phase_loop_runtime/closeout.py +865 -0
- phase_loop_runtime/closeout_classifier.py +120 -0
- phase_loop_runtime/closeout_evidence_audit.py +112 -0
- phase_loop_runtime/closeout_validation.py +144 -0
- phase_loop_runtime/closeout_validators.py +203 -0
- phase_loop_runtime/consiliency_gates.py +374 -0
- phase_loop_runtime/consiliency_ingest.py +399 -0
- phase_loop_runtime/consiliency_layout.py +176 -0
- phase_loop_runtime/consiliency_scaffold.py +305 -0
- phase_loop_runtime/cross_repo_channel.py +331 -0
- phase_loop_runtime/discovery.py +2368 -0
- phase_loop_runtime/dispatch_lock.py +167 -0
- phase_loop_runtime/doc_delta_validator.py +98 -0
- phase_loop_runtime/docs_audit.py +285 -0
- phase_loop_runtime/docs_freshness.py +468 -0
- phase_loop_runtime/docs_surfaces.py +150 -0
- phase_loop_runtime/dotfiles_profile_plugin.py +96 -0
- phase_loop_runtime/events.py +154 -0
- phase_loop_runtime/events_migration.py +96 -0
- phase_loop_runtime/evidence_audit.py +1082 -0
- phase_loop_runtime/evidence_audit_config.py +172 -0
- phase_loop_runtime/fleet_map.py +426 -0
- phase_loop_runtime/fleet_metrics.py +298 -0
- phase_loop_runtime/fleet_metrics_export.py +196 -0
- phase_loop_runtime/git_discipline.py +479 -0
- phase_loop_runtime/git_ops.py +122 -0
- phase_loop_runtime/git_topology.py +252 -0
- phase_loop_runtime/governed_bundle.py +137 -0
- phase_loop_runtime/governed_premerge.py +219 -0
- phase_loop_runtime/governed_review.py +264 -0
- phase_loop_runtime/handoff.py +932 -0
- phase_loop_runtime/handoff_path.py +14 -0
- phase_loop_runtime/injection.py +740 -0
- phase_loop_runtime/install_status.py +88 -0
- phase_loop_runtime/lane_scheduler.py +317 -0
- phase_loop_runtime/launcher.py +2760 -0
- phase_loop_runtime/lease_store.py +553 -0
- phase_loop_runtime/maintenance.py +401 -0
- phase_loop_runtime/migrate_handoffs.py +201 -0
- phase_loop_runtime/models.py +2144 -0
- phase_loop_runtime/observability.py +1290 -0
- phase_loop_runtime/panel_invoker.py +1638 -0
- phase_loop_runtime/phase_loop_drift_audit.py +388 -0
- phase_loop_runtime/phase_worktree_executor.py +433 -0
- phase_loop_runtime/pipeline_adapter/__init__.py +12 -0
- phase_loop_runtime/pipeline_adapter/branch_ops.py +274 -0
- phase_loop_runtime/pipeline_adapter/flag.py +55 -0
- phase_loop_runtime/pipeline_adapter/markers.py +13 -0
- phase_loop_runtime/pipeline_adapter/merge_policy.py +72 -0
- phase_loop_runtime/pipeline_adapter/ratification.py +100 -0
- phase_loop_runtime/pipeline_adapter/sibling_matcher.py +123 -0
- phase_loop_runtime/plan_ir.py +523 -0
- phase_loop_runtime/plan_manifest.py +415 -0
- phase_loop_runtime/planner_validation.py +176 -0
- phase_loop_runtime/profiles.py +462 -0
- phase_loop_runtime/prompts.py +365 -0
- phase_loop_runtime/provenance.py +165 -0
- phase_loop_runtime/publishing.py +339 -0
- phase_loop_runtime/py.typed +1 -0
- phase_loop_runtime/reconcile.py +1550 -0
- phase_loop_runtime/redaction.py +184 -0
- phase_loop_runtime/reflection_sync.py +293 -0
- phase_loop_runtime/release_guard.py +186 -0
- phase_loop_runtime/render.py +432 -0
- phase_loop_runtime/repo_validation.py +427 -0
- phase_loop_runtime/review_summary.py +150 -0
- phase_loop_runtime/roadmap_lint.py +425 -0
- phase_loop_runtime/route_log.py +59 -0
- phase_loop_runtime/route_policy/__init__.py +11 -0
- phase_loop_runtime/route_policy/fixtures/route_selection.golden.json +263 -0
- phase_loop_runtime/runner.py +8400 -0
- phase_loop_runtime/runtime_paths.py +225 -0
- phase_loop_runtime/runtime_projection.py +113 -0
- phase_loop_runtime/runtime_resources.py +36 -0
- phase_loop_runtime/schema_export.py +292 -0
- phase_loop_runtime/schemas/phase_loop_closeout.schema.json +366 -0
- phase_loop_runtime/skill_install.py +176 -0
- phase_loop_runtime/skill_inventory.py +662 -0
- phase_loop_runtime/skill_paths.py +42 -0
- phase_loop_runtime/skill_sources_plugin.py +37 -0
- phase_loop_runtime/skills_bundle/claude-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/claude-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/claude-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-execute-detailed/SKILL.md +152 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/SKILL.md +745 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/claude-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-phase-loop/SKILL.md +115 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/SKILL.md +439 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/SKILL.md +334 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/SKILL.md +776 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/claude-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/SKILL.md +243 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/SKILL.md +249 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/SKILL.md +74 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/codex-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/codex-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/codex-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-execute-detailed/SKILL.md +154 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/SKILL.md +231 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/codex-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-phase-loop/SKILL.md +248 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/SKILL.md +150 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/SKILL.md +307 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/codex-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/SKILL.md +94 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/SKILL.md +114 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/SKILL.md +102 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/gemini-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-execute-detailed/SKILL.md +144 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/SKILL.md +198 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/gemini-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-phase-loop/SKILL.md +111 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/SKILL.md +147 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/SKILL.md +217 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/gemini-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/SKILL.md +87 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/SKILL.md +109 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/SKILL.md +98 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/opencode-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-execute-detailed/SKILL.md +144 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/SKILL.md +196 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/opencode-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-phase-loop/SKILL.md +113 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/SKILL.md +147 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/SKILL.md +211 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/opencode-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/SKILL.md +87 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/SKILL.md +109 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/SKILL.md +98 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/state.py +156 -0
- phase_loop_runtime/state_degradation.py +136 -0
- phase_loop_runtime/state_ops.py +676 -0
- phase_loop_runtime/train_ledger.py +215 -0
- phase_loop_runtime/train_roadmap.py +339 -0
- phase_loop_runtime/train_runner.py +1197 -0
- phase_loop_runtime/verification_evidence.py +650 -0
- phase_loop_runtime/verification_evidence_validator.py +75 -0
- phase_loop_runtime/visual_evidence_validator.py +36 -0
- phase_loop_runtime/worker_pool.py +123 -0
- phase_loop_runtime/worktree_index.py +270 -0
- phase_loop_runtime-0.2.0.data/data/share/phase-loop-runtime/protocol/protocol.md +2475 -0
- phase_loop_runtime-0.2.0.dist-info/METADATA +106 -0
- phase_loop_runtime-0.2.0.dist-info/RECORD +370 -0
- phase_loop_runtime-0.2.0.dist-info/WHEEL +5 -0
- phase_loop_runtime-0.2.0.dist-info/entry_points.txt +9 -0
- phase_loop_runtime-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from http import HTTPStatus
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
from socketserver import TCPServer
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib import error, request
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
import ipaddress
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ACK_POLICY_TOOL_REQUIRED = "tool_ack_required"
|
|
22
|
+
CLAUDE_ROUTE_NAMES = {"claude_channel", "claude_agent_view", "claude_print"}
|
|
23
|
+
CLAUDE_ROUTE_STATUSES = {"received", "working", "needs_permission", "needs_input", "blocked", "done", "error", "stale"}
|
|
24
|
+
CLAUDE_AUTH_POSTURES = {"subscription_local", "api_key", "unknown"}
|
|
25
|
+
CLAUDE_BILLING_POSTURES = {"subscription_included", "api_key_billed", "usage_credit", "unknown"}
|
|
26
|
+
REPLY_STATUSES = {"received", "working", "blocked", "done", "error"}
|
|
27
|
+
FORBIDDEN_ATTACHMENT_FIELDS = {
|
|
28
|
+
"content",
|
|
29
|
+
"data",
|
|
30
|
+
"text",
|
|
31
|
+
"payload",
|
|
32
|
+
"secret",
|
|
33
|
+
"token",
|
|
34
|
+
"api_key",
|
|
35
|
+
"private_key",
|
|
36
|
+
"oauth",
|
|
37
|
+
"keychain",
|
|
38
|
+
"local_env",
|
|
39
|
+
"provider_payload",
|
|
40
|
+
"terminal_transcript",
|
|
41
|
+
}
|
|
42
|
+
FORBIDDEN_ROUTE_RESULT_VALUE_MARKERS = (
|
|
43
|
+
"authorization:",
|
|
44
|
+
"bearer ",
|
|
45
|
+
"api_key=",
|
|
46
|
+
"secret=",
|
|
47
|
+
"password=",
|
|
48
|
+
"oauth",
|
|
49
|
+
"keychain",
|
|
50
|
+
"local env value",
|
|
51
|
+
"provider payload",
|
|
52
|
+
"raw provider payload",
|
|
53
|
+
"terminal transcript",
|
|
54
|
+
"raw terminal",
|
|
55
|
+
"raw-terminal",
|
|
56
|
+
"raw prompt",
|
|
57
|
+
"raw tool input",
|
|
58
|
+
"sk-ant-",
|
|
59
|
+
"ghp_",
|
|
60
|
+
"github_pat_",
|
|
61
|
+
)
|
|
62
|
+
FORBIDDEN_PERMISSION_FIELDS = {
|
|
63
|
+
"args",
|
|
64
|
+
"arguments",
|
|
65
|
+
"content",
|
|
66
|
+
"data",
|
|
67
|
+
"input",
|
|
68
|
+
"payload",
|
|
69
|
+
"raw_input",
|
|
70
|
+
"secret",
|
|
71
|
+
"token",
|
|
72
|
+
"api_key",
|
|
73
|
+
"private_key",
|
|
74
|
+
"oauth",
|
|
75
|
+
"keychain",
|
|
76
|
+
"local_env",
|
|
77
|
+
"provider_payload",
|
|
78
|
+
"terminal_transcript",
|
|
79
|
+
}
|
|
80
|
+
PERMISSION_VERDICTS = {"allow", "deny"}
|
|
81
|
+
SESSION_STATES = {"disconnected", "starting", "ready", "needs_permission", "needs_input", "blocked", "stopped", "stale"}
|
|
82
|
+
CHANNEL_HEALTH_STATES = {"disconnected", "starting", "ready", "needs_permission", "needs_input", "blocked", "stopped"}
|
|
83
|
+
CLIENT_READY_STATES = {"starting", "ready", "needs_permission", "needs_input"}
|
|
84
|
+
CLIENT_FINAL_REPLY_STATUSES = {"blocked", "done", "error"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class LoopbackThreadingHTTPServer(ThreadingHTTPServer):
|
|
88
|
+
daemon_threads = True
|
|
89
|
+
block_on_close = False
|
|
90
|
+
|
|
91
|
+
def server_bind(self) -> None:
|
|
92
|
+
TCPServer.server_bind(self)
|
|
93
|
+
host, port = self.server_address[:2]
|
|
94
|
+
self.server_name = str(host)
|
|
95
|
+
self.server_port = int(port)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class ChannelEventEnvelope:
|
|
100
|
+
event_id: str
|
|
101
|
+
session_id: str
|
|
102
|
+
sender: str
|
|
103
|
+
content: str
|
|
104
|
+
attachments: tuple[dict[str, Any], ...]
|
|
105
|
+
created_at: str
|
|
106
|
+
ack_policy: str = ACK_POLICY_TOOL_REQUIRED
|
|
107
|
+
|
|
108
|
+
def to_json(self) -> dict[str, Any]:
|
|
109
|
+
data = asdict(self)
|
|
110
|
+
data["attachments"] = list(self.attachments)
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class ChannelReplyPayload:
|
|
116
|
+
event_id: str
|
|
117
|
+
status: str
|
|
118
|
+
text: str = ""
|
|
119
|
+
artifacts: tuple[dict[str, Any], ...] = ()
|
|
120
|
+
error: str | None = None
|
|
121
|
+
final: bool = False
|
|
122
|
+
|
|
123
|
+
def to_json(self) -> dict[str, Any]:
|
|
124
|
+
data = asdict(self)
|
|
125
|
+
data["artifacts"] = list(self.artifacts)
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class ClaudeRouteResult:
|
|
131
|
+
route: str
|
|
132
|
+
session_id: str
|
|
133
|
+
event_id: str
|
|
134
|
+
status: str
|
|
135
|
+
text: str = ""
|
|
136
|
+
artifacts: tuple[dict[str, Any], ...] = ()
|
|
137
|
+
auth_posture: str = "unknown"
|
|
138
|
+
billing_posture: str = "unknown"
|
|
139
|
+
trust_state: dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
permission_state: dict[str, Any] = field(default_factory=dict)
|
|
141
|
+
warnings: tuple[str, ...] = ()
|
|
142
|
+
evidence_refs: tuple[dict[str, Any], ...] = ()
|
|
143
|
+
|
|
144
|
+
def __post_init__(self) -> None:
|
|
145
|
+
_require_literal(self.route, CLAUDE_ROUTE_NAMES, "Claude route")
|
|
146
|
+
_require_literal(self.status, CLAUDE_ROUTE_STATUSES, "Claude route status")
|
|
147
|
+
_require_literal(self.auth_posture, CLAUDE_AUTH_POSTURES, "Claude auth posture")
|
|
148
|
+
_require_literal(self.billing_posture, CLAUDE_BILLING_POSTURES, "Claude billing posture")
|
|
149
|
+
for field_name in ("session_id", "event_id"):
|
|
150
|
+
value = getattr(self, field_name)
|
|
151
|
+
if not isinstance(value, str) or not value:
|
|
152
|
+
raise ValueError(f"{field_name} is required")
|
|
153
|
+
object.__setattr__(self, "text", _metadata_only_text(self.text, "text"))
|
|
154
|
+
object.__setattr__(self, "artifacts", tuple(_metadata_only_route_objects(self.artifacts, "artifacts")))
|
|
155
|
+
object.__setattr__(self, "trust_state", _metadata_summary(self.trust_state))
|
|
156
|
+
object.__setattr__(self, "permission_state", _metadata_summary(self.permission_state))
|
|
157
|
+
object.__setattr__(self, "warnings", tuple(_metadata_only_warnings(self.warnings)))
|
|
158
|
+
object.__setattr__(self, "evidence_refs", tuple(_metadata_only_route_objects(self.evidence_refs, "evidence_refs")))
|
|
159
|
+
|
|
160
|
+
def to_json(self) -> dict[str, Any]:
|
|
161
|
+
data = asdict(self)
|
|
162
|
+
data["artifacts"] = list(self.artifacts)
|
|
163
|
+
data["warnings"] = list(self.warnings)
|
|
164
|
+
data["evidence_refs"] = list(self.evidence_refs)
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class PermissionRequestEnvelope:
|
|
170
|
+
request_id: str
|
|
171
|
+
session_id: str
|
|
172
|
+
event_id: str | None
|
|
173
|
+
tool_name: str
|
|
174
|
+
description: str
|
|
175
|
+
input_preview: str
|
|
176
|
+
risk_class: str
|
|
177
|
+
audit_ref: str
|
|
178
|
+
requested_at: str
|
|
179
|
+
|
|
180
|
+
def to_json(self) -> dict[str, Any]:
|
|
181
|
+
return asdict(self)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class PermissionVerdictPayload:
|
|
186
|
+
request_id: str
|
|
187
|
+
verdict: str
|
|
188
|
+
actor: str
|
|
189
|
+
reason: str
|
|
190
|
+
decided_at: str
|
|
191
|
+
|
|
192
|
+
def to_json(self) -> dict[str, Any]:
|
|
193
|
+
return asdict(self)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class PermissionAuditEntry:
|
|
198
|
+
request_id: str
|
|
199
|
+
session_id: str
|
|
200
|
+
event_id: str | None
|
|
201
|
+
verdict: str
|
|
202
|
+
actor: str
|
|
203
|
+
reason: str
|
|
204
|
+
audit_ref: str
|
|
205
|
+
decided_at: str
|
|
206
|
+
|
|
207
|
+
def to_json(self) -> dict[str, Any]:
|
|
208
|
+
return asdict(self)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class SessionRegistryRecord:
|
|
213
|
+
session_id: str
|
|
214
|
+
adapter: str
|
|
215
|
+
cwd: str
|
|
216
|
+
state: str = "starting"
|
|
217
|
+
auth_posture: dict[str, Any] = field(default_factory=dict)
|
|
218
|
+
trust_state: dict[str, Any] = field(default_factory=dict)
|
|
219
|
+
channel_health: str = "starting"
|
|
220
|
+
last_event_id: str | None = None
|
|
221
|
+
last_reply_at: str | None = None
|
|
222
|
+
permission_state: dict[str, Any] = field(default_factory=lambda: {"pending": 0, "last_verdict": None})
|
|
223
|
+
updated_at: str = field(default_factory=lambda: _utc_now())
|
|
224
|
+
process_pid: int | None = None
|
|
225
|
+
|
|
226
|
+
def to_json(self) -> dict[str, Any]:
|
|
227
|
+
return {
|
|
228
|
+
"session_id": self.session_id,
|
|
229
|
+
"adapter": self.adapter,
|
|
230
|
+
"cwd": self.cwd,
|
|
231
|
+
"state": self.state,
|
|
232
|
+
"auth_posture": dict(self.auth_posture),
|
|
233
|
+
"trust_state": dict(self.trust_state),
|
|
234
|
+
"channel_health": self.channel_health,
|
|
235
|
+
"last_event_id": self.last_event_id,
|
|
236
|
+
"last_reply_at": self.last_reply_at,
|
|
237
|
+
"permission_state": dict(self.permission_state),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class ChannelEventState:
|
|
243
|
+
envelope: ChannelEventEnvelope
|
|
244
|
+
replies: list[ChannelReplyPayload] = field(default_factory=list)
|
|
245
|
+
acknowledged: bool = False
|
|
246
|
+
acknowledged_at: str | None = None
|
|
247
|
+
|
|
248
|
+
def to_json(self) -> dict[str, Any]:
|
|
249
|
+
return {
|
|
250
|
+
**self.envelope.to_json(),
|
|
251
|
+
"acknowledged": self.acknowledged,
|
|
252
|
+
"acknowledged_at": self.acknowledged_at,
|
|
253
|
+
"replies": [reply.to_json() for reply in self.replies],
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class ChannelSidecar:
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
bearer_token: str | None = None,
|
|
262
|
+
allowed_senders: set[str] | None = None,
|
|
263
|
+
allowed_verdict_actors: set[str] | None = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
if bearer_token == "":
|
|
266
|
+
raise ValueError("bearer_token must not be empty")
|
|
267
|
+
self._bearer_token = bearer_token
|
|
268
|
+
self._allowed_senders = set(allowed_senders or set())
|
|
269
|
+
self._allowed_verdict_actors = set(allowed_verdict_actors or set())
|
|
270
|
+
self._events_by_session: dict[str, list[str]] = {}
|
|
271
|
+
self._events_by_id: dict[str, ChannelEventState] = {}
|
|
272
|
+
self._permission_requests_by_session: dict[str, list[str]] = {}
|
|
273
|
+
self._permission_requests_by_id: dict[str, PermissionRequestEnvelope] = {}
|
|
274
|
+
self._permission_audit_by_session: dict[str, list[PermissionAuditEntry]] = {}
|
|
275
|
+
self._sessions_by_id: dict[str, SessionRegistryRecord] = {}
|
|
276
|
+
self._hook_events_by_session: dict[str, list[dict[str, Any]]] = {}
|
|
277
|
+
self._lock = threading.RLock()
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def bearer_token_configured(self) -> bool:
|
|
281
|
+
return self._bearer_token is not None
|
|
282
|
+
|
|
283
|
+
def authenticate(self, authorization: str | None) -> HTTPStatus | None:
|
|
284
|
+
if self._bearer_token is None:
|
|
285
|
+
return None
|
|
286
|
+
if not authorization:
|
|
287
|
+
return HTTPStatus.UNAUTHORIZED
|
|
288
|
+
if authorization != f"Bearer {self._bearer_token}":
|
|
289
|
+
return HTTPStatus.FORBIDDEN
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def create_message(
|
|
293
|
+
self,
|
|
294
|
+
session_id: str,
|
|
295
|
+
*,
|
|
296
|
+
sender: str,
|
|
297
|
+
content: str,
|
|
298
|
+
attachments: list[dict[str, Any]] | None = None,
|
|
299
|
+
) -> ChannelEventEnvelope:
|
|
300
|
+
if not session_id:
|
|
301
|
+
raise ValueError("session_id is required")
|
|
302
|
+
if not isinstance(sender, str) or not sender:
|
|
303
|
+
raise ValueError("sender is required")
|
|
304
|
+
if self._allowed_senders and sender not in self._allowed_senders:
|
|
305
|
+
raise PermissionError("sender is not allowed")
|
|
306
|
+
if not isinstance(content, str):
|
|
307
|
+
raise ValueError("content must be a string")
|
|
308
|
+
clean_attachments = tuple(_metadata_only_attachments(attachments or []))
|
|
309
|
+
envelope = ChannelEventEnvelope(
|
|
310
|
+
event_id=str(uuid.uuid4()),
|
|
311
|
+
session_id=session_id,
|
|
312
|
+
sender=sender,
|
|
313
|
+
content=content,
|
|
314
|
+
attachments=clean_attachments,
|
|
315
|
+
created_at=_utc_now(),
|
|
316
|
+
)
|
|
317
|
+
with self._lock:
|
|
318
|
+
self._events_by_id[envelope.event_id] = ChannelEventState(envelope=envelope)
|
|
319
|
+
self._events_by_session.setdefault(session_id, []).append(envelope.event_id)
|
|
320
|
+
if session_id in self._sessions_by_id:
|
|
321
|
+
record = self._sessions_by_id[session_id]
|
|
322
|
+
record.last_event_id = envelope.event_id
|
|
323
|
+
record.channel_health = "starting" if record.channel_health == "disconnected" else record.channel_health
|
|
324
|
+
record.updated_at = _utc_now()
|
|
325
|
+
return envelope
|
|
326
|
+
|
|
327
|
+
def list_events(self, session_id: str) -> list[dict[str, Any]]:
|
|
328
|
+
with self._lock:
|
|
329
|
+
event_ids = list(self._events_by_session.get(session_id, []))
|
|
330
|
+
return [self._events_by_id[event_id].to_json() for event_id in event_ids]
|
|
331
|
+
|
|
332
|
+
def get_event(self, event_id: str) -> dict[str, Any] | None:
|
|
333
|
+
with self._lock:
|
|
334
|
+
state = self._events_by_id.get(event_id)
|
|
335
|
+
return state.to_json() if state else None
|
|
336
|
+
|
|
337
|
+
def record_reply(self, payload: ChannelReplyPayload | dict[str, Any]) -> dict[str, Any]:
|
|
338
|
+
reply = payload if isinstance(payload, ChannelReplyPayload) else _reply_payload(payload)
|
|
339
|
+
with self._lock:
|
|
340
|
+
state = self._events_by_id.get(reply.event_id)
|
|
341
|
+
if state is None:
|
|
342
|
+
raise KeyError(f"unknown event_id: {reply.event_id}")
|
|
343
|
+
state.replies.append(reply)
|
|
344
|
+
self._update_session_from_reply(state.envelope.session_id, reply)
|
|
345
|
+
if reply.final:
|
|
346
|
+
state.acknowledged = True
|
|
347
|
+
state.acknowledged_at = _utc_now()
|
|
348
|
+
return state.to_json()
|
|
349
|
+
|
|
350
|
+
def record_status(self, payload: ChannelReplyPayload | dict[str, Any]) -> dict[str, Any]:
|
|
351
|
+
return self.record_reply(payload)
|
|
352
|
+
|
|
353
|
+
def create_permission_request(self, session_id: str, payload: dict[str, Any]) -> PermissionRequestEnvelope:
|
|
354
|
+
with self._lock:
|
|
355
|
+
event_id = self._sessions_by_id.get(session_id).last_event_id if session_id in self._sessions_by_id else None
|
|
356
|
+
request_payload = _permission_request_payload(session_id, payload, event_id=event_id)
|
|
357
|
+
self._permission_requests_by_id[request_payload.request_id] = request_payload
|
|
358
|
+
self._permission_requests_by_session.setdefault(session_id, []).append(request_payload.request_id)
|
|
359
|
+
self._update_permission_state(session_id)
|
|
360
|
+
return request_payload
|
|
361
|
+
|
|
362
|
+
def list_permission_requests(self, session_id: str) -> list[dict[str, Any]]:
|
|
363
|
+
with self._lock:
|
|
364
|
+
request_ids = list(self._permission_requests_by_session.get(session_id, []))
|
|
365
|
+
return [self._permission_requests_by_id[request_id].to_json() for request_id in request_ids]
|
|
366
|
+
|
|
367
|
+
def record_permission_verdict(self, session_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
368
|
+
verdict = _permission_verdict_payload(payload)
|
|
369
|
+
with self._lock:
|
|
370
|
+
request_payload = self._permission_requests_by_id.get(verdict.request_id)
|
|
371
|
+
if request_payload is None or request_payload.session_id != session_id:
|
|
372
|
+
raise KeyError(f"unknown request_id: {verdict.request_id}")
|
|
373
|
+
if self._allowed_verdict_actors and verdict.actor not in self._allowed_verdict_actors:
|
|
374
|
+
raise PermissionError("verdict actor is not allowed")
|
|
375
|
+
audit_ref = f"claude_permission:{session_id}:{verdict.request_id}"
|
|
376
|
+
audit_entry = PermissionAuditEntry(
|
|
377
|
+
request_id=verdict.request_id,
|
|
378
|
+
session_id=session_id,
|
|
379
|
+
event_id=request_payload.event_id,
|
|
380
|
+
verdict=verdict.verdict,
|
|
381
|
+
actor=verdict.actor,
|
|
382
|
+
reason=verdict.reason,
|
|
383
|
+
audit_ref=audit_ref,
|
|
384
|
+
decided_at=verdict.decided_at,
|
|
385
|
+
)
|
|
386
|
+
self._permission_audit_by_session.setdefault(session_id, []).append(audit_entry)
|
|
387
|
+
self._update_permission_state(session_id)
|
|
388
|
+
if session_id in self._sessions_by_id:
|
|
389
|
+
if verdict.verdict == "deny":
|
|
390
|
+
self._sessions_by_id[session_id].state = "blocked"
|
|
391
|
+
self._sessions_by_id[session_id].channel_health = "blocked"
|
|
392
|
+
elif self._sessions_by_id[session_id].permission_state.get("pending") == 0:
|
|
393
|
+
self._sessions_by_id[session_id].state = "ready"
|
|
394
|
+
self._sessions_by_id[session_id].channel_health = "ready"
|
|
395
|
+
return audit_entry.to_json()
|
|
396
|
+
|
|
397
|
+
def list_permission_audit(self, session_id: str) -> list[dict[str, Any]]:
|
|
398
|
+
with self._lock:
|
|
399
|
+
return [entry.to_json() for entry in self._permission_audit_by_session.get(session_id, [])]
|
|
400
|
+
|
|
401
|
+
def register_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
402
|
+
session_id = _required_string(payload, "session_id")
|
|
403
|
+
adapter = _required_string(payload, "adapter")
|
|
404
|
+
cwd = _required_string(payload, "cwd")
|
|
405
|
+
state = str(payload.get("state") or "starting")
|
|
406
|
+
channel_health = str(payload.get("channel_health") or "starting")
|
|
407
|
+
if state not in SESSION_STATES:
|
|
408
|
+
raise ValueError("state must be one of: " + ", ".join(sorted(SESSION_STATES)))
|
|
409
|
+
if channel_health not in CHANNEL_HEALTH_STATES:
|
|
410
|
+
raise ValueError("channel_health must be one of: " + ", ".join(sorted(CHANNEL_HEALTH_STATES)))
|
|
411
|
+
record = SessionRegistryRecord(
|
|
412
|
+
session_id=session_id,
|
|
413
|
+
adapter=adapter,
|
|
414
|
+
cwd=cwd,
|
|
415
|
+
state=state,
|
|
416
|
+
auth_posture=_metadata_summary(payload.get("auth_posture") or {}),
|
|
417
|
+
trust_state=_metadata_summary(payload.get("trust_state") or {}),
|
|
418
|
+
channel_health=channel_health,
|
|
419
|
+
process_pid=payload.get("process_pid") if isinstance(payload.get("process_pid"), int) else None,
|
|
420
|
+
)
|
|
421
|
+
with self._lock:
|
|
422
|
+
self._sessions_by_id[session_id] = record
|
|
423
|
+
self._update_permission_state(session_id)
|
|
424
|
+
return record.to_json()
|
|
425
|
+
|
|
426
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
427
|
+
with self._lock:
|
|
428
|
+
return [record.to_json() for record in self._sessions_by_id.values()]
|
|
429
|
+
|
|
430
|
+
def get_session(self, session_id: str) -> dict[str, Any] | None:
|
|
431
|
+
with self._lock:
|
|
432
|
+
record = self._sessions_by_id.get(session_id)
|
|
433
|
+
return record.to_json() if record else None
|
|
434
|
+
|
|
435
|
+
def update_session_state(self, session_id: str, *, state: str | None = None, channel_health: str | None = None) -> dict[str, Any]:
|
|
436
|
+
with self._lock:
|
|
437
|
+
record = self._sessions_by_id.get(session_id)
|
|
438
|
+
if record is None:
|
|
439
|
+
raise KeyError(f"unknown session_id: {session_id}")
|
|
440
|
+
if state is not None:
|
|
441
|
+
if state not in SESSION_STATES:
|
|
442
|
+
raise ValueError("state must be one of: " + ", ".join(sorted(SESSION_STATES)))
|
|
443
|
+
record.state = state
|
|
444
|
+
if channel_health is not None:
|
|
445
|
+
if channel_health not in CHANNEL_HEALTH_STATES:
|
|
446
|
+
raise ValueError("channel_health must be one of: " + ", ".join(sorted(CHANNEL_HEALTH_STATES)))
|
|
447
|
+
record.channel_health = channel_health
|
|
448
|
+
record.updated_at = _utc_now()
|
|
449
|
+
return record.to_json()
|
|
450
|
+
|
|
451
|
+
def mark_stale_sessions(self, *, older_than_seconds: int, live_pids: set[int] | None = None) -> list[dict[str, Any]]:
|
|
452
|
+
cutoff = datetime.now(timezone.utc) - timedelta(seconds=older_than_seconds)
|
|
453
|
+
stale: list[dict[str, Any]] = []
|
|
454
|
+
with self._lock:
|
|
455
|
+
for record in self._sessions_by_id.values():
|
|
456
|
+
updated_at = _parse_utc(record.updated_at)
|
|
457
|
+
pid_missing = record.process_pid is not None and live_pids is not None and record.process_pid not in live_pids
|
|
458
|
+
if record.state not in {"stopped", "stale"} and (updated_at < cutoff or pid_missing):
|
|
459
|
+
record.state = "stale"
|
|
460
|
+
record.channel_health = "stopped"
|
|
461
|
+
record.updated_at = _utc_now()
|
|
462
|
+
stale.append(record.to_json())
|
|
463
|
+
return stale
|
|
464
|
+
|
|
465
|
+
def record_hook_event(self, session_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
466
|
+
hook_event = {
|
|
467
|
+
"event_id": str(uuid.uuid4()),
|
|
468
|
+
"session_id": session_id,
|
|
469
|
+
"hook": _required_string(payload, "hook"),
|
|
470
|
+
"cwd": str(payload.get("cwd") or ""),
|
|
471
|
+
"permission_mode": str(payload.get("permission_mode") or ""),
|
|
472
|
+
"received_at": _utc_now(),
|
|
473
|
+
}
|
|
474
|
+
with self._lock:
|
|
475
|
+
self._hook_events_by_session.setdefault(session_id, []).append(hook_event)
|
|
476
|
+
if session_id in self._sessions_by_id:
|
|
477
|
+
record = self._sessions_by_id[session_id]
|
|
478
|
+
if hook_event["hook"] == "Notification":
|
|
479
|
+
if record.permission_state.get("pending", 0) > 0:
|
|
480
|
+
record.state = "needs_permission"
|
|
481
|
+
record.channel_health = "needs_permission"
|
|
482
|
+
else:
|
|
483
|
+
record.state = "needs_input"
|
|
484
|
+
record.channel_health = "needs_input"
|
|
485
|
+
record.updated_at = hook_event["received_at"]
|
|
486
|
+
return hook_event
|
|
487
|
+
|
|
488
|
+
def _update_session_from_reply(self, session_id: str, reply: ChannelReplyPayload) -> None:
|
|
489
|
+
record = self._sessions_by_id.get(session_id)
|
|
490
|
+
if record is None:
|
|
491
|
+
return
|
|
492
|
+
record.last_event_id = reply.event_id
|
|
493
|
+
record.last_reply_at = _utc_now()
|
|
494
|
+
if reply.status in {"blocked", "error"}:
|
|
495
|
+
record.state = "blocked"
|
|
496
|
+
record.channel_health = "blocked"
|
|
497
|
+
elif reply.status in {"received", "working", "done"}:
|
|
498
|
+
record.state = "ready"
|
|
499
|
+
record.channel_health = "ready"
|
|
500
|
+
record.updated_at = record.last_reply_at
|
|
501
|
+
|
|
502
|
+
def _update_permission_state(self, session_id: str) -> None:
|
|
503
|
+
record = self._sessions_by_id.get(session_id)
|
|
504
|
+
if record is None:
|
|
505
|
+
return
|
|
506
|
+
request_ids = self._permission_requests_by_session.get(session_id, [])
|
|
507
|
+
audit = self._permission_audit_by_session.get(session_id, [])
|
|
508
|
+
decided = {entry.request_id for entry in audit}
|
|
509
|
+
record.permission_state = {
|
|
510
|
+
"pending": len([request_id for request_id in request_ids if request_id not in decided]),
|
|
511
|
+
"last_verdict": audit[-1].verdict if audit else None,
|
|
512
|
+
"last_request_id": request_ids[-1] if request_ids else None,
|
|
513
|
+
"last_audit_ref": audit[-1].audit_ref if audit else None,
|
|
514
|
+
}
|
|
515
|
+
if record.permission_state["pending"] > 0 and record.state not in {"blocked", "stopped", "stale"}:
|
|
516
|
+
record.state = "needs_permission"
|
|
517
|
+
record.channel_health = "needs_permission"
|
|
518
|
+
record.updated_at = _utc_now()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class ChannelSidecarClientError(RuntimeError):
|
|
522
|
+
def __init__(self, reason: str, *, status_code: int | None = None) -> None:
|
|
523
|
+
super().__init__(reason)
|
|
524
|
+
self.reason = reason
|
|
525
|
+
self.status_code = status_code
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class ChannelSidecarClient:
|
|
529
|
+
def __init__(
|
|
530
|
+
self,
|
|
531
|
+
*,
|
|
532
|
+
base_url: str,
|
|
533
|
+
session_id: str,
|
|
534
|
+
sender: str = "phase-loop",
|
|
535
|
+
bearer_token: str | None = None,
|
|
536
|
+
timeout_seconds: float = 60,
|
|
537
|
+
poll_interval_seconds: float = 0.25,
|
|
538
|
+
opener: Any | None = None,
|
|
539
|
+
) -> None:
|
|
540
|
+
if not is_loopback_http_url(base_url):
|
|
541
|
+
raise ValueError("claude channel client requires a loopback http base_url")
|
|
542
|
+
if not session_id:
|
|
543
|
+
raise ValueError("session_id is required")
|
|
544
|
+
if not sender:
|
|
545
|
+
raise ValueError("sender is required")
|
|
546
|
+
self.base_url = base_url.rstrip("/")
|
|
547
|
+
self.session_id = session_id
|
|
548
|
+
self.sender = sender
|
|
549
|
+
self._bearer_token = bearer_token
|
|
550
|
+
self.timeout_seconds = timeout_seconds
|
|
551
|
+
self.poll_interval_seconds = poll_interval_seconds
|
|
552
|
+
self._opener = opener or request.urlopen
|
|
553
|
+
|
|
554
|
+
def preflight(self) -> dict[str, Any]:
|
|
555
|
+
session = self._request_json("GET", f"/sessions/{self.session_id}")
|
|
556
|
+
state = str(session.get("state") or "")
|
|
557
|
+
health = str(session.get("channel_health") or "")
|
|
558
|
+
if state not in CLIENT_READY_STATES or health not in CLIENT_READY_STATES:
|
|
559
|
+
raise ChannelSidecarClientError(f"channel session is not ready: state={state or 'unknown'} health={health or 'unknown'}")
|
|
560
|
+
return session
|
|
561
|
+
|
|
562
|
+
def send_and_wait(self, content: str, *, attachments: list[dict[str, Any]] | None = None) -> ClaudeRouteResult:
|
|
563
|
+
session = self.preflight()
|
|
564
|
+
envelope = self._request_json(
|
|
565
|
+
"POST",
|
|
566
|
+
f"/sessions/{self.session_id}/message",
|
|
567
|
+
{"sender": self.sender, "content": content, "attachments": attachments or []},
|
|
568
|
+
)
|
|
569
|
+
event_id = str(envelope.get("event_id") or "")
|
|
570
|
+
if not event_id:
|
|
571
|
+
raise ChannelSidecarClientError("channel sidecar returned no event id")
|
|
572
|
+
final_event = self._wait_for_final_event(event_id)
|
|
573
|
+
latest_session = session
|
|
574
|
+
if final_event is None:
|
|
575
|
+
try:
|
|
576
|
+
latest_session = self._request_json("GET", f"/sessions/{self.session_id}")
|
|
577
|
+
except ChannelSidecarClientError:
|
|
578
|
+
latest_session = session
|
|
579
|
+
return self._event_result(latest_session, final_event, event_id)
|
|
580
|
+
|
|
581
|
+
def _wait_for_final_event(self, event_id: str) -> dict[str, Any] | None:
|
|
582
|
+
deadline = time.monotonic() + self.timeout_seconds
|
|
583
|
+
while time.monotonic() <= deadline:
|
|
584
|
+
events_payload = self._request_json("GET", f"/sessions/{self.session_id}/events")
|
|
585
|
+
for event_payload in events_payload.get("events", []):
|
|
586
|
+
if event_payload.get("event_id") != event_id:
|
|
587
|
+
continue
|
|
588
|
+
replies = event_payload.get("replies") or []
|
|
589
|
+
if event_payload.get("acknowledged") or any(reply.get("final") for reply in replies):
|
|
590
|
+
return event_payload
|
|
591
|
+
time.sleep(self.poll_interval_seconds)
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
def _event_result(self, session: dict[str, Any], event_payload: dict[str, Any] | None, event_id: str) -> ClaudeRouteResult:
|
|
595
|
+
if event_payload is None:
|
|
596
|
+
pending = (session.get("permission_state") or {}).get("pending")
|
|
597
|
+
session_state = str(session.get("state") or "")
|
|
598
|
+
if isinstance(pending, (int, float)) and pending > 0:
|
|
599
|
+
status = "needs_permission"
|
|
600
|
+
text = "channel permission required"
|
|
601
|
+
warnings = ("channel permission request is pending",)
|
|
602
|
+
elif session_state == "needs_input":
|
|
603
|
+
status = "needs_input"
|
|
604
|
+
text = "channel session needs input"
|
|
605
|
+
warnings = ("channel hook reported needs input",)
|
|
606
|
+
else:
|
|
607
|
+
status = "stale"
|
|
608
|
+
text = "channel reply timed out"
|
|
609
|
+
warnings = ("channel reply/status acknowledgement timed out",)
|
|
610
|
+
return ClaudeRouteResult(
|
|
611
|
+
route="claude_channel",
|
|
612
|
+
session_id=self.session_id,
|
|
613
|
+
event_id=event_id,
|
|
614
|
+
status=status,
|
|
615
|
+
text=text,
|
|
616
|
+
auth_posture=_client_auth_posture(session),
|
|
617
|
+
billing_posture=_client_billing_posture(session),
|
|
618
|
+
trust_state=session.get("trust_state") or {},
|
|
619
|
+
permission_state=session.get("permission_state") or {},
|
|
620
|
+
warnings=warnings,
|
|
621
|
+
evidence_refs=({"kind": "claude_channel_event", "event_id": event_id},),
|
|
622
|
+
)
|
|
623
|
+
final_reply = _final_reply(event_payload)
|
|
624
|
+
status = str(final_reply.get("status") or "working") if final_reply else "working"
|
|
625
|
+
if status not in CLAUDE_ROUTE_STATUSES:
|
|
626
|
+
status = "error"
|
|
627
|
+
return ClaudeRouteResult(
|
|
628
|
+
route="claude_channel",
|
|
629
|
+
session_id=self.session_id,
|
|
630
|
+
event_id=event_id,
|
|
631
|
+
status=status,
|
|
632
|
+
text=str(final_reply.get("text") or "") if final_reply else "",
|
|
633
|
+
artifacts=tuple(final_reply.get("artifacts") or []) if final_reply else (),
|
|
634
|
+
auth_posture=_client_auth_posture(session),
|
|
635
|
+
billing_posture=_client_billing_posture(session),
|
|
636
|
+
trust_state=session.get("trust_state") or {},
|
|
637
|
+
permission_state=session.get("permission_state") or {},
|
|
638
|
+
warnings=tuple(_client_warnings(event_payload)),
|
|
639
|
+
evidence_refs=({"kind": "claude_channel_event", "event_id": event_id},),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def _request_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
643
|
+
body = None if payload is None else json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
644
|
+
headers = {"Accept": "application/json"}
|
|
645
|
+
if body is not None:
|
|
646
|
+
headers["Content-Type"] = "application/json"
|
|
647
|
+
if self._bearer_token:
|
|
648
|
+
headers["Authorization"] = f"Bearer {self._bearer_token}"
|
|
649
|
+
req = request.Request(f"{self.base_url}{path}", data=body, headers=headers, method=method)
|
|
650
|
+
try:
|
|
651
|
+
with self._opener(req, timeout=max(self.timeout_seconds, 1)) as response:
|
|
652
|
+
decoded = response.read().decode("utf-8")
|
|
653
|
+
except error.HTTPError as exc:
|
|
654
|
+
raise ChannelSidecarClientError(_client_http_reason(exc), status_code=exc.code) from exc
|
|
655
|
+
except error.URLError as exc:
|
|
656
|
+
raise ChannelSidecarClientError(f"channel sidecar unreachable: {exc.reason.__class__.__name__}") from exc
|
|
657
|
+
data = json.loads(decoded or "{}")
|
|
658
|
+
if not isinstance(data, dict):
|
|
659
|
+
raise ChannelSidecarClientError("channel sidecar returned non-object JSON")
|
|
660
|
+
return data
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def is_loopback_host(host: str) -> bool:
|
|
664
|
+
normalized = host.strip().lower()
|
|
665
|
+
if normalized == "localhost":
|
|
666
|
+
return True
|
|
667
|
+
# Accept the full loopback ranges (127.0.0.0/8, ::1), not just 127.0.0.1, so a
|
|
668
|
+
# sidecar bound to e.g. 127.0.0.2 is correctly classified as loopback. Anything
|
|
669
|
+
# that is not a parseable loopback IP (or `localhost`) is non-loopback.
|
|
670
|
+
try:
|
|
671
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
672
|
+
except ValueError:
|
|
673
|
+
return False
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def is_loopback_http_url(url: str) -> bool:
|
|
677
|
+
"""True iff `url` is an http URL bound to a loopback host.
|
|
678
|
+
|
|
679
|
+
Single source of truth for the Channel sidecar transport posture: a Channel
|
|
680
|
+
route must point at a loopback sidecar (no remote/non-loopback transport).
|
|
681
|
+
Used by both the build-time route preflight (launcher) and the sidecar client.
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
if not url:
|
|
685
|
+
return False
|
|
686
|
+
parsed = urlparse(url)
|
|
687
|
+
return parsed.scheme == "http" and bool(parsed.hostname) and is_loopback_host(parsed.hostname)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def make_handler(sidecar: ChannelSidecar) -> type[BaseHTTPRequestHandler]:
|
|
691
|
+
class ChannelSidecarHandler(BaseHTTPRequestHandler):
|
|
692
|
+
server_version = "PhaseLoopChannelSidecar/0.1"
|
|
693
|
+
|
|
694
|
+
def do_POST(self) -> None: # noqa: N802
|
|
695
|
+
try:
|
|
696
|
+
self._handle_post()
|
|
697
|
+
except ValueError as exc:
|
|
698
|
+
self._write_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
|
699
|
+
except KeyError as exc:
|
|
700
|
+
self._write_json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
|
|
701
|
+
except PermissionError as exc:
|
|
702
|
+
self._write_json({"error": str(exc)}, HTTPStatus.FORBIDDEN)
|
|
703
|
+
|
|
704
|
+
def do_GET(self) -> None: # noqa: N802
|
|
705
|
+
if self._path_parts()[:1] != ["sessions"]:
|
|
706
|
+
self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
707
|
+
return
|
|
708
|
+
parts = self._path_parts()
|
|
709
|
+
if len(parts) == 1:
|
|
710
|
+
self._write_json({"sessions": sidecar.list_sessions()})
|
|
711
|
+
return
|
|
712
|
+
if len(parts) == 2:
|
|
713
|
+
session = sidecar.get_session(parts[1])
|
|
714
|
+
if session is None:
|
|
715
|
+
self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
716
|
+
return
|
|
717
|
+
self._write_json(session)
|
|
718
|
+
return
|
|
719
|
+
if len(parts) == 4 and parts[2] == "permission" and parts[3] in {"requests", "audit"}:
|
|
720
|
+
payload_key = "requests" if parts[3] == "requests" else "audit"
|
|
721
|
+
payload = (
|
|
722
|
+
sidecar.list_permission_requests(parts[1])
|
|
723
|
+
if parts[3] == "requests"
|
|
724
|
+
else sidecar.list_permission_audit(parts[1])
|
|
725
|
+
)
|
|
726
|
+
self._write_json({payload_key: payload})
|
|
727
|
+
return
|
|
728
|
+
if len(parts) != 3 or parts[2] != "events":
|
|
729
|
+
self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
730
|
+
return
|
|
731
|
+
events = sidecar.list_events(parts[1])
|
|
732
|
+
if "text/event-stream" in self.headers.get("Accept", ""):
|
|
733
|
+
self.send_response(HTTPStatus.OK)
|
|
734
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
735
|
+
self.end_headers()
|
|
736
|
+
for event in events:
|
|
737
|
+
self.wfile.write(f"data: {json.dumps(event, sort_keys=True)}\n\n".encode("utf-8"))
|
|
738
|
+
return
|
|
739
|
+
self._write_json({"events": events})
|
|
740
|
+
|
|
741
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
def _handle_post(self) -> None:
|
|
745
|
+
parts = self._path_parts()
|
|
746
|
+
if len(parts) not in {3, 4} or parts[0] != "sessions":
|
|
747
|
+
self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
748
|
+
return
|
|
749
|
+
payload = self._read_json()
|
|
750
|
+
session_id = parts[1]
|
|
751
|
+
if parts[2] == "register":
|
|
752
|
+
payload.setdefault("session_id", session_id)
|
|
753
|
+
self._write_json(sidecar.register_session(payload), HTTPStatus.CREATED)
|
|
754
|
+
return
|
|
755
|
+
if parts[2] == "state":
|
|
756
|
+
self._write_json(sidecar.update_session_state(session_id, state=payload.get("state"), channel_health=payload.get("channel_health")))
|
|
757
|
+
return
|
|
758
|
+
if parts[2] == "hook":
|
|
759
|
+
self._write_json(sidecar.record_hook_event(session_id, payload), HTTPStatus.CREATED)
|
|
760
|
+
return
|
|
761
|
+
if parts[2] == "message":
|
|
762
|
+
if auth_status := sidecar.authenticate(self.headers.get("Authorization")):
|
|
763
|
+
self._write_json({"error": "authentication required"}, auth_status)
|
|
764
|
+
return
|
|
765
|
+
envelope = sidecar.create_message(
|
|
766
|
+
session_id,
|
|
767
|
+
sender=payload.get("sender", ""),
|
|
768
|
+
content=payload.get("content", ""),
|
|
769
|
+
attachments=payload.get("attachments", []),
|
|
770
|
+
)
|
|
771
|
+
self._write_json(envelope.to_json(), HTTPStatus.CREATED)
|
|
772
|
+
return
|
|
773
|
+
if len(parts) == 4 and parts[2] == "permission" and parts[3] == "request":
|
|
774
|
+
request_payload = sidecar.create_permission_request(session_id, payload)
|
|
775
|
+
self._write_json(request_payload.to_json(), HTTPStatus.CREATED)
|
|
776
|
+
return
|
|
777
|
+
if len(parts) == 4 and parts[2] == "permission" and parts[3] == "verdict":
|
|
778
|
+
if auth_status := sidecar.authenticate(self.headers.get("Authorization")):
|
|
779
|
+
self._write_json({"error": "authentication required"}, auth_status)
|
|
780
|
+
return
|
|
781
|
+
audit_entry = sidecar.record_permission_verdict(session_id, payload)
|
|
782
|
+
self._write_json(audit_entry, HTTPStatus.CREATED)
|
|
783
|
+
return
|
|
784
|
+
if parts[2] in {"reply", "status"}:
|
|
785
|
+
if auth_status := sidecar.authenticate(self.headers.get("Authorization")):
|
|
786
|
+
self._write_json({"error": "authentication required"}, auth_status)
|
|
787
|
+
return
|
|
788
|
+
payload.setdefault("event_id", payload.get("eventId"))
|
|
789
|
+
state = sidecar.record_reply(payload) if parts[2] == "reply" else sidecar.record_status(payload)
|
|
790
|
+
self._write_json(state)
|
|
791
|
+
return
|
|
792
|
+
self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
793
|
+
|
|
794
|
+
def _path_parts(self) -> list[str]:
|
|
795
|
+
return [part for part in urlparse(self.path).path.split("/") if part]
|
|
796
|
+
|
|
797
|
+
def _read_json(self) -> dict[str, Any]:
|
|
798
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
799
|
+
if length <= 0:
|
|
800
|
+
return {}
|
|
801
|
+
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
802
|
+
if not isinstance(data, dict):
|
|
803
|
+
raise ValueError("payload must be a JSON object")
|
|
804
|
+
return data
|
|
805
|
+
|
|
806
|
+
def _write_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
807
|
+
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
808
|
+
self.send_response(status)
|
|
809
|
+
self.send_header("Content-Type", "application/json")
|
|
810
|
+
self.send_header("Content-Length", str(len(body)))
|
|
811
|
+
self.send_header("Connection", "close")
|
|
812
|
+
self.end_headers()
|
|
813
|
+
self.wfile.write(body)
|
|
814
|
+
self.close_connection = True
|
|
815
|
+
|
|
816
|
+
return ChannelSidecarHandler
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def build_server(
|
|
820
|
+
host: str = "127.0.0.1",
|
|
821
|
+
port: int = 0,
|
|
822
|
+
sidecar: ChannelSidecar | None = None,
|
|
823
|
+
*,
|
|
824
|
+
bearer_token: str | None = None,
|
|
825
|
+
allowed_senders: set[str] | None = None,
|
|
826
|
+
allowed_verdict_actors: set[str] | None = None,
|
|
827
|
+
) -> ThreadingHTTPServer:
|
|
828
|
+
if not is_loopback_host(host):
|
|
829
|
+
raise ValueError("claude channel sidecar binds to loopback hosts only")
|
|
830
|
+
return LoopbackThreadingHTTPServer(
|
|
831
|
+
(host, port),
|
|
832
|
+
make_handler(sidecar or ChannelSidecar(bearer_token=bearer_token, allowed_senders=allowed_senders, allowed_verdict_actors=allowed_verdict_actors)),
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def main(argv: list[str] | None = None) -> int:
|
|
837
|
+
parser = argparse.ArgumentParser(description="Run the local phase-loop Claude Channel sidecar.")
|
|
838
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
839
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
840
|
+
args = parser.parse_args(argv)
|
|
841
|
+
server = build_server(args.host, args.port)
|
|
842
|
+
sys.stderr.write(f"phase-loop channel sidecar listening on http://{args.host}:{args.port}\n")
|
|
843
|
+
try:
|
|
844
|
+
server.serve_forever()
|
|
845
|
+
except KeyboardInterrupt:
|
|
846
|
+
return 130
|
|
847
|
+
finally:
|
|
848
|
+
server.server_close()
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _metadata_only_attachments(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
853
|
+
if not isinstance(attachments, list):
|
|
854
|
+
raise ValueError("attachments must be a list")
|
|
855
|
+
clean: list[dict[str, Any]] = []
|
|
856
|
+
for attachment in attachments:
|
|
857
|
+
if not isinstance(attachment, dict):
|
|
858
|
+
raise ValueError("attachments must contain metadata objects")
|
|
859
|
+
forbidden = FORBIDDEN_ATTACHMENT_FIELDS.intersection({str(key).lower() for key in attachment})
|
|
860
|
+
if forbidden:
|
|
861
|
+
raise ValueError(f"attachment contains non-metadata fields: {', '.join(sorted(forbidden))}")
|
|
862
|
+
clean.append(dict(attachment))
|
|
863
|
+
return clean
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _reply_payload(payload: dict[str, Any]) -> ChannelReplyPayload:
|
|
867
|
+
event_id = payload.get("event_id")
|
|
868
|
+
status = payload.get("status")
|
|
869
|
+
if not isinstance(event_id, str) or not event_id:
|
|
870
|
+
raise ValueError("event_id is required")
|
|
871
|
+
if status not in REPLY_STATUSES:
|
|
872
|
+
raise ValueError("status must be one of: " + ", ".join(sorted(REPLY_STATUSES)))
|
|
873
|
+
artifacts = payload.get("artifacts") or []
|
|
874
|
+
if not isinstance(artifacts, list):
|
|
875
|
+
raise ValueError("artifacts must be a list")
|
|
876
|
+
return ChannelReplyPayload(
|
|
877
|
+
event_id=event_id,
|
|
878
|
+
status=status,
|
|
879
|
+
text=str(payload.get("text") or ""),
|
|
880
|
+
artifacts=tuple(_metadata_only_attachments(artifacts)),
|
|
881
|
+
error=payload.get("error"),
|
|
882
|
+
final=bool(payload.get("final", False)),
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _final_reply(event_payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
887
|
+
replies = event_payload.get("replies") or []
|
|
888
|
+
for reply in reversed(replies):
|
|
889
|
+
if reply.get("final") or reply.get("status") in CLIENT_FINAL_REPLY_STATUSES:
|
|
890
|
+
return reply
|
|
891
|
+
return replies[-1] if replies else None
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _client_warnings(event_payload: dict[str, Any]) -> list[str]:
|
|
895
|
+
warnings = []
|
|
896
|
+
if event_payload.get("ack_policy") == ACK_POLICY_TOOL_REQUIRED and not event_payload.get("acknowledged"):
|
|
897
|
+
warnings.append("channel reply tool acknowledgement missing")
|
|
898
|
+
return warnings
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _client_auth_posture(session: dict[str, Any]) -> str:
|
|
902
|
+
posture = session.get("auth_posture") or {}
|
|
903
|
+
method = str(posture.get("method") or posture.get("authMethod") or "").lower()
|
|
904
|
+
status = str(posture.get("status") or "").lower()
|
|
905
|
+
if method == "subscription" and status in {"authenticated", "ok", "ready"}:
|
|
906
|
+
return "subscription_local"
|
|
907
|
+
if method in {"api_key", "apikey", "key"}:
|
|
908
|
+
return "api_key"
|
|
909
|
+
return "unknown"
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _client_billing_posture(session: dict[str, Any]) -> str:
|
|
913
|
+
auth_posture = _client_auth_posture(session)
|
|
914
|
+
if auth_posture == "subscription_local":
|
|
915
|
+
return "subscription_included"
|
|
916
|
+
if auth_posture == "api_key":
|
|
917
|
+
return "api_key_billed"
|
|
918
|
+
return "unknown"
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _client_http_reason(exc: error.HTTPError) -> str:
|
|
922
|
+
if exc.code in {HTTPStatus.UNAUTHORIZED.value, HTTPStatus.FORBIDDEN.value}:
|
|
923
|
+
return "channel sidecar authentication failed"
|
|
924
|
+
if exc.code == HTTPStatus.NOT_FOUND.value:
|
|
925
|
+
return "channel session not found"
|
|
926
|
+
return f"channel sidecar http error: {exc.code}"
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _permission_request_payload(session_id: str, payload: dict[str, Any], *, event_id: str | None = None) -> PermissionRequestEnvelope:
|
|
930
|
+
if not session_id:
|
|
931
|
+
raise ValueError("session_id is required")
|
|
932
|
+
forbidden = FORBIDDEN_PERMISSION_FIELDS.intersection({str(key).lower() for key in payload})
|
|
933
|
+
if forbidden:
|
|
934
|
+
raise ValueError(f"permission request contains raw or secret-like fields: {', '.join(sorted(forbidden))}")
|
|
935
|
+
allowed = {"tool_name", "description", "input_preview", "risk_class"}
|
|
936
|
+
extra = set(payload) - allowed
|
|
937
|
+
if extra:
|
|
938
|
+
raise ValueError(f"permission request contains unsupported fields: {', '.join(sorted(extra))}")
|
|
939
|
+
tool_name = payload.get("tool_name")
|
|
940
|
+
description = payload.get("description")
|
|
941
|
+
input_preview = payload.get("input_preview")
|
|
942
|
+
risk_class = payload.get("risk_class")
|
|
943
|
+
for field_name, value in (
|
|
944
|
+
("tool_name", tool_name),
|
|
945
|
+
("description", description),
|
|
946
|
+
("input_preview", input_preview),
|
|
947
|
+
("risk_class", risk_class),
|
|
948
|
+
):
|
|
949
|
+
if not isinstance(value, str) or not value:
|
|
950
|
+
raise ValueError(f"{field_name} is required")
|
|
951
|
+
_raise_if_secret_like_value(value, field_name)
|
|
952
|
+
request_id = str(uuid.uuid4())
|
|
953
|
+
return PermissionRequestEnvelope(
|
|
954
|
+
request_id=request_id,
|
|
955
|
+
session_id=session_id,
|
|
956
|
+
event_id=event_id,
|
|
957
|
+
tool_name=tool_name,
|
|
958
|
+
description=description,
|
|
959
|
+
input_preview=input_preview,
|
|
960
|
+
risk_class=risk_class,
|
|
961
|
+
audit_ref=f"claude_permission:{session_id}:{request_id}",
|
|
962
|
+
requested_at=_utc_now(),
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _permission_verdict_payload(payload: dict[str, Any]) -> PermissionVerdictPayload:
|
|
967
|
+
request_id = payload.get("request_id")
|
|
968
|
+
verdict = payload.get("verdict")
|
|
969
|
+
actor = payload.get("actor")
|
|
970
|
+
reason = payload.get("reason")
|
|
971
|
+
if not isinstance(request_id, str) or not request_id:
|
|
972
|
+
raise ValueError("request_id is required")
|
|
973
|
+
if verdict not in PERMISSION_VERDICTS:
|
|
974
|
+
raise ValueError("verdict must be one of: " + ", ".join(sorted(PERMISSION_VERDICTS)))
|
|
975
|
+
if not isinstance(actor, str) or not actor:
|
|
976
|
+
raise ValueError("actor is required")
|
|
977
|
+
if not isinstance(reason, str) or not reason:
|
|
978
|
+
raise ValueError("reason is required")
|
|
979
|
+
return PermissionVerdictPayload(
|
|
980
|
+
request_id=request_id,
|
|
981
|
+
verdict=verdict,
|
|
982
|
+
actor=actor,
|
|
983
|
+
reason=reason,
|
|
984
|
+
decided_at=_utc_now(),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _require_literal(value: str, allowed: set[str], label: str) -> None:
|
|
989
|
+
if value not in allowed:
|
|
990
|
+
raise ValueError(f"{label} must be one of: " + ", ".join(sorted(allowed)))
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _required_string(payload: dict[str, Any], field_name: str) -> str:
|
|
994
|
+
value = payload.get(field_name)
|
|
995
|
+
if not isinstance(value, str) or not value:
|
|
996
|
+
raise ValueError(f"{field_name} is required")
|
|
997
|
+
return value
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _metadata_only_text(value: str, field_name: str) -> str:
|
|
1001
|
+
if not isinstance(value, str):
|
|
1002
|
+
raise ValueError(f"{field_name} must be a string")
|
|
1003
|
+
_raise_if_secret_like_value(value, field_name)
|
|
1004
|
+
return value
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _metadata_only_warnings(warnings: tuple[str, ...] | list[str]) -> list[str]:
|
|
1008
|
+
if not isinstance(warnings, (tuple, list)):
|
|
1009
|
+
raise ValueError("warnings must be a list")
|
|
1010
|
+
clean: list[str] = []
|
|
1011
|
+
for warning in warnings:
|
|
1012
|
+
if not isinstance(warning, str):
|
|
1013
|
+
raise ValueError("warnings must contain strings")
|
|
1014
|
+
_raise_if_secret_like_value(warning, "warnings")
|
|
1015
|
+
clean.append(warning)
|
|
1016
|
+
return clean
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _metadata_only_route_objects(items: tuple[dict[str, Any], ...] | list[dict[str, Any]], field_name: str) -> list[dict[str, Any]]:
|
|
1020
|
+
if not isinstance(items, (tuple, list)):
|
|
1021
|
+
raise ValueError(f"{field_name} must be a list")
|
|
1022
|
+
clean: list[dict[str, Any]] = []
|
|
1023
|
+
for item in items:
|
|
1024
|
+
if not isinstance(item, dict):
|
|
1025
|
+
raise ValueError(f"{field_name} must contain metadata objects")
|
|
1026
|
+
clean.append(_metadata_summary(item))
|
|
1027
|
+
return clean
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _metadata_summary(value: Any) -> dict[str, Any]:
|
|
1031
|
+
if not isinstance(value, dict):
|
|
1032
|
+
raise ValueError("metadata summaries must be objects")
|
|
1033
|
+
forbidden = FORBIDDEN_ATTACHMENT_FIELDS.union(FORBIDDEN_PERMISSION_FIELDS)
|
|
1034
|
+
clean: dict[str, Any] = {}
|
|
1035
|
+
for key, item in value.items():
|
|
1036
|
+
normalized = str(key).lower()
|
|
1037
|
+
if normalized in forbidden:
|
|
1038
|
+
raise ValueError(f"metadata summary contains secret-like field: {key}")
|
|
1039
|
+
if isinstance(item, (str, int, float, bool)) or item is None:
|
|
1040
|
+
if isinstance(item, str):
|
|
1041
|
+
_raise_if_secret_like_value(item, str(key))
|
|
1042
|
+
clean[str(key)] = item
|
|
1043
|
+
elif isinstance(item, list):
|
|
1044
|
+
clean[str(key)] = [
|
|
1045
|
+
_metadata_list_value(entry, str(key))
|
|
1046
|
+
for entry in item
|
|
1047
|
+
if isinstance(entry, (str, int, float, bool)) or entry is None
|
|
1048
|
+
]
|
|
1049
|
+
else:
|
|
1050
|
+
rendered = str(item)
|
|
1051
|
+
_raise_if_secret_like_value(rendered, str(key))
|
|
1052
|
+
clean[str(key)] = rendered
|
|
1053
|
+
return clean
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _metadata_list_value(value: str | int | float | bool | None, field_name: str) -> str | int | float | bool | None:
|
|
1057
|
+
if isinstance(value, str):
|
|
1058
|
+
_raise_if_secret_like_value(value, field_name)
|
|
1059
|
+
return value
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _raise_if_secret_like_value(value: str, field_name: str) -> None:
|
|
1063
|
+
normalized = value.lower()
|
|
1064
|
+
for marker in FORBIDDEN_ROUTE_RESULT_VALUE_MARKERS:
|
|
1065
|
+
if marker in normalized:
|
|
1066
|
+
raise ValueError(f"{field_name} contains secret-like or raw metadata")
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _parse_utc(value: str) -> datetime:
|
|
1070
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def process_exists(pid: int) -> bool:
|
|
1074
|
+
try:
|
|
1075
|
+
os.kill(pid, 0)
|
|
1076
|
+
except OSError:
|
|
1077
|
+
return False
|
|
1078
|
+
return True
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _utc_now() -> str:
|
|
1082
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
if __name__ == "__main__":
|
|
1086
|
+
raise SystemExit(main())
|