Compare commits

...

3 commits

Author SHA1 Message Date
d14639e80a docs(plan): /kaizen command — implementation plan (TODO 11)
7 tasks: friction-scan.py (TDD, --json/--nudge) + tests; kaizen.md command;
/review-repo nudge hookup + STATUS/TODO; dogfood run. Mirrors /review-repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:09:29 +02:00
1a0e30e278 docs(spec): /kaizen — kaizen-loop command (TODO 11)
Curate-only consume pass over FRICTION.md Open signals: interactive guided
session, add/change/park/remove verdicts (park-with-resurrection-trigger to
protect out-of-phase tooling on a solo project), single source = FRICTION.md,
ledger is the durable record. Mirrors /review-repo (command md + stdlib scanner).
Stage 1 on-demand + stage-2 nudge; headless/cron deferred (TODO 11.3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:05:09 +02:00
e5867422d0 docs(todo): defer kaizen-loop automation to the notify + cron stack
Per brainstorm: ship the on-demand command + recurrence/age nudge first;
revisit a scheduled headless (report-only) run once ntfy + scheduled jobs exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:49:26 +02:00
3 changed files with 818 additions and 0 deletions

View file

@ -120,6 +120,12 @@
Recurrence-triggered plus a light periodic sweep.
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
first build is an **on-demand** command plus a light recurrence/age **nudge**
(printed reminder when the loop is overdue). Wiring a **scheduled headless
run** — report-only: it proposes add/change/**park**/remove and notifies, but
does not auto-curate/commit — waits until the notification (ntfy) +
scheduled-job stack exists. Look into automating it then.
12. **Spin-up / build order** — what is the right order of operations when spinning up
from scratch (OS, DNS, Authentik, Caddy, …)?

View file

@ -0,0 +1,641 @@
# `/kaizen` Command Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the `/kaizen` kaizen-loop command — a stdlib scanner that parses `docs/FRICTION.md` *Open signals* plus an interactive command that curates them (add/change/park/remove) into the decisions ledger.
**Architecture:** Mirrors `/review-repo` exactly: a deterministic stdlib Phase-0 scanner (`scripts/friction-scan.py`, unit-tested) feeds a markdown command (`.claude/commands/kaizen.md`) that drives the interactive curation. The same scanner powers a stage-2 nudge surfaced in `/review-repo`.
**Tech Stack:** Python 3 standard library only (matches `scripts/repo-scan.py`); pytest; markdown command docs.
**Spec:** `docs/superpowers/specs/2026-06-14-kaizen-command-design.md`
---
## File structure
- Create: `scripts/friction-scan.py` — stdlib parser of `FRICTION.md` *Open signals*; `--json` (default) and `--nudge` modes. One responsibility: turn the prose signal log into structured data + the nudge line.
- Create: `tests/test_friction_scan.py` — unit tests for the parser (string-based, deterministic via `--today`), matching `tests/test_repo_scan.py`.
- Create: `.claude/commands/kaizen.md` — the interactive curation process.
- Modify: `.claude/commands/review-repo.md` — add the stage-2 nudge line to its report.
- Modify: `STATUS.md` — add a `/kaizen` row.
- Modify: `docs/TODO.md` — mark item 11.1 in progress / built.
All scanner logic lives in functions that take strings/data (not files) so tests need no fixtures on disk; only `load_signals(path, today)` and `main()` touch the filesystem.
---
## Task 1: Scanner scaffold — section extraction + signal splitting
**Files:**
- Create: `scripts/friction-scan.py`
- Test: `tests/test_friction_scan.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_friction_scan.py
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]`")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -v`
Expected: FAIL — `friction-scan.py` does not exist / `extract_open_section` undefined.
- [ ] **Step 3: Write minimal implementation**
```python
#!/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")
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]
if __name__ == "__main__": # pragma: no cover (filled in Task 4)
pass
```
- [ ] **Step 4: Run test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -v`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add scripts/friction-scan.py tests/test_friction_scan.py
git commit -m "feat(kaizen): friction-scan section extraction + signal split"
```
---
## Task 2: Per-signal fields — tag, first_seen, age_days
**Files:**
- Modify: `scripts/friction-scan.py`
- Test: `tests/test_friction_scan.py`
- [ ] **Step 1: Write the failing test**
```python
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
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py::test_parse_signal_extracts_tag_and_date_and_age -v`
Expected: FAIL — `parse_signal` undefined.
- [ ] **Step 3: Write minimal implementation**
Add near the top (after imports):
```python
TAG_RE = re.compile(r"`\[(friction|gotcha|recurring|unused)\]`")
DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
```
Add the function (above the `__main__` block):
```python
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
return {
"tag": tag_m.group(1) if tag_m else None,
"first_seen": first_seen,
"age_days": age_days,
"recurrence_count": 1, # refined in Task 3
"referenced_paths": [], # filled in Task 3
"still_exists": True, # filled in Task 3
"text": " ".join(raw.split()),
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -v`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add scripts/friction-scan.py tests/test_friction_scan.py
git commit -m "feat(kaizen): parse tag/first_seen/age per signal"
```
---
## Task 3: Recurrence count + referenced paths + still_exists
**Files:**
- Modify: `scripts/friction-scan.py`
- Test: `tests/test_friction_scan.py`
- [ ] **Step 1: Write the failing test**
```python
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
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -k "recurrence or paths or still_exists" -v`
Expected: FAIL — `parse_recurrence` / `parse_paths` undefined.
- [ ] **Step 3: Write minimal implementation**
Add the regexes near the others:
```python
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")
```
Add the helpers (above `parse_signal`):
```python
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
```
Then update `parse_signal` — replace the three placeholder fields:
```python
paths = parse_paths(raw)
still_exists = all(os.path.exists(os.path.join(REPO_ROOT, p)) for p in paths) if 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()),
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -v`
Expected: PASS (10 tests).
- [ ] **Step 5: Commit**
```bash
git add scripts/friction-scan.py tests/test_friction_scan.py
git commit -m "feat(kaizen): recurrence count + referenced-path existence"
```
---
## Task 4: CLI — `load_signals`, `--json`, `--nudge`
**Files:**
- Modify: `scripts/friction-scan.py`
- Test: `tests/test_friction_scan.py`
- [ ] **Step 1: Write the failing test**
```python
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)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -k "nudge or load_signals" -v`
Expected: FAIL — `nudge_line` / `load_signals` undefined.
- [ ] **Step 3: Write minimal implementation**
Add thresholds near the top (after `FRICTION = ...`):
```python
# Nudge thresholds (tunable; the /kaizen self-eval phase revisits these).
NUDGE_MIN_OPEN = 8
NUDGE_MAX_AGE_DAYS = 21
NUDGE_MIN_RECURRENCE = 3
```
Add the functions and replace the `__main__` block:
```python
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()
```
- [ ] **Step 4: Run tests + smoke-test the CLI**
Run: `.venv/bin/python -m pytest tests/test_friction_scan.py -v`
Expected: PASS (14 tests).
Run: `python3 scripts/friction-scan.py --nudge`
Expected: one line like `kaizen: 13 open signals, oldest 14d, max recurrence 5x — OVERDUE — run /kaizen`.
Run: `python3 scripts/friction-scan.py | head -20`
Expected: a JSON array of signal objects.
- [ ] **Step 5: Commit**
```bash
git add scripts/friction-scan.py tests/test_friction_scan.py
git commit -m "feat(kaizen): friction-scan CLI (--json default, --nudge)"
```
---
## Task 5: The `/kaizen` command document
**Files:**
- Create: `.claude/commands/kaizen.md`
- [ ] **Step 1: Create the command file**
Write `.claude/commands/kaizen.md` with exactly this content:
````markdown
# 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.
````
- [ ] **Step 2: Verify it parses against the real log**
Run: `python3 scripts/friction-scan.py --today 2026-06-15 | python3 -c "import sys,json; print(len(json.load(sys.stdin)), 'signals')"`
Expected: prints a non-zero signal count with no traceback.
- [ ] **Step 3: Lint**
Run: `make lint`
Expected: passes (markdown isn't linted by yamllint/ansible-lint, but this confirms nothing else broke).
- [ ] **Step 4: Commit**
```bash
git add .claude/commands/kaizen.md
git commit -m "feat(kaizen): /kaizen command — interactive friction curation"
```
---
## Task 6: Stage-2 nudge in `/review-repo` + STATUS/TODO
**Files:**
- Modify: `.claude/commands/review-repo.md`
- Modify: `STATUS.md`
- Modify: `docs/TODO.md`
- [ ] **Step 1: Add the nudge to the review-repo command**
In `.claude/commands/review-repo.md`, find the "Phase 0 — deterministic pre-scan" section
(it runs `scripts/repo-scan.py`). Immediately after that paragraph, add:
```markdown
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.
```
- [ ] **Step 2: Add a STATUS row**
In `STATUS.md`, under "Real and working today", add a row:
```markdown
| `/kaizen` | Curate `docs/FRICTION.md` Open signals → decisions ledger (`scripts/friction-scan.py` Phase 0 + `.claude/commands/kaizen.md`). On-demand; `--nudge` surfaces in `/review-repo`. Headless/cron deferred (TODO 11.3). |
```
- [ ] **Step 3: Update TODO 11**
In `docs/TODO.md` item 11, mark sub-item 1 built:
Change `1. Build `/retro`: ...` to begin with `1. ~~Build `/retro``... ` — i.e. strike it
through and append: `DONE — built as `/kaizen` (scope narrowed to curate-only per the
2026-06-14 spec; `/retro` name dropped). `scripts/friction-scan.py` + `.claude/commands/kaizen.md`.`
- [ ] **Step 4: Lint**
Run: `make lint`
Expected: passes.
- [ ] **Step 5: Commit**
```bash
git add .claude/commands/review-repo.md STATUS.md docs/TODO.md
git commit -m "feat(kaizen): nudge in /review-repo; STATUS + TODO"
```
---
## Task 7: Dogfood — first real `/kaizen` run
This task is **not** automated; it is the first real use, done interactively with the operator.
- [ ] **Step 1:** Run `/kaizen` against the current Open signals (there are several,
including the 3 added 2026-06-14 and the 5× execution-mode-menu signal).
- [ ] **Step 2:** Work the interactive curation (Phase 2) with the operator, applying
verdicts on approval.
- [ ] **Step 3:** Confirm the close-out: ledger updated, `make lint` green, summary printed.
This both processes the backlog and validates the command end-to-end.
---
## Self-review notes (author)
- **Spec coverage:** scope-A curate-only → Task 5 Phase 02; verdict model incl. PARK →
Task 5 Phase 2 + ledger; single source FRICTION.md → Task 4 `load_signals`; interactive
apply (B) → Task 5; ledger format → Task 5 Phase 3; scanner schema → Tasks 24; nudge +
thresholds → Task 4 + Task 6; out-of-scope items → not built (correct); `/review-repo`
relationship → Task 6 nudge. All covered.
- **No placeholders:** every code step shows complete code; the command doc is written in
full.
- **Type consistency:** the signal dict keys (`tag, first_seen, age_days,
recurrence_count, referenced_paths, still_exists, text`) are identical across Tasks 24
and the command doc; `nudge_line` reads `age_days`/`recurrence_count` only.

View file

@ -0,0 +1,171 @@
# `/kaizen` — kaizen-loop command (design)
**Status:** Designed, not built. Resolves `docs/TODO.md` item 11 (Kaizen loop).
**Date:** 2026-06-14.
## Context
boma runs a kaizen (continuous-improvement) loop on its own methodology and tooling.
The *capture* half already works: raw signals are appended to `docs/FRICTION.md`
*Open signals* during work (tags `[friction]`/`[gotcha]`/`[recurring]`/`[unused]`). The
*consume* half — periodically reading those signals, deciding **add / change / park /
remove**, migrating durable knowledge into the right docs, and archiving consumed signals
into the decisions ledger — is **manual** and therefore easy to skip. The one kaizen
review on record (`FRICTION.md`, 2026-06-10 ledger block) was done by hand; its own
process note says "the `/retro` tool (TODO 11) still isn't built, so this review was
manual."
This spec defines the command that makes the consume pass a repeatable, low-friction
ritual. The name **`/kaizen`** is chosen over the placeholder `/retro`: a "retro" is a
backward-looking, time-boxed ritual, whereas this is continuous improvement — and
`FRICTION.md` already speaks this language ("kaizen friction log", "Kaizen reviews —
decisions ledger"), so the command name reinforces the artifact names.
## Decisions (from the 2026-06-14 brainstorm)
1. **Scope: curate-only.** `/kaizen` consumes the `FRICTION.md` *Open signals*; it does
**not** auto-harvest new signals. Capture stays manual and continuous. `FRICTION.md`
is the **single input source** (single source of truth).
2. **Verdict model: add / change / park / remove**, with a critical split on the
reductive side to protect a solo, phase-shifting project:
- **Knowledge is never removed** — it is *migrated* to the right doc or *archived* to
the ledger. The reductive verdicts act only on *active surface* (scripts, checks,
conventions, plugins), never on understanding.
- **park***out-of-phase but not obsolete*; plausibly valuable in a later focus.
Moved out of the active surface but recorded in the ledger with **(a)** where it now
lives (git SHA/branch/doc) and **(b)** an explicit **resurrection trigger**.
- **remove** — reserved for the *obsolete*: superseded, wrong, never worked, duplicated.
- Every reductive verdict must classify *why unused*: **obsolete → remove**,
**out-of-phase → park**. The default for "not touched lately but not wrong" is **park**.
- Reversibility safety net: single operator + everything in git, so even a wrong
`remove` is `git revert`-able with a ledger breadcrumb; `park` lowers the cost further.
- Precedent in-repo: `docs/runbooks/claude-code-setup.md` already lists "Deferred
plugins … with triggers" — park-with-a-trigger, made a first-class kaizen outcome.
3. **Trigger model: on-demand command + a light nudge, staged.**
- Stage 1 (this spec): the **on-demand** `/kaizen` command.
- Stage 2 (this spec, small follow-on): a **nudge**`friction-scan.py --nudge`
prints a one-line "loop overdue" reminder, surfaced inside `/review-repo`'s report.
- Deferred (TODO 11.3): a **scheduled headless** run (report-only) once the
notification (ntfy) + scheduled-job/cron stack exists.
4. **Apply model: interactive guided session.** `/kaizen` proposes verdicts (one or
grouped); the operator approves / modifies / rejects each; on approval the command
performs the mechanical edit and shows the diff, then commits at close-out. There is
no auto-applied "safe class" (unlike `/review-repo`): every kaizen verdict is a
judgment call, so the human is in the loop for each. Report-only behaviour is reserved
for the future headless path.
## Components
Mirrors the `/review-repo` shape (`.claude/commands/review-repo.md` + `scripts/repo-scan.py`):
1. **`scripts/friction-scan.py`** — stdlib only; parses `FRICTION.md` *Open signals* and
emits structured data. Two modes:
- `--json` (default): the Phase-0 input for `/kaizen`.
- `--nudge`: prints one line and signals "overdue" per the thresholds below.
2. **`.claude/commands/kaizen.md`** — the interactive curation process (session flow below).
3. **`tests/test_friction_scan.py`** — unit tests for the parser (matches the
`tests/test_repo_scan.py` convention).
4. **`/review-repo` hook-up (stage 2)** — `review-repo.md` calls `friction-scan.py
--nudge` and includes the line in its report.
5. **Deferred:** the headless/cron path (TODO 11.3).
### `friction-scan.py` output schema
Per *Open signal*: `{tag, first_seen, age_days, recurrence_count, referenced_paths,
still_exists, text}`.
- `tag` — one of `friction` / `gotcha` / `recurring` / `unused`.
- `first_seen` / `age_days` — parsed from the leading `date — [tag]` marker.
- `recurrence_count` — best-effort from explicit markers in the entry (entries already
write "5th occurrence (06-05/06/06/…)"); refined by the human during triage.
- `referenced_paths` / `still_exists` — paths the signal names and whether they still
exist on disk (a missing target hints the signal may be already-resolved).
`--nudge` reports **overdue** when **any** holds: `recurrence_count >= 3` for any signal,
open count `>= 8`, or oldest `age_days >= 21`. Thresholds are constants, tunable, and the
self-eval phase revisits them.
## The `/kaizen` session flow
**Phase 0 — scan (deterministic).** Run `friction-scan.py --json`. Produces the agenda
and the cheap "is this still real?" check (`still_exists`).
**Phase 1 — triage.** Order signals by recurrence, then age, then tag. Group signals
sharing a root cause (e.g. the execution-mode-menu and brainstorming-gate signals are both
"external skill script vs boma convention" — curated together). Present the agenda before
editing anything: counts of open / recurring / likely-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** —
- **systematize** → migrate the durable lesson into its right home (a runbook, an ADR,
`CLAUDE.md`, a new `repo-scan.py` check, or a hook),
- **change** → adjust an existing tool/convention/config rather than document it,
- **park** → ledger row with git location + resurrection trigger,
- **remove** → obsolete; ledger row with the reason,
- **already-built** → the systematization already exists / the fix landed elsewhere; archive,
- **accepted** → conscious no-op (revisit-if-recurs); archive,
- **keep-open** → still accruing; leave in *Open signals* (the only verdict with no ledger row).
The ledger verdict vocabulary is therefore `SYSTEMATIZE · CHANGE · PARK · REMOVE ·
ALREADY-BUILT · ACCEPTED` (keep-open produces no row). These extend the verdicts the
2026-06-10 ledger block already used (CHANGE, MIGRATE, already built, accepted).
The operator approves / modifies / rejects each. On approval, the command performs the
mechanical edit (migrate the text into the target doc; **move the signal from *Open
signals* into the ledger table**; delete/park the file) and shows the diff. **park and
remove both delete from the active tree** — the difference is the ledger row (park records
a resurrection trigger). Git history + the ledger row *are* the park mechanism; there is
no `parked/` graveyard directory.
**Phase 3 — close-out.**
- Write a new dated review block in the ledger (newest-first, same shape as the 2026-06-10
block).
- **Bias-to-remove discipline check** — if every verdict this pass was "add", flag that
the loop is only accreting.
- **Self-eval** (light) — is `/kaizen` being run often enough (oldest-consumed age); should
the nudge thresholds change.
- `make lint` if code/docs changed; commit per `CLAUDE.md` git conventions (the curation
is one logical unit — straight to `main` if small/safe, a branch if sweeping).
- Print a one-line summary: consumed X · parked Y · removed Z · kept-open W · migrated →
\<docs>.
### Ledger row format
A new dated block extends the existing `## Kaizen reviews — decisions ledger` table:
| column | content |
|---|---|
| Signal (first seen) | the signal + first-seen date + recurrence (e.g. "5× 06-05…06-14") |
| Verdict | `SYSTEMATIZE` · `CHANGE` · `PARK` · `REMOVE` · `ALREADY-BUILT` · `ACCEPTED` |
| Resolution / where it lives now | systematize → the doc/guard it migrated to; **park** → git location + resurrection trigger; remove → why obsolete |
Parked rows stay permanently visible in the ledger with their trigger, so a future phase
can `grep PARK` and revive — the explicit answer to "don't drop something we'll come back to."
## Out of scope (YAGNI)
- **Headless/cron run** — deferred to the notify + cron stack (TODO 11.3).
- **Auto-harvesting new signals** — rejected; capture stays manual, and the `[unused]` tag
is how dormant tooling enters the loop.
- **Decision/ADR re-challenge** — that is TODO 13 ("Intentions"), a separate future
command; `/kaizen` curates methodology/tooling signals, not service/architecture decisions.
- **Auto tooling-usage inventory** — rejected for the same reason as auto-harvest.
- **A separate report artifact** (à la `docs/reviews/`) — the ledger *is* the durable
record; the interactive session is the "report".
## Relationship to `/review-repo`
`/review-repo` audits **repo drift** (code/doc staleness, conformance). `/kaizen` curates
**methodology/tooling** friction. They stay distinct. Future integration (not in this
spec): when `/review-repo` sees a finding recur across runs, it could append a
`[recurring]` signal to `FRICTION.md`, making `/review-repo` a *producer* into the single
input source that `/kaizen` consumes.
## Build order
1. `scripts/friction-scan.py` (`--json`) + `tests/test_friction_scan.py`.
2. `.claude/commands/kaizen.md` (the session flow).
3. First real `/kaizen` run against the current Open signals (dogfood).
4. Stage 2: `--nudge` + `/review-repo` hook-up.
5. (Deferred) headless/cron — TODO 11.3.