Skip to content

Commit 38847d6

Browse files
jet52claude
andcommitted
Add cached rule freshness checking against ndcourts.gov (v2.0.0)
Rule staleness checks now fetch effective dates from ndcourts.gov and compare against bundled dates, with results cached for 90 days. Includes standalone script for manual/forced checks and updated tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9859678 commit 38847d6

11 files changed

Lines changed: 470 additions & 103 deletions

README.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ python app.py
188188
## Architecture
189189

190190
- **`core/`** — Shared analysis engine (PDF extraction, mechanical checks, semantic checks, report builder)
191-
- **`scripts/`** — CLI scripts for the Claude Code skill workflow (`check_brief.py`, `build_report.py`)
191+
- **`scripts/`** — CLI scripts for the Claude Code skill workflow (`check_brief.py`, `build_report.py`, `check_rule_freshness.py`)
192192
- **`references/`** — Check definitions, rules summary, and bundled rule text
193193
- **`web/`** — Flask web interface (upload form, report viewer, JSON API)
194194
- **`SKILL.md`** — Self-contained Claude Code skill definition (bundles all rules and check definitions inline; works with or without PyMuPDF)
@@ -220,13 +220,20 @@ The full text of the following rules is bundled in `references/rules/`:
220220
| `rule-3.4.md` | N.D.R.Ct. 3.4 | Privacy Protection for Filings |
221221
| `rule-11.6.md` | N.D.R.Ct. 11.6 | Medium-Neutral Case Citations |
222222

223-
Rules were last copied from the authoritative source on **2026-02-17**.
223+
Rules were last verified current against ndcourts.gov on **2026-03-07**.
224224

225-
## TODO
225+
## Rule Freshness Checking
226226

227-
- [ ] **Rule freshness check**: Add a feature (script or startup check) that compares the bundled rule files against the current versions at ndcourts.gov to detect whether any rules have been amended since the bundled copies were last updated. Candidate URLs:
228-
- https://www.ndcourts.gov/legal-resources/rules/ndrappp/28
229-
- https://www.ndcourts.gov/legal-resources/rules/ndrappp/29
230-
- https://www.ndcourts.gov/legal-resources/rules/ndrappp/32
231-
- https://www.ndcourts.gov/legal-resources/rules/ndrappp/34
232-
- https://www.ndcourts.gov/legal-resources/rules/ndrct/3-4
227+
The skill automatically checks whether bundled rules are still current by comparing their effective dates against the live versions on ndcourts.gov. This check runs as part of the normal compliance workflow via `get_version_warnings()` in `core/version_check.py`.
228+
229+
- **Cached**: Results are stored in `~/.cache/jetbriefcheck/rule_staleness.json` and reused for 90 days. No network calls on most runs.
230+
- **Fail-open**: If ndcourts.gov is unreachable, the check silently succeeds.
231+
- **Per-rule tracking**: Each rule's effective date is tracked independently. If any rule is amended, a warning is emitted identifying which rule changed and linking to the ndcourts.gov page.
232+
233+
To force a live check (bypassing the cache):
234+
235+
```bash
236+
python3 scripts/check_rule_freshness.py
237+
```
238+
239+
When a rule is flagged as stale, update the bundled `.md` file, update `BUNDLED_EFFECTIVE_DATES` in `core/version_check.py`, recompute hashes in `version.json`, and bump `rules_verified`.

SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: jetbriefcheck
3-
version: 1.8.0
3+
version: 2.0.0
44
description: >-
55
Triggers when a user uploads a legal brief PDF for compliance review against the
66
North Dakota Rules of Appellate Procedure. Analyzes the brief and produces a
@@ -52,7 +52,9 @@ Use `$VENV_PYTHON` in all subsequent commands instead of hardcoded `python3`.
5252

5353
The user uploads a PDF via drag-and-drop. Save the uploaded file to a temporary location, then execute the phases below.
5454

55-
### Phase 0: Save the Uploaded PDF
55+
### Phase 0: Update Check and Save the Uploaded PDF
56+
57+
**Update check:** Run `python3 ~/.claude/skills/jetbriefcheck/check_update.py` silently. If it prints output, include it as a note to the user.
5658

5759
Save the uploaded file to the current working directory, preserving its original filename:
5860

TODO.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# TODO
2+
3+
(empty)

build_zip.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ cp -r "$REPO_ROOT/scripts" "$DEST/scripts"
1616
cp "$REPO_ROOT/SKILL.md" "$DEST/SKILL.md"
1717
cp "$REPO_ROOT/requirements.txt" "$DEST/requirements.txt"
1818
cp "$REPO_ROOT/version.json" "$DEST/version.json"
19+
cp "$REPO_ROOT/check_update.py" "$DEST/check_update.py"
1920

2021
# Clean build artifacts
2122
find "$DEST" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true

check_update.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
"""Check for skill/plugin updates against the latest GitHub release.
3+
4+
Shared across jet52 projects: jetmemo-skill, jetredline, jetbriefcheck, jetcite.
5+
Uses a weekly cache to avoid hitting the GitHub API on every invocation.
6+
Fails open — never blocks the user's work.
7+
"""
8+
9+
import json
10+
import sys
11+
import time
12+
import urllib.request
13+
from pathlib import Path
14+
15+
REPO = "jet52/jetbriefcheck"
16+
SKILL_NAME = "jetbriefcheck"
17+
18+
GITHUB_API = f"https://api.github.com/repos/{REPO}/releases/latest"
19+
CACHE_DIR = Path.home() / ".cache" / SKILL_NAME
20+
CACHE_FILE = CACHE_DIR / "update_check.json"
21+
CHECK_INTERVAL = 7 * 86400 # 1 week in seconds
22+
TIMEOUT = 3.0
23+
24+
25+
def _read_local_version() -> str | None:
26+
"""Read the locally installed version from version.json."""
27+
version_json = Path(__file__).resolve().parent / "version.json"
28+
try:
29+
data = json.loads(version_json.read_text())
30+
return data.get("version")
31+
except Exception:
32+
return None
33+
34+
35+
def _parse_version(v: str) -> tuple[int, ...]:
36+
"""Parse a semver string into a comparable tuple."""
37+
return tuple(int(x) for x in v.split("."))
38+
39+
40+
def _read_cache() -> dict | None:
41+
"""Read the cached update check result."""
42+
try:
43+
data = json.loads(CACHE_FILE.read_text())
44+
if time.time() - data.get("checked", 0) < CHECK_INTERVAL:
45+
return data
46+
except Exception:
47+
pass
48+
return None
49+
50+
51+
def _write_cache(remote_version: str) -> None:
52+
"""Write the update check result to cache."""
53+
try:
54+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
55+
CACHE_FILE.write_text(json.dumps({
56+
"checked": time.time(),
57+
"remote_version": remote_version,
58+
}))
59+
except Exception:
60+
pass
61+
62+
63+
def _fetch_latest() -> str | None:
64+
"""Fetch the latest release tag from GitHub."""
65+
req = urllib.request.Request(
66+
GITHUB_API, headers={"Accept": "application/vnd.github+json"}
67+
)
68+
try:
69+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
70+
data = json.loads(resp.read())
71+
return data.get("tag_name", "").lstrip("v")
72+
except Exception:
73+
return None
74+
75+
76+
def check_for_update() -> str | None:
77+
"""Check for updates. Returns an advisory string if one is available, None otherwise."""
78+
local = _read_local_version()
79+
if not local:
80+
return None
81+
82+
# Try cache first
83+
cache = _read_cache()
84+
if cache:
85+
remote = cache.get("remote_version")
86+
else:
87+
remote = _fetch_latest()
88+
if remote:
89+
_write_cache(remote)
90+
91+
if not remote:
92+
return None
93+
94+
try:
95+
if _parse_version(remote) > _parse_version(local):
96+
return (
97+
f"{SKILL_NAME} v{local} -> v{remote} available: "
98+
f"https://github.com/{REPO}/releases/latest"
99+
)
100+
except (ValueError, TypeError):
101+
pass
102+
103+
return None
104+
105+
106+
def main():
107+
msg = check_for_update()
108+
if msg:
109+
print(msg)
110+
else:
111+
local = _read_local_version() or "unknown"
112+
print(f"{SKILL_NAME} v{local} (up to date)")
113+
114+
115+
if __name__ == "__main__":
116+
main()

core/version_check.py

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,53 @@
44
- Local version info reading
55
- Remote version check (lightweight, fail-open)
66
- Rule content hash verification
7-
- Rule staleness warning based on age since last verification
7+
- Rule staleness check against ndcourts.gov (cached, 90-day refresh)
88
"""
99

1010
from __future__ import annotations
1111

1212
import hashlib
1313
import json
14+
import re
15+
import sys
1416
from datetime import date, datetime
1517
from pathlib import Path
1618
from typing import Optional
19+
from urllib.request import Request, urlopen
1720

1821
PROJECT_DIR = Path(__file__).resolve().parent.parent
1922
VERSION_FILE = PROJECT_DIR / "version.json"
2023
RULES_DIR = PROJECT_DIR / "references" / "rules"
24+
STALENESS_CACHE = Path.home() / ".cache" / "jetbriefcheck" / "rule_staleness.json"
25+
STALENESS_MAX_AGE_DAYS = 90
26+
27+
# Map rule file stems to ndcourts.gov URLs
28+
RULE_URLS = {
29+
"rule-14": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/14",
30+
"rule-21": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/21",
31+
"rule-28": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/28",
32+
"rule-29": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/29",
33+
"rule-30": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/30",
34+
"rule-32": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/32",
35+
"rule-34": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/34",
36+
"rule-3.4": "https://www.ndcourts.gov/legal-resources/rules/ndrct/3-4",
37+
"rule-11.6": "https://www.ndcourts.gov/legal-resources/rules/ndrct/11-6",
38+
}
39+
40+
# Effective dates at the time rules were last bundled (update when rules are refreshed)
41+
BUNDLED_EFFECTIVE_DATES = {
42+
"rule-14": "2020-03-01",
43+
"rule-21": "2022-03-01",
44+
"rule-28": "2025-06-01",
45+
"rule-29": "2022-03-01",
46+
"rule-30": "2023-01-25",
47+
"rule-32": "2024-04-01",
48+
"rule-34": "2025-09-01",
49+
"rule-3.4": "2025-03-01",
50+
"rule-11.6": "2025-03-01",
51+
}
52+
53+
EFFECTIVE_DATE_RE = re.compile(r"Effective\s+Date[:\s]+(\d{1,2}/\d{1,2}/\d{4})")
2154

2255

2356
def load_local_version() -> dict:
@@ -68,30 +101,98 @@ def check_rule_hashes(local_version: dict) -> list[str]:
68101
return warnings
69102

70103

71-
def check_rule_staleness(local_version: dict) -> Optional[str]:
72-
"""Check if rules are older than the configured freshness threshold.
104+
def _load_staleness_cache() -> dict:
105+
"""Load the cached staleness result. Returns empty dict if missing/corrupt."""
106+
try:
107+
return json.loads(STALENESS_CACHE.read_text(encoding="utf-8"))
108+
except (FileNotFoundError, json.JSONDecodeError, OSError):
109+
return {}
73110

74-
Returns a warning string if stale, None otherwise.
75-
"""
76-
verified_str = local_version.get("rules_verified")
77-
if not verified_str:
111+
112+
def _save_staleness_cache(data: dict) -> None:
113+
"""Write staleness cache, creating parent dirs as needed."""
114+
try:
115+
STALENESS_CACHE.parent.mkdir(parents=True, exist_ok=True)
116+
STALENESS_CACHE.write_text(json.dumps(data, indent=2), encoding="utf-8")
117+
except OSError:
118+
pass # fail silently — cache is advisory
119+
120+
121+
def _fetch_effective_date(url: str, timeout: float = 10.0) -> Optional[str]:
122+
"""Fetch a rule page on ndcourts.gov and extract the effective date as YYYY-MM-DD."""
123+
try:
124+
req = Request(url, headers={"User-Agent": "jetbriefcheck-freshness-check"})
125+
with urlopen(req, timeout=timeout) as resp:
126+
html = resp.read().decode("utf-8", errors="replace")
127+
except Exception:
128+
return None
129+
130+
match = EFFECTIVE_DATE_RE.search(html)
131+
if not match:
78132
return None
79133

80134
try:
81-
verified_date = datetime.strptime(verified_str, "%Y-%m-%d").date()
135+
dt = datetime.strptime(match.group(1), "%m/%d/%Y")
136+
return dt.strftime("%Y-%m-%d")
82137
except ValueError:
83138
return None
84139

85-
max_days = local_version.get("rules_freshness_days", 90)
86-
age_days = (date.today() - verified_date).days
87140

88-
if age_days > max_days:
89-
return (
90-
f"Bundled rules were last verified {age_days} days ago "
91-
f"({verified_str}). Consider checking ndcourts.gov for amendments "
92-
f"to Rules 28, 29, 30, 32, 34, and 3.4."
93-
)
94-
return None
141+
def _check_rules_live() -> list[str]:
142+
"""Fetch effective dates from ndcourts.gov and compare against bundled dates.
143+
144+
Returns a list of warning strings for any stale rules. Saves results to cache.
145+
"""
146+
stale = []
147+
live_dates = {}
148+
149+
for rule, url in RULE_URLS.items():
150+
bundled = BUNDLED_EFFECTIVE_DATES.get(rule)
151+
if not bundled:
152+
continue
153+
live = _fetch_effective_date(url)
154+
if live:
155+
live_dates[rule] = live
156+
if live != bundled:
157+
stale.append(
158+
f"Rule {rule} may be outdated: bundled effective date "
159+
f"{bundled}, ndcourts.gov shows {live}. "
160+
f"Check {url}"
161+
)
162+
163+
_save_staleness_cache({
164+
"last_checked": date.today().isoformat(),
165+
"live_dates": live_dates,
166+
"stale_rules": [s.split(":")[0].replace("Rule ", "") for s in stale],
167+
"warnings": stale,
168+
})
169+
170+
return stale
171+
172+
173+
def check_rule_staleness(local_version: dict) -> list[str]:
174+
"""Check bundled rules against ndcourts.gov effective dates (cached).
175+
176+
Uses a local cache at ~/.cache/jetbriefcheck/rule_staleness.json.
177+
Re-checks live every 90 days. Returns a list of warning strings.
178+
"""
179+
cache = _load_staleness_cache()
180+
last_checked_str = cache.get("last_checked")
181+
182+
if last_checked_str:
183+
try:
184+
last_checked = datetime.strptime(last_checked_str, "%Y-%m-%d").date()
185+
age = (date.today() - last_checked).days
186+
if age < STALENESS_MAX_AGE_DAYS:
187+
return cache.get("warnings", [])
188+
except ValueError:
189+
pass
190+
191+
# Cache is missing, expired, or corrupt — check live (fail-open)
192+
try:
193+
return _check_rules_live()
194+
except Exception:
195+
return []
95196

96197

97198
def fetch_remote_version(timeout: float = 2.0) -> Optional[dict]:
@@ -162,10 +263,8 @@ def get_version_warnings(check_remote: bool = True, timeout: float = 2.0) -> lis
162263
# Rule hash integrity check
163264
warnings.extend(check_rule_hashes(local))
164265

165-
# Rule staleness check
166-
staleness = check_rule_staleness(local)
167-
if staleness:
168-
warnings.append(staleness)
266+
# Rule staleness check (cached, checks ndcourts.gov every 90 days)
267+
warnings.extend(check_rule_staleness(local))
169268

170269
# Remote version check (fail-open)
171270
if check_remote:

deploy_skill.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from pathlib import Path
1818

1919
SKILL_NAME = "jetbriefcheck"
20-
COPY_ITEMS = ["SKILL.md", "scripts", "references", "core", "requirements.txt", "version.json"]
20+
COPY_ITEMS = ["SKILL.md", "scripts", "references", "core", "requirements.txt", "version.json", "check_update.py"]
2121

2222

2323
def _get_skills_dir() -> Path:

scripts/check_brief.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from core.models import BriefType
2929
from core.pdf_extract import extract_brief
3030
from core.version_check import get_version_warnings
31+
from check_update import check_for_update
3132

3233

3334
def main():
@@ -48,7 +49,12 @@ def main():
4849
help="Skip remote version check")
4950
args = parser.parse_args()
5051

51-
# Version and rule freshness warnings
52+
# Update check (cached, weekly)
53+
update_msg = check_for_update()
54+
if update_msg:
55+
print(f"Note: {update_msg}", file=sys.stderr)
56+
57+
# Rule freshness warnings
5258
warnings = get_version_warnings(check_remote=not args.skip_version_check)
5359
for w in warnings:
5460
print(f"Warning: {w}", file=sys.stderr)

0 commit comments

Comments
 (0)