Files
Claude 2d3a29e4b3 Apply lessons from studying real GitHub projects
Studied via direct fetches: SakanaAI/AI-Scientist-v2, WecoAI/aideml,
anthropics/skills, obra/superpowers, UniM0cha/claude-self-improving-
skills, SamuelSchmidgall/AgentLaboratory, princeton-nlp/SWE-agent,
openai/preparedness (PaperBench), open-mmlab/mmsegmentation,
facebookresearch/dinov2, karpathy/nanoGPT. Applied the top verified
findings:

- extract_commands: join backslash-continued commands (every dinov2 run
  command was previously truncated to an unrunnable `python ... \` stub)
  and classify entrypoint-first with word-boundary keywords (nanoGPT's
  `train.py ... --eval_iters=20` classified as evaluation, which would
  execute training while bypassing the authorization gate). Quick-start
  sections now count as run sections. Both real-repo patterns added as
  regression fixtures.
- research-thinking-loop: draft/debug/improve iteration types with a
  3-attempt debug cap (AIDE journal semantics), no-parsed-metric ⇒ buggy
  and never best, replication across 3 seeds before a candidate replaces
  current_research (AI-Scientist-v2 multi-seed evaluation), metric-only
  best selection note, typed stop reasons, defaults table, ledger-first
  grounding.
- annotate_readme: PaperBench-style evidence tiers per annotation
  (code-development / execution / result-match) and a weighted 0-1
  reproduction score in the header chip and readme_section_coverage.
- lessons_store: touch/prune lifecycle with usage-extended staleness
  windows, credential-shape blocklist (AKIA/ghp_/sk-/xox/AIza),
  best-effort security wording, what-NOT-to-record and human-reviewed
  promotion flow in the policy.
- Demo bundles and preview images regenerated with score and tiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT2VAaQodjqTJdjBHSStxA
2026-07-26 18:22:07 +00:00

258 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""User-owned lesson store backing the continuous-learning policy.
Appends compact JSONL lessons under ~/.rigorpilot/ and distills them into
PERSONAL_RIGOR.md. Lessons are an advisory overlay: they never modify skill
files and never relax rigor gates. See references/continuous-learning-policy.md.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
VALID_KINDS = {"failure-fix", "user-correction", "preference", "generalization"}
# Best-effort blocklist: keyword shapes plus common bare-credential formats.
# This is a guardrail, not a guarantee — callers still must not pass secrets.
SECRET_RE = re.compile(
r"(api[_-]?key|secret|token|password|passwd|authorization|bearer\s+\S|-----BEGIN"
r"|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{16,}"
r"|xox[a-z]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})",
re.IGNORECASE,
)
MAX_FIELD_CHARS = 300
SUMMARY_LIMIT_PER_KIND = 12
# Staleness windows for prune, in days; doubled once a lesson proves useful
# (use_count >= 3), mirroring usage-driven curation in self-improving skills.
PRUNE_WINDOW_DAYS = {
"failure-fix": 90,
"user-correction": 180,
"preference": 365,
"generalization": 365,
}
def lessons_home() -> Path:
root = os.environ.get("RIGORPILOT_HOME")
return Path(root).expanduser() if root else Path.home() / ".rigorpilot"
def lessons_enabled() -> bool:
return os.environ.get("RIGORPILOT_LESSONS", "1") != "0"
def lessons_path() -> Path:
return lessons_home() / "lessons.jsonl"
def overlay_path() -> Path:
return lessons_home() / "PERSONAL_RIGOR.md"
def repo_fingerprint(repo: Path) -> str:
name = repo.name
readme = repo / "README.md"
digest = ""
if readme.exists():
digest = hashlib.sha1(readme.read_bytes()).hexdigest()[:10]
return f"{name}@{digest}" if digest else name
def sanitize(text: str) -> Optional[str]:
cleaned = " ".join(str(text or "").split())[:MAX_FIELD_CHARS]
if not cleaned:
return None
if SECRET_RE.search(cleaned):
return None
return cleaned
def load_lessons(path: Optional[Path] = None) -> List[Dict[str, Any]]:
target = path or lessons_path()
if not target.exists():
return []
lessons: List[Dict[str, Any]] = []
for line in target.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(item, dict) and item.get("summary"):
lessons.append(item)
return lessons
def record_lesson(
*,
kind: str,
skill: str,
summary: str,
detail: str = "",
fingerprint: str = "",
) -> Optional[Path]:
"""Append one lesson. Returns the store path, or None if skipped."""
if not lessons_enabled():
return None
if kind not in VALID_KINDS:
raise ValueError(f"Unknown lesson kind: {kind}")
clean_summary = sanitize(summary)
if clean_summary is None:
return None
clean_detail = sanitize(detail) or ""
existing = load_lessons()
for item in existing[-50:]:
if item.get("kind") == kind and item.get("skill") == skill and item.get("summary") == clean_summary:
return lessons_path()
entry = {
"ts": int(time.time()),
"kind": kind,
"skill": sanitize(skill) or "unknown",
"summary": clean_summary,
"detail": clean_detail,
"fingerprint": sanitize(fingerprint) or "",
}
path = lessons_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
return path
def rewrite_store(lessons: List[Dict[str, Any]]) -> Path:
path = lessons_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"".join(json.dumps(item, ensure_ascii=False) + "\n" for item in lessons),
encoding="utf-8",
)
return path
def touch_lesson(summary: str) -> bool:
"""Mark a lesson as used: bump use_count, refresh last_used."""
lessons = load_lessons()
hit = False
now = int(time.time())
for item in reversed(lessons):
if item.get("summary") == summary:
item["use_count"] = int(item.get("use_count", 0)) + 1
item["last_used"] = now
hit = True
break
if hit:
rewrite_store(lessons)
return hit
def prune(now: Optional[int] = None) -> int:
"""Drop stale lessons per kind-specific windows; useful lessons live longer."""
lessons = load_lessons()
current = now if now is not None else int(time.time())
kept: List[Dict[str, Any]] = []
for item in lessons:
window_days = PRUNE_WINDOW_DAYS.get(str(item.get("kind")), 180)
if int(item.get("use_count", 0)) >= 3:
window_days *= 2
reference = int(item.get("last_used") or item.get("ts") or current)
if (current - reference) <= window_days * 86400:
kept.append(item)
removed = len(lessons) - len(kept)
if removed:
rewrite_store(kept)
return removed
def summarize(path: Optional[Path] = None) -> Path:
lessons = load_lessons(path)
lines = [
"# Personal Rigor Overlay",
"",
"Advisory lessons distilled from real runs. On any conflict with the",
"RigorPilot references or SKILL.md contracts, the repository wins.",
"",
]
for kind in ("generalization", "preference", "user-correction", "failure-fix"):
matching = [item for item in lessons if item.get("kind") == kind]
if not matching:
continue
lines.append(f"## {kind}")
lines.append("")
seen: set = set()
shown = 0
for item in reversed(matching):
key = item.get("summary")
if key in seen:
continue
seen.add(key)
suffix = f" `{item['fingerprint']}`" if item.get("fingerprint") else ""
lines.append(f"- {item['summary']}{suffix}")
shown += 1
if shown >= SUMMARY_LIMIT_PER_KIND:
break
lines.append("")
target = overlay_path()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
return target
def main() -> int:
parser = argparse.ArgumentParser(description="Record or summarize RigorPilot lessons.")
sub = parser.add_subparsers(dest="command", required=True)
rec = sub.add_parser("record", help="Append one lesson to the store.")
rec.add_argument("--kind", required=True, choices=sorted(VALID_KINDS))
rec.add_argument("--skill", required=True)
rec.add_argument("--summary", required=True)
rec.add_argument("--detail", default="")
rec.add_argument("--repo", default="", help="Repo path used to derive a fingerprint.")
sub.add_parser("summarize", help="Distill the store into PERSONAL_RIGOR.md.")
sub.add_parser("list", help="Print stored lessons as JSON lines.")
touch = sub.add_parser("touch", help="Mark a lesson as used (bumps use_count).")
touch.add_argument("--summary", required=True)
sub.add_parser("prune", help="Drop stale lessons per kind-specific windows.")
args = parser.parse_args()
if args.command == "touch":
hit = touch_lesson(args.summary)
print(json.dumps({"touched": hit}, ensure_ascii=False))
return 0
if args.command == "prune":
removed = prune()
print(json.dumps({"pruned": removed}, ensure_ascii=False))
return 0
if args.command == "record":
fingerprint = repo_fingerprint(Path(args.repo).resolve()) if args.repo else ""
path = record_lesson(
kind=args.kind,
skill=args.skill,
summary=args.summary,
detail=args.detail,
fingerprint=fingerprint,
)
print(json.dumps({"recorded": path is not None, "store": str(path) if path else None}, ensure_ascii=False))
return 0
if args.command == "summarize":
target = summarize()
print(json.dumps({"overlay": str(target)}, ensure_ascii=False))
return 0
for item in load_lessons():
print(json.dumps(item, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())