init.py python
549 lines 18.5 KB
Raw
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439 feat: K1-P1 complete — agent provenance, build-verification… Sonnet 4.6 patch 60 days 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, footprint_tuples, 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 ) -> MigrateClass:
121 """Classify one footprint destination under ``--migrate`` (§K6.4 table)."""
122 if existing is None:
123 return MigrateClass.SEED
124
125 identical = existing == item.content
126 if is_living:
127 if promote:
128 return MigrateClass.UPDATED # promotion (write if differ; ownership if identical)
129 if identical:
130 return MigrateClass.UNCHANGED
131 return MigrateClass.PRESERVED
132
133 # Shared asset
134 if identical:
135 return MigrateClass.UNCHANGED
136 if item.destination == KN_R2_DEST and kn_r2_pass:
137 return MigrateClass.UPDATED
138 if force:
139 return MigrateClass.UPDATED
140 return MigrateClass.CONFLICT
141
142
143 def run_init(args: Namespace, ctx: CliContext) -> int:
144 """Execute ``overseer init``."""
145 report = CommandReport()
146 migrate = bool(getattr(args, "migrate", False))
147 include_preserved = bool(getattr(args, "include_preserved", False))
148 promote = _promote(args)
149
150 if migrate and not args.non_interactive and not args.from_config and not args.regime:
151 # §K6.4: require non-interactive for CI/fixtures OR from-config/regime
152 pass # regime/from-config checked below via fail-closed
153
154 repo_root = resolve_repo_root(cwd=ctx.cwd, repo_arg=args.repo, command="init")
155 config_path = resolve_config_path(repo_root, args.config)
156
157 if not is_within_repo(repo_root, config_path):
158 ctx.output.error("refused: config path outside repo root")
159 return 4
160
161 try:
162 confine_path(repo_root, ".")
163 except PathEscapeError as exc:
164 ctx.output.error(format_config_error(exc, repo_root))
165 return 4
166
167 existing_config_path = config_path
168 has_config = existing_config_path.is_file()
169 existing_lock_path = lock_path(repo_root)
170 has_lock = existing_lock_path.is_file()
171
172 if has_config and not args.force:
173 try:
174 existing_config = load_config(existing_config_path)
175 except ConfigError as exc:
176 ctx.output.error(format_config_error(exc, repo_root))
177 return 2
178
179 try:
180 existing_lock = read_version_lock(existing_lock_path) if has_lock else None
181 except Exception as exc:
182 report.data["refusal_reason"] = str(exc)
183 ctx.output.error(f"existing install differs: {exc}")
184 return 4
185
186 try:
187 planned_config, _planned_text = _resolve_init_config(args, repo_root, config_path)
188 except ConfigError as exc:
189 ctx.output.error(format_config_error(exc, repo_root))
190 return 2
191
192 try:
193 validate_muse_working_dir(repo_root, planned_config.vcs.muse.working_dir)
194 except ConfigError as exc:
195 ctx.output.error(format_config_error(exc, repo_root))
196 return 2
197
198 try:
199 rendered = resolve_footprint(planned_config, kit=ctx.kit)
200 except ConfigError as exc:
201 ctx.output.error(format_config_error(exc, repo_root))
202 return 2
203
204 if configs_equal(existing_config, planned_config) and _footprint_matches(
205 repo_root, rendered, existing_lock
206 ):
207 report.data["status"] = "already_current"
208 if ctx.output.json_mode:
209 ctx.output.emit_json(report.to_payload())
210 else:
211 ctx.output.emit("already current")
212 return 0
213
214 report.data["refusal_reason"] = "existing install differs"
215 ctx.output.error("refused: existing config or footprint differs (use --force)")
216 if ctx.output.json_mode:
217 ctx.output.emit_json(report.to_payload())
218 return 4
219
220 if migrate and not args.from_config and not args.regime and args.non_interactive:
221 # Fail closed if guessing required
222 try:
223 _resolve_init_config(args, repo_root, config_path)
224 except ConfigError:
225 ctx.output.error("migrate requires --from-config or --regime in --non-interactive mode")
226 return 2
227
228 try:
229 config, config_text = _resolve_init_config(args, repo_root, config_path)
230 except ConfigError as exc:
231 ctx.output.error(format_config_error(exc, repo_root))
232 return 2
233
234 try:
235 validate_muse_working_dir(repo_root, config.vcs.muse.working_dir)
236 except ConfigError as exc:
237 ctx.output.error(format_config_error(exc, repo_root))
238 return 2
239
240 try:
241 rendered = resolve_footprint(config, kit=ctx.kit)
242 except ConfigError as exc:
243 ctx.output.error(format_config_error(exc, repo_root))
244 return 2
245
246 if not migrate:
247 return _run_greenfield_init(
248 args=args,
249 ctx=ctx,
250 report=report,
251 repo_root=repo_root,
252 config_path=config_path,
253 config=config,
254 config_text=config_text,
255 rendered=rendered,
256 existing_lock_path=existing_lock_path,
257 has_lock=has_lock,
258 )
259
260 return _run_migrate_init(
261 args=args,
262 ctx=ctx,
263 report=report,
264 repo_root=repo_root,
265 config_path=config_path,
266 config=config,
267 config_text=config_text,
268 rendered=rendered,
269 existing_lock_path=existing_lock_path,
270 has_lock=has_lock,
271 promote=promote,
272 include_preserved=include_preserved,
273 )
274
275
276 def _run_greenfield_init(
277 *,
278 args: Namespace,
279 ctx: CliContext,
280 report: CommandReport,
281 repo_root: Path,
282 config_path: Path,
283 config,
284 config_text: str,
285 rendered: list[FootprintFile],
286 existing_lock_path: Path,
287 has_lock: bool,
288 ) -> int:
289 """§K4.2 greenfield init (unchanged by K6)."""
290 conflicts: list[str] = []
291 for item in rendered:
292 dest = repo_root / item.destination
293 if dest.is_file():
294 existing = dest.read_bytes()
295 if existing != item.content:
296 conflicts.append(item.destination)
297
298 if conflicts and not args.force:
299 report.data["conflicts"] = conflicts
300 ctx.output.error("refused: footprint conflicts without --force")
301 for path in conflicts:
302 ctx.output.error(f" conflict: {path}")
303 if ctx.output.json_mode:
304 ctx.output.emit_json(report.to_payload())
305 return 4
306
307 plan = {
308 "config": (
309 str(config_path.relative_to(repo_root))
310 if config_path.is_relative_to(repo_root)
311 else ".overseer/config.yaml"
312 ),
313 "files": [item.destination for item in rendered],
314 "conflicts": conflicts,
315 }
316 report.data["plan"] = plan
317
318 if args.dry_run:
319 report.data["dry_run"] = True
320 if ctx.output.json_mode:
321 ctx.output.emit_json(report.to_payload())
322 else:
323 ctx.output.emit("dry-run: no files written")
324 for item in rendered:
325 ctx.output.emit(f" would write: {item.destination}")
326 return 0
327
328 prior_installed_at = None
329 if has_lock:
330 try:
331 prior_installed_at = read_version_lock(existing_lock_path).installed_at
332 except Exception:
333 prior_installed_at = None
334
335 from cli.kit_root import kit_version
336 from cli.version_lock import build_version_lock
337
338 try:
339 config_path.parent.mkdir(parents=True, exist_ok=True)
340 atomic_write_text(config_path, config_text)
341 for item in rendered:
342 write_footprint_bytes(repo_root / item.destination, item.content, destination=item.destination)
343 lock = build_version_lock(
344 kit_version=kit_version(),
345 config_version=config.overseer_config_version,
346 footprint=footprint_tuples(rendered),
347 prior_installed_at=prior_installed_at,
348 )
349 write_version_lock(existing_lock_path, lock)
350 except WriteFailure as exc:
351 ctx.output.error(sanitize_text(str(exc), repo_root))
352 return 5
353
354 report.data["status"] = "initialized"
355 report.data["lock"] = lock.to_dict()
356 if ctx.output.json_mode:
357 ctx.output.emit_json(report.to_payload())
358 else:
359 ctx.output.emit("init complete")
360 for item in rendered:
361 ctx.output.emit(f" wrote: {item.destination}")
362 return 0
363
364
365 def _run_migrate_init(
366 *,
367 args: Namespace,
368 ctx: CliContext,
369 report: CommandReport,
370 repo_root: Path,
371 config_path: Path,
372 config,
373 config_text: str,
374 rendered: list[FootprintFile],
375 existing_lock_path: Path,
376 has_lock: bool,
377 promote: bool,
378 include_preserved: bool,
379 ) -> int:
380 """§K6.4 ``init --migrate`` living-doc preserve + origin rules."""
381 living = living_doc_destinations(config)
382 kn_r2_pass = False
383 kn_r2_rendered: bytes | None = None
384 kn_r2_dest_present = (repo_root / KN_R2_DEST).is_file()
385 if kn_r2_dest_present:
386 kn_r2_pass, kn_r2_rendered, kn_r2_diff = evaluate_kn_r2(repo_root, config, kit=ctx.kit)
387 if not kn_r2_pass and kn_r2_diff:
388 report.add_warning("KN-R2 semantic parity failed; rule remains a shared-asset conflict")
389 if args.verbose if hasattr(args, "verbose") else False:
390 ctx.output.error(kn_r2_diff)
391
392 classifications: dict[str, MigrateClass] = {}
393 write_plan: dict[str, bytes] = {}
394 origins: dict[str, str] = {}
395 lock_bytes: dict[str, bytes] = {}
396 conflicts: list[str] = []
397 preserved: list[str] = []
398 created: list[str] = []
399 unchanged: list[str] = []
400 updated: list[str] = []
401
402 for item in rendered:
403 dest_path = repo_root / item.destination
404 existing = _read_bytes_if_exists(dest_path)
405 is_living = item.destination in living
406 content = item.content
407 if item.destination == KN_R2_DEST and kn_r2_pass and kn_r2_rendered is not None:
408 content = kn_r2_rendered
409 item_for_class = FootprintFile(
410 destination=item.destination,
411 source=item.source,
412 content=content,
413 )
414 else:
415 item_for_class = item
416
417 klass = _classify_migrate(
418 item=item_for_class,
419 existing=existing,
420 is_living=is_living,
421 promote=promote,
422 kn_r2_pass=kn_r2_pass and item.destination == KN_R2_DEST,
423 force=bool(args.force),
424 )
425 classifications[item.destination] = klass
426
427 if klass == MigrateClass.CONFLICT:
428 conflicts.append(item.destination)
429 continue
430
431 if klass == MigrateClass.SEED:
432 write_plan[item.destination] = content
433 created.append(item.destination)
434 lock_bytes[item.destination] = content
435 origins[item.destination] = ORIGIN_PRESERVED if is_living else ORIGIN_KIT
436 elif klass == MigrateClass.UNCHANGED:
437 unchanged.append(item.destination)
438 assert existing is not None
439 lock_bytes[item.destination] = existing
440 origins[item.destination] = ORIGIN_PRESERVED if is_living else ORIGIN_KIT
441 elif klass == MigrateClass.PRESERVED:
442 preserved.append(item.destination)
443 assert existing is not None
444 lock_bytes[item.destination] = existing
445 origins[item.destination] = ORIGIN_PRESERVED
446 elif klass == MigrateClass.UPDATED:
447 updated.append(item.destination)
448 if is_living and promote:
449 if existing != content:
450 write_plan[item.destination] = content
451 lock_bytes[item.destination] = content
452 else:
453 assert existing is not None
454 lock_bytes[item.destination] = existing
455 origins[item.destination] = ORIGIN_KIT
456 else:
457 write_plan[item.destination] = content
458 lock_bytes[item.destination] = content
459 origins[item.destination] = ORIGIN_KIT
460
461 # include_preserved without force is a no-op for living-doc writes (already handled:
462 # promote requires both flags).
463 _ = include_preserved
464
465 if conflicts:
466 report.data["conflicts"] = conflicts
467 ctx.output.error("refused: shared-asset conflicts without --force")
468 for path in conflicts:
469 ctx.output.error(f" conflict: {path}")
470 if ctx.output.json_mode:
471 ctx.output.emit_json(report.to_payload())
472 return 4
473
474 report.data["created"] = created
475 report.data["preserved"] = preserved
476 report.data["unchanged"] = unchanged
477 report.data["updated"] = updated
478 report.data["conflicts"] = []
479 report.data["classifications"] = {k: v.value for k, v in classifications.items()}
480
481 if args.dry_run:
482 report.data["dry_run"] = True
483 if ctx.output.json_mode:
484 ctx.output.emit_json(report.to_payload())
485 else:
486 ctx.output.emit("dry-run: no files written")
487 for path in created:
488 ctx.output.emit(f" would seed: {path}")
489 for path in preserved:
490 ctx.output.emit(f" would preserve: {path}")
491 for path in updated:
492 ctx.output.emit(f" would update: {path}")
493 return 0
494
495 prior_installed_at = None
496 if has_lock:
497 try:
498 prior_installed_at = read_version_lock(existing_lock_path).installed_at
499 except Exception:
500 prior_installed_at = None
501
502 from cli.kit_root import kit_version
503
504 try:
505 config_path.parent.mkdir(parents=True, exist_ok=True)
506 atomic_write_text(config_path, config_text)
507 for dest, content in write_plan.items():
508 write_footprint_bytes(repo_root / dest, content, destination=dest)
509
510 from cli.version_lock import utc_now_iso
511
512 entries: list[FootprintEntry] = []
513 for item in sorted(rendered, key=lambda row: row.destination):
514 dest = item.destination
515 content = lock_bytes[dest]
516 entries.append(
517 FootprintEntry(
518 path=dest,
519 source=item.source,
520 sha256=sha256_hex(content),
521 origin=origins[dest],
522 )
523 )
524 lock = build_version_lock_from_entries(
525 kit_version=kit_version(),
526 config_version=config.overseer_config_version,
527 entries=entries,
528 installed_at=prior_installed_at or utc_now_iso(),
529 )
530 write_version_lock(existing_lock_path, lock)
531 except WriteFailure as exc:
532 ctx.output.error(sanitize_text(str(exc), repo_root))
533 return 5
534
535 report.data["status"] = "migrated"
536 report.data["lock"] = lock.to_dict()
537 if ctx.output.json_mode:
538 ctx.output.emit_json(report.to_payload())
539 else:
540 ctx.output.emit("migrate init complete")
541 for path in created:
542 ctx.output.emit(f" seeded: {path}")
543 for path in preserved:
544 ctx.output.emit(f" preserved: {path}")
545 for path in updated:
546 ctx.output.emit(f" updated: {path}")
547 for path in unchanged:
548 ctx.output.emit(f" unchanged: {path}")
549 return 0
File History 1 commit
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439 feat: K1-P1 complete — agent provenance, build-verification… Sonnet 4.6 patch 60 days ago