engine.py python
1,268 lines 42.7 KB
Raw
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