Compare commits

...

6 commits

Author SHA1 Message Date
a0762c563e docs(kaizen): bind-mount gotcha + consume 7 signals into the ledger (2026-06-17)
Migrate the single-file-bind-mount/stale-config gotcha (reload-in-place needs a
directory mount; restart-based roles don't) to docs/testing/gotchas.md, and move
all 7 open signals out of FRICTION.md's Open-signals section into the new
2026-06-17 decisions-ledger block: all consumed, 1 PARK (the ubongo
self-management gap, tracked in STATUS), 0 REMOVE. Relax test_load_signals to
accept an empty Open-signals section (the goal state after a kaizen pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:50:17 +02:00
c1323a3f29 feat(make): registry-login via vaulted Forgejo token (kaizen)
scripts/registry-login.sh reads vault.forgejo.registry_token and pipes it to
docker login --password-stdin (never echoed, never on argv); 'make registry-login'
wires it with the venv binaries. Adds the operator-minted CHANGEME vault stub
(fill via make edit-vault) and a per-machine prereq note in the claude-code-setup
runbook, so 'make caddy-image-push'/'molecule-image-push' become agent-completable
non-interactively. Consumes the 2026-06-15 signal in docs/FRICTION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:50:07 +02:00
39904a778a fix(hooks): scope vault-preflight to staged ansible; catch prose exec re-asks
guard-vault-preflight: block a locked 'git commit' only when the staged set
(git diff --cached, plus -a/--all) contains ansible content matching the
pre-commit ansible-lint hook's files: scope. Docs-/config-only commits never
trigger that hook, so they no longer need the vault — fixing the false block on
docs-only commits. Fails safe to block when unsure.

guard-execution-mode-menu: widen the execution-mode arm to also catch free-form
prose re-asks of the subagent-vs-inline choice ('which execution approach?',
'subagent vs inline', ...), which the literal-menu matcher missed; the push
re-ask is intentionally left to the dont-reask-settled-defaults memory.

Consumes two 2026-06-17 signals in docs/FRICTION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:49:55 +02:00
8f1c7d47ec fix(reverse_proxy,netbird_coordinator): create scaffold dirs in check mode
Add check_mode: false to the state:directory base_dir tasks so that 'make check'
on a brand-new compose service role creates the scaffold during --check and the
rest of the dry-run (templates + docker_compose_v2 up) can be evaluated instead
of failing on a missing project_src. The directive is inert under a normal
converge (incl. Molecule + its tagged second converge), so role tests are
unchanged. Consumes the 2026-06-16 signal in docs/FRICTION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:49:47 +02:00
b0c0150db2 feat(scan): repo-scan rename-incomplete check (kaizen)
When a numbered ADR announces a rename Old->New, flag design-doc lines where
Old still appears in present tense — skipping the announcing ADR, lines that
also name New, and historical/negation cues, and rejecting ADR-NNN tokens as
terms. Structural cousin of stale-deferred; run by /review-repo. Zero findings
on the current tree (the Traefik->Caddy ripple edits have landed). Consumes the
2026-06-14 KEEP-OPEN signal in docs/FRICTION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:49:41 +02:00
959f9b30b5 feat(statusline): show context-window usage % in the status line
Adds .claude/statusline.sh (reads context_window.used_percentage +
context_window_size straight from the statusLine JSON; green<70/yellow/red
bar) and wires it via .claude/settings.json statusLine. Committed in-repo so
it follows boma to any clone, matching how .claude/ already tracks hooks +
plugins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:47 +02:00
15 changed files with 531 additions and 179 deletions

View file

@ -6,7 +6,12 @@
# 1. The execution-mode menu — writing-plans / subagent-driven-development script a
# "Subagent-Driven vs Inline Execution — which approach?" menu at the plan→execution
# handoff. boma's standing preference is to NEVER present it and proceed
# subagent-driven. (Recorded by the 2026-06-10 kaizen review.)
# subagent-driven. (Recorded by the 2026-06-10 kaizen review; the 2026-06-17 review
# widened the matcher to also catch free-form *prose* re-asks of the same choice —
# e.g. "which execution approach?" — which the literal-menu matcher missed. The
# sibling push-vs-not re-ask is deliberately NOT hooked: a genuine "should I push?"
# is sometimes legitimate, so it stays a soft default via the
# dont-reask-settled-defaults memory rather than a hard block.)
# 2. The brainstorming spec-review gate — the brainstorming skill scripts "Spec written
# and committed … please review it before … the implementation plan." The standing
# agreement is to move directly from the committed spec to writing-plans. (Recorded
@ -39,7 +44,11 @@ text=$(jq -rs '
low="${text,,}"
if [[ "$low" == *"inline execution"* \
&& ( "$low" == *"which approach"* || "$low" == *"two execution options"* ) ]]; then
&& ( "$low" == *"which approach"* || "$low" == *"two execution options"* ) ]] \
|| [[ "$low" == *"subagent-driven or inline"* || "$low" == *"inline or subagent"* ]] \
|| [[ "$low" == *"subagent-driven vs inline"* || "$low" == *"subagent vs inline"* \
|| "$low" == *"inline vs subagent"* ]] \
|| [[ "$low" == *"execution approach"* && "$low" == *"?"* ]]; then
cat <<'JSON'
{"decision":"block","reason":"Execution-mode menu detected in your final message. boma standing preference (docs/FRICTION.md + always-subagent-driven-execution memory): never present the subagent-driven-vs-inline menu. Drop the menu and proceed with subagent-driven execution directly (superpowers:subagent-driven-development)."}
JSON

View file

@ -1,12 +1,16 @@
#!/usr/bin/env bash
#
# PreToolUse guard (Bash): block `git commit` when the rbw vault agent is locked.
# The pre-commit ansible-lint hook decrypts vault.yml via rbw, so a commit while
# locked fails deep with a confusing error. This catches it early with a clear fix.
# PreToolUse guard (Bash): block `git commit` ONLY when the rbw vault agent is locked
# AND the commit would actually need the vault. The pre-commit ansible-lint hook decrypts
# vault.yml via rbw — but it is scoped (`files: ^(roles|playbooks|inventories)/.*\.ya?ml$`,
# always_run:false), so a docs-/config-only commit never triggers it and needs no vault.
# (2026-06-17 kaizen, docs/FRICTION.md: the old guard blocked *every* locked commit, so a
# docs-only commit got snagged needing a vault password it never uses.)
#
# Fails OPEN: only blocks on a definitive "rbw present AND not unlocked" signal.
# If rbw is missing, the command isn't a plain `git commit`, or `--no-verify` is
# used, the action is allowed.
# Fails OPEN: blocks only on a definitive "Ansible content staged AND rbw locked" signal.
# rbw missing, not a plain `git commit`, `--no-verify`, or no Ansible content staged → allow.
# When unsure it errs toward blocking (asking for an unlock is cheap; a deep pre-commit
# failure is not).
#
set -uo pipefail
@ -22,14 +26,25 @@ case "$cmd" in
esac
command -v rbw >/dev/null 2>&1 || exit 0 # rbw not installed — allow
rbw unlocked >/dev/null 2>&1 && exit 0 # unlocked — allow
if rbw unlocked >/dev/null 2>&1; then
exit 0 # unlocked — allow
fi
# rbw is LOCKED. Only block if this commit would run the vault-decrypting ansible-lint
# hook — i.e. staged content matches its `files:` scope. Mirror that regex exactly.
ANSIBLE_RE='^(roles|playbooks|inventories)/.*\.ya?ml$'
# rbw present but not unlocked (locked or agent not running) — the commit would
# fail in the pre-commit hook, so block early with guidance.
cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0
files=$(git diff --cached --name-only 2>/dev/null) || exit 0
# `git commit -a/--all` also sweeps in modified tracked files that aren't staged yet.
# (Substring match — errs toward including them, which only ever over-blocks. Safe.)
case " $cmd " in
*" -a"*|*"--all"*) files="$files"$'\n'"$(git diff --name-only 2>/dev/null)" ;;
esac
# No Ansible content in the fileset → ansible-lint hook won't run → no vault needed → allow.
printf '%s\n' "$files" | grep -Eq "$ANSIBLE_RE" || exit 0
# Ansible content staged AND rbw locked — the commit would fail deep in pre-commit. Block.
cat <<'JSON'
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"rbw is locked — the pre-commit ansible-lint hook needs the vault password to decrypt vault.yml. Run: rbw unlock"}}
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"rbw is locked and this commit stages Ansible content — the pre-commit ansible-lint hook needs the vault password to decrypt vault.yml. Run: rbw unlock (docs-/config-only commits are exempt and won't hit this guard.)"}}
JSON
exit 0

View file

@ -69,5 +69,10 @@
]
}
]
},
"statusLine": {
"type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/statusline.sh\"",
"padding": 0
}
}

63
.claude/statusline.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
#
# Claude Code statusLine — shows working dir, model, and context-window usage.
# Wired via .claude/settings.json (statusLine.command). Receives the statusLine
# JSON on stdin; first stdout line is rendered (ANSI colour supported).
#
# Context usage comes straight from the input JSON — no transcript parsing:
# .context_window.used_percentage pre-calculated % of the window in use (input side)
# .context_window.context_window_size window size in tokens (1000000 for the 1M models)
# verified: Claude Code statusLine schema · code.claude.com/docs/en/statusline · 2026-06-17
#
# Fails soft: any parse problem prints nothing and exits 0 (never breaks the prompt).
set -uo pipefail
input=$(cat 2>/dev/null) || exit 0
command -v jq >/dev/null 2>&1 || exit 0
# pct<TAB>window<TAB>dir-basename<TAB>model-name (used_percentage preferred,
# else derived from current_usage, else 0). @tsv keeps spaces in the dir safe.
parsed=$(printf '%s' "$input" | jq -r '
(.workspace.current_dir // .cwd // "" | sub(".*/"; "")) as $dir
| (.model.display_name // "?") as $model
| (.context_window.context_window_size // 200000) as $win
| (
if (.context_window.used_percentage // null) != null then
.context_window.used_percentage
elif (.context_window.current_usage // null) != null then
((.context_window.current_usage.input_tokens
+ (.context_window.current_usage.cache_creation_input_tokens // 0)
+ (.context_window.current_usage.cache_read_input_tokens // 0)) / $win * 100)
else 0 end | floor
) as $pct
| [$pct, $win, $dir, $model] | @tsv
' 2>/dev/null) || exit 0
[ -z "$parsed" ] && exit 0
IFS=$'\t' read -r pct win dir model <<<"$parsed"
# Human window label: 1000000 -> 1M, 200000 -> 200k, else Nk.
case "$win" in
1000000) wlabel="1M" ;;
*) wlabel="$((win / 1000))k" ;;
esac
# Colour the bar/percentage by pressure: green <70, yellow 7089, red >=90.
if [ "$pct" -ge 90 ]; then col=$'\033[31m' # red
elif [ "$pct" -ge 70 ]; then col=$'\033[33m' # yellow
else col=$'\033[32m' # green
fi
dim=$'\033[2m'; rst=$'\033[0m'
# 10-cell bar; clamp fill to [0,10] so an over-100 reading can't overflow.
filled=$((pct / 10)); [ "$filled" -gt 10 ] && filled=10; [ "$filled" -lt 0 ] && filled=0
bar=""
for ((i = 0; i < 10; i++)); do
if [ "$i" -lt "$filled" ]; then bar+="█"; else bar+="░"; fi
done
printf '%s%s%s · %s · %s%s %d%%%s %sctx/%s%s\n' \
"$dim" "$dir" "$rst" \
"$model" \
"$col" "$bar" "$pct" "$rst" \
"$dim" "$wlabel" "$rst"

View file

@ -23,6 +23,11 @@ MOLECULE_DOCKERFILE := .docker/molecule-debian13/Dockerfile
# (the Go module proxy 403s Hetzner IPs); push the pinned tag to the Forgejo registry.
CADDY_IMAGE := forgejo.nyumbani.baobab.band/sjat/caddy-gandi:2.11.4
CADDY_DOCKERFILE := .docker/caddy-gandi/Dockerfile
# Forgejo container registry (same host/user as the image tags above). `make registry-login`
# logs the Docker daemon in using vault.forgejo.registry_token (2026-06-17 kaizen) so image
# pushes are agent-completable non-interactively.
REGISTRY_HOST := forgejo.nyumbani.baobab.band
REGISTRY_USER := sjat
# For TF_ENV=offsite, source the Hetzner token from the vault into the environment
# (rbw must be unlocked). Read in-memory; never written to a tfvars file (CLAUDE.md).
@ -37,7 +42,7 @@ endif
.PHONY: help setup collections lint test test-all check deploy encrypt decrypt \
edit-vault check-vault new-role \
tf-init tf-plan tf-apply tf-output tf-inventory tf-inventory-offsite \
molecule-image molecule-image-push caddy-image caddy-image-push
molecule-image molecule-image-push caddy-image caddy-image-push registry-login
help:
@echo ""
@ -69,6 +74,7 @@ help:
@echo " make molecule-image-push Push the test image to the Forgejo registry"
@echo " make caddy-image Build the custom Caddy + Gandi DNS-01 image (run on ubongo)"
@echo " make caddy-image-push Push the Caddy image to the Forgejo registry"
@echo " make registry-login Log Docker into the Forgejo registry (vaulted token)"
@echo ""
# ── Environment setup ─────────────────────────────────────────────────────────
@ -159,6 +165,13 @@ caddy-image:
caddy-image-push: caddy-image
docker push $(CADDY_IMAGE)
# Log the local Docker daemon into the Forgejo registry using the vaulted token, so the
# *-image-push targets above are agent-completable non-interactively (rbw must be unlocked).
registry-login:
@ANSIBLE_VAULT="$(ANSIBLE)-vault" PYTHON="$(PYTHON)" VAULT="$(VAULT)" \
REGISTRY_HOST="$(REGISTRY_HOST)" REGISTRY_USER="$(REGISTRY_USER)" \
bash scripts/registry-login.sh
# ── Terraform ─────────────────────────────────────────────────────────────────
tf-init:

View file

@ -22,90 +22,35 @@ earning its keep.
_(append new raw signals here; the next kaizen review consumes them)_
- `[friction]` **Image push to the Forgejo registry fails with `no basic auth
credentials`** (2026-06-15): `make caddy-image-push` (and `molecule-image-push`) fail
unless the Docker daemon on ubongo has an interactive `docker login
forgejo.nyumbani.baobab.band` session — and those creds are **not in vault** (only
`gandi` + `hetzner` are), so an agent can't complete a push non-interactively. The
build half is fully automatable; the push half silently requires a human. → candidate:
document the `docker login` step in `docs/runbooks/claude-code-setup.md`, **or** store
a scoped Forgejo registry token in vault + a `make registry-login` target (login via
`--password-stdin`, `no_log`) so pushes are agent-completable like every other
vault-backed action.
- `[gotcha]` **Single-file Docker bind mount + atomic config rewrite = stale config in
the running container** (2026-06-16): `reverse_proxy` bind-mounted the Caddyfile as a
single file; `ansible.builtin.template` writes atomically (temp + rename → new inode),
so the running container kept the OLD inode and `caddy reload` (in-container, no restart)
re-read stale config and silently no-op'd (`"config is unchanged"`). The NetBird route
never loaded → Caddy never requested its cert; surfaced only by a TLS handshake failure.
Fix: mount the config **directory** (`./caddy``/etc/caddy`) — directory mounts reflect
inode swaps, so live reload works (proven on askari). NOTE the sibling case: NetBird also
single-file-mounts `config.yaml`, but its handler does `docker compose restart` (not an
in-container reload), and a restart DOES re-resolve the bind mount (verified: 0 before,
1 after) — so restart-based roles are safe; only in-place-reload roles need the dir mount.
→ candidate gotcha doc (`docs/testing/gotchas.md`): "reload-in-place needs a directory
mount; restart-based roles are fine with a single-file mount."
- `[friction]` **`make check` always fails on the first-ever deploy of a compose service
role** (2026-06-16): in check mode the "ensure base_dir" task is reported-but-not-run, so
the later `community.docker.docker_compose_v2` up fails with `"…is not a directory"`
(missing `project_src`). Not a defect — a real deploy creates the dir — but it means the
CLAUDE.md "always `make check` before `make deploy`" step is guaranteed-red for any brand
new stateful role, which erodes trust in the check. → candidate: guard the compose-up with
`not ansible_check_mode` (clean "skipped" in dry-run; compose can't be meaningfully
dry-run before first deploy anyway), OR document the one-time expected failure. Decide one.
- `[recurring]` **Re-asked the operator about settled defaults — push + execution mode**
(2026-06-17): at the M5 plan handoff I asked (a) whether to push to origin and (b) which
execution mode (subagent-driven vs inline) — both already settled: CLAUDE.md says push to
`origin` often (off-machine backup), and TODO 10.5 / the standing agreement is "always
subagent-driven" (there's even `guard-execution-mode-menu.sh`). Same shape as the 5×
"execution-mode menu asked AGAIN" ledger entries — but this time the ask was my own
free-form prose ("want those pushed now?", "which execution approach?"), which the
existing menu-text matcher does NOT catch (it keys on the writing-plans menu's literal
text). → the gap is that the guard only matches that literal menu; free-form re-asks slip
through. Candidate: widen the Stop-hook matcher to also flag prose re-asks of
push-vs-not / subagent-vs-inline, since prose reminders have already failed this many
times. Default behaviour: **push as backup and proceed subagent-driven without asking.**
- `[friction]` **A docs-only commit still tripped the `rbw`-locked pre-commit guard**
(2026-06-17): committing only `docs/superpowers/specs/*.md` (no ansible content) was
blocked needing the vault password, although the 2026-06-10 kaizen fix scoped the
pre-commit `ansible-lint` hook (`always_run: false` + `files:` ansible content) so
docs-/config-only commits skip it and need no vault. So either the hook's `files:`
pattern still matches `docs/**` (or `.md`), or a blanket pre-commit step needs the
vault regardless. → check `.pre-commit-config.yaml`'s `files:`/`exclude:` against the
spec/plan paths; docs-only commits should not require `rbw`.
- `[friction]` **The agent can't manage `ubongo` (the control node it runs ON) without
the operator granting access** (2026-06-17): enrolling `ubongo` in the mesh needed two
manual operator grants because the agent runs as `claude` (no sudo) but the inventory
manages `ubongo` as `sjat`: (1) `claude`'s SSH key added to `sjat`'s `authorized_keys`
(`Permission denied (publickey)` otherwise), then (2) `NOPASSWD` sudo for `sjat`
(`Missing sudo password` otherwise). So the "AI-worker control node" (ADR-015) can drive
the whole fleet but not itself, unattended. This is the **pending `ansible`-user
bootstrap** gap (STATUS) biting in practice. → the proper fix is ubongo's bootstrap to a
key-trusted, NOPASSWD `ansible` (or `sjat`) management identity as part of `base`/its
control-node recipe, so control-node self-management doesn't need ad-hoc operator grants.
- `[recurring]` **ADRs claim cross-doc reconciliation they didn't actually perform**
(2026-06-14): ADR-024's Status + Consequences asserted "ADR-017 prose that mentioned
Traefik is updated to read Caddy" — but ADR-008/017/019 + CAPABILITIES still said
Traefik; the rename was left half-done across the doc set and the ADR over-claimed its
own follow-through. Surfaced only by a full-repo `grep Traefik` during `/review-repo`.
Same shape as the deferred-decision-goes-stale signal (a decision lands in one place,
its promised ripple edits don't). → candidate `repo-scan.py` check: when an ADR's text
asserts "X is updated to Y" / supersedes a named tool, flag remaining occurrences of the
old name (or verify the claimed edit landed) — the structural cousin of `stale-deferred`.
(KEEP-OPEN per the 2026-06-14 `/kaizen` run — it's its own build task.)
---
## Kaizen reviews — decisions ledger
Consumed signals and where their resolution now lives. Newest first.
### 2026-06-17
Second `/kaizen` run. 7 signals triaged; all 7 consumed (0 kept open). Two heavier items
(the `rename-incomplete` scan check and the Forgejo registry-login path) were built by
parallel subagents and verified against the diff. **Bias-to-remove note:** one PARK
(the ubongo self-management gap — out-of-phase, already tracked in STATUS) and zero
REMOVE; the rest accreted (migrate/change). None of the open signals were `[unused]`
*tooling*, so there was nothing to delete — the only reductive move available was parking
the out-of-phase build. **Cadence:** healthy — 3 days after the first run, every signal
02 days old except the one carried over from 2026-06-14; the "recurring ≥3" nudge in
`scripts/friction-scan.py` didn't fire this pass (all recurrence counts were 1), so the
thresholds need no change.
| Signal (first seen) | Verdict | Resolution / where it lives now |
|---|---|---|
| ADRs claim cross-doc reconciliation they didn't perform (06-14) | SYSTEMATIZE | New `rename-incomplete` check in `scripts/repo-scan.py` (+7 tests): when a numbered ADR announces a rename `Old``New`, flag any design-doc line where `Old` still appears in present tense (skips the announcing ADR, lines also naming `New`, and historical/negation cues; rejects `ADR-NNN` tokens as terms). 0 findings on the current tree — the Traefik→Caddy ripple edits have landed. Structural cousin of `stale-deferred`; run by `/review-repo`. (Was KEEP-OPEN on 2026-06-14 — now built.) |
| Image push to the Forgejo registry needs an interactive `docker login` (06-15) | SYSTEMATIZE → vault | Vault-backed login path so pushes are agent-completable: `vault.forgejo.registry_token` stub (CHANGEME, operator-minted) + `scripts/registry-login.sh` (reads the token, `docker login --password-stdin`, never echoes it) + `make registry-login` + a prereq note in `docs/runbooks/claude-code-setup.md`. Works once the operator fills the token via `make edit-vault`. |
| Single-file bind mount + atomic rewrite = stale config (06-16) | SYSTEMATIZE | → `docs/testing/gotchas.md` — "Single-file bind mount + atomic rewrite = stale config (reload-in-place only)": `template` writes a new inode, a single-file bind mount pins the old one, so an in-container reload reads stale config. Mount the config *directory* for reload-in-place roles; restart-based roles are fine with a single-file mount. |
| `make check` always fails on the first-ever deploy of a compose service role (06-16) | CHANGE | `check_mode: false` on the `state: directory` scaffold tasks in `roles/reverse_proxy` + `roles/netbird_coordinator`, so the base dirs exist under `--check` and the rest of the dry-run (templates + compose) evaluates instead of failing on a missing `project_src`. Inert under converge → Molecule unchanged. |
| Re-asked settled defaults — push + execution mode, in prose (06-17) | CHANGE (exec) + ACCEPTED (push) | Widened `.claude/hooks/guard-execution-mode-menu.sh` to also catch free-form *prose* re-asks of the subagent-vs-inline choice (`"which execution approach?"`, `"subagent vs inline"`, …), not just the literal menu; tested. The push re-ask stays a soft default via the `dont-reask-settled-defaults` memory — a genuine "should I push?" is sometimes legitimate, so it is deliberately not hard-blocked. |
| Docs-only commit tripped the rbw-locked pre-commit guard (06-17) | CHANGE | Root cause was NOT the ansible-lint `files:` scope (innocent) — it was `.claude/hooks/guard-vault-preflight.sh` blocking *every* locked `git commit`. Rewrote it to inspect the staged set (`git diff --cached`, plus `-a`/`--all`) and block only when Ansible content (`^(roles\|playbooks\|inventories)/.*\.ya?ml$`) is staged; docs-/config-only commits are now exempt. Fail-safe to block when unsure. Tested. |
| Agent can't self-manage `ubongo` (the control node it runs on) without operator grants (06-17) | PARK | The knowledge already lives in `STATUS.md` (control-node row: the interim `claude`-key + `sjat` NOPASSWD grants, and **Pending:** the proper `ansible`-user bootstrap) and the `ubongo-self-sufficiency` memory. Out-of-phase — the fix is the control-node bootstrap recipe, a tracked future build. **Resurrection trigger:** when building ubongo's `base` hardening / `ansible`-user bootstrap, fold in key-trusted NOPASSWD self-management so control-node self-management needs no ad-hoc operator grants. |
### 2026-06-14
First `/kaizen` run (dogfood). 12 signals triaged; 11 consumed, 1 kept open (#13 above —

View file

@ -50,6 +50,13 @@ Don't install these until their trigger lands — then add them here and to
- **The venv-activate hook** — this repo expects the Python `.venv` active for Bash
commands. If you use the user-level `~/.claude/hooks/activate-venv.sh` pattern,
replicate it; otherwise `source .venv/bin/activate` per session after `make setup`.
- **Forgejo registry login (for image pushes)**`make caddy-image-push` /
`molecule-image-push` need the Docker daemon authenticated to
`forgejo.nyumbani.baobab.band`. Run **`make registry-login`** once per machine: it reads
`vault.forgejo.registry_token` from the vault and does `docker login --password-stdin`
(no interactive prompt, so an agent can complete a push). The token is operator-minted
(Forgejo → Settings → Applications → Generate Token, package read+write) and set via
`make edit-vault`; until then `registry-login` prints how to obtain it. (2026-06-17 kaizen.)
## 4. A note on user-level settings

View file

@ -70,3 +70,21 @@ testing surprise is worth remembering past the session that hit it.
plus review. Only a real (or `--check`) call against the API surfaces them.
- → Treat a **check-mode run against the real API as a required gate** for such roles, or
build a render-only assertion that materializes and inspects the rendered module args.
## Single-file bind mount + atomic rewrite = stale config (reload-in-place only)
- **`ansible.builtin.template` writes atomically** (temp file + rename → a *new inode*). A
Docker **single-file** bind mount pins the *old* inode, so a container that reloads
config **in place** (no restart) keeps reading the stale file. Live hit: `reverse_proxy`
bind-mounted the Caddyfile as a single file; `caddy reload` (in-container) re-read the
old inode and silently no-op'd (`"config is unchanged"`). The new NetBird route never
loaded → Caddy never requested its cert → surfaced only as a downstream TLS handshake
failure.
- **Fix for reload-in-place roles: bind-mount the config *directory*, not the file**
(`./caddy``/etc/caddy`). Directory mounts reflect the inode swap, so the reload sees
the new file (proven on askari).
- **Restart-based roles are fine with a single-file mount.** Sibling case: `netbird`
single-file-mounts `config.yaml`, but its handler does `docker compose restart` (not an
in-container reload), and a **restart re-resolves the bind mount** (verified: route
count 0 before, 1 after). Rule of thumb: **reload-in-place needs a directory mount;
restart-based roles don't.**

View file

@ -1,86 +1,106 @@
$ANSIBLE_VAULT;1.1;AES256
32313030663934353361336234373562303537356334346238663836373238366136356331363761
6337323031666565663430303562646565303533653531640a636662373939363632383838613431
38313365626365373539653266326661393765333737386161666165666534636562353165386537
3934633033383966360a323965333139643764326236396635383863353437313966326665373537
65396564393130303030643861663964383436396561643666623837306366346333306430306238
66656136626566626262373037623531623633313664376166376161363336353930636538323339
38386564333432353363353663643539343765373662643836646666626339353539323033386230
31613165373035363533383862366638353035653836303737656534623361313064616365643131
64386165653835366137353339396364313661656333333635616338346561363765353934343162
64346462656566376539643030656461363161393936623332373632653731303031393437316636
36626165306161336262356161666531323336343663643661626365396437383230613636356530
62326363383138643162316464396666623332366434336462363531363836313833366237396464
38323635353238653432626361383434646538326531356333393337643066373262663462656466
65373036653265616137666533373930333239303732623832353337343434636434616562336135
38666137353266353130303235616362323633353735373163336138633838633738393637633964
66623866353265316336336566663034306664656365643832616232313732626464316563636335
63653930626565636630326661626561366539303964373933653437356537343361626438313439
35643165636662643463616337323063343633306536346538623331333365366533653634343538
63623261636366303261373338633939363338316463303065613436396163616537666265623439
31383361646531633863623230616635646138653630383537366335633030343530383735616435
35656464393432313563303030626133383761303763653530653837313930303034353136353237
37376366623836646236363062633938666135326631376235323061666465373865396235643937
32633736656539356332336237646137303534343337353139383637623165353338623566666535
30643134303235633362383064376234366235363262396362613731373364306362303634613138
39366230366262363237656631646361356464393266656166386337303663313136666261633836
32306132323239343539396232316564326361626462366561313561393635393233653633646431
39313039313139616262396334613035333633326135346365333537373138396535633137353832
63636335613237623234646234653435616635356637343964656463383864366534363438343938
39626364653832373062323434316134653831336534383934346231656533643435306465393065
31653731653438646361363732303664626438663533393837356562376633643933376132616236
65393432633831313433323930383736316630626230373963653536396637363436643136363962
37326534343237363961326438376137663034356532376433376461363337333562646136616462
61636131376264393236376532356539376536643632623864656331656630353362623133303830
34633461633539643262353263376363613566343261373930623139626364653232363538353330
33633634363232653439656236303262373265613762373165646131383537623438383835383962
33383931626136313036366562363732396561633631643561646536653665333733383261363833
66356461663965373234393237323037356331333339643931313936313234323432613563306630
33306638663839363565636661653830316265393639313065313062666534303039326465373636
64363033323837313030353132383562343337326366626635663439396231393537313932643337
30663031323231313938366436343735326165326433656633336465316630383961626664303536
38633964326431643362626631656131303539613033323039393630353766386339346363663362
33323034396136356362313163376438393739373738366363623636623634316537313461373066
38613062656231363532663133333438663535666566356336316266383763623765346237663838
64336435353437373264346561363265643339306532383539306363653564356362313430333066
65633733633938343830303537383231303036326132376263363531626565633664343038356661
31336139663061656437633138373438663966616338343565396562306638346437353730643664
30373133373863626137313062643062393035653463653231653465333166633063353137633538
62383331303164343236343539396461623738396234653333356632313664616263623061363563
34323165306533323362376161346364316135333535626261353730666131643938306366326263
31313934633137623638316534383234376333396131303034633636323037363732383263326335
32393766343161386537333062643434333333363538323366363231336666383161373432383563
65613537366139643032336230303133623431376231646662643666373532636565393639373930
65336630616462353837666431616662636635333532393331326539306233363539396266653239
31303031303330396632386131623134313536313433623064356636333230373962643339363736
30396130353466373136643935646436613636376636323530643031653334303863376432646534
39343165356232346539366233373135326338343663356164616265336235623332646365633466
35393533373663393762376332396136336236616635616535313336613034346436363665356565
32636536336634613531393434613435613962653862343737373237623261373836386663343831
66656135323838636638353963646638326531343635653937306230323237343933626135356533
66356263636438633164386535333762616438626439343462393833393731643037396662653737
31666361656530383437396230393663616133383764316437623939663631396561343266383766
62373636663631393637393763613337356337633264366434346561343263373931323335643135
31366661623137353336666630633365663764646234343035313130663562636361623532643461
63333961333338623966396662656262323830396439633337663431663235663962666238356630
30353331313462653061373638666235653938623931366466666164343566623238333237353265
30373064353132366634623966306632303832306630383637623465323134633133656333303964
35646637316236303364393363323137616132326437623238336631313530663230333362623633
34383032376538366464363032343262656164376166386237383563613630336666633965653730
64373236396564363164643637623736626532396630313131356563333238643665356166323837
31626338623665623165643763623661666439626435643237336433646132666366623661393832
37306533613966663936373061613331633934623462343236626234306130383738343631303231
32326339323738323537333363313538373266623363363636633462356234363466393263316235
39663033303165656366396334306535643361646663373935303230376466366632373563303231
64323264653036333039663965646630653934376239653236323063656137373830623563336463
37343461373737313539316361623763373733653930626532393565333938333761323631303332
39663530303439616561356561666532653762343339323435636164376664373731343132666539
63626637346563393765303065646564643661636130396439323736343764333633373331653333
66633465343433303038623638323965636533666639643266353163353436393036336639336133
32646664363565326539643763653832313336663262313634343635616333613434373333323036
61366435376265336638326132333439613431353633653762653836386235643965366436363866
35626664393139386337353335343930306130356335623131646261656434303966656431623231
66643730393430363838626434663933613536343533316262373564666665373663336363623166
63363037373634383961373035633239646235316137363036333765313864643365396165643432
36623465313036376261393566383539336638363836633232656136656533396663323366313062
64616632373333313466356362336234346564373832316433373963623263316635
30393235363534356266376565386264666431656562646165333332393034663939353961343531
6436663865303965393737633462393331393562336537650a326265306465366433393331343362
62383039303432653139623636396533313336326266363264393065343834316666613765346265
6238336566383161630a653130623266323738656338616239393032303863656438333839396438
36336264316331363033353934333463376462366165643737633334313761316565616635643361
63303361656536666130626337623832663065386263356364626530633435636537646664336363
37326435346639633463306337303232393363396638313631366365376230386231626435616364
34353237623761396537333230326266323063306466326539333237326233303036626161353965
65613461633261653763336432333830346431356636623738336332343865343035346263626231
30653337643964323336316365323461313338376131323861663962336238346235666664613932
62356566306233623936663734666639356233333537373866633361313933623532643161386463
36666565303737303663366631646535303863333761303731613332373665613261343064636561
35636337396139336461356338333164316135623838383564653066363064376662653039333664
62306165613433626264616334643761343263623566346432653638636138326136636335313235
64373236343630626632623337653334383132373662306165633038303830386336373634656438
31613431626166376536613439616363363464303763656238643339373365393636386138656634
34356332343366623462323434363338383061346430326434343139303866306331393465333332
36666339383066613762353866653032313435343663333433653563393564643038313062633338
37663565336636356631373238373638346233616336363630326134646434666165386336393530
32326431353637336432336639636663373330663433613931646638626564653364353036313562
31353763633561393538326663633731313363366230363230313232373230626162643062653561
61636166613761646534653230623234623035336332643961623636306161623934303364633664
38363239393863393431643662396362653437616138316336666638303663643831376466356633
32643661653635393836376663373838333366383833316635363864353862666534373530356231
39666132636361386236623734333862663133343939386432313730623439343961646633333936
36373232303263396435663163306135663361623531313734663438363730323835343866623533
37613830666236376461643238663336623032356238313563616437363638333338306438663461
62663035303737623635326536613564393962303462326233366137616565373034393839346565
66633637663463643030316639366633363866643366336166393535353366316364643335353035
32343935363536633162316634393530376332653635326464326631396464343931663133656465
38656335306639343736346334636661636635333066663565643764373432333161653063323231
37333464316536363037323362333432373162363833303130643534343461646563343564386565
33363437376237633739623863353232343438306639303561646139396137646234663534636237
30663332306162366233636666316336313538623730333162376264383463666661613436656265
37643738313335353030383663383461663036376637346334643531636438656133393731383138
63653338306639653266366337653865363432366364306262303232616536393738323334346334
33646331316330633738386236323533353734623234653335643633643866393761653232303162
31653530643934373435326539366663313033616338666633313131353334323931383635346235
32333166636135323438353735616265373132393762323037326635633162653363393639633939
37613231343766653437626235313666343837343865303863643035633863373235656439346538
32613934366138653331343931386131663134623063366435623835653535623334376333366562
65366365386239663338656233353435343639353037663732323064626261636363643261656164
66393538343638373064376536326537623735343064643736636466323636316130373565643635
31383531306431363533663935356339373934396361306138633266653431326132386563366565
34646465356139636563653732373932636461313935376235366266636136663238663030373662
31336635653666326538333931646235316262303838633138613464303837386162613263386438
34366230353439323531646637326634616336343963373831356163373462343664393538353835
66616235616363333430313539333637626665386562653834343139663133383463393236633662
30303937376661323535333132653966383231353033633334356663646265613934336636363138
65643265373334653932316262303136363763396134303730383536646538663134303964323432
39303833386361363364303231656364356365653939373661633661313739356665323834306635
32653933626166396462666235353238383266336361616531316433363433336238656539373661
62306362666333653865396364663235626230303133343764613536616237313634613734393631
32373761626161363935346437396161383365633138393164663539626439353832613437653261
35376464313236666236313965376133323333663030383965333935383237633461646266633363
35656561343033306162616361653062363538356136353837343866636632623765646366396638
35313134383361346332323232643030303434363732653236366538376166333237323566383930
62356162656538636464616432626637633531363237376337353562393731633635323536386363
31623936663935366164373062333465383137633661393062386135376635346262336538346165
34663561663134306635373737383430343332633365356238346264376136363862613762313166
39323635396434386537646435366630353462313463366666626135623633386336376466353830
64373763306464346336643531616233633237663534333536666264633564623030343533373430
38623738623264396564316666663763356136623865623662393766343566376465633839353466
66313333666131626639656464666130653239313864656330666666653237306131633139336565
33333536653638386431373432663132643033313566376261376535363563356135353735346464
66623032303639313163303739346637333839323939356364376133346363343163343661646661
65643462356234353562346435623463633665343037313032363362643461356566623731363030
66353530353237666132613435306537613038653062336663373366343834323238656162656538
31383037613535303564633732346332613939333037343334646530386331643336363834313365
66613535353631643566363062643930323966343636353666626237663733363062653564633562
39313034643238366331636332393537376130386463653831393366636634623833366436303961
34633162333462383834346637343337616166353035616362636462346364366539383333373964
62343635323331376237373761383535646233366465303364663731393061326436663137613238
35363338613339326336613535326635363236356264326330386662393666613264646465316339
36633263626632613862613439363630353462626636373265386332316162356439323134636433
34393334316561366231613339386339653663316661356662306230356537626431376133333736
31663034373033666663363634386134376237663061323732653365363662323335653433656665
30323566326530653365613937376264383435636438333734386131363737633163343734336564
32633432613033653263356334666436353036373738346430373133346239313566346236376138
64323565663961356331323937373032306330646664663761646131646235383731303564633366
32656537343836326465343264363531396631613161633236366363366537613439376464653866
61316164363032313630383063336436303661366438623666373538396432303331623139393833
33653061363766313465633964353439383136343832323630666630323865326461666137313132
62333963323564396139333130303539623935303733616138376466303461643439373934393432
38356535663333343232306135623261336534383233333632386638623232383737616239326666
38643139623730643439373939616562393162376564346130643135653334616338333739313935
33343563346664346531623161313937636562653132613831303132363665343333633232373633
30363034373362626266633562633634643635343161613262373463616437396566343733626561
30626462313261383461653532396263663139323134653764363963393665653165326130643031
36303962313433623034633930633762393263383830663261653631333830643637313332613136
66636364373137306539643566633830393030393932336632646565383638343639623232316165
63333637616237313262646539363835346138613234376164333238643139653934343365386632
37633562633863373033666564666665396261386537653866353533336233316339656365356563
33663561643531623837656237313938303363363662383830363435323661306164383932613438
63623963336361356639303162373935613734666466663933646239363939616239323164303364
62393765343539646633616630313463633963396432636637313162666432386637616233343532
39343366323236336266396438326565343561313330313539366439343262396361386662323434
30313433323063316531613338303239333539316133623961316432616632613066636136336436
35613864363731373333313637363036303561343263316466303935313664646635396331323039
31333134316535356134666365306263303337623636353430333731343037656661363333643165
30383734336662336632356537376232623664653436396464633037643839343238623330613136
31663137366437396330333231303636633461616230383339646261303964393539393637643730
30626463313434646566313939656432313066356232303435313734353930336265343431346362
36346666643230303565613563313634393338323735373965336138646432363534383066383236
30383230666335393861653937316233633534316666643865643439313938633230

View file

@ -4,6 +4,8 @@
path: "{{ netbird_coordinator__base_dir }}"
state: directory
mode: "0750"
# create the scaffold even in --check so dry-run can evaluate templates + compose (idempotent mkdir)
check_mode: false
tags: [config]
- name: Render the combined server config

View file

@ -4,6 +4,8 @@
path: "{{ reverse_proxy__base_dir }}"
state: directory
mode: "0750"
# create the scaffold even in --check so dry-run can evaluate templates + compose (idempotent mkdir)
check_mode: false
tags: [config]
- name: Ensure the Caddy config directory exists
@ -11,6 +13,8 @@
path: "{{ reverse_proxy__base_dir }}/caddy"
state: directory
mode: "0750"
# create the scaffold even in --check so dry-run can evaluate templates + compose (idempotent mkdir)
check_mode: false
tags: [config]
# Render into a directory that is bind-mounted whole (./caddy -> /etc/caddy). Mounting

32
scripts/registry-login.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
#
# Log the local Docker daemon into the Forgejo container registry using a token stored in
# the Ansible vault — so registry pushes (make caddy-image-push / molecule-image-push) are
# agent-completable non-interactively, like every other vault-backed action.
# (2026-06-17 kaizen, docs/FRICTION.md: the push half silently needed an interactive
# `docker login`; the creds weren't in the vault, so an agent couldn't complete a push.)
#
# Reads vault.forgejo.registry_token from the vault (rbw must be unlocked) and pipes it to
# `docker login --password-stdin`. The token never lands on argv or on disk and is never
# echoed (no `set -x`). Binaries/paths are overridable via env so the Makefile can pass the
# venv ansible-vault/python; defaults work when run from the repo root with the venv present.
#
set -euo pipefail
ANSIBLE_VAULT="${ANSIBLE_VAULT:-.venv/bin/ansible-vault}"
PYTHON="${PYTHON:-.venv/bin/python}"
VAULT="${VAULT:-inventories/production/group_vars/all/vault.yml}"
REGISTRY_HOST="${REGISTRY_HOST:-forgejo.nyumbani.baobab.band}"
REGISTRY_USER="${REGISTRY_USER:-sjat}"
token="$("$ANSIBLE_VAULT" view "$VAULT" \
| "$PYTHON" -c 'import sys, yaml; d = yaml.safe_load(sys.stdin) or {}; print((((d.get("vault") or {}).get("forgejo") or {}).get("registry_token")) or "", end="")')"
if [ -z "$token" ] || [ "$token" = "CHANGEME" ]; then
echo "registry-login: vault.forgejo.registry_token is unset or still CHANGEME." >&2
echo " Mint a Forgejo token (Settings -> Applications -> Generate Token, with package" >&2
echo " read+write scope, user $REGISTRY_USER) and set it via: make edit-vault" >&2
exit 1
fi
printf '%s' "$token" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin

View file

@ -41,6 +41,42 @@ LIST_ITEM_RE = re.compile(r"^\s*(\d+\.|[-*+])\s+(.*)")
DEFER_REF_RE = re.compile(r"ADR-(\d{3})\D{0,40}?deferred\D{0,12}?(\d+)", re.I)
RESOLVE_WORD_RE = re.compile(r"\b(?:resolv\w*|decid\w*|address\w*|complet\w*|done)\b", re.I)
# Rename-incomplete detection: an ADR announces a rename/supersession of a named
# term (Old → New); verify the OLD name no longer lingers in the design-doc set.
# (The structural cousin of stale-deferred — see docs/FRICTION.md, ADR-024.)
# A "specific" name is a backticked token or a capitalised proper-noun/identifier;
# common connective words are rejected so they can't be mistaken for a tool name.
_NAME = r"(?:`[^`]+`|[A-Z][A-Za-z0-9_+.-]{2,})"
RENAME_STOPWORDS = {
"was", "were", "the", "this", "that", "with", "from", "into", "and", "but",
"for", "are", "has", "had", "been", "now", "not", "all", "any", "use", "used",
"via", "per", "its", "our", "one", "two", "old", "new", "phase", "step",
"adr", "read", "name", "term", "tool", "prose", "roadmap",
}
# Trigger forms — each captures (old, new) as raw name tokens; the connective words
# are case-insensitive but the names must still satisfy _NAME (specific tokens).
RENAME_ASSERT_RES = (
# renamed X to Y
re.compile(rf"renamed\s+(?:from\s+)?({_NAME})\s+to\s+({_NAME})", re.I),
# replaced X with Y
re.compile(rf"replac\w*\s+({_NAME})\s+with\s+({_NAME})", re.I),
# superseded X with/by Y
re.compile(rf"supersed\w*\s+({_NAME})\s+(?:with|by)\s+({_NAME})", re.I),
# X ... (is/are/was/were/been) updated to read Y
re.compile(rf"({_NAME})\b.{{0,40}}?\b(?:is|are|was|were|been)?\s*"
rf"updated\s+to\s+read\s+[\"']?({_NAME})", re.I),
# X → Y / X -> Y on a line that also carries a rename/supersede/update cue
re.compile(rf"({_NAME})\s*(?:->|→)\s*({_NAME})"),
)
RENAME_ARROW_RES = (RENAME_ASSERT_RES[-1],) # arrow forms need a cue word on the line
RENAME_CUE_RE = re.compile(r"\b(?:renam\w*|replac\w*|supersed\w*|updated|rename)\b", re.I)
# Historical / negation cues — a lingering OLD name on such a line is legitimate
# history, not a missed ripple edit, so it is skipped.
RENAME_HIST_RE = re.compile(
r"\b(?:was|were|formerly|previously|no longer|instead of|rather than|reject\w*|"
r"reconsider\w*|supersed\w*|deprecat\w*|legacy|history|heritage|V4|"
r"actually ran|used to)\b", re.I)
# ADR-structure check (ADR-023): numbered ADRs must carry the four mandatory
# sections and a parseable Status line. Presence only — section ORDER is a
# template-demonstrated convention, not machine-enforced.
@ -142,6 +178,84 @@ def adr_structure_findings(adr_files):
return out
def _clean_name(tok):
"""Strip backticks/quotes from a captured name token. Return the bare name, or
None if it is not a 'specific' token (empty, multi-word, or a stopword)."""
s = tok.strip().strip("`\"'").strip()
s = s.rstrip(".,;:!?)") # trailing sentence punctuation is not part of the name
if not s or " " in s:
return None
if s.lower() in RENAME_STOPWORDS:
return None
# An ADR reference (ADR-017) is a document pointer, never the renamed *term* — a
# sentence like "the ADR-017 prose ... is updated to read Caddy" must not parse
# ADR-017 as the old name. Reject it so such lines skip (precision >> recall).
if re.fullmatch(r"ADR-\d{3}", s):
return None
# Must be backtick-able identifier or a capitalised proper noun (the _NAME shape
# already enforced this on capture; this is the after-stripping re-check).
if not re.fullmatch(r"[A-Za-z0-9_+.-]{3,}", s):
return None
return s
def _rename_assertion(line):
"""Parse a single ADR line for a tight Old→New rename assertion. Returns
(old, new) of cleaned specific names, or None. Conservative: precision >> recall."""
for rx in RENAME_ASSERT_RES:
m = rx.search(line)
if not m:
continue
# Arrow form only counts when the line also carries a rename/supersede cue.
if rx in RENAME_ARROW_RES and not RENAME_CUE_RE.search(line):
continue
old, new = _clean_name(m.group(1)), _clean_name(m.group(2))
if old and new and old != new:
return old, new
return None
def rename_incomplete_findings(adr_files, extra_docs):
"""adr_files: {rel_path: [lines]} for docs/decisions/*.md (the numbered ADRs make
the assertions). extra_docs: {rel_path: [lines]} for CAPABILITIES.md / ROADMAP.md.
When a numbered ADR announces a rename 'Old' -> 'New', flag any DESIGN-doc line
where 'Old' still appears as a whole word in present tense (skipping the announcing
ADR, lines that also name 'New', and lines carrying a historical/negation cue)."""
out = []
# The design-doc set we search: all decisions/*.md plus the two extra docs.
doc_set = dict(adr_files)
doc_set.update(extra_docs)
# Collect assertions only from numbered ADRs (NNN-*.md).
assertions = [] # (adr_num, announcer_path, old, new)
for rpath, lines in sorted(adr_files.items()):
base = os.path.basename(rpath)
if not ADR_FILE_RE.match(base):
continue
adr_num = base[:3]
for line in lines:
parsed = _rename_assertion(line)
if parsed:
assertions.append((adr_num, rpath, parsed[0], parsed[1]))
for adr_num, announcer, old, new in assertions:
old_re = re.compile(r"\b" + re.escape(old) + r"\b") # case-sensitive whole word
for rpath, lines in sorted(doc_set.items()):
if rpath == announcer: # the ADR that made the claim is exempt
continue
for i, raw in enumerate(lines, 1):
if not old_re.search(raw):
continue
if new in raw: # rename is being explained on this line
continue
if RENAME_HIST_RE.search(raw): # legitimate history / negation
continue
out.append({"check": "rename-incomplete", "severity": "medium",
"path": rpath, "line": i,
"detail": f"ADR-{adr_num} announced rename '{old}' -> "
f"'{new}' but '{old}' still appears here; confirm the "
"ripple edit landed or soften the ADR claim"})
return out
def walk_files():
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in PRUNE]
@ -192,8 +306,11 @@ def scan():
findings = []
adrs = adr_numbers()
adr_files = {} # docs/decisions/*.md → lines, for deferred-section parsing
extra_docs = {} # CAPABILITIES.md / ROADMAP.md → lines, for rename-incomplete
defer_refs = [] # repo-wide "resolves ADR-NNN deferred #K" references
decisions_dir = os.path.join("docs", "decisions")
rename_extra = {os.path.join("docs", "CAPABILITIES.md"),
os.path.join("docs", "ROADMAP.md")}
for path in walk_files():
rpath = rel(path)
if rpath.startswith(SKIP_PREFIX):
@ -223,6 +340,8 @@ def scan():
if rpath.startswith(decisions_dir) and rpath.endswith(".md"):
adr_files[rpath] = lines
if rpath in rename_extra:
extra_docs[rpath] = lines
for i, line in enumerate(lines, 1):
for m in DEFER_REF_RE.finditer(line):
@ -261,6 +380,7 @@ def scan():
"line": i, "detail": f"references '{ref}' which does not exist"})
findings.extend(deferred_findings(adr_files, defer_refs))
findings.extend(adr_structure_findings(adr_files))
findings.extend(rename_incomplete_findings(adr_files, extra_docs))
return findings

View file

@ -123,5 +123,8 @@ def test_nudge_line_overdue_on_age():
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
# May legitimately be empty right after a /kaizen pass consumes every open signal —
# an empty Open-signals section is the goal state, not a failure. Assert the function
# parses the real file into well-formed signals (validity holds vacuously when empty).
assert isinstance(sigs, list)
assert all(s["tag"] in {"friction", "gotcha", "recurring", "unused"} for s in sigs)

View file

@ -57,3 +57,99 @@ def test_non_numbered_file_is_skipped():
bare = ["# ADR template\n", "\n", "## Status\n", "\n", "<!-- hint -->\n"]
out = _checks(rs.adr_structure_findings({"docs/decisions/adr-template.md": bare}))
assert out == []
# --- rename-incomplete -------------------------------------------------------
def _renames(findings):
return [f for f in findings if f["check"] == "rename-incomplete"]
def test_rename_incomplete_flags_lingering_old_name():
# ADR announces `Foo` -> `Bar`; another decisions file still says Foo present-tense.
announcer = {"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar` across the design docs.\n"]}
other = {} # extra_docs (CAPABILITIES/ROADMAP) — none here
lingering = {"docs/decisions/030-other.md": [
"The Foo proxy renders config from the catalog.\n"]}
announcer.update(lingering)
out = _renames(rs.rename_incomplete_findings(announcer, other))
assert len(out) == 1
assert out[0]["path"] == "docs/decisions/030-other.md"
assert out[0]["line"] == 1
assert out[0]["severity"] == "medium"
assert "Foo" in out[0]["detail"] and "Bar" in out[0]["detail"]
def test_rename_incomplete_clean_rename_has_no_findings():
# The rename announced, and no other doc still mentions Foo.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar` across the design docs.\n"],
"docs/decisions/030-other.md": [
"The Bar proxy renders config from the catalog.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_skips_historical_cue_line():
# Foo lingers only on a line carrying a historical/negation cue → no finding.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar` across the design docs.\n"],
"docs/decisions/030-other.md": [
"Foo was rejected; we run Bar now.\n",
"The history of Foo informs the choice.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_skips_announcing_adr_itself():
# The announcing ADR mentions Foo (it has to) — must not flag itself.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n",
"We renamed `Foo` to `Bar`.\n",
"Operators who configured Foo should switch their habits.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_skips_line_naming_new_term():
# A line that mentions both Foo and Bar is explaining the rename → skipped.
adr_files = {
"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar`.\n"],
"docs/decisions/030-other.md": [
"Foo is being phased out for Bar in this paragraph.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []
def test_rename_incomplete_searches_extra_docs():
# A lingering OLD name in CAPABILITIES.md (an extra_docs file) is flagged.
adr_files = {"docs/decisions/050-rename.md": [
"## Decision\n", "We renamed `Foo` to `Bar`.\n"]}
extra = {"docs/CAPABILITIES.md": ["The Foo proxy is what we deploy.\n"]}
out = _renames(rs.rename_incomplete_findings(adr_files, extra))
assert len(out) == 1
assert out[0]["path"] == "docs/CAPABILITIES.md"
def test_rename_incomplete_ignores_ambiguous_adr_pointer_assertion():
# "the ADR-017 prose ... is updated to read Caddy" must NOT parse ADR-017 as the
# old name (it is a doc pointer). With ADR-017 rejected, no assertion → no finding,
# even though 'ADR-017' appears in many other docs.
adr_files = {
"docs/decisions/024-reverse-proxy.md": [
"## Consequences\n",
'- ADR-017 prose that mentioned Traefik is updated to read "Caddy".\n'],
"docs/decisions/008-testing.md": [
"Level 4 UI verification follows ADR-017.\n"],
}
out = _renames(rs.rename_incomplete_findings(adr_files, {}))
assert out == []