engine.py python
584 lines 17.4 KB
Raw
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439 feat: K1-P1 complete — agent provenance, build-verification… Sonnet 4.6 patch 54 days ago
1 """Governance Hygiene Agent orchestration (§9A-5)."""
2
3 from __future__ import annotations
4
5 import re
6 from dataclasses import dataclass
7 from datetime import date, datetime, timezone
8 from pathlib import Path
9
10 from adapters.base import VcsAdapter
11 from adapters.config import OverseerConfig
12 from adapters.errors import ReadError, WriteError
13 from adapters.runner import CommandRunner, quote_arg
14 from cli.atomic import WriteFailure, atomic_write_text
15 from tools.governance_hygiene.drift import detect_drift
16 from tools.governance_hygiene.patch import build_handover_patches, build_roadmap_patches
17 from tools.governance_hygiene.reads import ReadFailure, perform_verified_reads
18 from tools.governance_hygiene.realign import execute_realign_guard, plan_realign
19 from tools.governance_gates import scan_governance_gates
20 from tools.governance_gates.format import format_pending_gate_lines
21 from tools.governance_hygiene.types import DriftReport, GovernanceSyncResult, PatchPlan, VerifiedReads
22
23 GOVERNANCE_SYNC_MARKER = "last_governance_sync"
24
25
26 def _emit_governance_gate_footer(
27 config: OverseerConfig,
28 repo_root: Path,
29 *,
30 handover_text: str,
31 roadmap_text: str,
32 emit,
33 ) -> None:
34 """Append §KH1.9 gate reminders when governance-sync surface is enabled."""
35 if not config.governance_gates.remind:
36 return
37 if "governance-sync" not in config.governance_gates.surfaces:
38 return
39 result = scan_governance_gates(
40 config,
41 repo_root,
42 handover_text=handover_text,
43 roadmap_text=roadmap_text,
44 )
45 emit("")
46 for line in format_pending_gate_lines(result):
47 emit(line)
48
49
50 def run_governance_sync(
51 config: OverseerConfig,
52 repo_root: Path,
53 adapter: VcsAdapter,
54 runner: CommandRunner,
55 *,
56 dry_run: bool = True,
57 lane: str | None = None,
58 all_lanes: bool = False,
59 emit,
60 ) -> GovernanceSyncResult:
61 """Execute governance-sync; default dry-run is inert (§7)."""
62 if all_lanes and lane is not None:
63 return GovernanceSyncResult(
64 exit_code=2,
65 dry_run=dry_run,
66 reads=None,
67 drift=None,
68 plan=None,
69 committed=False,
70 commit_sha=None,
71 messages=("cannot use --lane with --all-lanes",),
72 )
73
74 if all_lanes:
75 return _run_all_lanes(
76 config,
77 repo_root,
78 adapter,
79 runner,
80 dry_run=dry_run,
81 emit=emit,
82 )
83
84 lane_name = lane
85 if config.docs.lanes is not None and lane is None:
86 lane_name = config.docs.default_lane
87 return _run_single_lane(
88 config,
89 repo_root,
90 adapter,
91 runner,
92 lane=lane_name,
93 dry_run=dry_run,
94 skip_missing=False,
95 emit=emit,
96 )
97
98
99 def _run_all_lanes(
100 config: OverseerConfig,
101 repo_root: Path,
102 adapter: VcsAdapter,
103 runner: CommandRunner,
104 *,
105 dry_run: bool,
106 emit,
107 ) -> GovernanceSyncResult:
108 """Sync every configured lane; skip lanes with missing doc files (§K8)."""
109 if config.docs.lanes is None:
110 lane_names: tuple[str | None, ...] = (None,)
111 else:
112 lane_names = tuple(sorted(config.docs.lanes.keys()))
113
114 last_result: GovernanceSyncResult | None = None
115 for lane_name in lane_names:
116 name_label = lane_name if lane_name is not None else "default"
117 emit(f"lane: {name_label}")
118 result = _run_single_lane(
119 config,
120 repo_root,
121 adapter,
122 runner,
123 lane=lane_name,
124 dry_run=dry_run,
125 skip_missing=True,
126 emit=emit,
127 )
128 last_result = result
129 if result.exit_code == 4:
130 emit(f"skipping lane {name_label}: missing governance doc(s)")
131 continue
132 if result.exit_code != 0:
133 return result
134 if last_result is None:
135 return GovernanceSyncResult(
136 exit_code=0,
137 dry_run=dry_run,
138 reads=None,
139 drift=None,
140 plan=None,
141 committed=False,
142 commit_sha=None,
143 messages=("no lanes configured",),
144 )
145 return last_result
146
147
148 def _run_single_lane(
149 config: OverseerConfig,
150 repo_root: Path,
151 adapter: VcsAdapter,
152 runner: CommandRunner,
153 *,
154 lane: str | None,
155 dry_run: bool,
156 skip_missing: bool,
157 emit,
158 ) -> GovernanceSyncResult:
159 """Sync one handover + roadmap pair."""
160 from adapters.config import resolve_lane_docs
161 from cli.docs_paths import lane_living_doc_abs
162
163 try:
164 lane_docs = resolve_lane_docs(config, lane)
165 except Exception as exc:
166 from adapters.errors import ConfigError
167
168 if isinstance(exc, ConfigError):
169 return GovernanceSyncResult(
170 exit_code=2,
171 dry_run=dry_run,
172 reads=None,
173 drift=None,
174 plan=None,
175 committed=False,
176 commit_sha=None,
177 messages=(str(exc),),
178 )
179 raise
180
181 handover_path = lane_living_doc_abs(repo_root, config, lane_docs, lane_docs.handover)
182 roadmap_path = lane_living_doc_abs(repo_root, config, lane_docs, lane_docs.roadmap)
183
184 for path in (handover_path, roadmap_path):
185 if not path.is_file():
186 if skip_missing:
187 return GovernanceSyncResult(
188 exit_code=4,
189 dry_run=dry_run,
190 reads=None,
191 drift=None,
192 plan=None,
193 committed=False,
194 commit_sha=None,
195 messages=(f"missing governance doc: {path.name}",),
196 )
197 return GovernanceSyncResult(
198 exit_code=4,
199 dry_run=dry_run,
200 reads=None,
201 drift=None,
202 plan=None,
203 committed=False,
204 commit_sha=None,
205 messages=(f"missing governance doc: {path.name}",),
206 )
207
208 reads = perform_verified_reads(config, adapter, runner, repo_root=repo_root)
209 if isinstance(reads, ReadFailure):
210 emit(f"read failed [{reads.regime}]: {reads.command}")
211 emit(reads.message)
212 return GovernanceSyncResult(
213 exit_code=2,
214 dry_run=dry_run,
215 reads=None,
216 drift=None,
217 plan=None,
218 committed=False,
219 commit_sha=None,
220 messages=(reads.message,),
221 error_command=reads.command,
222 )
223
224 handover_text = handover_path.read_text(encoding="utf-8")
225 roadmap_text = roadmap_path.read_text(encoding="utf-8")
226 drift = detect_drift(reads, handover_text, roadmap_text)
227
228 if drift.any_unreadable:
229 emit("drift detection unreadable — fail-closed")
230 return GovernanceSyncResult(
231 exit_code=2,
232 dry_run=dry_run,
233 reads=reads,
234 drift=drift,
235 plan=None,
236 committed=False,
237 commit_sha=None,
238 messages=("unreadable drift field",),
239 )
240
241 if drift.fully_aligned:
242 emit("governance-sync: aligned (D1–D3)")
243 _emit_governance_gate_footer(
244 config,
245 repo_root,
246 handover_text=handover_text,
247 roadmap_text=roadmap_text,
248 emit=emit,
249 )
250 return GovernanceSyncResult(
251 exit_code=0,
252 dry_run=dry_run,
253 reads=reads,
254 drift=drift,
255 plan=None,
256 committed=False,
257 commit_sha=None,
258 messages=("aligned",),
259 )
260
261 realign_planned, _ = plan_realign(config, adapter, reads, drift)
262 realign_summary, realign_error = execute_realign_guard(
263 config,
264 adapter,
265 reads,
266 drift,
267 dry_run=True,
268 )
269 if realign_error:
270 emit(f"realign dry-run failed: {realign_error}")
271 return GovernanceSyncResult(
272 exit_code=2,
273 dry_run=dry_run,
274 reads=reads,
275 drift=drift,
276 plan=None,
277 committed=False,
278 commit_sha=None,
279 messages=(realign_error,),
280 error_command=realign_error,
281 )
282
283 patched_handover, handover_sections = build_handover_patches(
284 handover_text,
285 reads,
286 drift,
287 realign_summary=realign_summary,
288 )
289 patched_roadmap, roadmap_sections = build_roadmap_patches(
290 roadmap_text,
291 reads,
292 drift,
293 )
294 sections = handover_sections + roadmap_sections
295
296 feature_branch = _feature_branch_name(config)
297 main_sha = reads.r1_github_main_sha or reads.r3_canonical_main_sha or reads.r2_anchor_sha
298 commit_message = _commit_message(main_sha, drift, sections, realign_summary)
299 pr_url = _build_pr_url(runner, repo_root, config, feature_branch)
300
301 plan = PatchPlan(
302 handover_text=patched_handover,
303 roadmap_text=patched_roadmap,
304 patched_sections=sections,
305 realign_planned=realign_planned,
306 realign_reason=realign_summary,
307 feature_branch=feature_branch,
308 commit_message=commit_message,
309 pr_url=pr_url,
310 )
311
312 emit(f"drift: D1={drift.d1_handover_vs_git} D2={drift.d2_anchor_vs_canonical} D3={drift.d3_queue_vs_merged}")
313 emit(f"planned sections: {', '.join(sections)}")
314 if realign_summary:
315 emit(realign_summary)
316 if dry_run:
317 emit("dry-run: no writes, commits, or realign apply")
318 if pr_url:
319 emit(f"docs-only PR URL (operator-gated): {pr_url}")
320 _emit_governance_gate_footer(
321 config,
322 repo_root,
323 handover_text=handover_text,
324 roadmap_text=roadmap_text,
325 emit=emit,
326 )
327 return GovernanceSyncResult(
328 exit_code=0,
329 dry_run=True,
330 reads=reads,
331 drift=drift,
332 plan=plan,
333 committed=False,
334 commit_sha=None,
335 messages=("dry-run plan",),
336 )
337
338 return _apply_plan(
339 config=config,
340 repo_root=repo_root,
341 adapter=adapter,
342 runner=runner,
343 reads=reads,
344 drift=drift,
345 plan=plan,
346 handover_path=handover_path,
347 roadmap_path=roadmap_path,
348 emit=emit,
349 )
350
351
352 def _apply_plan(
353 *,
354 config: OverseerConfig,
355 repo_root: Path,
356 adapter: VcsAdapter,
357 runner: CommandRunner,
358 reads: VerifiedReads,
359 drift: DriftReport,
360 plan: PatchPlan,
361 handover_path: Path,
362 roadmap_path: Path,
363 emit,
364 ) -> GovernanceSyncResult:
365 """Apply patches, optional realign, commit, and push on a feature branch."""
366 original_handover = handover_path.read_text(encoding="utf-8")
367 original_roadmap = roadmap_path.read_text(encoding="utf-8")
368
369 try:
370 atomic_write_text(handover_path, plan.handover_text)
371 atomic_write_text(roadmap_path, plan.roadmap_text)
372 except WriteFailure as exc:
373 atomic_write_text(handover_path, original_handover)
374 atomic_write_text(roadmap_path, original_roadmap)
375 emit(f"write failed: {exc}")
376 return GovernanceSyncResult(
377 exit_code=5,
378 dry_run=False,
379 reads=reads,
380 drift=drift,
381 plan=plan,
382 committed=False,
383 commit_sha=None,
384 messages=(str(exc),),
385 )
386
387 realign_summary, realign_error = execute_realign_guard(
388 config,
389 adapter,
390 reads,
391 drift,
392 dry_run=False,
393 )
394 if realign_error:
395 atomic_write_text(handover_path, original_handover)
396 atomic_write_text(roadmap_path, original_roadmap)
397 emit(f"realign failed: {realign_error}")
398 return GovernanceSyncResult(
399 exit_code=2,
400 dry_run=False,
401 reads=reads,
402 drift=drift,
403 plan=plan,
404 committed=False,
405 commit_sha=None,
406 messages=(realign_error,),
407 error_command=realign_error,
408 )
409
410 _write_sync_marker(repo_root)
411
412 rel_handover = _repo_relative(repo_root, handover_path)
413 rel_roadmap = _repo_relative(repo_root, roadmap_path)
414 checkout = _ensure_feature_branch(adapter, runner, repo_root, config, plan.feature_branch)
415 if checkout is not None:
416 atomic_write_text(handover_path, original_handover)
417 atomic_write_text(roadmap_path, original_roadmap)
418 emit(checkout)
419 return GovernanceSyncResult(
420 exit_code=2,
421 dry_run=False,
422 reads=reads,
423 drift=drift,
424 plan=plan,
425 committed=False,
426 commit_sha=None,
427 messages=(checkout,),
428 error_command=checkout,
429 )
430
431 commit = adapter.commit_feature(
432 branch=plan.feature_branch,
433 message=plan.commit_message,
434 paths=[rel_handover, rel_roadmap],
435 )
436 if isinstance(commit, (ReadError, WriteError)):
437 atomic_write_text(handover_path, original_handover)
438 atomic_write_text(roadmap_path, original_roadmap)
439 cmd = commit.command if hasattr(commit, "command") else "commit_feature"
440 emit(str(commit))
441 return GovernanceSyncResult(
442 exit_code=2,
443 dry_run=False,
444 reads=reads,
445 drift=drift,
446 plan=plan,
447 committed=False,
448 commit_sha=None,
449 messages=(str(commit),),
450 error_command=cmd,
451 )
452
453 push_error = _push_feature_branch(runner, repo_root, config, plan.feature_branch)
454 if push_error:
455 emit(push_error)
456
457 if plan.pr_url:
458 emit(f"docs-only PR URL (operator-gated — do not auto-open): {plan.pr_url}")
459
460 return GovernanceSyncResult(
461 exit_code=0,
462 dry_run=False,
463 reads=reads,
464 drift=drift,
465 plan=plan,
466 committed=commit.committed,
467 commit_sha=commit.sha,
468 messages=("applied",),
469 )
470
471
472 def _feature_branch_name(config: OverseerConfig) -> str:
473 slug = f"governance-sync-{date.today().isoformat()}"
474 pattern = config.vcs.git.feature_branch_pattern
475 return pattern.replace("{slug}", slug)
476
477
478 def _commit_message(
479 main_sha: str,
480 drift: DriftReport,
481 sections: tuple[str, ...],
482 realign_summary: str | None,
483 ) -> str:
484 drift_tokens = (
485 f"D1={drift.d1_handover_vs_git},"
486 f"D2={drift.d2_anchor_vs_canonical},"
487 f"D3={drift.d3_queue_vs_merged}"
488 )
489 subject = f"chore(governance): sync handover+roadmap to {main_sha[:7]} (drift: {drift_tokens})"
490 body_lines = ["Patched sections:", *[f"- {name}" for name in sections]]
491 if realign_summary:
492 body_lines.append(f"Realign: {realign_summary}")
493 return subject + "\n\n" + "\n".join(body_lines)
494
495
496 def _build_pr_url(
497 runner: CommandRunner,
498 repo_root: Path,
499 config: OverseerConfig,
500 feature_branch: str,
501 ) -> str | None:
502 if config.vcs.regime == "muse-only":
503 return None
504 remote = config.vcs.git.remote
505 main = config.vcs.git.main_branch
506 cmd = f"git remote get-url {quote_arg(remote)}"
507 result = runner.run(cmd, cwd=str(repo_root))
508 if not result.ok:
509 return f"https://github.com/<owner>/<repo>/compare/{main}...{feature_branch}?expand=1"
510 owner_repo = _parse_github_remote(result.stdout.strip())
511 if not owner_repo:
512 return f"https://github.com/<owner>/<repo>/compare/{main}...{feature_branch}?expand=1"
513 owner, repo = owner_repo
514 return f"https://github.com/{owner}/{repo}/compare/{main}...{feature_branch}?expand=1"
515
516
517 def _parse_github_remote(url: str) -> tuple[str, str] | None:
518 ssh = re.match(r"git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$", url)
519 if ssh:
520 return ssh.group("owner"), ssh.group("repo")
521 https = re.match(r"https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$", url)
522 if https:
523 return https.group("owner"), https.group("repo")
524 return None
525
526
527 def _write_sync_marker(repo_root: Path) -> None:
528 marker = repo_root / ".overseer" / GOVERNANCE_SYNC_MARKER
529 stamp = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
530 atomic_write_text(marker, stamp + "\n")
531
532
533 def _repo_relative(repo_root: Path, path: Path) -> str:
534 return path.resolve().relative_to(repo_root.resolve()).as_posix()
535
536
537 def _ensure_feature_branch(
538 adapter: VcsAdapter,
539 runner: CommandRunner,
540 repo_root: Path,
541 config: OverseerConfig,
542 branch: str,
543 ) -> str | None:
544 root = str(repo_root)
545 if config.vcs.regime == "muse-only":
546 create_cmd = f"muse -C {quote_arg(root)} checkout -b {quote_arg(branch)}"
547 create = runner.run(create_cmd, cwd=root)
548 if create.ok:
549 return None
550 switch_cmd = f"muse -C {quote_arg(root)} checkout {quote_arg(branch)}"
551 switch = runner.run(switch_cmd, cwd=root)
552 if not switch.ok:
553 return switch_cmd
554 return None
555
556 create = runner.run(
557 f"git checkout -b {quote_arg(branch)}",
558 cwd=root,
559 )
560 if create.ok:
561 return None
562 switch = runner.run(
563 f"git checkout {quote_arg(branch)}",
564 cwd=root,
565 )
566 if not switch.ok:
567 return f"git checkout {branch}"
568 return None
569
570
571 def _push_feature_branch(
572 runner: CommandRunner,
573 repo_root: Path,
574 config: OverseerConfig,
575 branch: str,
576 ) -> str | None:
577 if config.vcs.regime == "muse-only":
578 return None
579 remote = config.vcs.git.remote
580 cmd = f"git push -u {quote_arg(remote)} {quote_arg(branch)}"
581 result = runner.run(cmd, cwd=str(repo_root))
582 if not result.ok:
583 return cmd
584 return None
File History 1 commit
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439 feat: K1-P1 complete — agent provenance, build-verification… Sonnet 4.6 patch 54 days ago