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