@caddis/cli 0.4.39 → 0.4.42
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +19 -3
- package/bundles/antigravity-plugin/scripts/caddis_heavy_slot.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +313 -66
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +292 -11
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +8 -1
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +258 -9
- package/bundles/antigravity-plugin/settings.template.json +37 -0
- package/bundles/manifest.json +2 -2
- package/dist/cli.js +251 -130
- package/package.json +1 -1
|
@@ -23,9 +23,22 @@ import shutil
|
|
|
23
23
|
import subprocess
|
|
24
24
|
import sys
|
|
25
25
|
import time
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
26
27
|
from datetime import datetime, timezone
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
|
|
30
|
+
FIX_HINTS: dict[str, str] = {
|
|
31
|
+
"python-missing": "install Python 3.11 or later; on Linux also run: sudo apt install python-is-python3",
|
|
32
|
+
"python-store-alias": "this is the Microsoft Store alias; install Python from python.org or with winget, then turn off the alias in Settings > Apps > App execution aliases",
|
|
33
|
+
"python-old": "install Python 3.11 or later and make it the first python on PATH",
|
|
34
|
+
"git-missing": "install git (Windows: winget install Git.Git; Linux: sudo apt install git)",
|
|
35
|
+
"keys-none": "optional: run caddis keys to add GLM or DeepSeek keys",
|
|
36
|
+
"deny-rules": "run /caddis:setup-project-ai to add the missing deny rules",
|
|
37
|
+
"gitignore": "run /caddis:setup-project-ai to add the missing .caddis ignore entries",
|
|
38
|
+
"misfiled": "move the file into a caddis document folder, or delete it",
|
|
39
|
+
"host-guard": "see docs/guide/resource-protection.md (install-host-guard.ps1)",
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
# Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
|
|
30
43
|
# these — the recorded schemas may be stale and want a re-probe.
|
|
31
44
|
PROBED_VERSIONS = {"codex": "0.137", "agy": "1.1.5"}
|
|
@@ -36,6 +49,29 @@ AGENTS_MD_BUDGET = 200 # always-loaded rules file line budget (mirrors check_do
|
|
|
36
49
|
ARTIFACT_DIRS = (".caddis",)
|
|
37
50
|
ENV_PREFIX = "CADDIS"
|
|
38
51
|
|
|
52
|
+
# Known top-level document and working artifact folders under .caddis/
|
|
53
|
+
KNOWN_CADDIS_DIRS: set[str] = {
|
|
54
|
+
"plans",
|
|
55
|
+
"prd",
|
|
56
|
+
"kb",
|
|
57
|
+
"rca",
|
|
58
|
+
"decisions",
|
|
59
|
+
"parking-lot",
|
|
60
|
+
"reviews",
|
|
61
|
+
"todo-list",
|
|
62
|
+
"comms",
|
|
63
|
+
"handoffs",
|
|
64
|
+
"prompts",
|
|
65
|
+
"relay",
|
|
66
|
+
"session-state",
|
|
67
|
+
"advisory-hub-reports",
|
|
68
|
+
"orchestrator",
|
|
69
|
+
"spawn-session",
|
|
70
|
+
"metrics",
|
|
71
|
+
"agent-docs",
|
|
72
|
+
"backlog",
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
|
|
40
76
|
def _home() -> Path:
|
|
41
77
|
return Path(os.environ.get(f"{ENV_PREFIX}_FAKE_HOME") or Path.home())
|
|
@@ -288,7 +324,7 @@ def _file_signals(dest: Path) -> list[str]:
|
|
|
288
324
|
signals.append(f"{len(dangling)} dangling DOC-MAP link(s) — run /caddis:kb")
|
|
289
325
|
days = days_since_last_doctor()
|
|
290
326
|
if days is not None and days >= 30:
|
|
291
|
-
signals.append(f"{days} days since last caddis doctor — run caddis
|
|
327
|
+
signals.append(f"{days} days since last caddis doctor — run caddis doctor")
|
|
292
328
|
# Listed LAST but it is the most dangerous signal here: the others degrade a session's quality,
|
|
293
329
|
# this one silently runs old code and reports success. It is cheap (a file compare) and almost
|
|
294
330
|
# always empty, so it costs nothing to carry.
|
|
@@ -312,19 +348,30 @@ def nudge_line(dest: Path) -> str | None:
|
|
|
312
348
|
return f"[caddis] {head}{more}"
|
|
313
349
|
|
|
314
350
|
|
|
351
|
+
def _load_sibling(name: str, fallback_dir: Path | str | None = None):
|
|
352
|
+
"""Load a helper module by filename/module name: sibling first, then fallback_dir."""
|
|
353
|
+
scripts = Path(__file__).resolve().parent
|
|
354
|
+
file_name = name if name.endswith(".py") else f"{name}.py"
|
|
355
|
+
mod_name = name[:-3] if name.endswith(".py") else name
|
|
356
|
+
path = scripts / file_name
|
|
357
|
+
if not path.is_file() and fallback_dir:
|
|
358
|
+
path = Path(fallback_dir) / file_name
|
|
359
|
+
if not path.is_file():
|
|
360
|
+
return None
|
|
361
|
+
spec = importlib.util.spec_from_file_location(f"caddis_{mod_name}", path)
|
|
362
|
+
if spec is None or spec.loader is None:
|
|
363
|
+
return None
|
|
364
|
+
module = importlib.util.module_from_spec(spec)
|
|
365
|
+
spec.loader.exec_module(module)
|
|
366
|
+
return module
|
|
367
|
+
|
|
368
|
+
|
|
315
369
|
def hook_errors(dest: Path) -> str:
|
|
316
370
|
"""Recent hook failures, counted by hook. Empty when the ledger is clean."""
|
|
317
371
|
scripts = Path(__file__).resolve().parent
|
|
318
|
-
hook_log_path = scripts / "hook_log.py"
|
|
319
|
-
if not hook_log_path.is_file(): # source checkout; installed copies are siblings
|
|
320
|
-
hook_log_path = scripts.parent / "claude-harness" / "scripts" / "hook_log.py"
|
|
321
372
|
try:
|
|
322
|
-
|
|
323
|
-
if
|
|
324
|
-
return ""
|
|
325
|
-
hook_log = importlib.util.module_from_spec(spec)
|
|
326
|
-
spec.loader.exec_module(hook_log)
|
|
327
|
-
if not hook_log.summarise(str(dest), days=7):
|
|
373
|
+
hook_log = _load_sibling("hook_log.py", scripts.parent / "claude-harness" / "scripts")
|
|
374
|
+
if hook_log is None or not hook_log.summarise(str(dest), days=7):
|
|
328
375
|
return ""
|
|
329
376
|
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400))
|
|
330
377
|
counts: dict[str, int] = {}
|
|
@@ -342,6 +389,214 @@ def hook_errors(dest: Path) -> str:
|
|
|
342
389
|
return "" # a damaged ledger must not break the doctor
|
|
343
390
|
|
|
344
391
|
|
|
392
|
+
def machine_findings(which: Callable[[str], str | None] | None = None,
|
|
393
|
+
version: Callable[[str], str | None] | None = None) -> list[tuple[str, str]]:
|
|
394
|
+
"""Machine faults that stop caddis on a new laptop. Levels: OK, INFO, FAIL (never raises)."""
|
|
395
|
+
which = which or shutil.which # resolved at call time, so a test can patch shutil.which
|
|
396
|
+
version = version or _binary_version
|
|
397
|
+
|
|
398
|
+
out: list[tuple[str, str]] = []
|
|
399
|
+
|
|
400
|
+
# python
|
|
401
|
+
py_path = which("python")
|
|
402
|
+
if py_path is None:
|
|
403
|
+
out.append(("FAIL", f"python (on PATH): not found — {FIX_HINTS['python-missing']}"))
|
|
404
|
+
elif "windowsapps" in py_path.lower() or "WindowsApps" in py_path:
|
|
405
|
+
out.append(("FAIL", f"python (on PATH): [{py_path}] — {FIX_HINTS['python-store-alias']}"))
|
|
406
|
+
else:
|
|
407
|
+
ver_str = None
|
|
408
|
+
try:
|
|
409
|
+
ver_str = version(py_path)
|
|
410
|
+
except Exception:
|
|
411
|
+
ver_str = None
|
|
412
|
+
|
|
413
|
+
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", ver_str or "")
|
|
414
|
+
if not m:
|
|
415
|
+
out.append(("INFO", "python (on PATH): could not read its version"))
|
|
416
|
+
else:
|
|
417
|
+
major, minor = int(m.group(1)), int(m.group(2))
|
|
418
|
+
ver_display = (ver_str or "").strip()
|
|
419
|
+
if ver_display.startswith("Python "):
|
|
420
|
+
ver_display = ver_display[7:].strip()
|
|
421
|
+
if (major, minor) < (3, 11):
|
|
422
|
+
out.append(("FAIL", f"python (on PATH): {ver_display} [{py_path}] — {FIX_HINTS['python-old']}"))
|
|
423
|
+
else:
|
|
424
|
+
out.append(("OK", f"python (on PATH): {ver_display} [{py_path}]"))
|
|
425
|
+
|
|
426
|
+
# git
|
|
427
|
+
git_path = which("git")
|
|
428
|
+
if git_path is None:
|
|
429
|
+
out.append(("FAIL", f"git: not found — {FIX_HINTS['git-missing']}"))
|
|
430
|
+
else:
|
|
431
|
+
out.append(("OK", f"git: [{git_path}]"))
|
|
432
|
+
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def keys_line(env: Mapping[str, str]) -> tuple[str, str]:
|
|
437
|
+
scripts = Path(__file__).resolve().parent
|
|
438
|
+
try:
|
|
439
|
+
oss_model = _load_sibling("oss_model.py", scripts.parent / "claude-harness" / "scripts")
|
|
440
|
+
if oss_model is None:
|
|
441
|
+
return ("INFO", "keys: could not check")
|
|
442
|
+
|
|
443
|
+
env_dict = dict(env)
|
|
444
|
+
fake_home = env_dict.get(f"{ENV_PREFIX}_FAKE_HOME") or os.environ.get(f"{ENV_PREFIX}_FAKE_HOME")
|
|
445
|
+
if fake_home and "CADDIS_KEYS_FILE" not in env_dict:
|
|
446
|
+
env_dict["CADDIS_KEYS_FILE"] = str(Path(fake_home) / ".caddis" / "keys.env")
|
|
447
|
+
|
|
448
|
+
providers = oss_model.configured_providers(env_dict)
|
|
449
|
+
if providers:
|
|
450
|
+
return ("OK", f"keys: {', '.join(providers)}")
|
|
451
|
+
return ("INFO", f"keys: none — {FIX_HINTS['keys-none']}")
|
|
452
|
+
except Exception:
|
|
453
|
+
return ("INFO", "keys: could not check")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def host_guard_findings(
|
|
457
|
+
run: Callable[..., object] | None = None,
|
|
458
|
+
budget_path: Path | None = None,
|
|
459
|
+
) -> list[tuple[str, str]]:
|
|
460
|
+
"""Check host guard installation on Windows. Levels: WARN (never raises)."""
|
|
461
|
+
if sys.platform != "win32":
|
|
462
|
+
return []
|
|
463
|
+
|
|
464
|
+
run = run or subprocess.run
|
|
465
|
+
out: list[tuple[str, str]] = []
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
res = run(["sc", "query", "caddis-host-guard"], capture_output=True, text=True)
|
|
469
|
+
rc = getattr(res, "returncode", 0)
|
|
470
|
+
if rc != 0: # 1060 is "no such service"; any other failure also means "cannot confirm it runs"
|
|
471
|
+
out.append(("WARN", f"caddis-host-guard service not found — {FIX_HINTS['host-guard']}"))
|
|
472
|
+
except Exception:
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
if budget_path is None:
|
|
476
|
+
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
|
477
|
+
budget_path = Path(prog_data) / "caddis" / "host-budget.toml"
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
if not budget_path.is_file():
|
|
481
|
+
out.append(("WARN", f"{budget_path} missing — {FIX_HINTS['host-guard']}"))
|
|
482
|
+
except Exception:
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
return out
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ── project checks (read-only — WARN or INFO, never FAIL) ─────────────────────
|
|
489
|
+
def deny_rule_findings(dest: Path) -> list[tuple[str, str]]:
|
|
490
|
+
dest = Path(dest)
|
|
491
|
+
if _is_caddis_source(dest):
|
|
492
|
+
return []
|
|
493
|
+
|
|
494
|
+
scripts = Path(__file__).resolve().parent
|
|
495
|
+
tmpl_path = scripts.parent / "settings.template.json"
|
|
496
|
+
if not tmpl_path.is_file():
|
|
497
|
+
tmpl_path = scripts.parent / "claude-harness" / "settings.template.json"
|
|
498
|
+
if not tmpl_path.is_file():
|
|
499
|
+
return []
|
|
500
|
+
|
|
501
|
+
settings_path = dest / ".claude" / "settings.json"
|
|
502
|
+
if not settings_path.is_file():
|
|
503
|
+
return [("INFO", "no .claude/settings.json")]
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
tmpl = json.loads(tmpl_path.read_text(encoding="utf-8"))
|
|
507
|
+
required_deny = tmpl.get("permissions", {}).get("deny", [])
|
|
508
|
+
except Exception:
|
|
509
|
+
return []
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
513
|
+
dest_deny = set(settings.get("permissions", {}).get("deny", []))
|
|
514
|
+
except Exception:
|
|
515
|
+
dest_deny = set()
|
|
516
|
+
|
|
517
|
+
missing = [r for r in required_deny if r not in dest_deny]
|
|
518
|
+
if missing:
|
|
519
|
+
return [("WARN", f"deny rules: {len(missing)} missing — {FIX_HINTS['deny-rules']}")]
|
|
520
|
+
return []
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def gitignore_findings(dest: Path) -> list[tuple[str, str]]:
|
|
524
|
+
dest = Path(dest)
|
|
525
|
+
if _is_caddis_source(dest):
|
|
526
|
+
return []
|
|
527
|
+
if not (dest / ".caddis").is_dir():
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
scripts = Path(__file__).resolve().parent
|
|
531
|
+
try:
|
|
532
|
+
setup_mod = _load_sibling("setup_project_ai.py", scripts.parent / "scripts")
|
|
533
|
+
if setup_mod is None:
|
|
534
|
+
return [("INFO", "gitignore: could not check")]
|
|
535
|
+
artifact_gitignore = getattr(setup_mod, "ARTIFACT_GITIGNORE", None)
|
|
536
|
+
if not artifact_gitignore:
|
|
537
|
+
return [("INFO", "gitignore: could not check")]
|
|
538
|
+
|
|
539
|
+
entries = [
|
|
540
|
+
line.strip()
|
|
541
|
+
for line in artifact_gitignore.splitlines()
|
|
542
|
+
if line.strip() and not line.strip().startswith("#")
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
caddis_ignore = dest / ".caddis" / ".gitignore"
|
|
546
|
+
caddis_lines: set[str] = set()
|
|
547
|
+
if caddis_ignore.is_file():
|
|
548
|
+
try:
|
|
549
|
+
caddis_lines = {line.strip() for line in caddis_ignore.read_text(encoding="utf-8", errors="replace").splitlines()}
|
|
550
|
+
except OSError:
|
|
551
|
+
pass
|
|
552
|
+
|
|
553
|
+
root_ignore = dest / ".gitignore"
|
|
554
|
+
root_lines: set[str] = set()
|
|
555
|
+
if root_ignore.is_file():
|
|
556
|
+
try:
|
|
557
|
+
root_lines = {line.strip() for line in root_ignore.read_text(encoding="utf-8", errors="replace").splitlines()}
|
|
558
|
+
except OSError:
|
|
559
|
+
pass
|
|
560
|
+
|
|
561
|
+
missing: list[str] = []
|
|
562
|
+
for entry in entries:
|
|
563
|
+
if entry in caddis_lines or f".caddis/{entry}" in root_lines:
|
|
564
|
+
continue
|
|
565
|
+
missing.append(entry)
|
|
566
|
+
|
|
567
|
+
if missing:
|
|
568
|
+
first_three = ", ".join(missing[:3])
|
|
569
|
+
return [("WARN", f"gitignore: {len(missing)} missing ({first_three}) — {FIX_HINTS['gitignore']}")]
|
|
570
|
+
return []
|
|
571
|
+
except Exception:
|
|
572
|
+
return [("INFO", "gitignore: could not check")]
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def misfiled_findings(dest: Path) -> list[tuple[str, str]]:
|
|
576
|
+
dest = Path(dest)
|
|
577
|
+
if _is_caddis_source(dest):
|
|
578
|
+
return []
|
|
579
|
+
|
|
580
|
+
caddis_dir = dest / ".caddis"
|
|
581
|
+
if not caddis_dir.is_dir():
|
|
582
|
+
return []
|
|
583
|
+
|
|
584
|
+
out: list[tuple[str, str]] = []
|
|
585
|
+
try:
|
|
586
|
+
subdirs = sorted(p for p in caddis_dir.iterdir() if p.is_dir())
|
|
587
|
+
except OSError:
|
|
588
|
+
return []
|
|
589
|
+
|
|
590
|
+
for top_dir in subdirs:
|
|
591
|
+
if top_dir.name in _SKIP_DIRS:
|
|
592
|
+
continue
|
|
593
|
+
if top_dir.name not in KNOWN_CADDIS_DIRS:
|
|
594
|
+
md_files = list(top_dir.rglob("*.md"))
|
|
595
|
+
if md_files:
|
|
596
|
+
out.append(("WARN", f".caddis/{top_dir.name}/ is not a caddis folder ({len(md_files)} file(s)) — {FIX_HINTS['misfiled']}"))
|
|
597
|
+
return out
|
|
598
|
+
|
|
599
|
+
|
|
345
600
|
# ── report ───────────────────────────────────────────────────────────────────
|
|
346
601
|
def run(dest: Path, quiet: bool) -> int:
|
|
347
602
|
if quiet: # SessionStart mode: only the nudge, never non-zero
|
|
@@ -352,7 +607,7 @@ def run(dest: Path, quiet: bool) -> int:
|
|
|
352
607
|
|
|
353
608
|
hard = 0
|
|
354
609
|
print(f"=== caddis doctor — {dest} ===")
|
|
355
|
-
print(f"python: {sys.version.split()[0]} ({sys.executable})")
|
|
610
|
+
print(f"python (running this check): {sys.version.split()[0]} ({sys.executable})")
|
|
356
611
|
for name in ("codex", "agy", "claude"):
|
|
357
612
|
path = _which(name)
|
|
358
613
|
if path:
|
|
@@ -361,6 +616,32 @@ def run(dest: Path, quiet: bool) -> int:
|
|
|
361
616
|
else:
|
|
362
617
|
print(f" {name:7} not found on PATH (install, or open a new terminal so PATH refreshes)")
|
|
363
618
|
|
|
619
|
+
print("-- machine")
|
|
620
|
+
for level, text in machine_findings():
|
|
621
|
+
if level == "FAIL":
|
|
622
|
+
hard = 1
|
|
623
|
+
print(f" {level:4} {text}")
|
|
624
|
+
k_level, k_text = keys_line(os.environ)
|
|
625
|
+
if k_level == "FAIL":
|
|
626
|
+
hard = 1
|
|
627
|
+
print(f" {k_level:4} {k_text}")
|
|
628
|
+
|
|
629
|
+
host = host_guard_findings()
|
|
630
|
+
if host:
|
|
631
|
+
print("-- host")
|
|
632
|
+
for level, text in host:
|
|
633
|
+
print(f" {level:4} {text}")
|
|
634
|
+
|
|
635
|
+
print("-- project")
|
|
636
|
+
proj_findings = deny_rule_findings(dest) + gitignore_findings(dest) + misfiled_findings(dest)
|
|
637
|
+
if proj_findings:
|
|
638
|
+
for level, text in proj_findings:
|
|
639
|
+
if level == "FAIL":
|
|
640
|
+
hard = 1
|
|
641
|
+
print(f" {level:4} {text}")
|
|
642
|
+
else:
|
|
643
|
+
print(" OK deny rules, gitignore entries and document folders clean")
|
|
644
|
+
|
|
364
645
|
print("-- rules integrity")
|
|
365
646
|
findings = rules_findings(dest)
|
|
366
647
|
if findings:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
param(
|
|
3
3
|
[switch]$Apply,
|
|
4
4
|
[string]$NssmSha256,
|
|
5
|
+
# Where the NSSM binary to copy lives. Default: the first nssm.exe on PATH, else C:\Tools\nssm\nssm.exe.
|
|
6
|
+
[string]$NssmPath,
|
|
5
7
|
[ValidateSet('report', 'enforce')]
|
|
6
8
|
[string]$Mode = 'report'
|
|
7
9
|
)
|
|
@@ -9,7 +11,12 @@ param(
|
|
|
9
11
|
Set-StrictMode -Version Latest
|
|
10
12
|
$ErrorActionPreference = 'Stop'
|
|
11
13
|
|
|
12
|
-
$
|
|
14
|
+
if ($NssmPath) {
|
|
15
|
+
$sourceNssm = $NssmPath
|
|
16
|
+
} else {
|
|
17
|
+
$foundNssm = Get-Command 'nssm.exe' -ErrorAction SilentlyContinue
|
|
18
|
+
if ($foundNssm) { $sourceNssm = $foundNssm.Source } else { $sourceNssm = 'C:\Tools\nssm\nssm.exe' }
|
|
19
|
+
}
|
|
13
20
|
$serviceName = 'caddis-host-guard'
|
|
14
21
|
$programRoot = Join-Path $env:ProgramFiles 'caddis'
|
|
15
22
|
$nssmDir = Join-Path $programRoot 'nssm'
|