@ictechgy/context-guard 0.4.1 → 0.4.3
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.
- package/CHANGELOG.md +9 -0
- package/README.ko.md +61 -32
- package/README.md +90 -22
- package/context-guard-kit/README.md +39 -26
- package/context-guard-kit/benchmark_runner.py +273 -8
- package/context-guard-kit/claude_transcript_cost_audit.py +325 -12
- package/context-guard-kit/context_compress.py +153 -1
- package/context-guard-kit/context_filter.py +446 -0
- package/context-guard-kit/context_guard_cli.py +3 -0
- package/context-guard-kit/context_guard_diet.py +677 -2
- package/context-guard-kit/context_pack.py +1694 -2
- package/context-guard-kit/cost_guard.py +1870 -0
- package/context-guard-kit/setup_wizard.py +820 -29
- package/context-guard-kit/trim_command_output.py +396 -45
- package/docs/benchmark-fixtures/learned-compression.tasks.example.json +24 -0
- package/docs/benchmark-fixtures/learned-compression.variants.example.json +10 -0
- package/docs/benchmark-fixtures/visual-ocr.tasks.example.json +24 -0
- package/docs/benchmark-fixtures/visual-ocr.variants.example.json +10 -0
- package/docs/benchmark-workflow-examples.md +40 -0
- package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +169 -0
- package/docs/benchmark-workflows/measured-token-workflow.example.json +170 -0
- package/docs/benchmark-workflows/provider-cache-telemetry.example.json +170 -0
- package/docs/cache-diagnostics-schema.md +75 -0
- package/docs/cache-diagnostics.example.json +116 -0
- package/docs/cache-diagnostics.schema.json +460 -0
- package/docs/distribution.md +4 -2
- package/docs/experimental-benchmark-fixtures.md +36 -0
- package/package.json +11 -2
- package/packaging/homebrew/context-guard.rb.template +3 -2
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +21 -13
- package/plugins/context-guard/README.md +24 -10
- package/plugins/context-guard/bin/context-guard +3 -0
- package/plugins/context-guard/bin/context-guard-audit +325 -12
- package/plugins/context-guard/bin/context-guard-bench +273 -8
- package/plugins/context-guard/bin/context-guard-compress +153 -1
- package/plugins/context-guard/bin/context-guard-cost +1870 -0
- package/plugins/context-guard/bin/context-guard-diet +677 -2
- package/plugins/context-guard/bin/context-guard-filter +446 -0
- package/plugins/context-guard/bin/context-guard-pack +1694 -2
- package/plugins/context-guard/bin/context-guard-setup +820 -29
- package/plugins/context-guard/bin/context-guard-trim-output +396 -45
- package/plugins/context-guard/brief/README.md +10 -3
- package/plugins/context-guard/skills/optimize/SKILL.md +5 -2
- 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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
570
|
-
entry["
|
|
571
|
-
entry["
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
entry["
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
587
|
-
entry["
|
|
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.
|
|
1100
|
+
if claude_dir.is_symlink():
|
|
699
1101
|
raise SystemExit(f"Refusing to use symlinked Claude settings directory: {claude_dir}")
|
|
700
|
-
if settings_path.
|
|
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
|
|
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
|
|
1579
|
-
#
|
|
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:
|