Compare commits

...

7 commits

Author SHA1 Message Date
d1e1e38879 feat(kaizen): nudge in /review-repo; STATUS + TODO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:27:23 +02:00
8d2f564382 feat(kaizen): /kaizen command — interactive friction curation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:26:21 +02:00
fd1e83a378 fix(kaizen): scope still_exists to repo paths; test age nudge; tidy --today
- Add REPO_DIRS constant; still_exists now only checks tokens that start
  with a known repo top-level dir, ignoring plugin names (caddy-dns/gandi),
  make command fragments (tf-init/plan), and role-relative paths.
- Add test_still_exists_ignores_non_repo_tokens (was failing before fix).
- Add test_nudge_line_overdue_on_age to close coverage gap on age threshold.
- Add load_signals docstring.
- Replace manual --today date parsing with datetime.date.fromisoformat type
  converter so malformed dates give a clean argparse error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:25:03 +02:00
b185ac4765 feat(kaizen): friction-scan CLI (--json default, --nudge)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:18:16 +02:00
c6f66ee634 feat(kaizen): recurrence count + referenced-path existence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:17:39 +02:00
72b9262f34 feat(kaizen): parse tag/first_seen/age per signal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:17:03 +02:00
859732b04d feat(kaizen): friction-scan section extraction + signal split
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:16:36 +02:00
6 changed files with 361 additions and 4 deletions

View file

@ -0,0 +1,63 @@
# Kaizen — curate the friction log into improvements
Consume the **Open signals** in `docs/FRICTION.md`: decide a verdict for each, migrate
durable knowledge into the right docs, and archive consumed signals into the decisions
ledger. **Curate-only** — do not hunt for new signals; capture stays manual. This is an
interactive, judgment-dense pass: propose, the operator decides, you apply on approval.
Design: `docs/superpowers/specs/2026-06-14-kaizen-command-design.md`.
## Phase 0 — scan
Run `python3 scripts/friction-scan.py > /tmp/kaizen.json`. It returns each Open signal as
`{tag, first_seen, age_days, recurrence_count, referenced_paths, still_exists, text}`.
Treat `still_exists: false` as a hint the signal may already be resolved.
## Phase 1 — triage
Order signals by `recurrence_count` desc, then `age_days` desc, then tag. **Group signals
that share a root cause** and curate them together. Present the agenda before editing
anything: total open, how many recurring (≥3), how many look already-resolved.
## Phase 2 — per-signal curation (interactive)
For each signal/group, present: a one-line restatement, the evidence (age, recurrence,
still-real), and a proposed **verdict**. Verdicts:
- **SYSTEMATIZE** — migrate the durable lesson into its right home (a runbook, an ADR,
`CLAUDE.md`, a new `scripts/repo-scan.py` check, or a hook).
- **CHANGE** — adjust an existing tool/convention/config rather than document it.
- **PARK***out-of-phase but not obsolete*. Remove from the active tree, but write a
ledger row recording **where it now lives (git SHA/branch/doc) and a resurrection
trigger**. The default for "not touched lately but not wrong."
- **REMOVE***obsolete*: superseded, wrong, never worked, duplicated. Ledger row states
why.
- **ALREADY-BUILT** — the systematization already exists / the fix landed; archive.
- **ACCEPTED** — conscious no-op (revisit-if-recurs); archive.
- **KEEP-OPEN** — still accruing, not ripe; leave it in *Open signals* (no ledger row).
Rules:
- **Knowledge is never removed** — SYSTEMATIZE/migrate it; only *active surface* (scripts,
checks, conventions, plugins) is parked/removed.
- Every reductive verdict must classify *why unused*: **obsolete → REMOVE**,
**out-of-phase → PARK**.
- The operator approves / modifies / rejects each verdict. On approval: do the mechanical
edit (migrate text into the target doc; **move the signal from *Open signals* into the
ledger table**; delete the parked/removed file) and show the diff.
- PARK and REMOVE both delete from the active tree — the difference is the ledger row.
Git history + the ledger row are the park mechanism; never create a `parked/` directory.
## Phase 3 — close-out
- Add a new dated block under `## Kaizen reviews — decisions ledger` (newest first), same
shape as the existing block: a table with columns **Signal (first seen) | Verdict |
Resolution / where it lives now**.
- **Bias-to-remove discipline check:** if every verdict this pass was SYSTEMATIZE/CHANGE
(only accreting), say so explicitly.
- **Self-eval (light):** is `/kaizen` being run often enough (oldest consumed age)? Should
the nudge thresholds in `scripts/friction-scan.py` change? Note it.
- Run `make lint` if any code/docs changed; revert anything that breaks it.
- Commit per `CLAUDE.md` git conventions (one logical unit — straight to `main` if
small/safe, a branch if sweeping; show the diff first for a branch).
- Print a one-line summary: `consumed X · parked Y · removed Z · kept-open W · migrated → <docs>`.
## Headless / cron (future)
Deferred until the notify + cron stack exists (`docs/TODO.md` 11.3). When run
non-interactively, **report only**: print the proposed verdicts and the nudge, do not edit
or commit.

View file

@ -33,6 +33,10 @@ ADR "Deferred/Open" entry — a checklist to confirm) and `stale-deferred` (an e
another file describes as resolved but which isn't marked resolved in place —
high-confidence, usually auto-fixable by marking the source ADR's entry RESOLVED).
Also run `python3 scripts/friction-scan.py --nudge` and include its one-line output in the
report's summary — it flags when the kaizen loop (`/kaizen`) is overdue (recurring signals,
backlog size, or age). This is a reminder only; do not act on `FRICTION.md` from here.
### Phase 1 — fan-out judgement review
Scale to repo size:
- **Small** (≤ ~10 roles, like boma today): a few sub-agents, or one pass per area.

View file

@ -20,6 +20,7 @@ _Last reviewed: 2026-06-14._
| Pre-commit hooks | Configured: lint, gitleaks, vault-encryption guard. Activate with `pre-commit install` after `make setup`. |
| Vault password client | `scripts/vault-pass-client.sh` fetches the master password from Vaultwarden via `rbw` (wired as `vault_password_file`). Requires `rbw` installed + `rbw unlock`. |
| `/review-repo` | Repo audit: `scripts/repo-scan.py` (Phase 0) + `.claude/commands/review-repo.md`, reports to `docs/reviews/`. On-demand only; cron + email deferred (`docs/TODO.md`). |
| `/kaizen` | Curate `docs/FRICTION.md` Open signals → decisions ledger (`scripts/friction-scan.py` Phase 0, unit-tested, + `.claude/commands/kaizen.md`). Interactive, on-demand; `--nudge` (recurrence/age/backlog) surfaces in `/review-repo`. Headless/cron deferred (TODO 11.3). |
| Terraform HCL (`terraform/`) | Written (proxmox VM module + envs) — but never run; see below. Offsite env also written — see "Designed but not built". |
| `docs/hardware/reference.md` + `scripts/capacity-scan.py` | Present — reference doc (skeleton until real hardware) + stdlib scan; emits capacity JSON |
| `/capacity-review` | Works — on-demand capacity evaluation → `docs/hardware/reviews/`. Intent-based (no live usage yet) |

View file

@ -114,10 +114,12 @@
auto-install.)
11. **Kaizen loop** — set up ~2026-06-06 (one week from now).
1. Build `/retro`: reads `docs/FRICTION.md` + recurring `/review-repo`
findings + a tooling-usage inventory; proposes add / change / **remove**
(biased to remove); records decisions as ADRs; evaluates itself.
Recurrence-triggered plus a light periodic sweep.
1. ~~Build `/retro`~~ **DONE — built as `/kaizen`** (`scripts/friction-scan.py` +
`.claude/commands/kaizen.md`; spec `docs/superpowers/specs/2026-06-14-kaizen-command-design.md`).
Scope narrowed to **curate-only** per the 2026-06-14 spec (no auto-harvest, no
tooling-usage inventory; decision re-challenge is TODO 13, not this). Verdicts are
add / change / **park** / remove (park-with-resurrection-trigger). The `--nudge`
(recurrence/age/backlog) surfaces in `/review-repo`; headless/cron is 11.3.
2. Keep appending raw signals to `docs/FRICTION.md` (live now) until the
retro consumes them.
3. **Automation deferred (revisit when the notify + cron stack is up):** the

160
scripts/friction-scan.py Executable file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Parse docs/FRICTION.md 'Open signals' into structured data for /kaizen.
Stdlib only. Modes:
--json (default): emit the open signals as JSON (Phase-0 input for /kaizen)
--nudge : print a one-line 'loop overdue?' summary
Authoritative design: docs/superpowers/specs/2026-06-14-kaizen-command-design.md
"""
import argparse
import datetime
import json
import os
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
# Top-level repo dirs — only tokens under these are treated as repo-root paths
# for still_exists (avoids false negatives on plugin names, make commands, etc.).
REPO_DIRS = ("roles/", "scripts/", "docs/", "playbooks/", "inventories/", "tests/", "terraform/", ".claude/")
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)
DATELIST_RE = re.compile(r"\((\d{2}-\d{2}(?:/[\d/-]+)+)\)")
BACKTICK_RE = re.compile(r"`([^`]+)`")
PATH_EXTS = (".py", ".yml", ".yaml", ".md", ".sh", ".tf", ".j2", ".toml", ".cfg", ".hcl")
def extract_open_section(text):
"""Return the body between '## Open signals' and the next '## ' heading."""
lines = text.splitlines()
start = None
for i, line in enumerate(lines):
if line.strip().lower() == "## open signals":
start = i + 1
break
if start is None:
return ""
end = len(lines)
for j in range(start, len(lines)):
if lines[j].startswith("## "):
end = j
break
return "\n".join(lines[start:end])
def split_signals(section):
"""Split the Open-signals body into raw per-signal blocks.
A signal starts with a top-level '- ' bullet; indented or blank lines are
continuations. Returns a list of multi-line strings with the leading '- '
stripped from the first line."""
signals = []
current = None
for line in section.splitlines():
if line.startswith("- "):
if current is not None:
signals.append("\n".join(current).strip())
current = [line[2:]]
elif current is not None:
if line.strip() == "" or line.startswith(" "):
current.append(line.strip())
else:
signals.append("\n".join(current).strip())
current = None
if current is not None:
signals.append("\n".join(current).strip())
return [s for s in signals if s]
def parse_recurrence(text):
"""Best-effort recurrence count from explicit markers; default 1."""
counts = [1]
m = ORDINAL_RE.search(text)
if m:
counts.append(int(m.group(1)))
dl = DATELIST_RE.search(text)
if dl:
counts.append(dl.group(1).count("/") + 1)
return max(counts)
def parse_paths(text):
"""Backtick tokens that look like repo paths (contain '/' or a known ext)."""
out, seen = [], set()
for m in BACKTICK_RE.finditer(text):
tok = m.group(1).strip()
if ("/" in tok or tok.endswith(PATH_EXTS)) and tok not in seen:
seen.add(tok)
out.append(tok)
return out
def parse_signal(raw, today):
"""Turn one raw signal block into a structured dict."""
tag_m = TAG_RE.search(raw)
date_m = DATE_RE.search(raw)
if date_m:
first_seen = date_m.group(0)
seen = datetime.date(int(date_m.group(1)), int(date_m.group(2)), int(date_m.group(3)))
age_days = (today - seen).days
else:
first_seen = None
age_days = None
paths = parse_paths(raw)
repo_paths = [p for p in paths if p.startswith(REPO_DIRS)]
still_exists = all(os.path.exists(os.path.join(REPO_ROOT, p)) for p in repo_paths) if repo_paths else True
return {
"tag": tag_m.group(1) if tag_m else None,
"first_seen": first_seen,
"age_days": age_days,
"recurrence_count": parse_recurrence(raw),
"referenced_paths": paths,
"still_exists": still_exists,
"text": " ".join(raw.split()),
}
def load_signals(path, today):
"""Read a FRICTION.md file and return its Open signals as parsed dicts."""
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", type=datetime.date.fromisoformat,
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()
today = args.today or 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()

127
tests/test_friction_scan.py Normal file
View file

@ -0,0 +1,127 @@
import importlib.util
import os
_SPEC = importlib.util.spec_from_file_location(
"friction_scan",
os.path.join(os.path.dirname(__file__), "..", "scripts", "friction-scan.py"),
)
fs = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(fs)
SAMPLE = """# FRICTION.md
## Open signals
_(append new raw signals here)_
- `[gotcha]` **First thing** (2026-06-01): body line one.
continuation line two.
- `[friction]` **Second thing** (2026-06-10): only one line.
---
## Kaizen reviews — decisions ledger
- `[gotcha]` **Should not be parsed** (2026-01-01): in the ledger.
"""
def test_extract_open_section_stops_at_next_heading():
section = fs.extract_open_section(SAMPLE)
assert "First thing" in section
assert "Second thing" in section
assert "Should not be parsed" not in section
def test_split_signals_finds_two_items_and_joins_continuations():
signals = fs.split_signals(fs.extract_open_section(SAMPLE))
assert len(signals) == 2
assert "continuation line two" in signals[0]
assert signals[1].startswith("`[friction]`")
import datetime
TODAY = datetime.date(2026, 6, 15)
def test_parse_signal_extracts_tag_and_date_and_age():
raw = fs.split_signals(fs.extract_open_section(SAMPLE))[0]
sig = fs.parse_signal(raw, TODAY)
assert sig["tag"] == "gotcha"
assert sig["first_seen"] == "2026-06-01"
assert sig["age_days"] == 14
assert "First thing" in sig["text"]
def test_parse_signal_handles_missing_date():
sig = fs.parse_signal("`[unused]` **No date here** something", TODAY)
assert sig["tag"] == "unused"
assert sig["first_seen"] is None
assert sig["age_days"] is None
def test_recurrence_from_ordinal():
assert fs.parse_recurrence("blah 5th occurrence (06-05/06/06) blah") == 5
def test_recurrence_from_datelist_when_no_ordinal():
# three slash-separated date fragments -> recurrence 3
assert fs.parse_recurrence("recurred (06-05/06-09/06-10) again") == 3
def test_recurrence_defaults_to_one():
assert fs.parse_recurrence("a one-off gotcha") == 1
def test_parse_paths_picks_repo_paths_only():
paths = fs.parse_paths("see `scripts/repo-scan.py` and `latest` and `foo.yml`")
assert "scripts/repo-scan.py" in paths
assert "foo.yml" in paths
assert "latest" not in paths
def test_still_exists_false_for_missing_path():
sig = fs.parse_signal("`[unused]` **x** (2026-06-01): `scripts/nope-not-real.py`", TODAY)
assert sig["still_exists"] is False
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_still_exists_ignores_non_repo_tokens():
sig = fs.parse_signal("`[gotcha]` **x** (2026-06-01): `caddy-dns/gandi` and `make tf-init/plan`", TODAY)
assert sig["still_exists"] is True
def test_nudge_line_overdue_on_age():
sigs = [{"age_days": 21, "recurrence_count": 1}]
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)