sidegraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,827 @@
1
+ """Guided, preview-first orchestration for repository bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shlex
8
+ import sys
9
+ import traceback
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from time import monotonic
14
+ from typing import NoReturn
15
+
16
+ from sidegraph.bootstrap.apply import apply_review, render_markdown_report
17
+ from sidegraph.bootstrap.catalog import load_canonical_catalog
18
+ from sidegraph.bootstrap.integrations import verify_integration
19
+ from sidegraph.bootstrap.model import (
20
+ BootstrapCandidate,
21
+ BootstrapPlan,
22
+ BootstrapReport,
23
+ HostKind,
24
+ IntegrationResult,
25
+ ProfileDetection,
26
+ ProofResult,
27
+ ReviewAction,
28
+ ReviewResult,
29
+ RunStatus,
30
+ )
31
+ from sidegraph.bootstrap.planner import plan_sources
32
+ from sidegraph.bootstrap.proof import prove_task_context
33
+ from sidegraph.bootstrap.review import render_candidate, review_plan
34
+ from sidegraph.bootstrap.scan import scan_sources
35
+ from sidegraph.capture import redact
36
+ from sidegraph.config import resolve_store_path
37
+ from sidegraph.domains import collect_domain_candidates
38
+ from sidegraph.engine.reader import GraphifyReader
39
+ from sidegraph.profiles import FlowProfile, detect_profile, get_profile
40
+ from sidegraph.store import Store
41
+
42
+
43
+ class BootstrapArgumentParser(argparse.ArgumentParser):
44
+ def error(self, message: str) -> NoReturn:
45
+ self.print_usage(sys.stderr)
46
+ self.exit(1, f"{self.prog}: error: {message}\n")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DomainSuggestions:
51
+ count: int = 0
52
+ titles: tuple[str, ...] = ()
53
+
54
+
55
+ def build_parser() -> BootstrapArgumentParser:
56
+ parser = BootstrapArgumentParser(
57
+ prog="sidegraph-bootstrap",
58
+ description="Preview, review, and explicitly confirm Sidegraph onboarding.",
59
+ )
60
+ parser.add_argument("--root", default=".", help="repository root (default: current directory)")
61
+ parser.add_argument("--db", help="Sidegraph store directory")
62
+ parser.add_argument("--graph", help="Graphify graph.json path")
63
+ parser.add_argument("--profile", help="flow profile name")
64
+ parser.add_argument("--docs", action="append", help="additional document or directory")
65
+ parser.add_argument("--include", action="append", help="explicitly include a source file")
66
+ parser.add_argument(
67
+ "--host",
68
+ choices=tuple(host.value for host in HostKind),
69
+ default=HostKind.CLAUDE_CODE.value,
70
+ )
71
+ parser.add_argument("--codex-config", help="Codex config.toml path")
72
+ parser.add_argument("--candidate", help="review only one candidate key")
73
+ parser.add_argument("--task", help="repository file path used for retrieval proof")
74
+ parser.add_argument("--report", help="write an aggregate Markdown report")
75
+ parser.add_argument(
76
+ "--resume",
77
+ action="store_true",
78
+ help=(
79
+ "marker for a resumed run; the flow is idempotent, so a rerun behaves identically "
80
+ "with or without it"
81
+ ),
82
+ )
83
+ return parser
84
+
85
+
86
+ def _resolve_graph_path(value: str | None, root: Path) -> Path:
87
+ raw = value or os.environ.get("SIDEGRAPH_GRAPH") or "graphify-out/graph.json"
88
+ path = Path(raw)
89
+ return path if path.is_absolute() else root / path
90
+
91
+
92
+ def _resolve_optional_path(value: str | None, root: Path) -> Path | None:
93
+ if value is None:
94
+ return None
95
+ path = Path(value)
96
+ return path.resolve() if path.is_absolute() else (root / path).resolve()
97
+
98
+
99
+ def _selected_host_config_inputs(
100
+ args: argparse.Namespace, root: Path
101
+ ) -> tuple[tuple[str, Path], ...]:
102
+ if args.host == HostKind.CLAUDE_CODE.value:
103
+ return (
104
+ ("Claude Code MCP config", (root / ".mcp.json").resolve()),
105
+ ("Claude Code hooks config", (root / ".claude" / "settings.json").resolve()),
106
+ )
107
+ codex_config = _resolve_optional_path(args.codex_config, root)
108
+ return (
109
+ ("Codex config", codex_config or (root / ".codex" / "config.toml").resolve()),
110
+ ("Codex hooks config", (root / ".codex" / "hooks" / "hooks.json").resolve()),
111
+ )
112
+
113
+
114
+ def _validate_report_destination(
115
+ args: argparse.Namespace,
116
+ root: Path,
117
+ store_dir: Path,
118
+ graph_path: Path,
119
+ source_paths: tuple[str, ...] = (),
120
+ ) -> Path | None:
121
+ report_path = _resolve_optional_path(args.report, root)
122
+ if report_path is None:
123
+ return None
124
+ protected_files = (
125
+ ("graph input", graph_path.resolve()),
126
+ *_selected_host_config_inputs(args, root),
127
+ *(
128
+ (f"scanned source {source_path}", (root / source_path).resolve())
129
+ for source_path in source_paths
130
+ ),
131
+ )
132
+ for label, protected_path in protected_files:
133
+ if report_path == protected_path:
134
+ raise ValueError(
135
+ f"--report destination conflicts with protected {label}: {protected_path}"
136
+ )
137
+ try:
138
+ report_path.relative_to(store_dir.resolve())
139
+ except ValueError:
140
+ pass
141
+ else:
142
+ raise ValueError(
143
+ f"--report destination conflicts with protected Sidegraph store: {store_dir}"
144
+ )
145
+ return report_path
146
+
147
+
148
+ def require_selected_profile(detection: ProfileDetection) -> FlowProfile:
149
+ if detection.selected is not None:
150
+ return get_profile(detection.selected)
151
+ if detection.matches:
152
+ matches = ", ".join(detection.matches)
153
+ raise ValueError(f"multiple flow profiles match ({matches}); choose --profile explicitly")
154
+ raise ValueError("no flow profile detected; choose --profile explicitly")
155
+
156
+
157
+ def load_optional_reader(path: Path) -> GraphifyReader | None:
158
+ if not path.exists():
159
+ return None
160
+ return GraphifyReader(path)
161
+
162
+
163
+ def select_candidate(plan: BootstrapPlan, key: str | None) -> BootstrapPlan:
164
+ if key is None:
165
+ return plan
166
+ matches = tuple(candidate for candidate in plan.candidates if candidate.key == key)
167
+ if not matches:
168
+ raise ValueError(f"unknown candidate key {key!r}")
169
+ return plan.model_copy(update={"candidates": matches})
170
+
171
+
172
+ def select_refreshed_candidate(
173
+ plan: BootstrapPlan, selected: BootstrapCandidate | None
174
+ ) -> BootstrapPlan:
175
+ """Track a content-keyed selection by its stable source ref and fragment."""
176
+ if selected is None:
177
+ return plan
178
+ matches = tuple(
179
+ candidate
180
+ for candidate in plan.candidates
181
+ if (candidate.ref, candidate.fragment) == (selected.ref, selected.fragment)
182
+ )
183
+ if len(matches) > 1:
184
+ raise ValueError(f"multiple refreshed candidates match source {selected.ref!r}")
185
+ return plan.model_copy(update={"candidates": matches})
186
+
187
+
188
+ def _candidate_warning_text(plan: BootstrapPlan) -> str:
189
+ count = len(plan.candidates)
190
+ return f"Found {count} candidate" + ("" if count == 1 else "s")
191
+
192
+
193
+ def render_preview(plan: BootstrapPlan) -> None:
194
+ print(_candidate_warning_text(plan))
195
+ for number, candidate in enumerate(plan.candidates, start=1):
196
+ print(f" {number}. [{candidate.key}]")
197
+ for line in render_candidate(candidate).splitlines():
198
+ print(f" {line}")
199
+
200
+
201
+ def render_diagnostic(plan: BootstrapPlan, review: ReviewResult | None = None) -> None:
202
+ skipped = (
203
+ 0 if review is None else sum(item.action == ReviewAction.SKIP for item in review.items)
204
+ )
205
+ print(
206
+ f"DIAGNOSTIC no activation ({len(plan.files_read)} documents, "
207
+ f"{len(plan.candidates)} candidates, {skipped} skipped); no writes"
208
+ )
209
+
210
+
211
+ def _resume_argv(
212
+ args: argparse.Namespace,
213
+ root: Path,
214
+ store_dir: Path,
215
+ graph_path: Path,
216
+ profile_name: str,
217
+ candidate_key: str | None,
218
+ ) -> list[str]:
219
+ argv = [
220
+ "sidegraph-bootstrap",
221
+ "--resume",
222
+ "--root",
223
+ str(root),
224
+ "--db",
225
+ str(store_dir),
226
+ "--graph",
227
+ str(graph_path),
228
+ "--profile",
229
+ profile_name,
230
+ "--host",
231
+ args.host,
232
+ ]
233
+ for value in args.docs or ():
234
+ argv.extend(("--docs", value))
235
+ for value in args.include or ():
236
+ argv.extend(("--include", value))
237
+ optional = (
238
+ ("--codex-config", args.codex_config),
239
+ ("--candidate", candidate_key),
240
+ ("--task", args.task),
241
+ (
242
+ "--report",
243
+ str(_resolve_optional_path(args.report, root)) if args.report is not None else None,
244
+ ),
245
+ )
246
+ for flag, value in optional:
247
+ if value is not None:
248
+ argv.extend((flag, value))
249
+ return argv
250
+
251
+
252
+ def _resume_command(
253
+ args: argparse.Namespace,
254
+ root: Path,
255
+ store_dir: Path,
256
+ graph_path: Path,
257
+ profile_name: str,
258
+ candidate_key: str | None,
259
+ ) -> str:
260
+ return shlex.join(_resume_argv(args, root, store_dir, graph_path, profile_name, candidate_key))
261
+
262
+
263
+ def render_missing_graph(
264
+ graph_path: Path,
265
+ *,
266
+ root: Path,
267
+ resume_command: str,
268
+ ) -> None:
269
+ print(f"ACTIONABLE graph not readable: {graph_path}")
270
+ if graph_path == root / "graphify-out" / "graph.json":
271
+ print(f"NEXT cd {shlex.quote(str(root))} && graphify update .")
272
+ print(f"RESUME {resume_command}")
273
+
274
+
275
+ def render_changed_plan_diff(previous: BootstrapPlan, refreshed: BootstrapPlan) -> None:
276
+ print("CHANGED warning-producing inputs changed; stale actions discarded")
277
+ old = {candidate.key: candidate.title for candidate in previous.candidates}
278
+ new = {candidate.key: candidate.title for candidate in refreshed.candidates}
279
+ for key in sorted(old.keys() - new.keys()):
280
+ print(f" - {old[key]} [{key}]")
281
+ for key in sorted(new.keys() - old.keys()):
282
+ print(f" + {new[key]} [{key}]")
283
+ render_preview(refreshed)
284
+
285
+
286
+ def _render_action_summary(review: ReviewResult) -> None:
287
+ print("\nReviewed redacted action summary")
288
+ for item in review.items:
289
+ candidate = item.candidate
290
+ print(f"- {item.action.value}: {candidate.title} [{candidate.key}]")
291
+ print(f" source: {candidate.ref}")
292
+ print(f" context: {candidate.context}")
293
+ print(f" choice: {candidate.choice}")
294
+ print(f" rejected: {candidate.rejected or '-'}")
295
+ print(f" consequences: {candidate.consequences or '-'}")
296
+ for anchor in candidate.anchors:
297
+ print(
298
+ f" anchor: {anchor.descriptor.name} ({anchor.status}, tier {anchor.tier or '-'})"
299
+ )
300
+
301
+
302
+ def confirm_review(review: ReviewResult) -> bool:
303
+ _render_action_summary(review)
304
+ return input("Type literal 'confirm' to write reviewed actions: ").strip() == "confirm"
305
+
306
+
307
+ def render_cancelled_without_writes() -> None:
308
+ print("CANCELLED confirmation was not literal 'confirm'; no writes")
309
+
310
+
311
+ def render_input_ended_without_writes(phase: str, resume_command: str) -> None:
312
+ print(f"CANCELLED input ended during {phase}; no writes")
313
+ print(f"RESUME {resume_command}")
314
+
315
+
316
+ def with_exact_resume_command(
317
+ report: BootstrapReport,
318
+ args: argparse.Namespace,
319
+ root: Path,
320
+ store_dir: Path,
321
+ graph_path: Path,
322
+ profile_name: str,
323
+ candidate_key: str | None,
324
+ ) -> BootstrapReport:
325
+ command = _resume_command(args, root, store_dir, graph_path, profile_name, candidate_key)
326
+ return report.model_copy(update={"next_command": command})
327
+
328
+
329
+ def collect_post_apply_domain_candidates(
330
+ report: BootstrapReport,
331
+ store_dir: Path,
332
+ reader: GraphifyReader,
333
+ ) -> DomainSuggestions:
334
+ if report.status != RunStatus.COMPLETE:
335
+ return DomainSuggestions()
336
+ store: Store | None = None
337
+ try:
338
+ store = Store(store_dir)
339
+ candidates, _stats = collect_domain_candidates(store, reader, limit=5)
340
+ except Exception:
341
+ return DomainSuggestions()
342
+ finally:
343
+ if store is not None:
344
+ with suppress(Exception):
345
+ store.close()
346
+ return DomainSuggestions(
347
+ count=len(candidates),
348
+ titles=tuple(candidate.suggested_title for candidate in candidates),
349
+ )
350
+
351
+
352
+ def prove_after_apply(
353
+ report: BootstrapReport,
354
+ store_dir: Path,
355
+ reader: GraphifyReader,
356
+ *,
357
+ file_path: str | None,
358
+ ) -> ProofResult:
359
+ if report.status != RunStatus.COMPLETE:
360
+ return ProofResult(complete=False, reason=f"activation is {report.status.value}")
361
+ store: Store | None = None
362
+ try:
363
+ store = Store(store_dir)
364
+ return prove_task_context(
365
+ store,
366
+ reader,
367
+ accepted_record_ids=tuple(item.record_id for item in report.durable_accepted_records),
368
+ file_path=file_path,
369
+ )
370
+ except Exception as error:
371
+ return ProofResult(complete=False, reason=str(error) or type(error).__name__)
372
+ finally:
373
+ if store is not None:
374
+ with suppress(Exception):
375
+ store.close()
376
+
377
+
378
+ def write_report_only_when_requested(
379
+ path_value: str | None,
380
+ root: Path,
381
+ report: BootstrapReport,
382
+ integration: IntegrationResult,
383
+ proof: ProofResult,
384
+ task_proof: ProofResult | None,
385
+ elapsed_seconds: float,
386
+ ) -> None:
387
+ path = _resolve_optional_path(path_value, root)
388
+ if path is None:
389
+ return
390
+ path.parent.mkdir(parents=True, exist_ok=True)
391
+ path.write_text(
392
+ render_markdown_report(
393
+ report,
394
+ integration=integration,
395
+ proof=proof,
396
+ task_proof=task_proof,
397
+ elapsed_seconds=elapsed_seconds,
398
+ ),
399
+ encoding="utf-8",
400
+ )
401
+
402
+
403
+ def integration_ready_for_selected_host(result: IntegrationResult) -> bool:
404
+ if result.host == HostKind.CLAUDE_CODE:
405
+ return result.fully_supported
406
+ return (
407
+ result.host == HostKind.CODEX
408
+ and result.mcp == "verified"
409
+ and result.session_start == "verified"
410
+ and result.stop == "verified"
411
+ and result.pretool_read_grep == "unsupported"
412
+ )
413
+
414
+
415
+ def _accepted_anchors_complete(review: ReviewResult) -> bool:
416
+ accepted = [item.candidate for item in review.items if item.action == ReviewAction.ACCEPT]
417
+ return bool(accepted) and all(
418
+ any(anchor.status == "resolved" and anchor.tier == 2 for anchor in candidate.anchors)
419
+ and all(anchor.status == "resolved" for anchor in candidate.anchors)
420
+ for candidate in accepted
421
+ )
422
+
423
+
424
+ def completion_exit_code(
425
+ report: BootstrapReport,
426
+ review: ReviewResult,
427
+ integration: IntegrationResult,
428
+ proof: ProofResult,
429
+ ) -> int:
430
+ complete_for_selected_host = (
431
+ report.status == RunStatus.COMPLETE
432
+ and _accepted_anchors_complete(review)
433
+ and integration_ready_for_selected_host(integration)
434
+ and proof.complete
435
+ )
436
+ return 0 if complete_for_selected_host else 2
437
+
438
+
439
+ def _terminal_text(value: str) -> str:
440
+ redacted, _ = redact(value)
441
+ return "".join(
442
+ character
443
+ if ord(character) >= 32 and ord(character) != 127
444
+ else character.encode("unicode_escape").decode("ascii")
445
+ for character in redacted
446
+ )
447
+
448
+
449
+ def _render_structured_values(label: str, values: tuple[str, ...]) -> None:
450
+ if not values:
451
+ print(f"{label:<13}<none>")
452
+ return
453
+ for value in values:
454
+ print(f"{label:<13}{_terminal_text(value)}")
455
+
456
+
457
+ def _safe_input_error(error: str | None) -> str | None:
458
+ if error is None:
459
+ return None
460
+ allowed_prefixes = (
461
+ "source unavailable:",
462
+ "source changed after preview:",
463
+ "canonical catalog unavailable:",
464
+ "canonical catalog changed after preview",
465
+ "graph unavailable:",
466
+ "graph changed after preview",
467
+ )
468
+ details = tuple(part.strip() for part in error.split(";"))
469
+ return error if details and all(part.startswith(allowed_prefixes) for part in details) else None
470
+
471
+
472
+ def render_completion(
473
+ report: BootstrapReport,
474
+ review: ReviewResult,
475
+ integration: IntegrationResult,
476
+ proof: ProofResult,
477
+ domain_candidates: DomainSuggestions,
478
+ *,
479
+ task: str | None,
480
+ task_proof: ProofResult | None = None,
481
+ ) -> None:
482
+ if report.status == RunStatus.COMPLETE:
483
+ print("STORE ready")
484
+ else:
485
+ print(f"STORE {report.status.value}")
486
+ detail = _safe_input_error(report.error)
487
+ if detail is not None:
488
+ print(f"DETAIL {_terminal_text(detail)}")
489
+
490
+ print(
491
+ f"{'FAILED REF':<13}"
492
+ f"{_terminal_text(report.failed_ref) if report.failed_ref is not None else '<none>'}"
493
+ )
494
+ _render_structured_values("DURABLE", report.durable_candidate_keys)
495
+ _render_structured_values("PENDING", report.pending_candidate_keys)
496
+ _render_structured_values("VERIFY", report.verification_failures)
497
+ _render_structured_values("CHANGED", report.canonical_files)
498
+
499
+ if _accepted_anchors_complete(review):
500
+ print("ANCHORS ready")
501
+ elif any(item.action == ReviewAction.ACCEPT for item in review.items):
502
+ print("ANCHORS incomplete: accepted candidate has unresolved or ambiguous anchors")
503
+ else:
504
+ print(
505
+ "ANCHORS incomplete: activation needs at least one accepted candidate "
506
+ "(proposals neither block nor satisfy this check)"
507
+ )
508
+
509
+ if integration.host == HostKind.CLAUDE_CODE and integration.fully_supported:
510
+ print("INTEGRATION Claude Code MCP + hooks verified")
511
+ elif integration.host == HostKind.CODEX and integration_ready_for_selected_host(integration):
512
+ print("INTEGRATION Codex best-effort")
513
+ print(" Read/Grep PreToolUse unsupported")
514
+ else:
515
+ print(f"INTEGRATION {integration.host.value} incomplete")
516
+ if integration.host == HostKind.CODEX:
517
+ print(" Read/Grep PreToolUse unsupported")
518
+ if integration.next_action:
519
+ print(f"ACTION {integration.next_action}")
520
+
521
+ if proof.complete:
522
+ print("PROOF production retrieval returned")
523
+ if proof.primary_line is not None:
524
+ print(f"PROOF LINE {_terminal_text(proof.primary_line)}")
525
+ if proof.file_path is not None:
526
+ print(f"PROOF ANCHOR {_terminal_text(proof.file_path)}")
527
+ if proof.source is not None:
528
+ print(f"PROOF SOURCE {_terminal_text(proof.source)}")
529
+ if proof.selection_rule is not None:
530
+ print(f"PROOF RULE {_terminal_text(proof.selection_rule)}")
531
+ if proof.copyable_prompt is not None:
532
+ print(f"HOST PROMPT {_terminal_text(proof.copyable_prompt)}")
533
+ else:
534
+ reason = proof.reason or "retrieval proof unavailable"
535
+ print(f"PROOF incomplete: {_terminal_text(reason)}")
536
+ if task is not None:
537
+ print(f"TASK {_terminal_text(task)}")
538
+ if task_proof is None:
539
+ print("TASK PROOF incomplete: task proof was not run")
540
+ elif task_proof.complete:
541
+ print("TASK PROOF production retrieval returned")
542
+ if task_proof.primary_line is not None:
543
+ print(f"TASK LINE {_terminal_text(task_proof.primary_line)}")
544
+ if task_proof.file_path is not None:
545
+ print(f"TASK ANCHOR {_terminal_text(task_proof.file_path)}")
546
+ if task_proof.source is not None:
547
+ print(f"TASK SOURCE {_terminal_text(task_proof.source)}")
548
+ if task_proof.selection_rule is not None:
549
+ print(f"TASK RULE {_terminal_text(task_proof.selection_rule)}")
550
+ else:
551
+ reason = task_proof.reason or "retrieval proof unavailable"
552
+ print(f"TASK PROOF incomplete: {_terminal_text(reason)}")
553
+
554
+ if domain_candidates.count:
555
+ titles = ", ".join(domain_candidates.titles)
556
+ print(f"DOMAINS {domain_candidates.count} read-only suggestion(s): {titles}")
557
+ print("Optional orientation follow-up: sidegraph-domains bootstrap --dry-run")
558
+
559
+ if report.next_command is not None:
560
+ print(f"RESUME {report.next_command}")
561
+
562
+
563
+ def _main(args: argparse.Namespace) -> int:
564
+ started_at = monotonic()
565
+ root = Path(args.root).resolve()
566
+ store_dir = Path(resolve_store_path(args.db, root=str(root), warn_on_create=False)).resolve()
567
+ graph_path = _resolve_graph_path(args.graph, root).resolve()
568
+ _validate_report_destination(args, root, store_dir, graph_path)
569
+ detection = detect_profile(root, args.profile)
570
+ profile = require_selected_profile(detection)
571
+ docs = tuple(Path(path) for path in (args.docs or ()))
572
+ includes = tuple(Path(path) for path in (args.include or ()))
573
+ scan = scan_sources(root, profile, docs, includes)
574
+ protected_sources = scan.files
575
+ _validate_report_destination(
576
+ args,
577
+ root,
578
+ store_dir,
579
+ graph_path,
580
+ protected_sources,
581
+ )
582
+ catalog = load_canonical_catalog(store_dir)
583
+ reader = load_optional_reader(graph_path)
584
+ plan = select_candidate(
585
+ plan_sources(root, scan, profile, catalog=catalog, reader=reader),
586
+ args.candidate,
587
+ )
588
+ selected_source = plan.candidates[0] if args.candidate is not None else None
589
+ selected_candidate_key = args.candidate
590
+ render_preview(plan)
591
+ if not plan.candidates:
592
+ render_diagnostic(plan)
593
+ return 0
594
+
595
+ resume_command = _resume_command(
596
+ args,
597
+ root,
598
+ store_dir,
599
+ graph_path,
600
+ profile.name,
601
+ selected_candidate_key,
602
+ )
603
+ if reader is None:
604
+ render_missing_graph(
605
+ graph_path,
606
+ root=root,
607
+ resume_command=resume_command,
608
+ )
609
+ return 2
610
+
611
+ try:
612
+ review = review_plan(plan, reader=reader, catalog=catalog, read_line=input)
613
+ except EOFError:
614
+ render_input_ended_without_writes("candidate review", resume_command)
615
+ return 2
616
+ if not review.has_writes:
617
+ render_diagnostic(plan, review)
618
+ return 0
619
+
620
+ try:
621
+ refreshed_scan = scan_sources(root, profile, docs, includes)
622
+ protected_sources = tuple(dict.fromkeys((*scan.files, *refreshed_scan.files)))
623
+ _validate_report_destination(
624
+ args,
625
+ root,
626
+ store_dir,
627
+ graph_path,
628
+ protected_sources,
629
+ )
630
+ refreshed_catalog = load_canonical_catalog(store_dir)
631
+ refreshed_reader = load_optional_reader(graph_path)
632
+ refreshed = select_refreshed_candidate(
633
+ plan_sources(
634
+ root,
635
+ refreshed_scan,
636
+ profile,
637
+ catalog=refreshed_catalog,
638
+ reader=refreshed_reader,
639
+ ),
640
+ selected_source,
641
+ )
642
+ except (OSError, UnicodeError, ValueError) as error:
643
+ print(f"ACTIONABLE refresh failed: {error}")
644
+ print(f"RESUME {resume_command}")
645
+ return 2
646
+
647
+ selected_candidate_key = (
648
+ refreshed.candidates[0].key
649
+ if selected_source is not None and refreshed.candidates
650
+ else None
651
+ )
652
+ resume_command = _resume_command(
653
+ args,
654
+ root,
655
+ store_dir,
656
+ graph_path,
657
+ profile.name,
658
+ selected_candidate_key,
659
+ )
660
+
661
+ if refreshed_reader is None:
662
+ render_missing_graph(
663
+ graph_path,
664
+ root=root,
665
+ resume_command=resume_command,
666
+ )
667
+ return 2
668
+ if refreshed.fingerprint != plan.fingerprint:
669
+ render_changed_plan_diff(plan, refreshed)
670
+ try:
671
+ review = review_plan(
672
+ refreshed,
673
+ reader=refreshed_reader,
674
+ catalog=refreshed_catalog,
675
+ read_line=input,
676
+ )
677
+ except EOFError:
678
+ render_input_ended_without_writes("candidate review", resume_command)
679
+ return 2
680
+ if not review.has_writes:
681
+ render_diagnostic(refreshed, review)
682
+ return 0
683
+
684
+ try:
685
+ confirmed = confirm_review(review)
686
+ except EOFError:
687
+ render_input_ended_without_writes("final confirmation", resume_command)
688
+ return 2
689
+ if not confirmed:
690
+ render_cancelled_without_writes()
691
+ print(f"RESUME {resume_command}")
692
+ return 2
693
+
694
+ report = apply_review(refreshed, review, store_dir=store_dir, reader=refreshed_reader)
695
+ # Everything below this point runs AFTER apply_review has durably written canonical
696
+ # state (report reflects it). A failure here is never a "usage or operational error
697
+ # before review" — that meaning is reserved for exit 1 (see
698
+ # docs/getting-started/bootstrap.md#completion-recovery-and-hosts). It is at worst
699
+ # partial-recoverable, exit 2, with a resume command — never main()'s catch-all
700
+ # `except (OSError, UnicodeError, ValueError) -> return 1`, which would misreport a
701
+ # run that already has memory on disk as having failed before writing anything. The
702
+ # two handlers below therefore guard by BaseException, not by a named type list:
703
+ # matching that list against every exception this tail can actually raise (e.g. the
704
+ # unbounded recursion over a user-supplied .mcp.json in
705
+ # integrations._command_strings, reached from verify_integration below) is a losing
706
+ # game, and apply.py's own post-write handlers (_finalize, apply_review) already
707
+ # settled on the same BaseException convention for exactly this reason. That means a
708
+ # KeyboardInterrupt landing here (Ctrl-C after the write) is now reported and
709
+ # resolved as partial-recoverable — exit 2 with a resume command — exactly like any
710
+ # other post-write failure, never a bare interrupt traceback that drops the resume
711
+ # command for memory already on disk. This mirrors apply_review's own write loop,
712
+ # which already swallows a BaseException mid-write the same way. A Ctrl-C raised
713
+ # earlier, before apply_review runs (e.g. during a review prompt), is untouched by
714
+ # this and still propagates and aborts the process as before — nothing has been
715
+ # durably written yet for a resume to reconcile.
716
+ try:
717
+ domain_candidates = collect_post_apply_domain_candidates(
718
+ report,
719
+ store_dir,
720
+ refreshed_reader,
721
+ )
722
+ codex_config = _resolve_optional_path(args.codex_config, root)
723
+ integration = verify_integration(root, HostKind(args.host), codex_config=codex_config)
724
+ proof = prove_after_apply(
725
+ report,
726
+ store_dir,
727
+ refreshed_reader,
728
+ file_path=None,
729
+ )
730
+ elapsed_seconds = max(0.0, monotonic() - started_at)
731
+ task_proof = (
732
+ prove_after_apply(
733
+ report,
734
+ store_dir,
735
+ refreshed_reader,
736
+ file_path=args.task,
737
+ )
738
+ if args.task is not None
739
+ else None
740
+ )
741
+ exit_code = completion_exit_code(report, review, integration, proof)
742
+ if exit_code == 2:
743
+ report = with_exact_resume_command(
744
+ report,
745
+ args,
746
+ root,
747
+ store_dir,
748
+ graph_path,
749
+ profile.name,
750
+ selected_candidate_key,
751
+ )
752
+ render_completion(
753
+ report,
754
+ review,
755
+ integration,
756
+ proof,
757
+ domain_candidates,
758
+ task=args.task,
759
+ task_proof=task_proof,
760
+ )
761
+ except BaseException as error:
762
+ # Any exception here is post-durable-write and must resolve to
763
+ # partial-recoverable, never escape to main()'s narrower catch-all and get
764
+ # misreported as exit 1 (or, for KeyboardInterrupt/RecursionError, crash
765
+ # uncaught with memory already on disk). A further print here can itself fail on
766
+ # the same dead channel (or re-raise the same RecursionError); suppress that too
767
+ # rather than let it re-raise past this handler and get misreported as exit 1.
768
+ with suppress(BaseException):
769
+ # {error} alone renders str() only -- empty for KeyboardInterrupt() and
770
+ # thin for plenty of others, which made a real Ctrl-C after the write and a
771
+ # genuine internal bug indistinguishable on the ACTIONABLE line: both an
772
+ # empty reason. The type name identifies WHICH exception fired even when
773
+ # its message doesn't; the traceback goes to stderr (never stdout, which
774
+ # downstream tooling parses for ACTIONABLE/RESUME) so a genuine bug is
775
+ # still post-mortem debuggable.
776
+ print(
777
+ f"ACTIONABLE completion failed after durable write: "
778
+ f"{type(error).__name__}: {error}"
779
+ )
780
+ print(f"RESUME {resume_command}")
781
+ traceback.print_exception(error, file=sys.stderr)
782
+ return 2
783
+ try:
784
+ _validate_report_destination(
785
+ args,
786
+ root,
787
+ store_dir,
788
+ graph_path,
789
+ protected_sources,
790
+ )
791
+ write_report_only_when_requested(
792
+ args.report,
793
+ root,
794
+ report,
795
+ integration,
796
+ proof,
797
+ task_proof,
798
+ elapsed_seconds,
799
+ )
800
+ except BaseException as error:
801
+ # Same reasoning as the completion handler above: this runs after apply_review's
802
+ # durable write, so any exception here — not just OSError/UnicodeError/ValueError
803
+ # — is partial-recoverable, never exit 1 and never an uncaught crash. And, same
804
+ # as that handler, the diagnostic prints below must themselves be wrapped: this
805
+ # handler exists precisely for a dead output channel, so an unguarded print here
806
+ # would raise OSError, escape this except block and _main entirely, and land in
807
+ # main()'s own `except (OSError, ...) -> return 1` — misreporting a run that
808
+ # already durably wrote a canonical decision as exit 1. Suppressing here is what
809
+ # keeps this handler's own fix from being defeated by its own failure mode.
810
+ with suppress(BaseException):
811
+ # Same identity loss as the completion handler above: {error} alone is
812
+ # str()-only, so the type name goes on the ACTIONABLE line and the full
813
+ # traceback on stderr — see that handler's comment for why.
814
+ print(f"ACTIONABLE report write failed: {type(error).__name__}: {error}")
815
+ print(f"RESUME {resume_command}")
816
+ traceback.print_exception(error, file=sys.stderr)
817
+ return 2
818
+ return exit_code
819
+
820
+
821
+ def main(argv: list[str] | None = None) -> int:
822
+ args = build_parser().parse_args(argv)
823
+ try:
824
+ return _main(args)
825
+ except (OSError, UnicodeError, ValueError) as error:
826
+ print(f"ERROR {error}", file=sys.stderr)
827
+ return 1