init.py python
598 lines 20.5 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago
1 """``overseer init`` command (§K4.2 + §K6.4 ``--migrate``)."""
2
3 from __future__ import annotations
4
5 from argparse import Namespace
6 from enum import Enum
7 from pathlib import Path
8
9 from adapters.config import SUPPORTED_REGIMES, load_config
10 from adapters.errors import ConfigError
11 from cli.atomic import WriteFailure, atomic_write_text
12 from cli.config_gen import (
13 config_dict_to_yaml,
14 configs_equal,
15 default_config_dict,
16 detect_regime,
17 load_config_from_dict,
18 )
19 from cli.context import CliContext
20 from cli.digest import sha256_hex
21 from cli.docs_paths import living_doc_destinations, validate_muse_working_dir
22 from cli.footprint import FootprintFile, resolve_footprint
23 from cli.footprint_writes import write_footprint_bytes
24 from cli.kn_r2 import KN_R2_DEST, evaluate_kn_r2
25 from cli.output import CommandReport
26 from cli.paths import PathEscapeError, confine_path, is_within_repo, resolve_config_path, resolve_repo_root
27 from cli.sanitize import format_config_error, sanitize_text
28 from cli.version_lock import (
29 ORIGIN_KIT,
30 ORIGIN_PRESERVED,
31 FootprintEntry,
32 build_version_lock_from_entries,
33 lock_path,
34 read_version_lock,
35 write_version_lock,
36 )
37
38
39 class MigrateClass(str, Enum):
40 """Per-file migrate classification (§K6.4)."""
41
42 SEED = "seed"
43 UNCHANGED = "unchanged"
44 PRESERVED = "preserved"
45 UPDATED = "updated"
46 CONFLICT = "conflict"
47
48
49 def _read_bytes_if_exists(path: Path) -> bytes | None:
50 if path.is_file():
51 return path.read_bytes()
52 return None
53
54
55 def _footprint_matches(
56 repo_root: Path,
57 rendered,
58 lock,
59 ) -> bool:
60 """Return True when on-disk footprint matches rendered content and lock manifest."""
61 if lock is None:
62 return False
63 rendered_map = {item.destination: item for item in rendered}
64 if len(lock.footprint) != len(rendered_map):
65 return False
66 for entry in lock.footprint:
67 item = rendered_map.get(entry.path)
68 if item is None:
69 return False
70 expected = entry.sha256
71 on_disk = _read_bytes_if_exists(repo_root / entry.path)
72 if on_disk is None or sha256_hex(on_disk) != expected:
73 return False
74 if entry.origin == ORIGIN_PRESERVED:
75 continue
76 if sha256_hex(item.content) != entry.sha256:
77 return False
78 return True
79
80
81 def _resolve_init_config(args: Namespace, repo_root: Path, config_path: Path) -> tuple[object, str]:
82 """Determine config object and YAML text for init."""
83 if args.from_config:
84 from_path = Path(args.from_config).expanduser()
85 if not from_path.is_absolute():
86 from_path = (repo_root / from_path).resolve()
87 else:
88 from_path = from_path.resolve()
89 text = from_path.read_text(encoding="utf-8")
90 config = load_config(from_path)
91 return config, text
92
93 regime = args.regime
94 if regime is None:
95 regime = detect_regime(repo_root)
96 if regime is None and not args.non_interactive:
97 regime = input("Select regime [git-only|muse-only|muse+git-mirror]: ").strip()
98 if regime is None or regime not in SUPPORTED_REGIMES:
99 raise ConfigError("regime could not be determined (use --regime or --from-config)", str(config_path))
100
101 repo_name = args.repo_name or repo_root.name
102 docs_dir = args.docs_dir or "docs"
103 data = default_config_dict(regime=regime, repo_name=repo_name, docs_dir=docs_dir)
104 config = load_config_from_dict(data, str(config_path))
105 return config, config_dict_to_yaml(data)
106
107
108 def _promote(args: Namespace) -> bool:
109 return bool(getattr(args, "force", False) and getattr(args, "include_preserved", False))
110
111
112 def _classify_migrate(
113 *,
114 item: FootprintFile,
115 existing: bytes | None,
116 is_living: bool,
117 promote: bool,
118 kn_r2_pass: bool,
119 force: bool,
120 preserve_shared: bool = False,
121 ) -> MigrateClass:
122 """Classify one footprint destination under ``--migrate`` (§K6.4 + §PSA.3)."""
123 if existing is None:
124 return MigrateClass.SEED
125
126 identical = existing == item.content
127 if is_living:
128 if promote:
129 return MigrateClass.UPDATED # promotion (write if differ; ownership if identical)
130 if identical:
131 return MigrateClass.UNCHANGED
132 return MigrateClass.PRESERVED
133
134 # Shared asset
135 if identical:
136 return MigrateClass.UNCHANGED
137 if item.destination == KN_R2_DEST and kn_r2_pass:
138 return MigrateClass.UPDATED
139 # §PSA.3: consumer-owned shared assets stay on disk unless explicitly promoted
140 if preserve_shared and not promote:
141 return MigrateClass.PRESERVED
142 if force:
143 return MigrateClass.UPDATED
144 return MigrateClass.CONFLICT
145
146
147 def run_init(args: Namespace, ctx: CliContext) -> int:
148 """Execute ``overseer init``."""
149 report = CommandReport()
150 migrate = bool(getattr(args, "migrate", False))
151 include_preserved = bool(getattr(args, "include_preserved", False))
152 preserve_shared = bool(getattr(args, "preserve_shared_assets", False))
153 promote = _promote(args)
154
155 if migrate and not args.non_interactive and not args.from_config and not args.regime:
156 # §K6.4: require non-interactive for CI/fixtures OR from-config/regime
157 pass # regime/from-config checked below via fail-closed
158
159 repo_root = resolve_repo_root(cwd=ctx.cwd, repo_arg=args.repo, command="init")
160 config_path = resolve_config_path(repo_root, args.config)
161
162 if not is_within_repo(repo_root, config_path):
163 ctx.output.error("refused: config path outside repo root")
164 return 4
165
166 try:
167 confine_path(repo_root, ".")
168 except PathEscapeError as exc:
169 ctx.output.error(format_config_error(exc, repo_root))
170 return 4
171
172 existing_config_path = config_path
173 has_config = existing_config_path.is_file()
174 existing_lock_path = lock_path(repo_root)
175 has_lock = existing_lock_path.is_file()
176
177 if has_config and not args.force:
178 try:
179 existing_config = load_config(existing_config_path)
180 except ConfigError as exc:
181 ctx.output.error(format_config_error(exc, repo_root))
182 return 2
183
184 try:
185 existing_lock = read_version_lock(existing_lock_path) if has_lock else None
186 except Exception as exc:
187 report.data["refusal_reason"] = str(exc)
188 ctx.output.error(f"existing install differs: {exc}")
189 return 4
190
191 try:
192 planned_config, _planned_text = _resolve_init_config(args, repo_root, config_path)
193 except ConfigError as exc:
194 ctx.output.error(format_config_error(exc, repo_root))
195 return 2
196
197 try:
198 validate_muse_working_dir(repo_root, planned_config.vcs.muse.working_dir)
199 except ConfigError as exc:
200 ctx.output.error(format_config_error(exc, repo_root))
201 return 2
202
203 try:
204 rendered = resolve_footprint(planned_config, kit=ctx.kit)
205 except ConfigError as exc:
206 ctx.output.error(format_config_error(exc, repo_root))
207 return 2
208
209 if configs_equal(existing_config, planned_config) and _footprint_matches(
210 repo_root, rendered, existing_lock
211 ):
212 report.data["status"] = "already_current"
213 if ctx.output.json_mode:
214 ctx.output.emit_json(report.to_payload())
215 else:
216 ctx.output.emit("already current")
217 return 0
218
219 report.data["refusal_reason"] = "existing install differs"
220 ctx.output.error("refused: existing config or footprint differs (use --force)")
221 if ctx.output.json_mode:
222 ctx.output.emit_json(report.to_payload())
223 return 4
224
225 if migrate and not args.from_config and not args.regime and args.non_interactive:
226 # Fail closed if guessing required
227 try:
228 _resolve_init_config(args, repo_root, config_path)
229 except ConfigError:
230 ctx.output.error("migrate requires --from-config or --regime in --non-interactive mode")
231 return 2
232
233 try:
234 config, config_text = _resolve_init_config(args, repo_root, config_path)
235 except ConfigError as exc:
236 ctx.output.error(format_config_error(exc, repo_root))
237 return 2
238
239 try:
240 validate_muse_working_dir(repo_root, config.vcs.muse.working_dir)
241 except ConfigError as exc:
242 ctx.output.error(format_config_error(exc, repo_root))
243 return 2
244
245 try:
246 rendered = resolve_footprint(config, kit=ctx.kit)
247 except ConfigError as exc:
248 ctx.output.error(format_config_error(exc, repo_root))
249 return 2
250
251 if not migrate:
252 return _run_greenfield_init(
253 args=args,
254 ctx=ctx,
255 report=report,
256 repo_root=repo_root,
257 config_path=config_path,
258 config=config,
259 config_text=config_text,
260 rendered=rendered,
261 existing_lock_path=existing_lock_path,
262 has_lock=has_lock,
263 preserve_shared=preserve_shared,
264 promote=promote,
265 )
266
267 return _run_migrate_init(
268 args=args,
269 ctx=ctx,
270 report=report,
271 repo_root=repo_root,
272 config_path=config_path,
273 config=config,
274 config_text=config_text,
275 rendered=rendered,
276 existing_lock_path=existing_lock_path,
277 has_lock=has_lock,
278 promote=promote,
279 include_preserved=include_preserved,
280 preserve_shared=preserve_shared,
281 )
282
283
284 def _run_greenfield_init(
285 *,
286 args: Namespace,
287 ctx: CliContext,
288 report: CommandReport,
289 repo_root: Path,
290 config_path: Path,
291 config,
292 config_text: str,
293 rendered: list[FootprintFile],
294 existing_lock_path: Path,
295 has_lock: bool,
296 preserve_shared: bool = False,
297 promote: bool = False,
298 ) -> int:
299 """§K4.2 greenfield init + §PSA.4 shared-asset preserve."""
300 living = living_doc_destinations(config)
301 conflicts: list[str] = []
302 preserved_shared: list[str] = []
303 preserved_bytes: dict[str, bytes] = {}
304
305 for item in rendered:
306 dest = repo_root / item.destination
307 if not dest.is_file():
308 continue
309 existing = dest.read_bytes()
310 if existing == item.content:
311 continue
312 is_living = item.destination in living
313 if preserve_shared and not is_living and not promote:
314 preserved_shared.append(item.destination)
315 preserved_bytes[item.destination] = existing
316 continue
317 conflicts.append(item.destination)
318
319 if conflicts and not args.force:
320 report.data["conflicts"] = conflicts
321 ctx.output.error("refused: footprint conflicts without --force")
322 for path in conflicts:
323 ctx.output.error(f" conflict: {path}")
324 if ctx.output.json_mode:
325 ctx.output.emit_json(report.to_payload())
326 return 4
327
328 plan = {
329 "config": (
330 str(config_path.relative_to(repo_root))
331 if config_path.is_relative_to(repo_root)
332 else ".overseer/config.yaml"
333 ),
334 "files": [item.destination for item in rendered],
335 "conflicts": conflicts,
336 "preserved_shared": preserved_shared,
337 }
338 report.data["plan"] = plan
339
340 if args.dry_run:
341 report.data["dry_run"] = True
342 if ctx.output.json_mode:
343 ctx.output.emit_json(report.to_payload())
344 else:
345 ctx.output.emit("dry-run: no files written")
346 for item in rendered:
347 if item.destination in preserved_bytes:
348 ctx.output.emit(f" would preserve: {item.destination}")
349 else:
350 ctx.output.emit(f" would write: {item.destination}")
351 return 0
352
353 prior_installed_at = None
354 if has_lock:
355 try:
356 prior_installed_at = read_version_lock(existing_lock_path).installed_at
357 except Exception:
358 prior_installed_at = None
359
360 from cli.kit_root import kit_version
361
362 try:
363 config_path.parent.mkdir(parents=True, exist_ok=True)
364 atomic_write_text(config_path, config_text)
365 entries: list[FootprintEntry] = []
366 for item in sorted(rendered, key=lambda row: row.destination):
367 if item.destination in preserved_bytes:
368 content = preserved_bytes[item.destination]
369 origin = ORIGIN_PRESERVED
370 else:
371 write_footprint_bytes(
372 repo_root / item.destination, item.content, destination=item.destination
373 )
374 content = item.content
375 origin = ORIGIN_KIT
376 entries.append(
377 FootprintEntry(
378 path=item.destination,
379 source=item.source,
380 sha256=sha256_hex(content),
381 origin=origin,
382 )
383 )
384 from cli.version_lock import utc_now_iso
385
386 lock = build_version_lock_from_entries(
387 kit_version=kit_version(),
388 config_version=config.overseer_config_version,
389 entries=entries,
390 installed_at=prior_installed_at or utc_now_iso(),
391 )
392 write_version_lock(existing_lock_path, lock)
393 except WriteFailure as exc:
394 ctx.output.error(sanitize_text(str(exc), repo_root))
395 return 5
396
397 report.data["status"] = "initialized"
398 report.data["preserved"] = preserved_shared
399 report.data["lock"] = lock.to_dict()
400 if ctx.output.json_mode:
401 ctx.output.emit_json(report.to_payload())
402 else:
403 ctx.output.emit("init complete")
404 for item in rendered:
405 if item.destination in preserved_bytes:
406 ctx.output.emit(f" preserved: {item.destination}")
407 else:
408 ctx.output.emit(f" wrote: {item.destination}")
409 return 0
410
411
412 def _run_migrate_init(
413 *,
414 args: Namespace,
415 ctx: CliContext,
416 report: CommandReport,
417 repo_root: Path,
418 config_path: Path,
419 config,
420 config_text: str,
421 rendered: list[FootprintFile],
422 existing_lock_path: Path,
423 has_lock: bool,
424 promote: bool,
425 include_preserved: bool,
426 preserve_shared: bool = False,
427 ) -> int:
428 """§K6.4 ``init --migrate`` living-doc preserve + §PSA.3 shared-asset preserve."""
429 living = living_doc_destinations(config)
430 kn_r2_pass = False
431 kn_r2_rendered: bytes | None = None
432 kn_r2_dest_present = (repo_root / KN_R2_DEST).is_file()
433 if kn_r2_dest_present:
434 kn_r2_pass, kn_r2_rendered, kn_r2_diff = evaluate_kn_r2(repo_root, config, kit=ctx.kit)
435 if not kn_r2_pass and kn_r2_diff:
436 report.add_warning("KN-R2 semantic parity failed; rule remains a shared-asset conflict")
437 if args.verbose if hasattr(args, "verbose") else False:
438 ctx.output.error(kn_r2_diff)
439
440 classifications: dict[str, MigrateClass] = {}
441 write_plan: dict[str, bytes] = {}
442 origins: dict[str, str] = {}
443 lock_bytes: dict[str, bytes] = {}
444 conflicts: list[str] = []
445 preserved: list[str] = []
446 created: list[str] = []
447 unchanged: list[str] = []
448 updated: list[str] = []
449
450 for item in rendered:
451 dest_path = repo_root / item.destination
452 existing = _read_bytes_if_exists(dest_path)
453 is_living = item.destination in living
454 content = item.content
455 if item.destination == KN_R2_DEST and kn_r2_pass and kn_r2_rendered is not None:
456 content = kn_r2_rendered
457 item_for_class = FootprintFile(
458 destination=item.destination,
459 source=item.source,
460 content=content,
461 )
462 else:
463 item_for_class = item
464
465 klass = _classify_migrate(
466 item=item_for_class,
467 existing=existing,
468 is_living=is_living,
469 promote=promote,
470 kn_r2_pass=kn_r2_pass and item.destination == KN_R2_DEST,
471 force=bool(args.force),
472 preserve_shared=preserve_shared,
473 )
474 classifications[item.destination] = klass
475
476 if klass == MigrateClass.CONFLICT:
477 conflicts.append(item.destination)
478 continue
479
480 if klass == MigrateClass.SEED:
481 write_plan[item.destination] = content
482 created.append(item.destination)
483 lock_bytes[item.destination] = content
484 origins[item.destination] = ORIGIN_PRESERVED if is_living else ORIGIN_KIT
485 elif klass == MigrateClass.UNCHANGED:
486 unchanged.append(item.destination)
487 assert existing is not None
488 lock_bytes[item.destination] = existing
489 origins[item.destination] = ORIGIN_PRESERVED if is_living else ORIGIN_KIT
490 elif klass == MigrateClass.PRESERVED:
491 preserved.append(item.destination)
492 assert existing is not None
493 lock_bytes[item.destination] = existing
494 origins[item.destination] = ORIGIN_PRESERVED
495 elif klass == MigrateClass.UPDATED:
496 updated.append(item.destination)
497 if is_living and promote:
498 if existing != content:
499 write_plan[item.destination] = content
500 lock_bytes[item.destination] = content
501 else:
502 assert existing is not None
503 lock_bytes[item.destination] = existing
504 origins[item.destination] = ORIGIN_KIT
505 else:
506 write_plan[item.destination] = content
507 lock_bytes[item.destination] = content
508 origins[item.destination] = ORIGIN_KIT
509
510 # include_preserved without force is a no-op for living-doc writes (already handled:
511 # promote requires both flags).
512 _ = include_preserved
513
514 if conflicts:
515 report.data["conflicts"] = conflicts
516 ctx.output.error("refused: shared-asset conflicts without --force")
517 for path in conflicts:
518 ctx.output.error(f" conflict: {path}")
519 if ctx.output.json_mode:
520 ctx.output.emit_json(report.to_payload())
521 return 4
522
523 report.data["created"] = created
524 report.data["preserved"] = preserved
525 report.data["unchanged"] = unchanged
526 report.data["updated"] = updated
527 report.data["conflicts"] = []
528 report.data["classifications"] = {k: v.value for k, v in classifications.items()}
529
530 if args.dry_run:
531 report.data["dry_run"] = True
532 if ctx.output.json_mode:
533 ctx.output.emit_json(report.to_payload())
534 else:
535 ctx.output.emit("dry-run: no files written")
536 for path in created:
537 ctx.output.emit(f" would seed: {path}")
538 for path in preserved:
539 ctx.output.emit(f" would preserve: {path}")
540 for path in updated:
541 ctx.output.emit(f" would update: {path}")
542 return 0
543
544 prior_installed_at = None
545 if has_lock:
546 try:
547 prior_installed_at = read_version_lock(existing_lock_path).installed_at
548 except Exception:
549 prior_installed_at = None
550
551 from cli.kit_root import kit_version
552
553 try:
554 config_path.parent.mkdir(parents=True, exist_ok=True)
555 atomic_write_text(config_path, config_text)
556 for dest, content in write_plan.items():
557 write_footprint_bytes(repo_root / dest, content, destination=dest)
558
559 from cli.version_lock import utc_now_iso
560
561 entries: list[FootprintEntry] = []
562 for item in sorted(rendered, key=lambda row: row.destination):
563 dest = item.destination
564 content = lock_bytes[dest]
565 entries.append(
566 FootprintEntry(
567 path=dest,
568 source=item.source,
569 sha256=sha256_hex(content),
570 origin=origins[dest],
571 )
572 )
573 lock = build_version_lock_from_entries(
574 kit_version=kit_version(),
575 config_version=config.overseer_config_version,
576 entries=entries,
577 installed_at=prior_installed_at or utc_now_iso(),
578 )
579 write_version_lock(existing_lock_path, lock)
580 except WriteFailure as exc:
581 ctx.output.error(sanitize_text(str(exc), repo_root))
582 return 5
583
584 report.data["status"] = "migrated"
585 report.data["lock"] = lock.to_dict()
586 if ctx.output.json_mode:
587 ctx.output.emit_json(report.to_payload())
588 else:
589 ctx.output.emit("migrate init complete")
590 for path in created:
591 ctx.output.emit(f" seeded: {path}")
592 for path in preserved:
593 ctx.output.emit(f" preserved: {path}")
594 for path in updated:
595 ctx.output.emit(f" updated: {path}")
596 for path in unchanged:
597 ctx.output.emit(f" unchanged: {path}")
598 return 0
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 53 days ago