engine.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago
| 1 | """Governance Hygiene Agent orchestration (§9A-5, §GSW write-path order, §GSB reconcile).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import re |
| 7 | from dataclasses import dataclass, replace |
| 8 | from datetime import date, datetime, timezone |
| 9 | from pathlib import Path |
| 10 | |
| 11 | from adapters.base import VcsAdapter |
| 12 | from adapters.config import OverseerConfig |
| 13 | from adapters.errors import ReadError, WriteError |
| 14 | from adapters.runner import CommandRunner, quote_arg |
| 15 | from cli.atomic import WriteFailure, atomic_write_text |
| 16 | from tools.governance_hygiene.drift import detect_drift |
| 17 | from tools.governance_hygiene.patch import ( |
| 18 | build_handover_patches, |
| 19 | build_roadmap_patches, |
| 20 | extract_paste_ready_block, |
| 21 | ) |
| 22 | from tools.governance_hygiene.reads import ReadFailure, perform_verified_reads |
| 23 | from tools.governance_hygiene.realign import execute_realign_guard, plan_realign |
| 24 | from tools.governance_gates import scan_governance_gates |
| 25 | from tools.governance_gates.format import format_pending_gate_lines |
| 26 | from tools.cost_awareness.format import format_cost_awareness_lines |
| 27 | from tools.cost_awareness.surface import build_cost_awareness_report |
| 28 | from tools.verification_evidence_gate import ( |
| 29 | build_verification_evidence_gate, |
| 30 | format_verification_evidence_gate_line, |
| 31 | ) |
| 32 | from tools.independent_second_reviewer import ( |
| 33 | build_independent_second_reviewer_gate, |
| 34 | format_independent_second_reviewer_gate_line, |
| 35 | ) |
| 36 | from tools.governance_hygiene.types import DriftReport, GovernanceSyncResult, PatchPlan, VerifiedReads |
| 37 | from tools.workspace import workspace_relay_footer_state |
| 38 | |
| 39 | GOVERNANCE_SYNC_MARKER = "last_governance_sync" |
| 40 | |
| 41 | |
| 42 | def _emit_governance_gate_footer( |
| 43 | config: OverseerConfig, |
| 44 | repo_root: Path, |
| 45 | *, |
| 46 | handover_text: str, |
| 47 | roadmap_text: str, |
| 48 | emit, |
| 49 | kit_root: Path | None = None, |
| 50 | ) -> tuple[int | None, str]: |
| 51 | """Append §KH1.9 gate reminders, §PC.7 spend-awareness, and §MR.8 workspace_relay. |
| 52 | |
| 53 | Returns ``(exit_code_or_None, workspace_relay_state)``. |
| 54 | """ |
| 55 | if config.governance_gates.remind: |
| 56 | if "governance-sync" in config.governance_gates.surfaces: |
| 57 | result = scan_governance_gates( |
| 58 | config, |
| 59 | repo_root, |
| 60 | handover_text=handover_text, |
| 61 | roadmap_text=roadmap_text, |
| 62 | ) |
| 63 | emit("") |
| 64 | for line in format_pending_gate_lines(result): |
| 65 | emit(line) |
| 66 | |
| 67 | if ( |
| 68 | config.cost_awareness.enabled |
| 69 | and "governance-sync" in config.cost_awareness.surfaces |
| 70 | and kit_root is not None |
| 71 | ): |
| 72 | cost_report = build_cost_awareness_report( |
| 73 | config, |
| 74 | repo_root, |
| 75 | kit_root=kit_root, |
| 76 | handover_text=handover_text, |
| 77 | roadmap_text=roadmap_text, |
| 78 | ) |
| 79 | if cost_report.exit_code == 31: |
| 80 | emit("") |
| 81 | emit(cost_report.violation or "routing policy file missing or unreadable") |
| 82 | relay_state = workspace_relay_footer_state(config, repo_root) |
| 83 | emit("") |
| 84 | emit(f"workspace_relay: {relay_state}") |
| 85 | return 31, relay_state |
| 86 | emit("") |
| 87 | for line in format_cost_awareness_lines(cost_report): |
| 88 | emit(line) |
| 89 | |
| 90 | verification_gate = build_verification_evidence_gate( |
| 91 | config, |
| 92 | repo_root, |
| 93 | handover_text=handover_text, |
| 94 | roadmap_text=roadmap_text, |
| 95 | ) |
| 96 | ve_line = format_verification_evidence_gate_line(verification_gate) |
| 97 | if ve_line: |
| 98 | emit("") |
| 99 | emit(ve_line) |
| 100 | if ( |
| 101 | not verification_gate.ok |
| 102 | and verification_gate.mode == "require" |
| 103 | and verification_gate.token == "missing_verification_evidence" |
| 104 | ): |
| 105 | relay_state = workspace_relay_footer_state(config, repo_root) |
| 106 | emit("") |
| 107 | emit(f"workspace_relay: {relay_state}") |
| 108 | return 2, relay_state |
| 109 | |
| 110 | isr_gate = build_independent_second_reviewer_gate( |
| 111 | config, |
| 112 | repo_root, |
| 113 | handover_text=handover_text, |
| 114 | roadmap_text=roadmap_text, |
| 115 | ) |
| 116 | isr_line = format_independent_second_reviewer_gate_line(isr_gate) |
| 117 | if isr_line: |
| 118 | emit("") |
| 119 | emit(isr_line) |
| 120 | if isr_gate.remediation: |
| 121 | emit(isr_gate.remediation) |
| 122 | if ( |
| 123 | not isr_gate.ok |
| 124 | and isr_gate.mode == "require" |
| 125 | and isr_gate.token == "missing_independent_second_review" |
| 126 | ): |
| 127 | relay_state = workspace_relay_footer_state(config, repo_root) |
| 128 | emit("") |
| 129 | emit(f"workspace_relay: {relay_state}") |
| 130 | return 2, relay_state |
| 131 | |
| 132 | relay_state = workspace_relay_footer_state(config, repo_root) |
| 133 | emit("") |
| 134 | emit(f"workspace_relay: {relay_state}") |
| 135 | if relay_state not in {"not_configured", "ok"}: |
| 136 | emit( |
| 137 | "multi-repo SD-17 incomplete until ok workspace check-next exits 0 " |
| 138 | "(refresh relay tips; no peer writes from this command)" |
| 139 | ) |
| 140 | return None, relay_state |
| 141 | |
| 142 | |
| 143 | def run_governance_sync( |
| 144 | config: OverseerConfig, |
| 145 | repo_root: Path, |
| 146 | adapter: VcsAdapter, |
| 147 | runner: CommandRunner, |
| 148 | *, |
| 149 | dry_run: bool = True, |
| 150 | lane: str | None = None, |
| 151 | all_lanes: bool = False, |
| 152 | emit, |
| 153 | kit_root: Path | None = None, |
| 154 | ) -> GovernanceSyncResult: |
| 155 | """Execute governance-sync; default dry-run is inert (§7).""" |
| 156 | if all_lanes and lane is not None: |
| 157 | return GovernanceSyncResult( |
| 158 | exit_code=2, |
| 159 | dry_run=dry_run, |
| 160 | reads=None, |
| 161 | drift=None, |
| 162 | plan=None, |
| 163 | committed=False, |
| 164 | commit_sha=None, |
| 165 | messages=("cannot use --lane with --all-lanes",), |
| 166 | ) |
| 167 | |
| 168 | if all_lanes: |
| 169 | return _run_all_lanes( |
| 170 | config, |
| 171 | repo_root, |
| 172 | adapter, |
| 173 | runner, |
| 174 | dry_run=dry_run, |
| 175 | emit=emit, |
| 176 | kit_root=kit_root, |
| 177 | ) |
| 178 | |
| 179 | lane_name = lane |
| 180 | if config.docs.lanes is not None and lane is None: |
| 181 | lane_name = config.docs.default_lane |
| 182 | return _run_single_lane( |
| 183 | config, |
| 184 | repo_root, |
| 185 | adapter, |
| 186 | runner, |
| 187 | lane=lane_name, |
| 188 | dry_run=dry_run, |
| 189 | skip_missing=False, |
| 190 | emit=emit, |
| 191 | kit_root=kit_root, |
| 192 | ) |
| 193 | |
| 194 | |
| 195 | def _run_all_lanes( |
| 196 | config: OverseerConfig, |
| 197 | repo_root: Path, |
| 198 | adapter: VcsAdapter, |
| 199 | runner: CommandRunner, |
| 200 | *, |
| 201 | dry_run: bool, |
| 202 | emit, |
| 203 | kit_root: Path | None = None, |
| 204 | ) -> GovernanceSyncResult: |
| 205 | """Sync every configured lane; skip lanes with missing doc files (§K8).""" |
| 206 | if config.docs.lanes is None: |
| 207 | lane_names: tuple[str | None, ...] = (None,) |
| 208 | else: |
| 209 | lane_names = tuple(sorted(config.docs.lanes.keys())) |
| 210 | |
| 211 | last_result: GovernanceSyncResult | None = None |
| 212 | for lane_name in lane_names: |
| 213 | name_label = lane_name if lane_name is not None else "default" |
| 214 | emit(f"lane: {name_label}") |
| 215 | result = _run_single_lane( |
| 216 | config, |
| 217 | repo_root, |
| 218 | adapter, |
| 219 | runner, |
| 220 | lane=lane_name, |
| 221 | dry_run=dry_run, |
| 222 | skip_missing=True, |
| 223 | emit=emit, |
| 224 | kit_root=kit_root, |
| 225 | ) |
| 226 | last_result = result |
| 227 | if result.exit_code == 4: |
| 228 | emit(f"skipping lane {name_label}: missing governance doc(s)") |
| 229 | continue |
| 230 | if result.exit_code != 0: |
| 231 | return result |
| 232 | if last_result is None: |
| 233 | return GovernanceSyncResult( |
| 234 | exit_code=0, |
| 235 | dry_run=dry_run, |
| 236 | reads=None, |
| 237 | drift=None, |
| 238 | plan=None, |
| 239 | committed=False, |
| 240 | commit_sha=None, |
| 241 | messages=("no lanes configured",), |
| 242 | ) |
| 243 | return last_result |
| 244 | |
| 245 | |
| 246 | def _run_single_lane( |
| 247 | config: OverseerConfig, |
| 248 | repo_root: Path, |
| 249 | adapter: VcsAdapter, |
| 250 | runner: CommandRunner, |
| 251 | *, |
| 252 | lane: str | None, |
| 253 | dry_run: bool, |
| 254 | skip_missing: bool, |
| 255 | emit, |
| 256 | kit_root: Path | None = None, |
| 257 | ) -> GovernanceSyncResult: |
| 258 | """Sync one handover + roadmap pair.""" |
| 259 | from adapters.config import resolve_lane_docs |
| 260 | from cli.docs_paths import lane_living_doc_abs |
| 261 | |
| 262 | try: |
| 263 | lane_docs = resolve_lane_docs(config, lane) |
| 264 | except Exception as exc: |
| 265 | from adapters.errors import ConfigError |
| 266 | |
| 267 | if isinstance(exc, ConfigError): |
| 268 | return GovernanceSyncResult( |
| 269 | exit_code=2, |
| 270 | dry_run=dry_run, |
| 271 | reads=None, |
| 272 | drift=None, |
| 273 | plan=None, |
| 274 | committed=False, |
| 275 | commit_sha=None, |
| 276 | messages=(str(exc),), |
| 277 | ) |
| 278 | raise |
| 279 | |
| 280 | handover_path = lane_living_doc_abs(repo_root, config, lane_docs, lane_docs.handover) |
| 281 | roadmap_path = lane_living_doc_abs(repo_root, config, lane_docs, lane_docs.roadmap) |
| 282 | |
| 283 | for path in (handover_path, roadmap_path): |
| 284 | if not path.is_file(): |
| 285 | if skip_missing: |
| 286 | return GovernanceSyncResult( |
| 287 | exit_code=4, |
| 288 | dry_run=dry_run, |
| 289 | reads=None, |
| 290 | drift=None, |
| 291 | plan=None, |
| 292 | committed=False, |
| 293 | commit_sha=None, |
| 294 | messages=(f"missing governance doc: {path.name}",), |
| 295 | ) |
| 296 | return GovernanceSyncResult( |
| 297 | exit_code=4, |
| 298 | dry_run=dry_run, |
| 299 | reads=None, |
| 300 | drift=None, |
| 301 | plan=None, |
| 302 | committed=False, |
| 303 | commit_sha=None, |
| 304 | messages=(f"missing governance doc: {path.name}",), |
| 305 | ) |
| 306 | |
| 307 | reads = perform_verified_reads(config, adapter, runner, repo_root=repo_root) |
| 308 | if isinstance(reads, ReadFailure): |
| 309 | emit(f"read failed [{reads.regime}]: {reads.command}") |
| 310 | emit(reads.message) |
| 311 | return GovernanceSyncResult( |
| 312 | exit_code=2, |
| 313 | dry_run=dry_run, |
| 314 | reads=None, |
| 315 | drift=None, |
| 316 | plan=None, |
| 317 | committed=False, |
| 318 | commit_sha=None, |
| 319 | messages=(reads.message,), |
| 320 | error_command=reads.command, |
| 321 | ) |
| 322 | |
| 323 | handover_text = handover_path.read_text(encoding="utf-8") |
| 324 | roadmap_text = roadmap_path.read_text(encoding="utf-8") |
| 325 | drift = detect_drift(reads, handover_text, roadmap_text) |
| 326 | |
| 327 | if drift.any_unreadable: |
| 328 | emit("drift detection unreadable — fail-closed") |
| 329 | return GovernanceSyncResult( |
| 330 | exit_code=2, |
| 331 | dry_run=dry_run, |
| 332 | reads=reads, |
| 333 | drift=drift, |
| 334 | plan=None, |
| 335 | committed=False, |
| 336 | commit_sha=None, |
| 337 | messages=("unreadable drift field",), |
| 338 | ) |
| 339 | |
| 340 | if drift.fully_aligned: |
| 341 | emit("governance-sync: aligned (D1–D3)") |
| 342 | _write_sync_marker(repo_root, reads) |
| 343 | footer_code, workspace_relay = _emit_governance_gate_footer( |
| 344 | config, |
| 345 | repo_root, |
| 346 | handover_text=handover_text, |
| 347 | roadmap_text=roadmap_text, |
| 348 | emit=emit, |
| 349 | kit_root=kit_root, |
| 350 | ) |
| 351 | return GovernanceSyncResult( |
| 352 | exit_code=footer_code or 0, |
| 353 | dry_run=dry_run, |
| 354 | reads=reads, |
| 355 | drift=drift, |
| 356 | plan=None, |
| 357 | committed=False, |
| 358 | commit_sha=None, |
| 359 | messages=("aligned",), |
| 360 | workspace_relay=workspace_relay, |
| 361 | ) |
| 362 | |
| 363 | realign_planned, _ = plan_realign(config, adapter, reads, drift) |
| 364 | realign_summary, realign_error = execute_realign_guard( |
| 365 | config, |
| 366 | adapter, |
| 367 | reads, |
| 368 | drift, |
| 369 | dry_run=True, |
| 370 | ) |
| 371 | if realign_error: |
| 372 | emit(f"realign dry-run failed: {realign_error}") |
| 373 | return GovernanceSyncResult( |
| 374 | exit_code=2, |
| 375 | dry_run=dry_run, |
| 376 | reads=reads, |
| 377 | drift=drift, |
| 378 | plan=None, |
| 379 | committed=False, |
| 380 | commit_sha=None, |
| 381 | messages=(realign_error,), |
| 382 | error_command=realign_error, |
| 383 | ) |
| 384 | |
| 385 | # §GSP.6.1: roadmap patches first so handover NEXT regen sees D3-reconciled queue. |
| 386 | patched_roadmap, roadmap_sections = build_roadmap_patches( |
| 387 | roadmap_text, |
| 388 | reads, |
| 389 | drift, |
| 390 | ) |
| 391 | patched_handover, handover_sections, next_regen_token = build_handover_patches( |
| 392 | handover_text, |
| 393 | reads, |
| 394 | drift, |
| 395 | realign_summary=realign_summary, |
| 396 | config=config, |
| 397 | roadmap_text=patched_roadmap, |
| 398 | repo_root=repo_root, |
| 399 | ) |
| 400 | sections = handover_sections + roadmap_sections |
| 401 | |
| 402 | feature_branch = _feature_branch_name(config) |
| 403 | main_sha = reads.r1_github_main_sha or reads.r3_canonical_main_sha or reads.r2_anchor_sha |
| 404 | commit_message = _commit_message(main_sha, drift, sections, realign_summary) |
| 405 | pr_url = _build_pr_url(runner, repo_root, config, feature_branch) |
| 406 | |
| 407 | plan = PatchPlan( |
| 408 | handover_text=patched_handover, |
| 409 | roadmap_text=patched_roadmap, |
| 410 | patched_sections=sections, |
| 411 | realign_planned=realign_planned, |
| 412 | realign_reason=realign_summary, |
| 413 | feature_branch=feature_branch, |
| 414 | commit_message=commit_message, |
| 415 | pr_url=pr_url, |
| 416 | ) |
| 417 | |
| 418 | emit(f"drift: D1={drift.d1_handover_vs_git} D2={drift.d2_anchor_vs_canonical} D3={drift.d3_queue_vs_merged}") |
| 419 | emit(f"planned sections: {', '.join(sections)}") |
| 420 | emit(next_regen_token) |
| 421 | if realign_summary: |
| 422 | emit(realign_summary) |
| 423 | if dry_run and "land-b" in next_regen_token: |
| 424 | # §PMHF.3.4 rule 4: dry-run shows the planned land-b body. |
| 425 | land_b_block = extract_paste_ready_block(patched_handover) |
| 426 | if land_b_block: |
| 427 | emit(land_b_block) |
| 428 | if dry_run: |
| 429 | d1_d2_aligned = ( |
| 430 | drift.d1_handover_vs_git == "aligned" |
| 431 | and drift.d2_anchor_vs_canonical == "aligned" |
| 432 | ) |
| 433 | if d1_d2_aligned: |
| 434 | _write_sync_marker(repo_root, reads) |
| 435 | emit( |
| 436 | "dry-run: no governance-doc writes, commits, or realign apply " |
| 437 | "(may stamp local .overseer/last_governance_sync when D1/D2 aligned)" |
| 438 | ) |
| 439 | else: |
| 440 | emit("dry-run: no writes, commits, or realign apply") |
| 441 | if pr_url: |
| 442 | emit(f"docs-only PR URL (operator-gated): {pr_url}") |
| 443 | footer_code, workspace_relay = _emit_governance_gate_footer( |
| 444 | config, |
| 445 | repo_root, |
| 446 | handover_text=handover_text, |
| 447 | roadmap_text=roadmap_text, |
| 448 | emit=emit, |
| 449 | kit_root=kit_root, |
| 450 | ) |
| 451 | return GovernanceSyncResult( |
| 452 | exit_code=footer_code or 0, |
| 453 | dry_run=True, |
| 454 | reads=reads, |
| 455 | drift=drift, |
| 456 | plan=plan, |
| 457 | committed=False, |
| 458 | commit_sha=None, |
| 459 | messages=("dry-run plan",), |
| 460 | workspace_relay=workspace_relay, |
| 461 | ) |
| 462 | |
| 463 | return _apply_plan( |
| 464 | config=config, |
| 465 | repo_root=repo_root, |
| 466 | adapter=adapter, |
| 467 | runner=runner, |
| 468 | reads=reads, |
| 469 | drift=drift, |
| 470 | plan=plan, |
| 471 | handover_path=handover_path, |
| 472 | roadmap_path=roadmap_path, |
| 473 | emit=emit, |
| 474 | kit_root=kit_root, |
| 475 | handover_text=handover_text, |
| 476 | roadmap_text=roadmap_text, |
| 477 | ) |
| 478 | |
| 479 | |
| 480 | def _apply_plan( |
| 481 | *, |
| 482 | config: OverseerConfig, |
| 483 | repo_root: Path, |
| 484 | adapter: VcsAdapter, |
| 485 | runner: CommandRunner, |
| 486 | reads: VerifiedReads, |
| 487 | drift: DriftReport, |
| 488 | plan: PatchPlan, |
| 489 | handover_path: Path, |
| 490 | roadmap_path: Path, |
| 491 | emit, |
| 492 | kit_root: Path | None = None, |
| 493 | handover_text: str = "", |
| 494 | roadmap_text: str = "", |
| 495 | ) -> GovernanceSyncResult: |
| 496 | """Apply patches, commit, and push on a feature branch. |
| 497 | |
| 498 | Frozen §GSW.3.1 order: capture original branch state → realign on the |
| 499 | original branch → ensure feature branch (dual-HEAD under |
| 500 | ``muse+git-mirror``, §GSW.5.1) → write doc patches → commit → sync |
| 501 | marker (only after successful commit, §GSW.3.4) → push. Any failure |
| 502 | after the feature-branch switch restores docs + marker + original |
| 503 | branch (§GSW.4.2). |
| 504 | |
| 505 | §GSB amends step C only: a C0 reconcile of the dated sync-branch name |
| 506 | (fast-forward ancestor tips / deterministic ``-N`` uniquify) runs before |
| 507 | the C1 dual-HEAD ensure, so a same-day second ``--write`` never checks |
| 508 | out a stale tip into the shared working tree (§GSB.3.1–§GSB.3.4). |
| 509 | """ |
| 510 | original_handover = handover_path.read_text(encoding="utf-8") |
| 511 | original_roadmap = roadmap_path.read_text(encoding="utf-8") |
| 512 | marker_path = repo_root / ".overseer" / GOVERNANCE_SYNC_MARKER |
| 513 | prior_marker = ( |
| 514 | marker_path.read_text(encoding="utf-8") if marker_path.is_file() else None |
| 515 | ) |
| 516 | |
| 517 | def _failure(exit_code: int, message: str, error_command: str | None) -> GovernanceSyncResult: |
| 518 | return GovernanceSyncResult( |
| 519 | exit_code=exit_code, |
| 520 | dry_run=False, |
| 521 | reads=reads, |
| 522 | drift=drift, |
| 523 | plan=plan, |
| 524 | committed=False, |
| 525 | commit_sha=None, |
| 526 | messages=(message,), |
| 527 | error_command=error_command, |
| 528 | ) |
| 529 | |
| 530 | # Step A (§GSW.4.1): capture branch identity before any mutation. |
| 531 | branch_state, capture_command = _capture_branch_state(config, adapter, runner, repo_root) |
| 532 | if branch_state is None: |
| 533 | emit(f"branch capture failed: {capture_command}") |
| 534 | return _failure(2, f"branch capture failed: {capture_command}", capture_command) |
| 535 | |
| 536 | # Step B (§GSW.3.1): realign guard on the original branch — before any |
| 537 | # feature-branch switch and before any doc write. |
| 538 | realign_summary, realign_error = execute_realign_guard( |
| 539 | config, |
| 540 | adapter, |
| 541 | reads, |
| 542 | drift, |
| 543 | dry_run=False, |
| 544 | ) |
| 545 | if realign_error: |
| 546 | emit(f"realign failed: {realign_error}") |
| 547 | return _failure(2, realign_error, realign_error) |
| 548 | |
| 549 | def _rollback(*, restore_docs: bool) -> None: |
| 550 | _rollback_apply( |
| 551 | config=config, |
| 552 | repo_root=repo_root, |
| 553 | adapter=adapter, |
| 554 | runner=runner, |
| 555 | handover_path=handover_path, |
| 556 | roadmap_path=roadmap_path, |
| 557 | original_handover=original_handover, |
| 558 | original_roadmap=original_roadmap, |
| 559 | marker_path=marker_path, |
| 560 | prior_marker=prior_marker, |
| 561 | branch_state=branch_state, |
| 562 | restore_docs=restore_docs, |
| 563 | emit=emit, |
| 564 | ) |
| 565 | |
| 566 | # Step C0 (§GSB.3.2–§GSB.3.3): reconcile the dated sync-branch name on |
| 567 | # every applicable history before any checkout of that name. Ancestor or |
| 568 | # equal tips → fast-forward without checkout-as-FF; any diverged history |
| 569 | # → deterministic -N uniquify of the shared name. |
| 570 | reconciled, reconcile_error = _reconcile_feature_branch( |
| 571 | adapter, runner, repo_root, config, plan.feature_branch, branch_state |
| 572 | ) |
| 573 | if reconcile_error is not None: |
| 574 | _rollback(restore_docs=False) |
| 575 | emit(reconcile_error) |
| 576 | return _failure(2, reconcile_error, reconcile_error) |
| 577 | if reconciled != plan.feature_branch: |
| 578 | # §GSB.3.3: PatchPlan is frozen — replace it so the success-path |
| 579 | # commit, push, and result plan all observe the reconciled branch |
| 580 | # and a pr_url rebuilt for that branch. |
| 581 | plan = replace( |
| 582 | plan, |
| 583 | feature_branch=reconciled, |
| 584 | pr_url=_build_pr_url(runner, repo_root, config, reconciled), |
| 585 | ) |
| 586 | emit(f"governance-sync branch uniquified: {reconciled}") |
| 587 | |
| 588 | # Step C1 (§GSW.5): feature branch must exist and hold current HEAD(s) |
| 589 | # before any handover/roadmap patch write. |
| 590 | checkout = _ensure_feature_branch( |
| 591 | adapter, runner, repo_root, config, plan.feature_branch, branch_state |
| 592 | ) |
| 593 | if checkout is not None: |
| 594 | _rollback(restore_docs=False) |
| 595 | emit(checkout) |
| 596 | return _failure(2, checkout, checkout) |
| 597 | |
| 598 | # Step D: write doc patch bytes (tree now dirty on the feature branch). |
| 599 | try: |
| 600 | atomic_write_text(handover_path, plan.handover_text) |
| 601 | atomic_write_text(roadmap_path, plan.roadmap_text) |
| 602 | except WriteFailure as exc: |
| 603 | _rollback(restore_docs=True) |
| 604 | emit(f"write failed: {exc}") |
| 605 | return _failure(5, str(exc), None) |
| 606 | |
| 607 | # Step E: commit dirty docs already on the feature branch (§GSW.6). |
| 608 | rel_handover = _repo_relative(repo_root, handover_path) |
| 609 | rel_roadmap = _repo_relative(repo_root, roadmap_path) |
| 610 | commit = adapter.commit_feature( |
| 611 | branch=plan.feature_branch, |
| 612 | message=plan.commit_message, |
| 613 | paths=[rel_handover, rel_roadmap], |
| 614 | ) |
| 615 | if isinstance(commit, (ReadError, WriteError)): |
| 616 | _rollback(restore_docs=True) |
| 617 | cmd = commit.command if hasattr(commit, "command") else "commit_feature" |
| 618 | emit(str(commit)) |
| 619 | return _failure(2, str(commit), cmd) |
| 620 | |
| 621 | # Step F (§GSW.3.4): stamp the sync marker only after commit success. |
| 622 | if ( |
| 623 | drift.d1_handover_vs_git == "aligned" |
| 624 | and drift.d2_anchor_vs_canonical == "aligned" |
| 625 | ): |
| 626 | _write_sync_marker(repo_root, reads) |
| 627 | |
| 628 | # Step G: feature-branch push (Tier 1, regime-appropriate). |
| 629 | push_error = _push_feature_branch(runner, repo_root, config, plan.feature_branch) |
| 630 | if push_error: |
| 631 | emit(push_error) |
| 632 | |
| 633 | if plan.pr_url: |
| 634 | emit(f"docs-only PR URL (operator-gated — do not auto-open): {plan.pr_url}") |
| 635 | |
| 636 | _, workspace_relay = _emit_governance_gate_footer( |
| 637 | config, |
| 638 | repo_root, |
| 639 | handover_text=handover_text or plan.handover_text, |
| 640 | roadmap_text=roadmap_text or plan.roadmap_text, |
| 641 | emit=emit, |
| 642 | kit_root=kit_root, |
| 643 | ) |
| 644 | return GovernanceSyncResult( |
| 645 | exit_code=0, |
| 646 | dry_run=False, |
| 647 | reads=reads, |
| 648 | drift=drift, |
| 649 | plan=plan, |
| 650 | committed=commit.committed, |
| 651 | commit_sha=commit.sha, |
| 652 | messages=("applied",), |
| 653 | workspace_relay=workspace_relay, |
| 654 | ) |
| 655 | |
| 656 | |
| 657 | def _feature_branch_name(config: OverseerConfig) -> str: |
| 658 | slug = f"governance-sync-{date.today().isoformat()}" |
| 659 | pattern = config.vcs.git.feature_branch_pattern |
| 660 | return pattern.replace("{slug}", slug) |
| 661 | |
| 662 | |
| 663 | def _commit_message( |
| 664 | main_sha: str, |
| 665 | drift: DriftReport, |
| 666 | sections: tuple[str, ...], |
| 667 | realign_summary: str | None, |
| 668 | ) -> str: |
| 669 | drift_tokens = ( |
| 670 | f"D1={drift.d1_handover_vs_git}," |
| 671 | f"D2={drift.d2_anchor_vs_canonical}," |
| 672 | f"D3={drift.d3_queue_vs_merged}" |
| 673 | ) |
| 674 | subject = f"chore(governance): sync handover+roadmap to {main_sha[:7]} (drift: {drift_tokens})" |
| 675 | body_lines = ["Patched sections:", *[f"- {name}" for name in sections]] |
| 676 | if realign_summary: |
| 677 | body_lines.append(f"Realign: {realign_summary}") |
| 678 | return subject + "\n\n" + "\n".join(body_lines) |
| 679 | |
| 680 | |
| 681 | def _build_pr_url( |
| 682 | runner: CommandRunner, |
| 683 | repo_root: Path, |
| 684 | config: OverseerConfig, |
| 685 | feature_branch: str, |
| 686 | ) -> str | None: |
| 687 | if config.vcs.regime == "muse-only": |
| 688 | return None |
| 689 | remote = config.vcs.git.remote |
| 690 | main = config.vcs.git.main_branch |
| 691 | cmd = f"git remote get-url {quote_arg(remote)}" |
| 692 | result = runner.run(cmd, cwd=str(repo_root)) |
| 693 | if not result.ok: |
| 694 | return f"https://github.com/<owner>/<repo>/compare/{main}...{feature_branch}?expand=1" |
| 695 | owner_repo = _parse_github_remote(result.stdout.strip()) |
| 696 | if not owner_repo: |
| 697 | return f"https://github.com/<owner>/<repo>/compare/{main}...{feature_branch}?expand=1" |
| 698 | owner, repo = owner_repo |
| 699 | return f"https://github.com/{owner}/{repo}/compare/{main}...{feature_branch}?expand=1" |
| 700 | |
| 701 | |
| 702 | def _parse_github_remote(url: str) -> tuple[str, str] | None: |
| 703 | ssh = re.match(r"git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$", url) |
| 704 | if ssh: |
| 705 | return ssh.group("owner"), ssh.group("repo") |
| 706 | https = re.match(r"https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$", url) |
| 707 | if https: |
| 708 | return https.group("owner"), https.group("repo") |
| 709 | return None |
| 710 | |
| 711 | |
| 712 | def _write_sync_marker(repo_root: Path, reads: VerifiedReads | None = None) -> None: |
| 713 | """Write enriched ``last_governance_sync`` marker (§GFG.5.2).""" |
| 714 | marker = repo_root / ".overseer" / GOVERNANCE_SYNC_MARKER |
| 715 | stamp = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
| 716 | r1 = "" |
| 717 | r3 = "" |
| 718 | if reads is not None: |
| 719 | r1 = reads.r1_github_main_sha or "" |
| 720 | r3 = reads.r3_canonical_main_sha or "" |
| 721 | body = f"{stamp}\nr1={r1}\nr3={r3}\n" |
| 722 | atomic_write_text(marker, body) |
| 723 | |
| 724 | |
| 725 | def _repo_relative(repo_root: Path, path: Path) -> str: |
| 726 | return path.resolve().relative_to(repo_root.resolve()).as_posix() |
| 727 | |
| 728 | |
| 729 | @dataclass(frozen=True) |
| 730 | class BranchState: |
| 731 | """Regime-specific ``original_branch_state`` captured at step A (§GSW.4.1).""" |
| 732 | |
| 733 | git_branch: str | None |
| 734 | muse_branch: str | None |
| 735 | |
| 736 | |
| 737 | def _muse_command_prefix(adapter: VcsAdapter, repo_root: Path) -> str: |
| 738 | muse_cwd = str(getattr(adapter, "muse_cwd", repo_root)) |
| 739 | return f"muse -C {quote_arg(muse_cwd)}" |
| 740 | |
| 741 | |
| 742 | def _capture_branch_state( |
| 743 | config: OverseerConfig, |
| 744 | adapter: VcsAdapter, |
| 745 | runner: CommandRunner, |
| 746 | repo_root: Path, |
| 747 | ) -> tuple[BranchState | None, str | None]: |
| 748 | """Capture current branch identity per regime; fail closed on unreadable HEAD. |
| 749 | |
| 750 | Returns ``(state, None)`` on success or ``(None, failing_command)``. |
| 751 | """ |
| 752 | root = str(repo_root) |
| 753 | regime = config.vcs.regime |
| 754 | git_branch: str | None = None |
| 755 | muse_branch: str | None = None |
| 756 | |
| 757 | if regime in {"git-only", "muse+git-mirror"}: |
| 758 | command = "git rev-parse --abbrev-ref HEAD" |
| 759 | result = runner.run(command, cwd=root) |
| 760 | if not result.ok or not result.stdout.strip(): |
| 761 | return None, command |
| 762 | git_branch = result.stdout.strip() |
| 763 | |
| 764 | if regime in {"muse-only", "muse+git-mirror"}: |
| 765 | command = f"{_muse_command_prefix(adapter, repo_root)} rev-parse --abbrev-ref HEAD" |
| 766 | result = runner.run(command, cwd=root) |
| 767 | if not result.ok or not result.stdout.strip(): |
| 768 | return None, command |
| 769 | muse_branch = result.stdout.strip() |
| 770 | |
| 771 | return BranchState(git_branch=git_branch, muse_branch=muse_branch), None |
| 772 | |
| 773 | |
| 774 | def _ensure_feature_branch( |
| 775 | adapter: VcsAdapter, |
| 776 | runner: CommandRunner, |
| 777 | repo_root: Path, |
| 778 | config: OverseerConfig, |
| 779 | branch: str, |
| 780 | state: BranchState, |
| 781 | ) -> str | None: |
| 782 | """Place current HEAD(s) on ``branch`` before any doc write (§GSW.5). |
| 783 | |
| 784 | Under ``muse+git-mirror`` both the Muse HEAD and the Git HEAD must be on |
| 785 | ``branch`` (§GSW.5.1 dual-HEAD rule). Returns the failing command, or |
| 786 | ``None`` on success. |
| 787 | """ |
| 788 | regime = config.vcs.regime |
| 789 | if regime in {"muse-only", "muse+git-mirror"}: |
| 790 | muse_error = _ensure_muse_branch(adapter, runner, repo_root, branch, state.muse_branch) |
| 791 | if muse_error is not None: |
| 792 | return muse_error |
| 793 | if regime in {"git-only", "muse+git-mirror"}: |
| 794 | git_error = _ensure_git_branch(runner, repo_root, branch, state.git_branch) |
| 795 | if git_error is not None: |
| 796 | return git_error |
| 797 | return None |
| 798 | |
| 799 | |
| 800 | def _ensure_muse_branch( |
| 801 | adapter: VcsAdapter, |
| 802 | runner: CommandRunner, |
| 803 | repo_root: Path, |
| 804 | branch: str, |
| 805 | current: str | None, |
| 806 | ) -> str | None: |
| 807 | """Create/switch the Muse HEAD to ``branch`` (§GSW.5.2). |
| 808 | |
| 809 | Switch to an existing branch retries with ``--autoshelf`` so uncommitted |
| 810 | tracked changes carry across the checkout (§GSW.6.2 allowed secondary |
| 811 | guard; bare-checkout-only is the live defect, ``--force`` is forbidden). |
| 812 | """ |
| 813 | if current == branch: |
| 814 | return None |
| 815 | root = str(repo_root) |
| 816 | prefix = _muse_command_prefix(adapter, repo_root) |
| 817 | create = runner.run(f"{prefix} checkout -b {quote_arg(branch)}", cwd=root) |
| 818 | if create.ok: |
| 819 | return None |
| 820 | switch_cmd = f"{prefix} checkout {quote_arg(branch)}" |
| 821 | if runner.run(switch_cmd, cwd=root).ok: |
| 822 | return None |
| 823 | carry_cmd = f"{prefix} checkout --autoshelf {quote_arg(branch)}" |
| 824 | if runner.run(carry_cmd, cwd=root).ok: |
| 825 | return None |
| 826 | return carry_cmd |
| 827 | |
| 828 | |
| 829 | def _ensure_git_branch( |
| 830 | runner: CommandRunner, |
| 831 | repo_root: Path, |
| 832 | branch: str, |
| 833 | current: str | None, |
| 834 | ) -> str | None: |
| 835 | """Create/switch the Git HEAD to ``branch`` (git carries dirty changes).""" |
| 836 | if current == branch: |
| 837 | return None |
| 838 | root = str(repo_root) |
| 839 | create = runner.run(f"git checkout -b {quote_arg(branch)}", cwd=root) |
| 840 | if create.ok: |
| 841 | return None |
| 842 | switch_cmd = f"git checkout {quote_arg(branch)}" |
| 843 | if runner.run(switch_cmd, cwd=root).ok: |
| 844 | return None |
| 845 | return switch_cmd |
| 846 | |
| 847 | |
| 848 | _UNIQUIFY_LIMIT = 100 |
| 849 | |
| 850 | |
| 851 | @dataclass(frozen=True) |
| 852 | class _BranchReconcileProbe: |
| 853 | """Per-history §GSB.3.2 classification of the candidate dated branch. |
| 854 | |
| 855 | ``tip`` is ``T_exist`` and ``target`` is ``T_target`` in that history's |
| 856 | own id space (Git SHAs vs Muse ``sha256:`` ids are never cross-compared). |
| 857 | """ |
| 858 | |
| 859 | exists: bool |
| 860 | tip: str | None = None |
| 861 | target: str | None = None |
| 862 | ancestor: bool = False |
| 863 | |
| 864 | |
| 865 | def _reconcile_feature_branch( |
| 866 | adapter: VcsAdapter, |
| 867 | runner: CommandRunner, |
| 868 | repo_root: Path, |
| 869 | config: OverseerConfig, |
| 870 | branch: str, |
| 871 | state: BranchState, |
| 872 | ) -> tuple[str, str | None]: |
| 873 | """C0 reconcile of the dated sync-branch name before ensure (§GSB.3). |
| 874 | |
| 875 | Classifies the existing branch tip against ``T_target`` on every |
| 876 | applicable history. All existing sides ancestor/equal → fast-forward |
| 877 | the tips without checking ``branch`` out; any diverged side → |
| 878 | deterministic ``-N`` uniquify of the shared name (§GSB.3.2 cross-history |
| 879 | rule). Returns ``(reconciled_branch, failing_command_or_None)``. |
| 880 | """ |
| 881 | regime = config.vcs.regime |
| 882 | muse_probe: _BranchReconcileProbe | None = None |
| 883 | git_probe: _BranchReconcileProbe | None = None |
| 884 | |
| 885 | if regime in {"muse-only", "muse+git-mirror"}: |
| 886 | muse_probe, error = _classify_muse_branch( |
| 887 | adapter, runner, repo_root, config, branch, state.muse_branch |
| 888 | ) |
| 889 | if error is not None: |
| 890 | return branch, error |
| 891 | if regime in {"git-only", "muse+git-mirror"}: |
| 892 | git_probe, error = _classify_git_branch( |
| 893 | runner, repo_root, config, branch, state.git_branch |
| 894 | ) |
| 895 | if error is not None: |
| 896 | return branch, error |
| 897 | |
| 898 | existing = [p for p in (muse_probe, git_probe) if p is not None and p.exists] |
| 899 | if not existing: |
| 900 | return branch, None |
| 901 | |
| 902 | # §GSB.3.2 cross-history rule: if either side diverged, uniquify the |
| 903 | # shared name — never FF one history and uniquify the other under the |
| 904 | # same name. |
| 905 | if any(not probe.ancestor for probe in existing): |
| 906 | return _uniquify_branch(adapter, runner, repo_root, config, branch) |
| 907 | |
| 908 | if muse_probe is not None and muse_probe.exists and muse_probe.tip != muse_probe.target: |
| 909 | error = _fast_forward_muse( |
| 910 | adapter, runner, repo_root, branch, muse_probe.target or "", state.muse_branch |
| 911 | ) |
| 912 | if error is not None: |
| 913 | return branch, error |
| 914 | if git_probe is not None and git_probe.exists and git_probe.tip != git_probe.target: |
| 915 | error = _fast_forward_git( |
| 916 | runner, repo_root, branch, git_probe.target or "", state.git_branch |
| 917 | ) |
| 918 | if error is not None: |
| 919 | return branch, error |
| 920 | return branch, None |
| 921 | |
| 922 | |
| 923 | def _classify_git_branch( |
| 924 | runner: CommandRunner, |
| 925 | repo_root: Path, |
| 926 | config: OverseerConfig, |
| 927 | branch: str, |
| 928 | original: str | None, |
| 929 | ) -> tuple[_BranchReconcileProbe | None, str | None]: |
| 930 | """Probe existence + §GSB.3.2 ancestor classification on the Git history.""" |
| 931 | root = str(repo_root) |
| 932 | probe_cmd = f"git rev-parse --verify {quote_arg('refs/heads/' + branch)}" |
| 933 | probe = runner.run(probe_cmd, cwd=root) |
| 934 | if not probe.ok: |
| 935 | return _BranchReconcileProbe(exists=False), None |
| 936 | t_exist = probe.stdout.strip() |
| 937 | if not t_exist: |
| 938 | return None, probe_cmd |
| 939 | |
| 940 | # §GSB.3.2.1 reconcile base ref: O_H when HEAD is elsewhere; configured |
| 941 | # main when HEAD already names the dated branch. |
| 942 | base_ref = original if original and original != branch else config.vcs.git.main_branch |
| 943 | target_cmd = f"git rev-parse {quote_arg(base_ref)}" |
| 944 | target = runner.run(target_cmd, cwd=root) |
| 945 | if not target.ok or not target.stdout.strip(): |
| 946 | return None, target_cmd |
| 947 | t_target = target.stdout.strip() |
| 948 | |
| 949 | if t_exist == t_target: |
| 950 | return _BranchReconcileProbe(True, t_exist, t_target, True), None |
| 951 | ancestor_cmd = ( |
| 952 | f"git merge-base --is-ancestor {quote_arg(t_exist)} {quote_arg(t_target)}" |
| 953 | ) |
| 954 | result = runner.run(ancestor_cmd, cwd=root) |
| 955 | if result.exit_code == 0: |
| 956 | return _BranchReconcileProbe(True, t_exist, t_target, True), None |
| 957 | if result.exit_code == 1: |
| 958 | return _BranchReconcileProbe(True, t_exist, t_target, False), None |
| 959 | return None, ancestor_cmd |
| 960 | |
| 961 | |
| 962 | def _classify_muse_branch( |
| 963 | adapter: VcsAdapter, |
| 964 | runner: CommandRunner, |
| 965 | repo_root: Path, |
| 966 | config: OverseerConfig, |
| 967 | branch: str, |
| 968 | original: str | None, |
| 969 | ) -> tuple[_BranchReconcileProbe | None, str | None]: |
| 970 | """Probe existence + §GSB.3.2 ancestor classification on the Muse history.""" |
| 971 | root = str(repo_root) |
| 972 | prefix = _muse_command_prefix(adapter, repo_root) |
| 973 | probe_cmd = f"{prefix} rev-parse {quote_arg(branch)}" |
| 974 | probe = runner.run(probe_cmd, cwd=root) |
| 975 | if not probe.ok: |
| 976 | return _BranchReconcileProbe(exists=False), None |
| 977 | t_exist = probe.stdout.strip() |
| 978 | if not t_exist: |
| 979 | return None, probe_cmd |
| 980 | |
| 981 | base_ref = ( |
| 982 | original |
| 983 | if original and original != branch |
| 984 | else (config.vcs.muse.main_branch or "") |
| 985 | ) |
| 986 | target_cmd = f"{prefix} rev-parse {quote_arg(base_ref)}" |
| 987 | if not base_ref: |
| 988 | return None, target_cmd |
| 989 | target = runner.run(target_cmd, cwd=root) |
| 990 | if not target.ok or not target.stdout.strip(): |
| 991 | return None, target_cmd |
| 992 | t_target = target.stdout.strip() |
| 993 | |
| 994 | if t_exist == t_target: |
| 995 | return _BranchReconcileProbe(True, t_exist, t_target, True), None |
| 996 | # Muse 0.2.x has no --is-ancestor flag; the LCA equals T_exist exactly |
| 997 | # when T_exist is an ancestor of T_target. |
| 998 | ancestor_cmd = ( |
| 999 | f"{prefix} merge-base --json {quote_arg(t_exist)} {quote_arg(t_target)}" |
| 1000 | ) |
| 1001 | result = runner.run(ancestor_cmd, cwd=root) |
| 1002 | if not result.ok: |
| 1003 | return None, ancestor_cmd |
| 1004 | try: |
| 1005 | payload = json.loads(result.stdout) |
| 1006 | except json.JSONDecodeError: |
| 1007 | return None, ancestor_cmd |
| 1008 | if not isinstance(payload, dict): |
| 1009 | return None, ancestor_cmd |
| 1010 | ancestor = payload.get("merge_base") == t_exist |
| 1011 | return _BranchReconcileProbe(True, t_exist, t_target, ancestor), None |
| 1012 | |
| 1013 | |
| 1014 | def _fast_forward_git( |
| 1015 | runner: CommandRunner, |
| 1016 | repo_root: Path, |
| 1017 | branch: str, |
| 1018 | target: str, |
| 1019 | original: str | None, |
| 1020 | ) -> str | None: |
| 1021 | """§GSB.3.3 Git fast-forward: ancestor-validated tip move, never checkout-as-FF. |
| 1022 | |
| 1023 | Returns the failing command (or a clobber-refusal message) or ``None``. |
| 1024 | """ |
| 1025 | root = str(repo_root) |
| 1026 | if original != branch: |
| 1027 | ff_cmd = f"git branch -f {quote_arg(branch)} {quote_arg(target)}" |
| 1028 | if not runner.run(ff_cmd, cwd=root).ok: |
| 1029 | return ff_cmd |
| 1030 | return None |
| 1031 | |
| 1032 | # O_H == B (§GSB.3.2.1): git branch -f refuses the checked-out branch, |
| 1033 | # so move the ref directly, then refresh the stale worktree without |
| 1034 | # checkout --force (§GSB.3.3 / §GSB.3.4). |
| 1035 | status_cmd = "git status --porcelain" |
| 1036 | pre = runner.run(status_cmd, cwd=root) |
| 1037 | if not pre.ok: |
| 1038 | return status_cmd |
| 1039 | pre_dirty = bool(pre.stdout.strip()) |
| 1040 | update_cmd = f"git update-ref {quote_arg('refs/heads/' + branch)} {quote_arg(target)}" |
| 1041 | if not runner.run(update_cmd, cwd=root).ok: |
| 1042 | return update_cmd |
| 1043 | post = runner.run(status_cmd, cwd=root) |
| 1044 | if not post.ok: |
| 1045 | return status_cmd |
| 1046 | if not post.stdout.strip(): |
| 1047 | return None |
| 1048 | if pre_dirty: |
| 1049 | # Uncommitted operator work is mixed into the stale tree — a refresh |
| 1050 | # would clobber it. Fail closed (§GSB.3.3). |
| 1051 | return f"worktree refresh after {update_cmd} would clobber uncommitted changes" |
| 1052 | # Tree was clean at T_exist, so the only difference is committed stale |
| 1053 | # content — a clean-tree-verified reset discards nothing uncommitted. |
| 1054 | reset_cmd = f"git reset --hard {quote_arg(target)}" |
| 1055 | if not runner.run(reset_cmd, cwd=root).ok: |
| 1056 | return reset_cmd |
| 1057 | return None |
| 1058 | |
| 1059 | |
| 1060 | def _fast_forward_muse( |
| 1061 | adapter: VcsAdapter, |
| 1062 | runner: CommandRunner, |
| 1063 | repo_root: Path, |
| 1064 | branch: str, |
| 1065 | target: str, |
| 1066 | original: str | None, |
| 1067 | ) -> str | None: |
| 1068 | """§GSB.3.3 Muse fast-forward via ``muse update-ref`` (never checkout). |
| 1069 | |
| 1070 | When Muse HEAD already names the dated branch, a tip-only move leaves |
| 1071 | the shared worktree stale; refresh uses a clean-tree-verified |
| 1072 | ``muse reset --hard`` (live Muse refuses it on tracked changes, so it |
| 1073 | can never clobber operator work) — ``--force`` stays forbidden. |
| 1074 | """ |
| 1075 | root = str(repo_root) |
| 1076 | prefix = _muse_command_prefix(adapter, repo_root) |
| 1077 | update_cmd = f"{prefix} update-ref {quote_arg(branch)} {quote_arg(target)}" |
| 1078 | if original != branch: |
| 1079 | if not runner.run(update_cmd, cwd=root).ok: |
| 1080 | return update_cmd |
| 1081 | return None |
| 1082 | |
| 1083 | pre_dirty, error = _muse_tracked_dirty(runner, repo_root, prefix) |
| 1084 | if error is not None: |
| 1085 | return error |
| 1086 | if not pre_dirty: |
| 1087 | # Worktree matches T_exist bytes exactly: reset --hard while still |
| 1088 | # clean refreshes worktree + tip together (discarding only committed |
| 1089 | # T_exist content); update-ref then locks the frozen tip form. |
| 1090 | reset_cmd = f"{prefix} reset {quote_arg(target)} --hard" |
| 1091 | if not runner.run(reset_cmd, cwd=root).ok: |
| 1092 | return reset_cmd |
| 1093 | if not runner.run(update_cmd, cwd=root).ok: |
| 1094 | return update_cmd |
| 1095 | return None |
| 1096 | |
| 1097 | # Tracked changes present relative to the stale tip: this is either the |
| 1098 | # live mirror shape (shared worktree already holds T_target bytes) or |
| 1099 | # uncommitted operator work. Move the tip, then verify the worktree |
| 1100 | # matches the new tip; anything else fails closed (§GSB.3.4). |
| 1101 | if not runner.run(update_cmd, cwd=root).ok: |
| 1102 | return update_cmd |
| 1103 | post_dirty, error = _muse_tracked_dirty(runner, repo_root, prefix) |
| 1104 | if error is not None: |
| 1105 | return error |
| 1106 | if post_dirty: |
| 1107 | return f"worktree refresh after {update_cmd} would clobber uncommitted changes" |
| 1108 | return None |
| 1109 | |
| 1110 | |
| 1111 | def _muse_tracked_dirty( |
| 1112 | runner: CommandRunner, |
| 1113 | repo_root: Path, |
| 1114 | prefix: str, |
| 1115 | ) -> tuple[bool | None, str | None]: |
| 1116 | """Tracked-change dirtiness via ``muse status --json`` (§GSB.3.4). |
| 1117 | |
| 1118 | Muse 0.2.x sets ``dirty`` for untracked files too; staleness/clobber |
| 1119 | checks must key on tracked changes (``total_changes``) only. |
| 1120 | """ |
| 1121 | status_cmd = f"{prefix} status --json" |
| 1122 | result = runner.run(status_cmd, cwd=str(repo_root)) |
| 1123 | if not result.ok: |
| 1124 | return None, status_cmd |
| 1125 | try: |
| 1126 | payload = json.loads(result.stdout) |
| 1127 | except json.JSONDecodeError: |
| 1128 | return None, status_cmd |
| 1129 | if not isinstance(payload, dict): |
| 1130 | return None, status_cmd |
| 1131 | total = payload.get("total_changes") |
| 1132 | if isinstance(total, int): |
| 1133 | return total > 0, None |
| 1134 | if "dirty" in payload: |
| 1135 | return bool(payload["dirty"]), None |
| 1136 | return None, status_cmd |
| 1137 | |
| 1138 | |
| 1139 | def _uniquify_branch( |
| 1140 | adapter: VcsAdapter, |
| 1141 | runner: CommandRunner, |
| 1142 | repo_root: Path, |
| 1143 | config: OverseerConfig, |
| 1144 | branch: str, |
| 1145 | ) -> tuple[str, str | None]: |
| 1146 | """§GSB.3.3 deterministic uniquify: lowest ``-N`` (N≥2) free everywhere. |
| 1147 | |
| 1148 | The diverged existing branch is never deleted, rewound, or force-checked |
| 1149 | out; the suffix contains no secrets, paths, or hostnames. |
| 1150 | """ |
| 1151 | regime = config.vcs.regime |
| 1152 | root = str(repo_root) |
| 1153 | prefix = ( |
| 1154 | _muse_command_prefix(adapter, repo_root) if regime != "git-only" else None |
| 1155 | ) |
| 1156 | for suffix in range(2, _UNIQUIFY_LIMIT): |
| 1157 | candidate = f"{branch}-{suffix}" |
| 1158 | taken = False |
| 1159 | if prefix is not None: |
| 1160 | taken = runner.run( |
| 1161 | f"{prefix} rev-parse {quote_arg(candidate)}", cwd=root |
| 1162 | ).ok |
| 1163 | if not taken and regime in {"git-only", "muse+git-mirror"}: |
| 1164 | taken = runner.run( |
| 1165 | f"git rev-parse --verify {quote_arg('refs/heads/' + candidate)}", |
| 1166 | cwd=root, |
| 1167 | ).ok |
| 1168 | if not taken: |
| 1169 | return candidate, None |
| 1170 | return branch, f"could not uniquify branch {branch!r} within -{_UNIQUIFY_LIMIT - 1}" |
| 1171 | |
| 1172 | |
| 1173 | def _rollback_apply( |
| 1174 | *, |
| 1175 | config: OverseerConfig, |
| 1176 | repo_root: Path, |
| 1177 | adapter: VcsAdapter, |
| 1178 | runner: CommandRunner, |
| 1179 | handover_path: Path, |
| 1180 | roadmap_path: Path, |
| 1181 | original_handover: str, |
| 1182 | original_roadmap: str, |
| 1183 | marker_path: Path, |
| 1184 | prior_marker: str | None, |
| 1185 | branch_state: BranchState, |
| 1186 | restore_docs: bool, |
| 1187 | emit, |
| 1188 | ) -> None: |
| 1189 | """Restore docs → marker → original branch on mid-apply failure (§GSW.4.2). |
| 1190 | |
| 1191 | Doc bytes are restored first so the tree is clean before the restore |
| 1192 | checkout (§GSW.4.3); branch restore is best-effort on both histories and |
| 1193 | never uses ``--force``. |
| 1194 | """ |
| 1195 | if restore_docs: |
| 1196 | atomic_write_text(handover_path, original_handover) |
| 1197 | atomic_write_text(roadmap_path, original_roadmap) |
| 1198 | |
| 1199 | _restore_marker(marker_path, prior_marker) |
| 1200 | |
| 1201 | for failed_command in _restore_branch_state( |
| 1202 | config, adapter, runner, repo_root, branch_state |
| 1203 | ): |
| 1204 | emit(f"branch restore failed: {failed_command}") |
| 1205 | |
| 1206 | |
| 1207 | def _restore_marker(marker_path: Path, prior_marker: str | None) -> None: |
| 1208 | """Leave no new stamp behind on mid-apply failure (§GSW.3.4).""" |
| 1209 | if prior_marker is None: |
| 1210 | if marker_path.is_file(): |
| 1211 | marker_path.unlink() |
| 1212 | return |
| 1213 | if not marker_path.is_file() or marker_path.read_text(encoding="utf-8") != prior_marker: |
| 1214 | atomic_write_text(marker_path, prior_marker) |
| 1215 | |
| 1216 | |
| 1217 | def _restore_branch_state( |
| 1218 | config: OverseerConfig, |
| 1219 | adapter: VcsAdapter, |
| 1220 | runner: CommandRunner, |
| 1221 | repo_root: Path, |
| 1222 | state: BranchState, |
| 1223 | ) -> tuple[str, ...]: |
| 1224 | """Best-effort restore of captured branch identity on both histories. |
| 1225 | |
| 1226 | Returns the failing commands (empty on success). If one side fails the |
| 1227 | other is still attempted (§GSW.4.2 dual restore). |
| 1228 | """ |
| 1229 | root = str(repo_root) |
| 1230 | regime = config.vcs.regime |
| 1231 | errors: list[str] = [] |
| 1232 | |
| 1233 | if regime in {"muse-only", "muse+git-mirror"} and state.muse_branch: |
| 1234 | prefix = _muse_command_prefix(adapter, repo_root) |
| 1235 | probe = runner.run(f"{prefix} rev-parse --abbrev-ref HEAD", cwd=root) |
| 1236 | current = probe.stdout.strip() if probe.ok else None |
| 1237 | if current != state.muse_branch: |
| 1238 | restore_cmd = f"{prefix} checkout {quote_arg(state.muse_branch)}" |
| 1239 | if not runner.run(restore_cmd, cwd=root).ok: |
| 1240 | carry_cmd = f"{prefix} checkout --autoshelf {quote_arg(state.muse_branch)}" |
| 1241 | if not runner.run(carry_cmd, cwd=root).ok: |
| 1242 | errors.append(carry_cmd) |
| 1243 | |
| 1244 | if regime in {"git-only", "muse+git-mirror"} and state.git_branch: |
| 1245 | probe = runner.run("git rev-parse --abbrev-ref HEAD", cwd=root) |
| 1246 | current = probe.stdout.strip() if probe.ok else None |
| 1247 | if current != state.git_branch: |
| 1248 | restore_cmd = f"git checkout {quote_arg(state.git_branch)}" |
| 1249 | if not runner.run(restore_cmd, cwd=root).ok: |
| 1250 | errors.append(restore_cmd) |
| 1251 | |
| 1252 | return tuple(errors) |
| 1253 | |
| 1254 | |
| 1255 | def _push_feature_branch( |
| 1256 | runner: CommandRunner, |
| 1257 | repo_root: Path, |
| 1258 | config: OverseerConfig, |
| 1259 | branch: str, |
| 1260 | ) -> str | None: |
| 1261 | if config.vcs.regime == "muse-only": |
| 1262 | return None |
| 1263 | remote = config.vcs.git.remote |
| 1264 | cmd = f"git push -u {quote_arg(remote)} {quote_arg(branch)}" |
| 1265 | result = runner.run(cmd, cwd=str(repo_root)) |
| 1266 | if not result.ok: |
| 1267 | return cmd |
| 1268 | return None |
File History
2 commits
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439
feat: K1-P1 complete — agent provenance, build-verification…
Sonnet 4.6
patch
52 days ago