@caddis/cli 0.2.5 → 0.3.0

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 (54) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,4 @@
1
+ # claude-deepseek — convenience alias: a Claude Code session on DeepSeek (provider preset "deepseek").
2
+ # Thin delegator so the logic lives once in claude-oss.ps1.
3
+ & "$PSScriptRoot\claude-oss.ps1" deepseek @args
4
+ exit $LASTEXITCODE
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # claude-deepseek — convenience alias: a Claude Code session on DeepSeek (provider preset "deepseek").
3
+ exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/claude-oss.sh" deepseek "$@"
@@ -0,0 +1,4 @@
1
+ # claude-glm — convenience alias: a Claude Code session on GLM (provider preset "glm").
2
+ # Thin delegator so the logic lives once in claude-oss.ps1.
3
+ & "$PSScriptRoot\claude-oss.ps1" glm @args
4
+ exit $LASTEXITCODE
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # claude-glm — convenience alias: a Claude Code session on GLM (provider preset "glm").
3
+ exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/claude-oss.sh" glm "$@"
@@ -0,0 +1,64 @@
1
+ # claude-oss / claude-glm — launch a Claude Code session against an OSS provider's
2
+ # Anthropic-protocol endpoint (GLM, DeepSeek, or a custom OSS_BASE_URL/OSS_MODEL;
3
+ # OpenRouter/local models need an Anthropic-compatible gateway — see LOCAL-MODELS.md).
4
+ #
5
+ # Usage:
6
+ # claude-oss <provider> [claude args...] # e.g. claude-oss glm -p "say ok"
7
+ # claude-glm [claude args...] # convenience alias (provider = glm)
8
+ #
9
+ # Endpoint/model/key are resolved by oss_model.py (keys are never hardcoded — see
10
+ # CADDIS_KEYS_FILE). This reads $args directly and declares NO parameter block: a
11
+ # declared parameter would swallow claude's own flags (e.g. -p) before claude sees them.
12
+ # The ANTHROPIC_* env is saved and RESTORED in finally, so your default `claude` in this
13
+ # session is untouched.
14
+ $ErrorActionPreference = "Stop"
15
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
16
+ $invoked = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name)
17
+
18
+ # claude-glm forces provider=glm and passes every arg to claude; otherwise the first
19
+ # arg is the provider and the rest go to claude.
20
+ if ($invoked -like "claude-glm*") {
21
+ $provider = "glm"
22
+ $rest = $args
23
+ } else {
24
+ $provider = if ($args.Count -ge 1) { $args[0] } else { $null }
25
+ $rest = if ($args.Count -ge 2) { $args[1..($args.Count - 1)] } else { @() }
26
+ }
27
+
28
+ if (-not $provider) {
29
+ Write-Error "usage: claude-oss <provider> [claude args...] (providers: deepseek, glm, openrouter)"
30
+ exit 2
31
+ }
32
+
33
+ # Save the current ANTHROPIC_* so we can restore them — this launcher shares the session.
34
+ $restore = @{}
35
+ foreach ($name in "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_AUTH_TOKEN", "CADDIS_HEADLESS") {
36
+ $restore[$name] = [Environment]::GetEnvironmentVariable($name)
37
+ }
38
+
39
+ # claude's -p/--print means no human is watching. Say so in the env so the caddis hooks can
40
+ # act on it — the SessionStart relay injection is skipped for headless runs, because a run
41
+ # handed its task on the command line does not need a resume pointer and was observed
42
+ # EXECUTING the relay's leftover next step before its own prompt.
43
+ $headless = $false
44
+ foreach ($a in $rest) { if ($a -eq "-p" -or $a -eq "--print") { $headless = $true } }
45
+ if ($headless) { $env:CADDIS_HEADLESS = "1" }
46
+
47
+ try {
48
+ # Resolve endpoint/model/key. The three stdout lines are captured into a variable and
49
+ # assigned to the env — the key is never written to the console.
50
+ $cfg = & python "$scriptDir\oss_model.py" $provider
51
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
52
+
53
+ $env:ANTHROPIC_BASE_URL = $cfg[0]
54
+ $env:ANTHROPIC_MODEL = $cfg[1]
55
+ $env:ANTHROPIC_AUTH_TOKEN = $cfg[2]
56
+
57
+ & claude @rest
58
+ exit $LASTEXITCODE
59
+ } finally {
60
+ foreach ($name in $restore.Keys) {
61
+ Set-Item -Path "Env:$name" -Value $restore[$name] -ErrorAction SilentlyContinue
62
+ if ($null -eq $restore[$name]) { Remove-Item -Path "Env:$name" -ErrorAction SilentlyContinue }
63
+ }
64
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # claude-oss / claude-glm — launch a Claude Code session against an OSS provider's
3
+ # Anthropic-protocol endpoint (GLM, DeepSeek, or a custom OSS_BASE_URL/OSS_MODEL;
4
+ # OpenRouter/local models need an Anthropic-compatible gateway — see LOCAL-MODELS.md).
5
+ #
6
+ # Usage:
7
+ # claude-oss <provider> [claude args...] # e.g. claude-oss glm -p "say ok"
8
+ # claude-glm [claude args...] # convenience alias (provider = glm)
9
+ #
10
+ # The endpoint/model/key are resolved by oss_model.py (keys are never hardcoded — see
11
+ # CADDIS_KEYS_FILE). This runs as its OWN process and `exec`s claude, so the
12
+ # ANTHROPIC_* env it sets dies with it — your default `claude` is untouched.
13
+ set -euo pipefail
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+
17
+ # claude-glm is the same launcher symlinked/renamed: force provider=glm and treat every
18
+ # arg as a claude arg. Otherwise the first arg is the provider.
19
+ case "$(basename "$0")" in
20
+ claude-glm*) provider="glm" ;;
21
+ *)
22
+ provider="${1:-}"
23
+ if [ "$#" -gt 0 ]; then shift; fi
24
+ ;;
25
+ esac
26
+
27
+ if [ -z "${provider}" ]; then
28
+ echo "usage: claude-oss <provider> [claude args...] (providers: deepseek, glm, openrouter)" >&2
29
+ exit 2
30
+ fi
31
+
32
+ # claude's -p/--print means no human is watching. Say so in the env so the caddis hooks can
33
+ # act on it — the SessionStart relay injection is skipped for headless runs, because a run
34
+ # handed its task on the command line does not need a resume pointer and was observed
35
+ # EXECUTING the relay's leftover next step before its own prompt.
36
+ for _arg in "$@"; do
37
+ case "$_arg" in
38
+ -p|--print) export CADDIS_HEADLESS=1 ;;
39
+ esac
40
+ done
41
+
42
+ # Prefer python3, fall back to python.
43
+ PY="python3"; command -v "$PY" >/dev/null 2>&1 || PY="python"
44
+
45
+ # Resolve endpoint/model/key. On failure the resolver prints an actionable message to
46
+ # stderr and exits non-zero — propagate that (command substitution lets us check it).
47
+ if ! _out="$("$PY" "$SCRIPT_DIR/oss_model.py" "$provider")"; then
48
+ exit 3
49
+ fi
50
+
51
+ # Split the three lines (base_url, model, api_key) into the env WITHOUT echoing them.
52
+ {
53
+ IFS= read -r ANTHROPIC_BASE_URL
54
+ IFS= read -r ANTHROPIC_MODEL
55
+ IFS= read -r ANTHROPIC_AUTH_TOKEN
56
+ } <<< "$_out"
57
+ export ANTHROPIC_BASE_URL ANTHROPIC_MODEL ANTHROPIC_AUTH_TOKEN
58
+
59
+ # Hand off to the real claude with all remaining args (exec = env dies with this process).
60
+ exec claude "$@"
@@ -0,0 +1,126 @@
1
+ """Shared identity + config reader for a repo's caddis artifact dir — fail-open, dependency-free.
2
+
3
+ Two jobs:
4
+
5
+ 1. **Functional identity in ONE place.** The artifact dir name, the env-var prefix, and the
6
+ repo/tarball URL are constants here, so a future rename edits a constant instead of sweeping
7
+ the tree. Anything that needs them imports from this module.
8
+ 2. **The optional per-repo ``config.toml`` reader**, so the doc-coverage checker and Dream Memory
9
+ don't each reimplement it (the guard hook keeps its own tiny reader — it lives under hooks/ and
10
+ is safety-critical, so it stays self-contained). Every function returns a safe default rather
11
+ than raising: a missing file, a parse error, a missing section, or a wrong-typed value all
12
+ degrade to the caller's default — the "degrade gracefully, never block" bar of the rest of caddis.
13
+
14
+ The per-repo artifact dir is always ``.caddis`` — use :func:`artifact_root` / :func:`artifact_read` /
15
+ :func:`artifact_write`. A repo still carrying a pre-rename ``.claudster/`` (there should be none left
16
+ in this fleet) can be converted with ``/caddis:migrate-dir`` (``scripts/caddis_migrate_dir.py``);
17
+ nothing renames a repo's dir behind its back.
18
+
19
+ Config schema (see ``.caddis/config.toml.example``)::
20
+
21
+ [doc_coverage]
22
+ route_tree = "frontend/src/routeTree.gen.ts"
23
+ page_guide = "UI_PAGE_GUIDE.md"
24
+ claude_md_budget = 200
25
+ ignore_routes = ["/health"]
26
+
27
+ [dream_memory]
28
+ prune_age_days = 14
29
+ max_facts = 200
30
+ surface_limit = 5
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ from pathlib import Path
37
+
38
+ # ── functional identity (rename here, not everywhere) ────────────────────────
39
+ #: The per-repo artifact directory caddis writes into.
40
+ ARTIFACT_DIR = ".caddis"
41
+ #: Read-preference order for the artifact dir. Single-element tuple kept so importers that iterate
42
+ #: this (a future rename adds an element here, not at each call site) don't need to change shape.
43
+ ARTIFACT_DIRS: tuple[str, ...] = (ARTIFACT_DIR,)
44
+
45
+ #: Environment-variable prefix — every caddis env var is ``<ENV_PREFIX>_<SUFFIX>``.
46
+ ENV_PREFIX = "CADDIS"
47
+
48
+ #: The published plugin repo (mirror). Everything URL-shaped derives from this one slug.
49
+ REPO_SLUG = "saajunaid/caddis-plugin"
50
+ REPO_URL = f"https://github.com/{REPO_SLUG}"
51
+ TARBALL_URL = f"https://codeload.github.com/{REPO_SLUG}/tar.gz/refs/heads/main"
52
+
53
+
54
+ def env_name(suffix: str) -> str:
55
+ """The current env-var name for ``suffix`` (e.g. ``"GUARD_DISABLED"`` → ``CADDIS_GUARD_DISABLED``)."""
56
+ return f"{ENV_PREFIX}_{suffix}"
57
+
58
+
59
+ def env_get(suffix: str, default: str | None = None) -> str | None:
60
+ """``$CADDIS_<suffix>``."""
61
+ val = os.environ.get(env_name(suffix))
62
+ return default if val is None else val
63
+
64
+
65
+ # ── artifact-dir resolution ───────────────────────────────────────────────────
66
+ def artifact_root(root) -> Path:
67
+ """The artifact dir to WRITE into for ``root`` — always ``.caddis``.
68
+
69
+ Returns a path that may not exist yet — callers ``mkdir`` as needed.
70
+ """
71
+ return Path(root) / ARTIFACT_DIR
72
+
73
+
74
+ def artifact_dir_name(root) -> str:
75
+ """Just the directory NAME the repo lives in (``".caddis"``) — for messages."""
76
+ return artifact_root(root).name
77
+
78
+
79
+ def artifact_read(root, *parts) -> Path:
80
+ """``.caddis/<parts>`` — the path to read an artifact from."""
81
+ return artifact_root(root).joinpath(*parts)
82
+
83
+
84
+ def artifact_write(root, *parts) -> Path:
85
+ """Path under :func:`artifact_root` — where a NEW artifact should be written."""
86
+ return artifact_root(root).joinpath(*parts)
87
+
88
+
89
+ def load_config(root, section: str) -> dict:
90
+ """Return the ``[section]`` table from the repo's ``.caddis/config.toml``, or ``{}`` on any problem."""
91
+ try:
92
+ import tomllib # Python 3.11+ stdlib
93
+ except Exception:
94
+ return {}
95
+ cfg = artifact_read(root, "config.toml")
96
+ if not cfg.is_file():
97
+ return {}
98
+ try:
99
+ with open(cfg, "rb") as fh:
100
+ data = tomllib.load(fh)
101
+ except Exception:
102
+ return {}
103
+ sec = data.get(section, {})
104
+ return sec if isinstance(sec, dict) else {}
105
+
106
+
107
+ def get_int(cfg: dict, key: str, default: int) -> int:
108
+ """A positive int from ``cfg[key]``, else ``default`` (rejects bool, non-int, and < 1)."""
109
+ v = cfg.get(key, default)
110
+ if isinstance(v, bool) or not isinstance(v, int) or v < 1:
111
+ return default
112
+ return v
113
+
114
+
115
+ def get_str(cfg: dict, key: str, default: str) -> str:
116
+ """A non-empty string from ``cfg[key]``, else ``default``."""
117
+ v = cfg.get(key, default)
118
+ return v if isinstance(v, str) and v.strip() else default
119
+
120
+
121
+ def get_str_list(cfg: dict, key: str, default: list[str]) -> list[str]:
122
+ """A list-of-strings from ``cfg[key]``, else ``default``."""
123
+ v = cfg.get(key, default)
124
+ if isinstance(v, list) and all(isinstance(x, str) for x in v):
125
+ return v
126
+ return default
@@ -0,0 +1,356 @@
1
+ """claudster_doctor — read-only health + maintenance report for a caddis install, any harness.
2
+
3
+ Diagnoses the things that silently break a cross-harness setup: a harness binary missing from PATH
4
+ (the Windows "new terminal" gotcha), a harness version that has drifted past the probed contract,
5
+ rules-file integrity (root AGENTS.md present; CLAUDE.md is a shim, not a fork), the user-level skills
6
+ path, and python for the tooling itself. It also computes DETERMINISTIC maintenance signals (no LLM):
7
+ an oversize always-loaded AGENTS.md, dangling DOC-MAP links, harness-contract version drift, and days
8
+ since the last doctor run. Read-only; exit 1 on a hard failure.
9
+
10
+ Usage:
11
+ python claudster_doctor.py # check cwd + all known harnesses
12
+ python claudster_doctor.py --dest <proj> # check a specific project's rules
13
+ python claudster_doctor.py --quiet # only the one-line maintenance nudge (for SessionStart)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ # Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
27
+ # these — the recorded schemas may be stale and want a re-probe.
28
+ PROBED_VERSIONS = {"codex": "0.137", "agy": "1.1.5"}
29
+ AGENTS_MD_BUDGET = 200 # always-loaded rules file line budget (mirrors check_doc_coverage)
30
+
31
+ # Per-repo artifact dir + env prefix, mirrored from claude-harness/scripts/claudster_config.py —
32
+ # the doctor is a standalone, import-free diagnostic that must run from a bare checkout.
33
+ ARTIFACT_DIRS = (".caddis",)
34
+ ENV_PREFIX = "CADDIS"
35
+
36
+
37
+ def _home() -> Path:
38
+ return Path(os.environ.get(f"{ENV_PREFIX}_FAKE_HOME") or Path.home())
39
+
40
+
41
+ def _user_scope_path(name: str) -> Path:
42
+ """A ``~/.caddis/<name>`` user-scope file."""
43
+ return _home() / ".caddis" / name
44
+
45
+
46
+ def _now() -> datetime:
47
+ return datetime.now(timezone.utc)
48
+
49
+
50
+ def _which(name: str) -> str | None:
51
+ hit = shutil.which(name)
52
+ if hit:
53
+ return hit
54
+ # off-PATH but installed (the agy case on Windows): probe the known install dir
55
+ if name == "agy":
56
+ cand = _home() / "AppData" / "Local" / "agy" / "bin" / "agy.exe"
57
+ if cand.is_file():
58
+ return str(cand)
59
+ return None
60
+
61
+
62
+ def _binary_version(path: str) -> str | None:
63
+ try:
64
+ out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20)
65
+ return (out.stdout or out.stderr).strip().splitlines()[0] if (out.stdout or out.stderr) else None
66
+ except Exception:
67
+ return None
68
+
69
+
70
+ # ── rules integrity (self-contained fork detector — same rule as claudster_migrate_rules --check) ──
71
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", "done", "archive"}
72
+
73
+
74
+ def _is_shim(text: str) -> bool:
75
+ return any(ln.strip() == "@AGENTS.md" for ln in text.splitlines())
76
+
77
+
78
+ def rules_findings(dest: Path) -> list[str]:
79
+ """Fork detector: a non-shim CLAUDE.md beside an AGENTS.md, or a bare CLAUDE.md with no sibling."""
80
+ dest = Path(dest)
81
+ out: list[str] = []
82
+ for dirpath, dirnames, filenames in os.walk(dest):
83
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
84
+ if "CLAUDE.md" not in filenames:
85
+ continue
86
+ d = Path(dirpath)
87
+ rel = (d / "CLAUDE.md").relative_to(dest).as_posix()
88
+ try:
89
+ text = (d / "CLAUDE.md").read_text(encoding="utf-8", errors="replace")
90
+ except OSError:
91
+ continue
92
+ if "AGENTS.md" in filenames:
93
+ if not _is_shim(text):
94
+ out.append(f"{rel}: not a shim but a sibling AGENTS.md exists (drifted shim)")
95
+ else:
96
+ out.append(f"{rel}: bare CLAUDE.md with no sibling AGENTS.md (fork — run /caddis:add-rules)")
97
+ return out
98
+
99
+
100
+ def oversize_rules_files(dest: Path, budget: int = AGENTS_MD_BUDGET) -> list[tuple[str, int]]:
101
+ """Always-loaded rules files (AGENTS.md, or CLAUDE.md pre-migration) over the line budget."""
102
+ dest = Path(dest)
103
+ out: list[tuple[str, int]] = []
104
+ for dirpath, dirnames, filenames in os.walk(dest):
105
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
106
+ d = Path(dirpath)
107
+ f = "AGENTS.md" if "AGENTS.md" in filenames else ("CLAUDE.md" if "CLAUDE.md" in filenames else None)
108
+ if not f:
109
+ continue
110
+ try:
111
+ n = len((d / f).read_text(encoding="utf-8", errors="replace").splitlines())
112
+ except OSError:
113
+ continue
114
+ if n > budget:
115
+ out.append(((d / f).relative_to(dest).as_posix(), n))
116
+ return out
117
+
118
+
119
+ def _severity_word(ratio: float) -> str:
120
+ """Coarse severity label so a 1.1x overage doesn't read identically to a 5x one
121
+ (register 0e: the nudge used to print the exact same sentence for both)."""
122
+ if ratio >= 3.0:
123
+ return "way"
124
+ if ratio >= 1.5:
125
+ return "well"
126
+ return "slightly"
127
+
128
+
129
+ def oversize_message(rel_path: str, lines: int, budget: int = AGENTS_MD_BUDGET) -> str:
130
+ """One-line, severity-scaled description of an oversize rules file. Shared by the
131
+ SessionStart/PreCompact nudge (nudge_line) and the PostToolUse re-warn
132
+ (hooks/rules_budget_nudge.py) so both read identically and the threshold lives in
133
+ exactly one place: ``AGENTS_MD_BUDGET`` above (mirrored in check_doc_coverage.py)."""
134
+ ratio = lines / budget if budget else 0.0
135
+ return (f"AGENTS.md {_severity_word(ratio)} over budget "
136
+ f"({rel_path}: {lines} lines, {ratio:.1f}x the {budget}-line budget) — run claude-md-curator")
137
+
138
+
139
+ def docmap_dangling(dest: Path) -> list[str]:
140
+ """Dangling `.md` links in <artifact-dir>/kb/DOC-MAP.md (the '/caddis:kb' signal)."""
141
+ dm = next((p for p in (Path(dest) / n / "kb" / "DOC-MAP.md" for n in ARTIFACT_DIRS) if p.is_file()), None)
142
+ if dm is None:
143
+ return []
144
+ import re
145
+ text = dm.read_text(encoding="utf-8", errors="replace")
146
+ text = re.sub(r"\A---\r?\n.*?\r?\n---\r?\n", "", text, flags=re.DOTALL)
147
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
148
+ dangling = []
149
+ for target in re.findall(r"\]\(([^)]+\.md)\)", text):
150
+ target = target.strip()
151
+ if target.startswith(("http://", "https://")):
152
+ continue
153
+ resolved = (dm.parent / target).resolve()
154
+ if not resolved.exists():
155
+ dangling.append(target)
156
+ return dangling
157
+
158
+
159
+ # ── maintenance signals + nudge ──────────────────────────────────────────────
160
+ def _last_doctor_path() -> Path:
161
+ return _user_scope_path("last-doctor.json")
162
+
163
+
164
+ def days_since_last_doctor() -> int | None:
165
+ p = _last_doctor_path()
166
+ if not p.is_file():
167
+ return None
168
+ try:
169
+ ts = json.loads(p.read_text(encoding="utf-8")).get("timestamp")
170
+ last = datetime.fromisoformat(ts)
171
+ return (_now() - last).days
172
+ except Exception:
173
+ return None
174
+
175
+
176
+ def _stamp_last_doctor() -> None:
177
+ p = _last_doctor_path()
178
+ p.parent.mkdir(parents=True, exist_ok=True)
179
+ p.write_text(json.dumps({"timestamp": _now().isoformat(timespec="seconds")}) + "\n", encoding="utf-8")
180
+
181
+
182
+ def version_drift() -> list[str]:
183
+ out = []
184
+ for name, probed in PROBED_VERSIONS.items():
185
+ path = _which(name)
186
+ if not path:
187
+ continue
188
+ ver = _binary_version(path) or ""
189
+ # crude: if the probed version string isn't a substring of the reported version, it drifted
190
+ digits = "".join(c for c in ver if c.isdigit() or c == ".")
191
+ if probed not in digits and digits:
192
+ out.append(f"{name} {digits.strip('.')} vs probed {probed} — re-probe the contract")
193
+ return out
194
+
195
+
196
+ def _is_caddis_source(dest: Path) -> bool:
197
+ """True for the caddis authoring repo, where `.github/tools/` is the MASTER, not a copy.
198
+
199
+ Without this the drift check fires permanently in the one repo where a difference is the
200
+ normal state — and a check that is always red is a check nobody reads."""
201
+ return (dest / "claude-harness").is_dir()
202
+
203
+
204
+ def _norm(text: str) -> str:
205
+ return text.replace("\r\n", "\n").strip()
206
+
207
+
208
+ def vendored_drift(dest: Path, plugin_root: Path | None = None) -> list[str]:
209
+ """Vendored `.github/tools/*.py` copies that no longer match the plugin's shipped copy.
210
+
211
+ WHY THIS EXISTS
212
+ ---------------
213
+ `/caddis:cross-review` resolved its tool as `.github/tools/oss_review.py` FIRST and the plugin
214
+ copy second, with nothing comparing them. On 2026-08-10 a repo ran a stale vendored
215
+ `oss_review.py` and got a false CLEAN on a database write path; the fixed copy was sitting
216
+ unused in the plugin the whole time. The bug had been fixed on 2026-08-01 and the fix simply
217
+ never reached the caller.
218
+
219
+ That is worse than an ordinary stale file. It means **any** caddis fix is untrustworthy in a
220
+ repo that vendored early, and nothing anywhere reports the skew. The vendoring itself was
221
+ reasonable when it happened — the plugin did not ship these scripts until 2026-07-30 — which is
222
+ exactly why the copies are still lying around.
223
+
224
+ Pure file comparison, no subprocess, safe for SessionStart. Returns [] when there is nothing to
225
+ compare against (no plugin root, no vendored dir) — silence, not a complaint.
226
+ """
227
+ # The SessionStart hook passes ROOT as a str, not a Path. Coerce rather than assume — the
228
+ # first version of this function assumed Path and broke the hook's own regression test.
229
+ dest = Path(dest)
230
+ if _is_caddis_source(dest):
231
+ return []
232
+ root = plugin_root or (Path(os.environ["CLAUDE_PLUGIN_ROOT"])
233
+ if os.environ.get("CLAUDE_PLUGIN_ROOT") else None)
234
+ if root is None:
235
+ return []
236
+ tools = dest / ".github" / "tools"
237
+ if not tools.is_dir():
238
+ return []
239
+ out: list[str] = []
240
+ for local in sorted(tools.glob("*.py")):
241
+ shipped = root / "scripts" / local.name
242
+ if not shipped.is_file():
243
+ continue
244
+ try:
245
+ if _norm(local.read_text(encoding="utf-8", errors="ignore")) != \
246
+ _norm(shipped.read_text(encoding="utf-8", errors="ignore")):
247
+ out.append(f".github/tools/{local.name} differs from the caddis copy — "
248
+ "probably a stale vendor; delete it or keep the change deliberately")
249
+ except OSError:
250
+ continue
251
+ return out
252
+
253
+
254
+ def _file_signals(dest: Path) -> list[str]:
255
+ """PURE FILE-CHECK signals only (no subprocess) — safe + fast for SessionStart. One line each."""
256
+ signals: list[str] = []
257
+ over = oversize_rules_files(dest)
258
+ if over:
259
+ worst = max(over, key=lambda x: x[1])
260
+ signals.append(oversize_message(worst[0], worst[1]))
261
+ dangling = docmap_dangling(dest)
262
+ if dangling:
263
+ signals.append(f"{len(dangling)} dangling DOC-MAP link(s) — run /caddis:kb")
264
+ days = days_since_last_doctor()
265
+ if days is not None and days >= 30:
266
+ signals.append(f"{days} days since last caddis doctor — run caddis-init --doctor")
267
+ # Listed LAST but it is the most dangerous signal here: the others degrade a session's quality,
268
+ # this one silently runs old code and reports success. It is cheap (a file compare) and almost
269
+ # always empty, so it costs nothing to carry.
270
+ signals += vendored_drift(dest)
271
+ return signals
272
+
273
+
274
+ def maintenance_signals(dest: Path) -> list[str]:
275
+ """All deterministic signals (file checks + harness-version drift). For the full doctor report."""
276
+ return _file_signals(dest) + version_drift()
277
+
278
+
279
+ def nudge_line(dest: Path) -> str | None:
280
+ """The single SessionStart nudge line, or None. PURE FILE CHECKS only (no subprocess, no LLM, no
281
+ auto-fix) so it never slows session start; curator/kb stay human-triggered."""
282
+ sig = _file_signals(dest)
283
+ if not sig:
284
+ return None
285
+ head = sig[0]
286
+ more = f" (+{len(sig) - 1} more — run caddis doctor)" if len(sig) > 1 else ""
287
+ return f"[caddis] {head}{more}"
288
+
289
+
290
+ # ── report ───────────────────────────────────────────────────────────────────
291
+ def run(dest: Path, quiet: bool) -> int:
292
+ if quiet: # SessionStart mode: only the nudge, never non-zero
293
+ line = nudge_line(dest)
294
+ if line:
295
+ print(line)
296
+ return 0
297
+
298
+ hard = 0
299
+ print(f"=== caddis doctor — {dest} ===")
300
+ print(f"python: {sys.version.split()[0]} ({sys.executable})")
301
+ for name in ("codex", "agy", "claude"):
302
+ path = _which(name)
303
+ if path:
304
+ ver = _binary_version(path) or "?"
305
+ print(f" {name:7} OK {ver} [{path}]")
306
+ else:
307
+ print(f" {name:7} not found on PATH (install, or open a new terminal so PATH refreshes)")
308
+
309
+ print("-- rules integrity")
310
+ findings = rules_findings(dest)
311
+ if findings:
312
+ hard = 1
313
+ for f in findings:
314
+ print(f" FAIL {f}")
315
+ else:
316
+ root = dest / "AGENTS.md"
317
+ print(f" OK root AGENTS.md {'present' if root.is_file() else 'ABSENT (not migrated yet?)'}; shims are shims")
318
+
319
+ print("-- maintenance signals (deterministic — read-only)")
320
+ sig = maintenance_signals(dest)
321
+ if sig:
322
+ for s in sig:
323
+ print(f" ~ {s}")
324
+ else:
325
+ print(" none")
326
+
327
+ print("-- install registry")
328
+ reg = _user_scope_path("installs.json")
329
+ if reg.is_file():
330
+ try:
331
+ installs = json.loads(reg.read_text(encoding="utf-8")).get("installs", [])
332
+ vers = {e.get("version") for e in installs}
333
+ print(f" {len(installs)} install(s); versions: {sorted(v for v in vers if v)}"
334
+ + (" ⚠ skew" if len([v for v in vers if v]) > 1 else ""))
335
+ except Exception:
336
+ print(" (registry unreadable)")
337
+ else:
338
+ print(" no installs recorded")
339
+
340
+ _stamp_last_doctor()
341
+ print(f"\n{'FAIL — fix the above.' if hard else 'OK.'}")
342
+ return hard
343
+
344
+
345
+ def main(argv: list[str] | None = None) -> int:
346
+ if hasattr(sys.stdout, "reconfigure"):
347
+ sys.stdout.reconfigure(encoding="utf-8")
348
+ ap = argparse.ArgumentParser(description="caddis health + maintenance report (read-only).")
349
+ ap.add_argument("--dest", default=".", help="Project dir whose rules to check (default: cwd).")
350
+ ap.add_argument("--quiet", action="store_true", help="Print only the one-line maintenance nudge (SessionStart).")
351
+ args = ap.parse_args(argv)
352
+ return run(Path(args.dest).resolve(), args.quiet)
353
+
354
+
355
+ if __name__ == "__main__":
356
+ raise SystemExit(main())