@ictechgy/context-guard 0.4.1 → 0.4.4

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +62 -33
  3. package/README.md +91 -23
  4. package/context-guard-kit/README.md +39 -26
  5. package/context-guard-kit/benchmark_runner.py +273 -8
  6. package/context-guard-kit/claude_transcript_cost_audit.py +597 -12
  7. package/context-guard-kit/context_compress.py +153 -1
  8. package/context-guard-kit/context_filter.py +446 -0
  9. package/context-guard-kit/context_guard_cli.py +3 -0
  10. package/context-guard-kit/context_guard_diet.py +677 -2
  11. package/context-guard-kit/context_pack.py +1694 -2
  12. package/context-guard-kit/cost_guard.py +1870 -0
  13. package/context-guard-kit/setup_wizard.py +820 -29
  14. package/context-guard-kit/trim_command_output.py +396 -45
  15. package/docs/benchmark-fixtures/learned-compression.tasks.example.json +24 -0
  16. package/docs/benchmark-fixtures/learned-compression.variants.example.json +10 -0
  17. package/docs/benchmark-fixtures/visual-ocr.tasks.example.json +24 -0
  18. package/docs/benchmark-fixtures/visual-ocr.variants.example.json +10 -0
  19. package/docs/benchmark-workflow-examples.md +40 -0
  20. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +169 -0
  21. package/docs/benchmark-workflows/measured-token-workflow.example.json +170 -0
  22. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +170 -0
  23. package/docs/cache-diagnostics-schema.md +96 -0
  24. package/docs/cache-diagnostics.example.json +116 -0
  25. package/docs/cache-diagnostics.schema.json +460 -0
  26. package/docs/distribution.md +4 -2
  27. package/docs/experimental-benchmark-fixtures.md +36 -0
  28. package/package.json +11 -2
  29. package/packaging/homebrew/context-guard.rb.template +3 -2
  30. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  31. package/plugins/context-guard/README.ko.md +22 -14
  32. package/plugins/context-guard/README.md +24 -10
  33. package/plugins/context-guard/bin/context-guard +3 -0
  34. package/plugins/context-guard/bin/context-guard-audit +597 -12
  35. package/plugins/context-guard/bin/context-guard-bench +273 -8
  36. package/plugins/context-guard/bin/context-guard-compress +153 -1
  37. package/plugins/context-guard/bin/context-guard-cost +1870 -0
  38. package/plugins/context-guard/bin/context-guard-diet +677 -2
  39. package/plugins/context-guard/bin/context-guard-filter +446 -0
  40. package/plugins/context-guard/bin/context-guard-pack +1694 -2
  41. package/plugins/context-guard/bin/context-guard-setup +820 -29
  42. package/plugins/context-guard/bin/context-guard-trim-output +396 -45
  43. package/plugins/context-guard/brief/README.md +10 -3
  44. package/plugins/context-guard/skills/optimize/SKILL.md +5 -2
  45. package/plugins/context-guard/skills/setup/SKILL.md +3 -1
@@ -165,6 +165,21 @@ ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
165
165
  CODEX_SKILL_REL = ".agents/skills/context-guard/SKILL.md"
166
166
  CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
167
167
  CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
168
+ BRIEF_MODE_LEVELS = ("lite", "standard", "ultra")
169
+ BRIEF_MODE_OFF = "off"
170
+ BRIEF_MODE_CHOICES = (*BRIEF_MODE_LEVELS, BRIEF_MODE_OFF)
171
+ BRIEF_MODE_BLOCK_END = "<!-- END context-guard:brief-mode -->"
172
+ BRIEF_MODE_BEGIN_RE = re.compile(
173
+ r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
174
+ )
175
+ BRIEF_MODE_BLOCK_RE = re.compile(
176
+ r"(?:\n{0,2})?"
177
+ r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
178
+ r".*?"
179
+ r"<!-- END context-guard:brief-mode -->"
180
+ r"(?:\n{0,2})?",
181
+ re.DOTALL,
182
+ )
168
183
 
169
184
 
170
185
  class CapabilityClass:
@@ -385,6 +400,265 @@ def render_codex_skill() -> str:
385
400
  ])
386
401
 
387
402
 
403
+ def _brief_mode_source_candidates(level: str) -> list[Path]:
404
+ """Return deterministic source candidates for packaged/repo brief snippets."""
405
+ filename = f"brief-mode.{level}.md"
406
+ here = Path(__file__).resolve()
407
+ return [
408
+ here.parent / "brief" / filename,
409
+ here.parent.parent / "brief" / filename,
410
+ here.parent.parent / "plugins" / "context-guard" / "brief" / filename,
411
+ here.parent / "plugins" / "context-guard" / "brief" / filename,
412
+ ]
413
+
414
+
415
+ def _extract_brief_mode_block(level: str, text: str) -> str | None:
416
+ """Extract the single marker-delimited block for ``level`` from a snippet file."""
417
+ matches = list(BRIEF_MODE_BLOCK_RE.finditer(text))
418
+ level_matches = [match for match in matches if match.group("level") == level]
419
+ if len(level_matches) != 1:
420
+ return None
421
+ block = level_matches[0].group(0).strip()
422
+ if BRIEF_MODE_BLOCK_END not in block or not BRIEF_MODE_BEGIN_RE.search(block):
423
+ return None
424
+ return block
425
+
426
+
427
+ def render_fallback_brief_mode_block(level: str) -> str:
428
+ """Render a resilient advisory brief-mode block when packaged files are absent."""
429
+ descriptions = {
430
+ "lite": "Keep replies focused. Trim pleasantries and repeated context, but keep helpful explanations.",
431
+ "standard": "Lead with the result, prefer bullets, and keep only one short rationale when it matters.",
432
+ "ultra": "Use terse result-first bullets or tables with no preamble or self-narration.",
433
+ }
434
+ if level not in BRIEF_MODE_LEVELS:
435
+ raise ValueError(f"unknown brief mode level: {level}")
436
+ return "\n".join([
437
+ f"<!-- BEGIN context-guard:brief-mode level={level} version=1 -->",
438
+ f"## Response style: brief mode ({level}) — advisory",
439
+ "",
440
+ descriptions[level],
441
+ "This is best-effort guidance, not a hard rule.",
442
+ "",
443
+ "Always preserve this evidence, even when trimming wording:",
444
+ "",
445
+ "- Exact file paths, with line numbers where useful (e.g. `src/app.py:42`).",
446
+ "- The exact commands you ran.",
447
+ "- Relevant command output, error messages, stack traces, and exit codes — never hide a failure.",
448
+ "- Code in fenced blocks whenever code is needed for correctness.",
449
+ "- Verification status: what you ran and whether it passed or failed.",
450
+ "- The list of changed files.",
451
+ "- Known gaps, TODOs, and assumptions.",
452
+ "- Caveats and anything I should double-check.",
453
+ "",
454
+ "This guidance does not promise reduced tokens or cost; measure real results before claiming savings.",
455
+ BRIEF_MODE_BLOCK_END,
456
+ ])
457
+
458
+
459
+ def render_brief_mode_block(level: str) -> str:
460
+ """Render the marker-delimited advisory snippet for a brief-mode level."""
461
+ if level not in BRIEF_MODE_LEVELS:
462
+ raise ValueError(f"unknown brief mode level: {level}")
463
+ for candidate in _brief_mode_source_candidates(level):
464
+ try:
465
+ text = candidate.read_text(encoding="utf-8")
466
+ except OSError:
467
+ continue
468
+ block = _extract_brief_mode_block(level, text)
469
+ if block:
470
+ return block
471
+ return render_fallback_brief_mode_block(level)
472
+
473
+
474
+ def _brief_mode_levels_in_text(text: str) -> list[str]:
475
+ return [match.group("level") for match in BRIEF_MODE_BLOCK_RE.finditer(text)]
476
+
477
+
478
+ def _remove_brief_mode_blocks(text: str) -> tuple[str, list[str]]:
479
+ """Remove all ContextGuard-managed brief-mode blocks while preserving user text."""
480
+ levels = _brief_mode_levels_in_text(text)
481
+ stripped = BRIEF_MODE_BLOCK_RE.sub("\n\n", text)
482
+ stripped = re.sub(r"\n{3,}", "\n\n", stripped).strip("\n")
483
+ return ((stripped + "\n") if stripped else "", levels)
484
+
485
+
486
+ def _append_managed_block(existing: str, block: str) -> str:
487
+ if existing.strip():
488
+ return existing.rstrip("\n") + "\n\n" + block + "\n"
489
+ return block + "\n"
490
+
491
+
492
+ def compose_rule_file_text(
493
+ existing: str | None,
494
+ *,
495
+ with_init: bool,
496
+ brief_mode: str | None,
497
+ ) -> tuple[str, dict[str, Any]]:
498
+ """Compose final repo rule text for combined init and brief-mode mutations."""
499
+ text = existing or ""
500
+ original_text = text
501
+ existing_brief_levels = _brief_mode_levels_in_text(text)
502
+ meta: dict[str, Any] = {
503
+ "init_changed": False,
504
+ "init_present_before": ADAPTER_RULE_BLOCK_BEGIN in text,
505
+ "brief_levels_before": existing_brief_levels,
506
+ "brief_changed": False,
507
+ }
508
+ if with_init and ADAPTER_RULE_BLOCK_BEGIN not in text:
509
+ text = _append_managed_block(text, render_repo_rule_block())
510
+ meta["init_changed"] = True
511
+ if brief_mode:
512
+ stripped, removed_levels = _remove_brief_mode_blocks(text)
513
+ if brief_mode == BRIEF_MODE_OFF:
514
+ text = stripped
515
+ meta["brief_changed"] = bool(removed_levels)
516
+ else:
517
+ block = render_brief_mode_block(brief_mode)
518
+ text = _append_managed_block(stripped, block)
519
+ meta["brief_changed"] = removed_levels != [brief_mode] or text != original_text
520
+ meta["brief_levels_removed"] = removed_levels
521
+ meta["changed"] = text != original_text
522
+ return text, meta
523
+
524
+
525
+ def plan_or_write_rule_file_blocks(
526
+ path: Path,
527
+ *,
528
+ with_init: bool,
529
+ brief_mode: str | None,
530
+ applied: bool,
531
+ ) -> dict[str, Any]:
532
+ """Plan or apply managed rule-file blocks with one original backup per changed existing write."""
533
+ result: dict[str, Any] = {
534
+ "status": None,
535
+ "planned_actions": [],
536
+ "applied_actions": [],
537
+ "brief_mode_status": None,
538
+ "brief_mode_existing_levels": [],
539
+ "brief_mode_backup_path": None,
540
+ "reason": None,
541
+ }
542
+ state = _rule_file_state(path)
543
+ if state["status"] not in {"missing", "file"}:
544
+ reason = state.get("reason") or f"refused unsafe rule target: {path.name}"
545
+ result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
546
+ result["planned_actions"].append(reason)
547
+ return result
548
+
549
+ existing = state.get("text")
550
+ existing_text = str(existing or "")
551
+ result["brief_mode_existing_levels"] = _brief_mode_levels_in_text(existing_text)
552
+ rule_present = existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing_text
553
+ planned_meta: dict[str, Any] | None = None
554
+ if brief_mode:
555
+ _, planned_meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
556
+
557
+ if with_init:
558
+ if rule_present:
559
+ result["status"] = "exists"
560
+ result["planned_actions"].append("advisory ContextGuard rules already present")
561
+ elif not applied:
562
+ result["status"] = "planned"
563
+ result["planned_actions"].append("would add advisory ContextGuard rules")
564
+ elif not brief_mode:
565
+ result["status"] = "planned"
566
+ result["planned_actions"].append("run with --with-init to add advisory ContextGuard rules")
567
+
568
+ if brief_mode:
569
+ brief_changed = bool(planned_meta and planned_meta.get("brief_changed"))
570
+ if brief_mode == BRIEF_MODE_OFF:
571
+ if brief_changed:
572
+ result["brief_mode_status"] = "planned" if not applied else None
573
+ if not applied:
574
+ result["planned_actions"].append("would remove advisory brief-mode rules")
575
+ else:
576
+ result["brief_mode_status"] = "absent"
577
+ result["planned_actions"].append("advisory brief-mode rules already absent")
578
+ else:
579
+ levels = result["brief_mode_existing_levels"]
580
+ if not brief_changed:
581
+ result["brief_mode_status"] = "exists"
582
+ result["planned_actions"].append(f"advisory brief-mode {brief_mode} rules already present")
583
+ elif not applied:
584
+ result["brief_mode_status"] = "planned"
585
+ action = "refresh" if levels == [brief_mode] else ("replace" if levels else "add")
586
+ result["planned_actions"].append(f"would {action} advisory brief-mode {brief_mode} rules")
587
+
588
+ if not applied:
589
+ if result["status"] is None:
590
+ result["status"] = "planned" if result["planned_actions"] else "unchanged"
591
+ return result
592
+
593
+ final_text, meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
594
+ if not meta["changed"]:
595
+ if result["status"] is None:
596
+ result["status"] = "exists" if rule_present else "unchanged"
597
+ if result["brief_mode_status"] is None and brief_mode:
598
+ result["brief_mode_status"] = "absent" if brief_mode == BRIEF_MODE_OFF else "exists"
599
+ return result
600
+
601
+ backup_path = None
602
+ if existing is not None:
603
+ try:
604
+ backup_path = backup_existing(path)
605
+ except OSError as exc:
606
+ reason = f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"
607
+ result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
608
+ result["planned_actions"] = [reason]
609
+ return result
610
+ durability_warning = None
611
+ try:
612
+ atomic_write(
613
+ path,
614
+ final_text,
615
+ existing_mode_or_default(path, 0o644) if existing is not None else 0o644,
616
+ dir_mode=0o755,
617
+ )
618
+ except AtomicWriteDurabilityError as exc:
619
+ durability_warning = str(exc)
620
+ except OSError as exc:
621
+ reason = f"could not write repo rule file {path.name}: {exc.__class__.__name__}"
622
+ result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
623
+ result["planned_actions"] = [reason]
624
+ return result
625
+
626
+ if backup_path:
627
+ result["brief_mode_backup_path"] = str(backup_path)
628
+ if durability_warning:
629
+ result["status"] = "applied-durability-uncertain"
630
+ result["reason"] = durability_warning
631
+ if with_init:
632
+ if not durability_warning:
633
+ result["status"] = "applied" if meta["init_changed"] else "exists"
634
+ if meta["init_changed"]:
635
+ result["applied_actions"].append("wrote advisory ContextGuard rules")
636
+ else:
637
+ result["planned_actions"].append("advisory ContextGuard rules already present")
638
+ elif result["status"] is None:
639
+ result["status"] = "unchanged"
640
+ if brief_mode:
641
+ if brief_mode == BRIEF_MODE_OFF:
642
+ result["brief_mode_status"] = "removed" if meta["brief_changed"] else "absent"
643
+ if meta["brief_changed"]:
644
+ result["applied_actions"].append("removed advisory brief-mode rules")
645
+ else:
646
+ result["planned_actions"].append("advisory brief-mode rules already absent")
647
+ else:
648
+ before = meta.get("brief_levels_removed") or []
649
+ if before and before != [brief_mode]:
650
+ result["brief_mode_status"] = "replaced"
651
+ elif before == [brief_mode]:
652
+ result["brief_mode_status"] = "updated"
653
+ else:
654
+ result["brief_mode_status"] = "applied"
655
+ result["applied_actions"].append(f"wrote advisory brief-mode {brief_mode} rules")
656
+ if durability_warning:
657
+ result["planned_actions"].append(durability_warning)
658
+ result["planned_actions"].extend(result["applied_actions"])
659
+ return result
660
+
661
+
388
662
  def _read_rule_file_text(path: Path) -> str | None:
389
663
  """Best-effort no-follow read; only a missing file is treated as absent.
390
664
 
@@ -399,8 +673,38 @@ def _read_rule_file_text(path: Path) -> str | None:
399
673
  return None
400
674
 
401
675
 
676
+ def _existing_rule_parent_issue(path: Path) -> str | None:
677
+ """Return a reason when an existing parent component is unsafe to traverse.
678
+
679
+ Missing parent directories are intentionally allowed: atomic writes create them
680
+ with explicit modes. Existing symlink/non-directory parents are not allowed,
681
+ because plan/apply must agree and must never follow an attacker-swapped rule
682
+ directory outside the project.
683
+ """
684
+ parts = path.parts[1:-1] if path.is_absolute() else path.parts[:-1]
685
+ if not parts:
686
+ return None
687
+ current = Path(path.anchor) if path.is_absolute() else Path()
688
+ for part in parts:
689
+ current = current / part
690
+ try:
691
+ st = os.lstat(current)
692
+ except FileNotFoundError:
693
+ return None
694
+ except OSError as exc:
695
+ return f"could not inspect rule parent {current}: {exc.__class__.__name__}"
696
+ if stat.S_ISLNK(st.st_mode):
697
+ return f"refused to traverse symlinked rule parent: {current}"
698
+ if not stat.S_ISDIR(st.st_mode):
699
+ return f"refused non-directory rule parent: {current}"
700
+ return None
701
+
702
+
402
703
  def _rule_file_state(path: Path) -> dict[str, Any]:
403
704
  """Return a non-throwing state for project rule/skill files."""
705
+ parent_issue = _existing_rule_parent_issue(path)
706
+ if parent_issue:
707
+ return {"status": "unsafe", "text": None, "reason": parent_issue}
404
708
  try:
405
709
  st = os.lstat(path)
406
710
  except FileNotFoundError:
@@ -433,6 +737,7 @@ def write_repo_rule_init(path: Path) -> dict[str, Any]:
433
737
 
434
738
  Returns a status dict: ``applied`` (block written), ``exists`` (already
435
739
  present), or ``skipped`` (refused, e.g. symlinked target) with a reason.
740
+ Existing user-owned rule files are backed up before any changed write.
436
741
  """
437
742
  state = _rule_file_state(path)
438
743
  if state["status"] not in {"missing", "file"}:
@@ -443,15 +748,30 @@ def write_repo_rule_init(path: Path) -> dict[str, Any]:
443
748
  block = render_repo_rule_block()
444
749
  if existing:
445
750
  new_text = existing.rstrip("\n") + "\n\n" + block + "\n"
446
- mode = existing_mode_or_default(path, 0o644)
447
751
  else:
448
752
  new_text = block + "\n"
449
- mode = 0o644
753
+ mode = existing_mode_or_default(path, 0o644) if existing is not None else 0o644
754
+ backup_path = None
755
+ if existing is not None:
756
+ try:
757
+ backup_path = backup_existing(path)
758
+ except OSError as exc:
759
+ return {"status": "skipped", "reason": f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"}
760
+ durability_warning = None
450
761
  try:
451
762
  atomic_write(path, new_text, mode, dir_mode=0o755)
763
+ except AtomicWriteDurabilityError as exc:
764
+ durability_warning = str(exc)
452
765
  except OSError as exc:
453
- return {"status": "skipped", "reason": f"could not write repo rule file {path.name}: {exc.__class__.__name__}"}
454
- return {"status": "applied"}
766
+ result = {"status": "skipped", "reason": f"could not write repo rule file {path.name}: {exc.__class__.__name__}"}
767
+ if backup_path:
768
+ result["backup_path"] = str(backup_path)
769
+ return result
770
+ result = {"status": "applied", "backup_path": str(backup_path) if backup_path else None}
771
+ if durability_warning:
772
+ result["status"] = "applied-durability-uncertain"
773
+ result["reason"] = durability_warning
774
+ return result
455
775
 
456
776
 
457
777
  def codex_skill_status(path: Path) -> str:
@@ -518,12 +838,14 @@ def build_adapter_plan(
518
838
  with_init: bool,
519
839
  with_skill: bool,
520
840
  applied: bool,
841
+ brief_mode: str | None = None,
521
842
  ) -> list[dict[str, Any]]:
522
843
  """Render a per-adapter plan, performing safe repo-rule writes when applied.
523
844
 
524
- Only repo-rule adapters write, and only when both ``with_init`` and ``applied``
525
- are set. Native-plugin entries mirror the Claude settings result; native-skill
526
- and report-only entries are advisory and never write.
845
+ Repo-rule adapters write when ``applied`` is set and either ``with_init`` or
846
+ project-scope ``brief_mode`` requested a managed rule-file block. Native-plugin
847
+ entries mirror the Claude settings result; native-skill and report-only entries
848
+ are advisory and never write.
527
849
  """
528
850
  detected = set(detect_agents(root))
529
851
  plan: list[dict[str, Any]] = []
@@ -541,6 +863,14 @@ def build_adapter_plan(
541
863
  "applied_actions": [],
542
864
  "unsupported_reason": None,
543
865
  }
866
+ if brief_mode:
867
+ entry["brief_mode"] = brief_mode
868
+ entry["brief_mode_status"] = "unsupported"
869
+ entry["brief_mode_level"] = None if brief_mode == BRIEF_MODE_OFF else brief_mode
870
+ entry["brief_mode_file"] = None
871
+ entry["brief_mode_existing_levels"] = []
872
+ entry["brief_mode_backup_path"] = None
873
+ entry["brief_mode_reason"] = None
544
874
  if scope == "user" and adapter.key != "claude":
545
875
  entry["status"] = "unsupported"
546
876
  entry["writable"] = False
@@ -549,6 +879,8 @@ def build_adapter_plan(
549
879
  "use --scope project or run the helper commands manually."
550
880
  )
551
881
  entry["planned_actions"] = [entry["unsupported_reason"]]
882
+ if brief_mode:
883
+ entry["brief_mode_reason"] = entry["unsupported_reason"]
552
884
  plan.append(entry)
553
885
  continue
554
886
  if adapter.capability == CapabilityClass.NATIVE_PLUGIN:
@@ -562,29 +894,91 @@ def build_adapter_plan(
562
894
  entry["status"] = "planned"
563
895
  else:
564
896
  entry["status"] = "unchanged"
897
+ if brief_mode:
898
+ rule_path = adapter_rule_path(root, adapter)
899
+ entry["rule_file"] = str(rule_path.relative_to(root)) if rule_path and scope == "project" else adapter.rule_file
900
+ entry["brief_mode_file"] = entry.get("rule_file")
901
+ if scope != "project" or rule_path is None:
902
+ entry["brief_mode_status"] = "unsupported"
903
+ entry["brief_mode_reason"] = "brief-mode rule-file writes are project-scope only"
904
+ entry["planned_actions"].append(entry["brief_mode_reason"])
905
+ else:
906
+ result = plan_or_write_rule_file_blocks(
907
+ rule_path,
908
+ with_init=False,
909
+ brief_mode=brief_mode,
910
+ applied=applied,
911
+ )
912
+ entry["brief_mode_status"] = result["brief_mode_status"]
913
+ entry["brief_mode_existing_levels"] = result["brief_mode_existing_levels"]
914
+ entry["brief_mode_backup_path"] = result["brief_mode_backup_path"]
915
+ entry["brief_mode_reason"] = result.get("reason")
916
+ for action in result.get("planned_actions", []):
917
+ entry["planned_actions"].append(f"{action} in {entry['rule_file']}")
918
+ for action in result.get("applied_actions", []):
919
+ entry["applied_actions"].append(f"{action} in {entry['rule_file']}")
920
+ if result.get("applied_actions"):
921
+ entry["status"] = (
922
+ result["status"]
923
+ if result.get("status") == "applied-durability-uncertain"
924
+ else "applied"
925
+ )
926
+ if result.get("reason"):
927
+ entry["brief_mode_reason"] = result.get("reason")
565
928
  elif adapter.capability == CapabilityClass.REPO_RULE:
566
929
  entry["writable"] = True
567
930
  rule_path = adapter_rule_path(root, adapter)
568
931
  entry["rule_file"] = str(rule_path.relative_to(root)) if rule_path else adapter.rule_file
569
- if rule_path is not None and repo_rule_block_present(rule_path):
570
- entry["status"] = "exists"
571
- entry["planned_actions"] = [f"advisory ContextGuard rules already present in {entry['rule_file']}"]
572
- elif not with_init:
573
- entry["status"] = "planned"
574
- entry["planned_actions"] = [f"run with --with-init to add advisory ContextGuard rules to {entry['rule_file']}"]
575
- elif not applied:
576
- entry["status"] = "planned"
577
- entry["planned_actions"] = [f"would add advisory ContextGuard rules to {entry['rule_file']}"]
578
- elif rule_path is not None:
579
- result = write_repo_rule_init(rule_path)
932
+ if brief_mode and scope != "project":
933
+ entry["brief_mode_status"] = "unsupported"
934
+ entry["brief_mode_reason"] = "brief-mode rule-file writes are project-scope only"
935
+ entry["planned_actions"].append(entry["brief_mode_reason"])
936
+ elif brief_mode and rule_path is not None:
937
+ entry["brief_mode_file"] = entry["rule_file"]
938
+ result = plan_or_write_rule_file_blocks(
939
+ rule_path,
940
+ with_init=with_init,
941
+ brief_mode=brief_mode,
942
+ applied=applied,
943
+ )
580
944
  entry["status"] = result["status"]
581
- if result["status"] == "applied":
582
- entry["applied_actions"] = [f"wrote advisory ContextGuard rules to {entry['rule_file']}"]
583
- entry["planned_actions"] = list(entry["applied_actions"])
584
- elif result["status"] == "exists":
945
+ entry["brief_mode_status"] = result["brief_mode_status"]
946
+ entry["brief_mode_existing_levels"] = result["brief_mode_existing_levels"]
947
+ entry["brief_mode_backup_path"] = result["brief_mode_backup_path"]
948
+ entry["brief_mode_reason"] = result.get("reason")
949
+ entry["planned_actions"] = [f"{action} in {entry['rule_file']}" for action in result.get("planned_actions", [])]
950
+ entry["applied_actions"] = [f"{action} in {entry['rule_file']}" for action in result.get("applied_actions", [])]
951
+ if result.get("applied_actions"):
952
+ entry["status"] = (
953
+ result["status"]
954
+ if result.get("status") == "applied-durability-uncertain"
955
+ else "applied"
956
+ )
957
+ else:
958
+ if rule_path is not None and repo_rule_block_present(rule_path):
959
+ entry["status"] = "exists"
585
960
  entry["planned_actions"] = [f"advisory ContextGuard rules already present in {entry['rule_file']}"]
586
- else:
587
- entry["planned_actions"] = [result.get("reason", "skipped")]
961
+ elif not with_init:
962
+ entry["status"] = "planned"
963
+ entry["planned_actions"] = [f"run with --with-init to add advisory ContextGuard rules to {entry['rule_file']}"]
964
+ elif not applied:
965
+ entry["status"] = "planned"
966
+ entry["planned_actions"] = [f"would add advisory ContextGuard rules to {entry['rule_file']}"]
967
+ elif rule_path is not None:
968
+ result = write_repo_rule_init(rule_path)
969
+ entry["status"] = result["status"]
970
+ if result["status"] in {"applied", "applied-durability-uncertain"}:
971
+ entry["applied_actions"] = [f"wrote advisory ContextGuard rules to {entry['rule_file']}"]
972
+ entry["planned_actions"] = list(entry["applied_actions"])
973
+ if result.get("reason"):
974
+ entry["planned_actions"].append(result["reason"])
975
+ entry["reason"] = result["reason"]
976
+ if result.get("backup_path"):
977
+ entry["rule_backup_path"] = result["backup_path"]
978
+ elif result["status"] == "exists":
979
+ entry["planned_actions"] = [f"advisory ContextGuard rules already present in {entry['rule_file']}"]
980
+ else:
981
+ entry["planned_actions"] = [result.get("reason", "skipped")]
588
982
  if adapter.key == "codex" and adapter.project_skill_rel:
589
983
  skill_path = root / adapter.project_skill_rel
590
984
  entry["project_skill_file"] = adapter.project_skill_rel
@@ -623,8 +1017,16 @@ def build_adapter_plan(
623
1017
  entry["planned_actions"].append(skill_result.get("reason", "skipped"))
624
1018
  elif adapter.capability == CapabilityClass.NATIVE_SKILL:
625
1019
  entry["planned_actions"] = [adapter.summary]
1020
+ if brief_mode:
1021
+ entry["brief_mode_status"] = "unsupported"
1022
+ entry["brief_mode_reason"] = "adapter has no managed rule-file target"
1023
+ entry["planned_actions"].append(entry["brief_mode_reason"])
626
1024
  else: # REPORT_ONLY
627
1025
  entry["planned_actions"] = [adapter.summary]
1026
+ if brief_mode:
1027
+ entry["brief_mode_status"] = "unsupported"
1028
+ entry["brief_mode_reason"] = "adapter has no managed rule-file target"
1029
+ entry["planned_actions"].append(entry["brief_mode_reason"])
628
1030
  plan.append(entry)
629
1031
  return plan
630
1032
 
@@ -695,9 +1097,9 @@ def validate_settings_target(root: Path, settings_path: Path, *, allow_home_sett
695
1097
  "pass --root <project>, or use --allow-home-settings if you intentionally want this."
696
1098
  )
697
1099
  claude_dir = root / ".claude"
698
- if claude_dir.exists() and claude_dir.is_symlink():
1100
+ if claude_dir.is_symlink():
699
1101
  raise SystemExit(f"Refusing to use symlinked Claude settings directory: {claude_dir}")
700
- if settings_path.exists() and settings_path.is_symlink():
1102
+ if settings_path.is_symlink():
701
1103
  raise SystemExit(f"Refusing to write through symlinked settings file: {settings_path}")
702
1104
  if claude_dir.exists():
703
1105
  try:
@@ -885,6 +1287,16 @@ def _read_optional_text_no_follow(path: Path) -> str | None:
885
1287
  raise SystemExit(f"Could not read {path} without following symlinks: {exc}") from exc
886
1288
 
887
1289
 
1290
+ def _path_exists_no_follow(path: Path) -> bool:
1291
+ try:
1292
+ path.lstat()
1293
+ except FileNotFoundError:
1294
+ return False
1295
+ except OSError:
1296
+ return False
1297
+ return True
1298
+
1299
+
888
1300
  def _parse_json_object_text(text: str | None, path: Path) -> dict[str, Any]:
889
1301
  if text is None:
890
1302
  return {}
@@ -1218,6 +1630,362 @@ def run_post_setup_diet_scan(root: Path) -> dict[str, Any]:
1218
1630
  return {"status": "failed", "reason": "invalid-report"}
1219
1631
 
1220
1632
 
1633
+ def doctor_check(
1634
+ ident: str,
1635
+ status: str,
1636
+ severity: str,
1637
+ message: str,
1638
+ *,
1639
+ detail: Any | None = None,
1640
+ next_action: str | None = None,
1641
+ ) -> dict[str, Any]:
1642
+ check = {
1643
+ "id": ident,
1644
+ "status": status,
1645
+ "severity": severity,
1646
+ "message": message,
1647
+ }
1648
+ if detail is not None:
1649
+ check["detail"] = detail
1650
+ if next_action:
1651
+ check["next_action"] = next_action
1652
+ return check
1653
+
1654
+
1655
+ def _setup_command(args: argparse.Namespace, *, apply: bool, root: Path | None = None) -> str:
1656
+ parts = ["context-guard", "setup", "--scope", normalize_scope(getattr(args, "scope", "project"))]
1657
+ if root is not None and normalize_scope(getattr(args, "scope", "project")) == "project":
1658
+ parts.extend(["--root", str(root)])
1659
+ selected = explicit_agent_selection(args)
1660
+ if selected:
1661
+ parts.extend(["--agent", ",".join(selected)])
1662
+ elif normalize_scope(getattr(args, "scope", "project")) == "user":
1663
+ parts.extend(["--agent", "claude"])
1664
+ if getattr(args, "with_init", False):
1665
+ parts.append("--with-init")
1666
+ if getattr(args, "with_skill", False):
1667
+ parts.append("--with-skill")
1668
+ brief_mode = getattr(args, "brief_mode", None)
1669
+ if brief_mode:
1670
+ parts.extend(["--brief-mode", str(brief_mode)])
1671
+ for attr, flag in (
1672
+ ("no_denies", "--no-denies"),
1673
+ ("no_statusline", "--no-statusline"),
1674
+ ("no_bash_hook", "--no-bash-hook"),
1675
+ ("no_read_guard", "--no-read-guard"),
1676
+ ("no_model_defaults", "--no-model-defaults"),
1677
+ ("no_diet_scan", "--no-diet-scan"),
1678
+ ):
1679
+ if getattr(args, attr, False):
1680
+ parts.append(flag)
1681
+ if getattr(args, "failed_attempt_nudge", None) is False:
1682
+ parts.append("--no-failed-attempt-nudge")
1683
+ elif getattr(args, "failed_attempt_nudge", None) is True:
1684
+ parts.append("--failed-attempt-nudge")
1685
+ parts.append("--yes" if apply else "--plan")
1686
+ return shlex.join(parts)
1687
+
1688
+
1689
+ def _doctor_status(checks: list[dict[str, Any]]) -> str:
1690
+ if any(check.get("status") == "error" or check.get("severity") == "error" for check in checks):
1691
+ return "error"
1692
+ if any(check.get("status") == "warning" or check.get("severity") in {"high", "medium"} for check in checks):
1693
+ return "warning"
1694
+ return "ok"
1695
+
1696
+
1697
+ def _helper_availability_check(*, include_diet: bool = True) -> dict[str, Any]:
1698
+ helpers = {
1699
+ HELPER_STATUSLINE: "statusline_merged.sh",
1700
+ HELPER_REWRITE_BASH: "rewrite_bash_for_token_budget.py",
1701
+ HELPER_GUARD_READ: "guard_large_read.py",
1702
+ HELPER_FAILED_NUDGE: "failed_attempt_nudge.py",
1703
+ }
1704
+ if include_diet:
1705
+ helpers[HELPER_DIET] = "context_guard_diet.py"
1706
+ resolved: dict[str, str] = {}
1707
+ missing: list[str] = []
1708
+ for helper, kit_script in helpers.items():
1709
+ try:
1710
+ resolved[helper] = shlex.join(helper_argv(helper, kit_script, shell=("bash" if kit_script.endswith(".sh") else None)))
1711
+ except SystemExit:
1712
+ missing.append(helper)
1713
+ if missing:
1714
+ return doctor_check(
1715
+ "helper-availability",
1716
+ "error",
1717
+ "error",
1718
+ "Some ContextGuard helper commands could not be resolved.",
1719
+ detail={"missing": missing, "resolved": resolved},
1720
+ next_action="Reinstall ContextGuard or run from a complete checkout.",
1721
+ )
1722
+ return doctor_check(
1723
+ "helper-availability",
1724
+ "ok",
1725
+ "low",
1726
+ "Required ContextGuard helper commands are resolvable.",
1727
+ detail={"resolved": resolved},
1728
+ )
1729
+
1730
+
1731
+ def _adapter_warning_detail(entry: dict[str, Any]) -> dict[str, Any]:
1732
+ detail = {
1733
+ "key": entry.get("key"),
1734
+ "status": entry.get("status"),
1735
+ "planned_actions": entry.get("planned_actions", []),
1736
+ "unsupported_reason": entry.get("unsupported_reason"),
1737
+ }
1738
+ for key in ("brief_mode", "brief_mode_status", "brief_mode_reason", "brief_mode_file"):
1739
+ if key in entry:
1740
+ detail[key] = entry.get(key)
1741
+ return detail
1742
+
1743
+
1744
+ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
1745
+ """Return a read-only setup health report.
1746
+
1747
+ This intentionally mirrors setup planning while never prompting, backing up,
1748
+ writing settings, writing rule files, or creating rollback records.
1749
+ """
1750
+ require_no_follow_file_ops_supported()
1751
+ scope = normalize_scope(getattr(args, "scope", "project"))
1752
+ root = resolve_scope_root(args.root, scope)
1753
+ settings_path = root / SETTINGS_REL
1754
+ helper_check = _helper_availability_check(include_diet=not getattr(args, "no_diet_scan", False))
1755
+ checks: list[dict[str, Any]] = [helper_check]
1756
+ warnings: list[str] = []
1757
+ if scope == "user":
1758
+ warnings.append("user-scope verify is read-only; applying user-scope setup still requires --yes and an explicit agent")
1759
+
1760
+ selected_agents = explicit_agent_selection(args)
1761
+ targets = resolve_target_adapters(root, selected_agents)
1762
+ claude_targeted = any(adapter.key == "claude" for adapter in targets)
1763
+
1764
+ original_text = None
1765
+ original: dict[str, Any] = {}
1766
+ settings: dict[str, Any] = {}
1767
+ if claude_targeted:
1768
+ try:
1769
+ validate_settings_target(root, settings_path, allow_home_settings=(args.allow_home_settings or scope == "user"))
1770
+ original_text = _read_optional_text_no_follow(settings_path)
1771
+ original = _parse_json_object_text(original_text, settings_path)
1772
+ settings = json.loads(json.dumps(original))
1773
+ checks.append(doctor_check(
1774
+ "settings-target",
1775
+ "ok",
1776
+ "low",
1777
+ "Claude settings target is readable without following symlinks.",
1778
+ detail={
1779
+ "exists": original_text is not None,
1780
+ "path": str(settings_path),
1781
+ },
1782
+ ))
1783
+ except SystemExit as exc:
1784
+ checks.append(doctor_check(
1785
+ "settings-target",
1786
+ "error",
1787
+ "error",
1788
+ "Claude settings target could not be read as a safe JSON object.",
1789
+ detail={
1790
+ "exists": _path_exists_no_follow(settings_path),
1791
+ "path": str(settings_path),
1792
+ "error": str(exc),
1793
+ },
1794
+ next_action=f"Fix or remove {settings_path} before running setup or verify again.",
1795
+ ))
1796
+ return {
1797
+ "schema_version": "contextguard.doctor.v1",
1798
+ "status": "error",
1799
+ "root": str(root),
1800
+ "scope": scope,
1801
+ "settings_path": str(settings_path),
1802
+ "read_only": True,
1803
+ "warnings": warnings,
1804
+ "checks": checks,
1805
+ "setup_plan": {
1806
+ "changed": False,
1807
+ "actions": [],
1808
+ "adapter_plan": [],
1809
+ },
1810
+ "diet_scan": {"status": "skipped", "reason": "settings-target-error"},
1811
+ "recommended_commands": [],
1812
+ }
1813
+ else:
1814
+ checks.append(doctor_check(
1815
+ "settings-target",
1816
+ "ok",
1817
+ "low",
1818
+ "Claude settings target was not requested for selected adapters.",
1819
+ detail={"path": str(settings_path)},
1820
+ ))
1821
+
1822
+ if helper_check.get("status") == "error":
1823
+ diet_scan = {"status": "skipped", "reason": "helper-unavailable"}
1824
+ return {
1825
+ "schema_version": "contextguard.doctor.v1",
1826
+ "status": "error",
1827
+ "root": str(root),
1828
+ "scope": scope,
1829
+ "settings_path": str(settings_path),
1830
+ "read_only": True,
1831
+ "warnings": warnings,
1832
+ "checks": checks,
1833
+ "setup_plan": {
1834
+ "changed": False,
1835
+ "actions": [],
1836
+ "adapter_plan": [],
1837
+ },
1838
+ "diet_scan": diet_scan,
1839
+ "recommended_commands": [],
1840
+ }
1841
+
1842
+ choices = choices_from_args(args)
1843
+ actions = apply_choices(settings, choices) if claude_targeted else []
1844
+ changed = (settings != original) if claude_targeted else False
1845
+ if changed:
1846
+ checks.append(doctor_check(
1847
+ "setup-plan",
1848
+ "warning",
1849
+ "medium",
1850
+ "ContextGuard setup is not fully applied for the requested selections.",
1851
+ detail={"planned_action_count": len(actions), "planned_actions": actions},
1852
+ next_action=_setup_command(args, apply=False, root=root),
1853
+ ))
1854
+ else:
1855
+ checks.append(doctor_check(
1856
+ "setup-plan",
1857
+ "ok",
1858
+ "low",
1859
+ "Requested setup settings are already satisfied.",
1860
+ detail={"planned_action_count": 0},
1861
+ ))
1862
+
1863
+ adapter_plan = build_adapter_plan(
1864
+ root,
1865
+ targets,
1866
+ scope=scope,
1867
+ claude_actions=actions,
1868
+ claude_changed=changed,
1869
+ claude_applied=False,
1870
+ with_init=bool(getattr(args, "with_init", False)),
1871
+ with_skill=bool(getattr(args, "with_skill", False)),
1872
+ applied=False,
1873
+ brief_mode=getattr(args, "brief_mode", None),
1874
+ )
1875
+ adapter_warnings = [
1876
+ _adapter_warning_detail(entry)
1877
+ for entry in adapter_plan
1878
+ if entry.get("status") in {"planned", "unsupported", "skipped"}
1879
+ ]
1880
+ if adapter_warnings:
1881
+ checks.append(doctor_check(
1882
+ "adapter-plan",
1883
+ "warning",
1884
+ "medium",
1885
+ "Some requested adapters still have planned or unsupported setup actions.",
1886
+ detail={"adapters": adapter_warnings},
1887
+ next_action=_setup_command(args, apply=False, root=root),
1888
+ ))
1889
+ else:
1890
+ checks.append(doctor_check(
1891
+ "adapter-plan",
1892
+ "ok",
1893
+ "low",
1894
+ "Requested adapter setup plan has no pending supported writes.",
1895
+ detail={"adapter_count": len(adapter_plan)},
1896
+ ))
1897
+
1898
+ diet_scan = None
1899
+ if getattr(args, "no_diet_scan", False):
1900
+ diet_scan = {"status": "skipped", "reason": "disabled-by-flag"}
1901
+ checks.append(doctor_check(
1902
+ "diet-scan",
1903
+ "ok",
1904
+ "low",
1905
+ "Context hygiene scan was skipped by flag.",
1906
+ detail=diet_scan,
1907
+ ))
1908
+ else:
1909
+ diet_next_action = shlex.join(["context-guard", "diet", "scan", str(root), "--json"])
1910
+ diet_scan = run_post_setup_diet_scan(root)
1911
+ if diet_scan.get("status") != "completed":
1912
+ checks.append(doctor_check(
1913
+ "diet-scan",
1914
+ "warning",
1915
+ "medium",
1916
+ "Context hygiene scan could not complete.",
1917
+ detail=diet_scan,
1918
+ next_action=diet_next_action,
1919
+ ))
1920
+ else:
1921
+ counts = diet_scan.get("severity_counts", {})
1922
+ high_medium = int(counts.get("high", 0) or 0) + int(counts.get("medium", 0) or 0)
1923
+ if high_medium:
1924
+ checks.append(doctor_check(
1925
+ "diet-scan",
1926
+ "warning",
1927
+ "medium",
1928
+ "Context hygiene scan found high/medium findings.",
1929
+ detail=diet_scan,
1930
+ next_action=diet_next_action,
1931
+ ))
1932
+ else:
1933
+ checks.append(doctor_check(
1934
+ "diet-scan",
1935
+ "ok",
1936
+ "low",
1937
+ "Context hygiene scan has no high/medium findings.",
1938
+ detail=diet_scan,
1939
+ ))
1940
+
1941
+ recommended = [_setup_command(args, apply=False, root=root)]
1942
+ if changed or adapter_warnings:
1943
+ recommended.append(_setup_command(args, apply=True, root=root))
1944
+ return {
1945
+ "schema_version": "contextguard.doctor.v1",
1946
+ "status": _doctor_status(checks),
1947
+ "root": str(root),
1948
+ "scope": scope,
1949
+ "settings_path": str(settings_path),
1950
+ "read_only": True,
1951
+ "warnings": warnings,
1952
+ "checks": checks,
1953
+ "setup_plan": {
1954
+ "changed": changed,
1955
+ "actions": actions,
1956
+ "adapter_plan": adapter_plan,
1957
+ },
1958
+ "diet_scan": diet_scan,
1959
+ "recommended_commands": recommended,
1960
+ }
1961
+
1962
+
1963
+ def render_doctor_text(report: dict[str, Any]) -> str:
1964
+ lines = [
1965
+ f"ContextGuard doctor ({report.get('status', 'unknown')})",
1966
+ "read-only health check; no changes made",
1967
+ f"scope={report.get('scope')}",
1968
+ f"root={report.get('root')}",
1969
+ f"settings={report.get('settings_path')}",
1970
+ ]
1971
+ warnings = report.get("warnings") or []
1972
+ if warnings:
1973
+ lines.append("warnings:")
1974
+ lines.extend(f"- {warning}" for warning in warnings)
1975
+ lines.append("checks:")
1976
+ for check in report.get("checks", []):
1977
+ lines.append(
1978
+ f"- [{str(check.get('status', '')).upper()}] {check.get('id')}: {check.get('message')}"
1979
+ )
1980
+ if check.get("next_action"):
1981
+ lines.append(f" next: {check['next_action']}")
1982
+ commands = report.get("recommended_commands") or []
1983
+ if commands:
1984
+ lines.append("recommended next commands:")
1985
+ lines.extend(f"- {command}" for command in commands)
1986
+ return "\n".join(lines) + "\n"
1987
+
1988
+
1221
1989
  def apply_choices(settings: dict[str, Any], choices: Choices) -> list[str]:
1222
1990
  actions: list[str] = []
1223
1991
  if choices.model_defaults:
@@ -1461,12 +2229,17 @@ def render_text(result: SetupResult) -> str:
1461
2229
  # Only surface the cross-agent section when a non-Claude adapter is engaged,
1462
2230
  # keeping the default Claude-only text output unchanged.
1463
2231
  extra_adapters = [entry for entry in (result.adapter_plan or []) if entry.get("key") != "claude"]
1464
- if extra_adapters:
2232
+ brief_adapters = [entry for entry in (result.adapter_plan or []) if entry.get("brief_mode")]
2233
+ if extra_adapters or brief_adapters:
1465
2234
  lines.append("cross-agent adapters:")
1466
2235
  for entry in result.adapter_plan or []:
1467
2236
  lines.append(f"- {entry['key']} [{entry['capability']}] status={entry['status']}")
1468
2237
  for action in entry.get("planned_actions", []):
1469
2238
  lines.append(f" - {action}")
2239
+ if entry.get("brief_mode_backup_path"):
2240
+ lines.append(f" - backup={entry['brief_mode_backup_path']}")
2241
+ if entry.get("rule_backup_path"):
2242
+ lines.append(f" - backup={entry['rule_backup_path']}")
1470
2243
  if result.apply_requested and not result.applied:
1471
2244
  lines.append("No supported writes were applied.")
1472
2245
  elif not result.applied:
@@ -1575,8 +2348,8 @@ def run(args: argparse.Namespace) -> SetupResult:
1575
2348
  finally:
1576
2349
  release_settings_lock(lock_fd)
1577
2350
 
1578
- # Build the per-adapter plan; repo-rule writes happen here only when both
1579
- # --with-init and an applying run (--yes) are in effect.
2351
+ # Build the per-adapter plan; repo-rule writes happen here when an applying
2352
+ # run (--yes) requested --with-init or project-scope --brief-mode.
1580
2353
  adapter_plan = build_adapter_plan(
1581
2354
  root,
1582
2355
  targets,
@@ -1587,6 +2360,7 @@ def run(args: argparse.Namespace) -> SetupResult:
1587
2360
  with_init=bool(getattr(args, "with_init", False)),
1588
2361
  with_skill=bool(getattr(args, "with_skill", False)),
1589
2362
  applied=apply_requested,
2363
+ brief_mode=getattr(args, "brief_mode", None),
1590
2364
  )
1591
2365
  # Surface any repo-rule writes in the top-level actions for visibility. Claude
1592
2366
  # actions are already in ``actions``; only adapter-side writes are appended.
@@ -1634,6 +2408,7 @@ def build_parser() -> argparse.ArgumentParser:
1634
2408
  parser.add_argument("--yes", action="store_true", help="apply the recommended/selected setup without prompts")
1635
2409
  parser.add_argument("--plan", action="store_true", help="show the setup plan without writing files")
1636
2410
  parser.add_argument("--dry-run", action="store_true", help="alias for --plan")
2411
+ parser.add_argument("--verify", action="store_true", help="run a read-only setup health check; never writes or prompts")
1637
2412
  parser.add_argument("--json", action="store_true", help="print machine-readable result")
1638
2413
  parser.add_argument("--no-backup", action="store_true", help="do not create .bak-* before modifying existing settings")
1639
2414
  parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
@@ -1670,6 +2445,12 @@ def build_parser() -> argparse.ArgumentParser:
1670
2445
  action="store_true",
1671
2446
  help="also generate optional project-local skill files where supported, currently Codex .agents/skills/context-guard/SKILL.md.",
1672
2447
  )
2448
+ parser.add_argument(
2449
+ "--brief-mode",
2450
+ choices=BRIEF_MODE_CHOICES,
2451
+ default=None,
2452
+ help="plan/apply advisory brief-mode snippets in project rule files; choose lite, standard, ultra, or off to remove.",
2453
+ )
1673
2454
  parser.add_argument(
1674
2455
  "--list-adapters",
1675
2456
  dest="list_adapters",
@@ -1699,6 +2480,8 @@ def main() -> int:
1699
2480
  args = parser.parse_args()
1700
2481
  if args.dry_run:
1701
2482
  args.plan = True
2483
+ if args.verify and args.yes:
2484
+ parser.error("--verify is read-only and cannot be combined with --yes")
1702
2485
  if getattr(args, "list_adapters", False):
1703
2486
  payload = adapter_registry_payload()
1704
2487
  if args.json:
@@ -1708,6 +2491,14 @@ def main() -> int:
1708
2491
  for item in payload:
1709
2492
  print(f"- {item['key']} [{item['capability']}] {item['display_name']}: {item['summary']}")
1710
2493
  return 0
2494
+ if args.verify:
2495
+ args.plan = True
2496
+ result = run_doctor(args)
2497
+ if args.json:
2498
+ print(json.dumps(result, indent=2, sort_keys=True))
2499
+ else:
2500
+ print(render_doctor_text(result))
2501
+ return 0
1711
2502
  # Safety default for non-interactive Claude Code Bash calls: do not write
1712
2503
  # unless --yes is explicit.
1713
2504
  if not sys.stdin.isatty() and not args.yes: