From b185ac4765c53148f45e6914e4323e00bd094cf4 Mon Sep 17 00:00:00 2001 From: sjat Date: Sun, 14 Jun 2026 21:18:16 +0200 Subject: [PATCH] feat(kaizen): friction-scan CLI (--json default, --nudge) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/friction-scan.py | 45 +++++++++++++++++++++++++++++++++++-- tests/test_friction_scan.py | 26 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) mode change 100644 => 100755 scripts/friction-scan.py diff --git a/scripts/friction-scan.py b/scripts/friction-scan.py old mode 100644 new mode 100755 index 66925ff..fcdd993 --- a/scripts/friction-scan.py +++ b/scripts/friction-scan.py @@ -16,6 +16,11 @@ import re REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FRICTION = os.path.join(REPO_ROOT, "docs", "FRICTION.md") +# Nudge thresholds (tunable; the /kaizen self-eval phase revisits these). +NUDGE_MIN_OPEN = 8 +NUDGE_MAX_AGE_DAYS = 21 +NUDGE_MIN_RECURRENCE = 3 + TAG_RE = re.compile(r"`\[(friction|gotcha|recurring|unused)\]`") DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})") ORDINAL_RE = re.compile(r"(\d+)(?:st|nd|rd|th)\s+(?:occurrence|reinforcement|time)", re.I) @@ -113,5 +118,41 @@ def parse_signal(raw, today): } -if __name__ == "__main__": # pragma: no cover (filled in Task 4) - pass +def load_signals(path, today): + with open(path, encoding="utf-8") as fh: + text = fh.read() + return [parse_signal(s, today) for s in split_signals(extract_open_section(text))] + + +def nudge_line(signals): + n = len(signals) + ages = [s["age_days"] for s in signals if s.get("age_days") is not None] + oldest = max(ages) if ages else 0 + max_rec = max((s["recurrence_count"] for s in signals), default=0) + overdue = n >= NUDGE_MIN_OPEN or oldest >= NUDGE_MAX_AGE_DAYS or max_rec >= NUDGE_MIN_RECURRENCE + status = "OVERDUE — run /kaizen" if overdue else "ok" + return f"kaizen: {n} open signals, oldest {oldest}d, max recurrence {max_rec}x — {status}" + + +def main(): + parser = argparse.ArgumentParser(description="Parse FRICTION.md Open signals for /kaizen.") + parser.add_argument("--nudge", action="store_true", help="print a one-line overdue summary") + parser.add_argument("--today", help="override today's date (YYYY-MM-DD) for testing") + parser.add_argument("--file", default=FRICTION, help="path to FRICTION.md") + args = parser.parse_args() + + if args.today: + y, m, d = args.today.split("-") + today = datetime.date(int(y), int(m), int(d)) + else: + today = datetime.date.today() + + signals = load_signals(args.file, today) + if args.nudge: + print(nudge_line(signals)) + else: + print(json.dumps(signals, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_friction_scan.py b/tests/test_friction_scan.py index 7ae437f..66984cf 100644 --- a/tests/test_friction_scan.py +++ b/tests/test_friction_scan.py @@ -89,3 +89,29 @@ def test_still_exists_false_for_missing_path(): def test_still_exists_true_for_real_path(): sig = fs.parse_signal("`[gotcha]` **x** (2026-06-01): `scripts/repo-scan.py`", TODAY) assert sig["still_exists"] is True + + +def test_nudge_line_overdue_on_recurrence(): + sigs = [{"age_days": 2, "recurrence_count": 5}] + line = fs.nudge_line(sigs) + assert "OVERDUE" in line + assert "max recurrence 5x" in line + + +def test_nudge_line_ok_when_quiet(): + sigs = [{"age_days": 3, "recurrence_count": 1}, {"age_days": 1, "recurrence_count": 1}] + line = fs.nudge_line(sigs) + assert "ok" in line + assert "OVERDUE" not in line + + +def test_nudge_line_overdue_on_count(): + sigs = [{"age_days": 1, "recurrence_count": 1} for _ in range(8)] + assert "OVERDUE" in fs.nudge_line(sigs) + + +def test_load_signals_reads_real_friction_file(): + path = os.path.join(os.path.dirname(__file__), "..", "docs", "FRICTION.md") + sigs = fs.load_signals(path, TODAY) + assert len(sigs) >= 1 + assert all(s["tag"] in {"friction", "gotcha", "recurring", "unused"} for s in sigs)