Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/check-readme-articles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: README article index freshness

# Fails if any docs/articles/NN-*.md is not listed in README.md AND docs/articles/README.md.
# This is the CI side of the freshness guard (the local pre-commit hook is the dev-side mirror).
# Runs on PRs from forks too, so external contributors get the check.

on:
push:
branches: [main]
paths:
- 'docs/articles/**'
- 'README.md'
- 'scripts/check-readme-articles.sh'
pull_request:
paths:
- 'docs/articles/**'
- 'README.md'
- 'scripts/check-readme-articles.sh'

jobs:
check-readme-articles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify every article is indexed in the READMEs
run: bash scripts/check-readme-articles.sh
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ Follow the BitSafe Content Strategy + Brand Guide (internal — abridged here):
- ≤2 pull-quote lines per article, max.
- No marketing fluff.

## Adding a new article (and the README freshness guard)

Every `docs/articles/NN-*.md` source file MUST be listed in **both** indexes:

- the series table in the top-level [`README.md`](README.md)
- the source-file table in [`docs/articles/README.md`](docs/articles/README.md)

This is enforced. Adding an article file without updating the indexes is a hard failure:

- **CI:** `.github/workflows/check-readme-articles.yml` runs on every push/PR that touches
`docs/articles/**` or `README.md` and blocks merge if an article is unindexed.
- **Locally:** run `bash scripts/install-git-hooks.sh` once after cloning to install a
pre-commit hook that runs the same check. Bypass (rarely) with `git commit --no-verify`.

Run the check manually any time: `bash scripts/check-readme-articles.sh`.
Test the guard itself: `bash scripts/test-check-readme-articles.sh`.

## Code snippets

- Apache 2.0 licensed (matches the framework upstream).
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ This is **case study content**, not a framework manual. The upstream NanoClaw pr
| 6 | Lessons | [Cost Discipline — Why the Bill Grew, What We Caught, How to Catch It Sooner](https://hub.bitsafe.finance/cost-discipline) | [`06-cost-discipline.md`](docs/articles/06-cost-discipline.md) | published |
| 7 | Lessons | Monitors & Alerts — Catching What You Can't Prevent | [`07-monitors-and-alerts.md`](docs/articles/07-monitors-and-alerts.md) | drafting |
| 8 | Lessons | Capability Coverage & Harness Guards — Why the Model Shouldn't Have to Remember What It Can Do | [`08-capability-coverage-and-harness-guards.md`](docs/articles/08-capability-coverage-and-harness-guards.md) | drafting |
| 9 | Lessons | Guard Parity — A Guard Only Protects Where It's Wired | [`09-guard-parity.md`](docs/articles/09-guard-parity.md) | drafting |
| 10 | Lessons | The Completeness Trap — You Can't Audit Your Own Blind Spots | [`10-completeness-trap.md`](docs/articles/10-completeness-trap.md) | drafting |
| 11 | Lessons | Retros, Recursively — How the AI Studies Its Own Output | [`11-retros-recursively.md`](docs/articles/11-retros-recursively.md) | drafting |

Articles are published as-ready to BitSafe's hub (`hub.bitsafe.finance`); drafts and source live here.

Expand Down
113 changes: 113 additions & 0 deletions docs/articles/11-retros-recursively.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: "Retros, Recursively — How the AI Studies Its Own Output"
slug: 11-retros-recursively
series: How BitSafe Runs on Notion
part: 11
published: 2026-06-04
audience: [App Developers, Trading Firms, Founders]
---

# Retros, Recursively — How the AI Studies Its Own Output

This is Part 11 of BitSafe's NanoClaw case study series. Parts 6 and 7 were postmortems-as-playbooks: cost discipline, then the surfacing layers that catch what cost discipline can't see in time. This part is the layer above both of those — the retrospective process itself. How does an AI system that runs 24/7 learn from its own day? What stops a "retro" from becoming a self-congratulatory log of work it did? And once you have retros, how do you keep them from rotting into the same wallpaper as any other digest?

The short answer is **recursion with a cooling-off gate, action items written as their own block, and a meta-layer that fires only when the signal warrants**. The long answer is the rest of this article.

## Three layers of looking back

NanoClaw runs three distinct review cadences. Each answers a different question; each is implemented as a separate script with its own surfacing path.

**Layer 1 — Daily retro.** Fires at 07:00 UTC. A short script reads the prior 24 hours of CHANGELOG entries, JSONL counters (`data/state/*-violations.jsonl`, `data/state/*-history.jsonl`), and the admin channel's alarm history. It produces a 5-7 line digest in the admin channel: how many fixes shipped, how many alarms fired, which patterns recurred, and any new soft-rule violations counted by the conciseness / phrase-filter / ask-admin counters. The audience is the on-call human; the goal is "did anything drift overnight that I should know about." It never produces action items on its own — it surfaces signal density.

**Layer 2 — Ad-hoc session retro.** Fires when the human asks ("run a retro") or when a long session closes. A subagent reads the session's commit history + the prompts that triggered it + the memory files the session touched, and writes a Notion page under the NanoClaw pillar named `Retro YYYY-MM-DD — <theme>`. The page has a fixed shape: **What shipped**, **What's unfinished**, **Patterns surfaced**, **Action Items**. The Action Items section is required to exist as its own heading with its own block list, and each item must be its own `to_do` block — never a sub-bullet under a parent observation.

**Layer 3 — Meta-analysis.** Fires on accumulated signal, not on a fixed cadence. Trigger rules are explicit: ≥10 CHANGELOG entries since the last meta, OR ≥2 new silent-failure incidents in a short window, OR ≥3 "decide-and-ship" decisions in one session, OR an explicit ask. Cooling-off floor of ≥48 hours. The output is a longer Notion page that names the cross-cutting *class* across recent retros — not "what shipped" but "what's the underlying system gap." A meta-analysis that produces only "things to ponder" with no concrete next step is itself a signal that the trigger fired too early; the false-fire is noted in the meta and the next trigger threshold is widened.

The three layers compose. The daily retro shows volume; the session retro shows shape; the meta-analysis shows class. None of them replace the others.

## The recursion rule

Retros are not one-shot. If today's retro produced two or more shippable fixes, the *next* retro will probably find adjacent ones — same lens, same day, slightly different angle. We made this explicit after watching it happen four times in May 2026: an afternoon retro shipped a five-track fix package; a post-merge retro three hours later found another three tracks the first pass missed, including a silent-failure instance in its own deployment of one of the fixes.

So the working rule is: **after any retro that ships ≥2 fixes, the next retro is permitted — but gated**. The cooling-off gate has three conditions, all of which must be true before the recursive pass fires:

1. **≥6 hours** since the prior retro.
2. **≥3 new CHANGELOG entries** OR **≥1 new silent-failure incident** OR **≥1 admin-channel alert at warning-or-above severity** since the prior retro.
3. **The prior retro's action items have actually merged to main** — not just dispatched, not just in-flight.

Without all three, retro N+1 fires against the same lagging signal that produced retro N. You re-discover the same fixes, file the same Tasks DB rows, and learn nothing new. The infinite-loop failure mode is real; we hit it before we wrote the gate.

The stopping rule is the mirror image: stop iterating when a retro produces ≤1 shippable fix that's already in flight, *or* when the only outputs are "monitor longer" / "ideas to explore" with no concrete code change. At that point the marginal yield has dropped below the cost of the retro itself.

## Action items must be their own block

This is the deepest piece of practical advice from the meta-analysis work. We audited 35 retros over a 14-day window in early June 2026. About 20% of the action items in those retros had been **dropped** — not by humans skimming, but by the automated extractor that walks Notion blocks looking for `to_do` and `numbered_list_item` types. The dropped items shared a property: they were written as sub-bullets under parent observations.

The failure shape looks like this:

```
## Patterns surfaced
- Pattern X is recurring across the last 3 incidents
- We should file an issue tracking the canonical fix
- Probe the 3 paths where it's silently failing today
```

Both indented items are real action items. Both got dropped — by the extractor (which interpreted the parent as the leaf) and by humans (whose eyes skim past indented continuation text). Seven of the 35 audited retros had zero detected action items not because there were none, but because they were all sub-bullets.

The fix is structural. Action items go in their own heading, as their own top-level block:

```
## Patterns surfaced
- Pattern X is recurring across the last 3 incidents (commentary)

## Action Items
- [ ] File issue tracking the canonical fix
- [ ] Probe the 3 paths where Pattern X is silently failing today
```

In Notion the rule is: use the `to_do` block type for each item. The extractor walks `children` at the top level of the `## Action Items` heading and stops recursing if a child has its own children — that's the tell that an action got buried.

This is a small rule with a big payoff. The retro's job is to leave behind action items that someone (human or agent) will actually pick up. If half of them are getting dropped at extraction time, the retro is doing less than half the work you think it is.

## The case study from this week: a UX double-post

Here's the recursion in motion, from a real day.

The trigger was a user-visible bug: agents would post a long substantive answer to a Slack thread via an inter-process tool call, then post a short recap as their final turn ("Done — let me know if you need more"). The user saw two messages for one action. Dedup logic existed but the similarity heuristic couldn't match a 30-character recap to a 600-character body, so the recap leaked through.

The first retro on this — a 30-minute deep-think doc — named the failure shape, proposed the fix (a same-thread + short-length suppressor), and shipped it. Layer 1 daily retro the next morning surfaced "phrase-filter violations: 4 this week, top phrase: 'happy to'" — a count that had been quietly accumulating in a JSONL file without anyone noticing. That count was elevated because the same model that was producing recaps was also producing sycophantic openers.

The recursive retro 6 hours later, with the cooling-off gate satisfied, looked at *both* signals together and named the underlying class: bot over-narration. Two anti-patterns, one root cause. The output was a single CHANGELOG entry that landed three tracks in one branch — the recap suppressor, a phrase-filter counter (9 banned phrases including "happy to", "want me to", "got it"), and an ask-admin-warn counter (4 speculation patterns like "I think" / "I believe" that fire when the agent talks about its own capabilities without asking the admin bot first).

Then the meta-analysis layer kicked in two hours later. The accumulated signal — three tracks shipped, two new JSONL counters in the data/state directory, two new daily-retro lines — crossed the meta-analysis trigger (≥3 decide-and-ship decisions in one session). The meta named a class above the class: **soft-rule violations need counter-then-counter-line surfacing**, not just memory files. The memory file alone doesn't fire prompt-side reliably; the counter + retro line ensures the human sees recurrence even when the prompt-side rule is silently being violated.

The whole arc — bug report → fix → counter → retro line → meta-named class — took about six hours of elapsed time and produced three layers of artifact: the code fix, the new soft-rule counters, and the pattern that other soft rules should follow next. None of those three layers would have landed without the layer above it asking the question.

## The gap class that retros catch (and the one they don't)

Retros catch the *visible-output-quality* class well: tone, double-posts, recaps, sycophancy, action items that didn't get picked up, the daily volume of conciseness violations. The signal is in the output stream itself; the retro is a structured re-reading.

They catch the *silent-failure* class less well, on their own. A monitor that has been running in dry-run mode for two weeks doesn't show up in a retro of "what shipped today." It shows up in a retro of "what should have happened that didn't" — but you have to know to ask that question. The bridge between retros and silent failures is the on-divergence and on-silence alarms documented in Part 7. The retro looks at the alarm channel; if a class of work has been suspiciously quiet, the meta-analysis is where the *absence of signal* becomes a signal.

The pairing is the point. Retros surface what shipped; alarms surface what shipped that shouldn't have, or didn't ship that should have. Neither alone gives you the full picture.

## What this layer costs

The daily retro digest runs in under 30 seconds and posts one Slack line. The session retro is human-initiated and writes one Notion page. The meta-analysis is rarer and produces one longer Notion page maybe twice a week on a busy stretch. The total operational cost is small — well under $1/day in model time, dominated by the Notion page-write API calls.

The real cost is **attention**. A retro that no one reads is wasted compute. A retro that gets read but doesn't produce action items is wasted compute. A retro that produces action items that get dropped at extraction time is wasted compute *plus* a false sense of progress. The cheapest part of the system is generating the retros; the expensive part is making sure each one closes its own loop.

The shape that has worked, after about four months of iteration:

- Daily retro → one Slack line, scannable in 5 seconds, skimmed by the on-call human first thing in the morning.
- Session retro → one Notion page with an Action Items section structured for extraction; the page is the artifact, the action items are the deliverable.
- Meta-analysis → one Notion page when the trigger conditions are met, named for the class it surfaces, with its own Action Items that the next session picks up.

When any of those three stops being acted on, the layer above it has a problem to fix. The retro is the input; the loop is the output.

---

**Related reading inside this case study:**
- [Part 6: Cost Discipline](06-cost-discipline.md) — the first postmortem-as-playbook; this layer named the cost-vigilance dashboard
- [Part 7: Monitors & Alerts](07-monitors-and-alerts.md) — the surfacing layers retros depend on
- [Part 2: Architecture](02-architecture.md) — where the session retro's Notion-page artifact fits in the host + container model
1 change: 1 addition & 0 deletions docs/articles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Each published article on `hub.bitsafe.finance` has a source Markdown file here,
| 8 | [`08-capability-coverage-and-harness-guards.md`](08-capability-coverage-and-harness-guards.md) | [hub.bitsafe.finance/08-capability-coverage-and-harness-guards](https://hub.bitsafe.finance/08-capability-coverage-and-harness-guards) | drafting |
| 9 | [`09-guard-parity.md`](09-guard-parity.md) | [hub.bitsafe.finance/09-guard-parity](https://hub.bitsafe.finance/09-guard-parity) | drafting |
| 10 | [`10-completeness-trap.md`](10-completeness-trap.md) | [hub.bitsafe.finance/10-completeness-trap](https://hub.bitsafe.finance/10-completeness-trap) | drafting |
| 11 | [`11-retros-recursively.md`](11-retros-recursively.md) | [hub.bitsafe.finance/11-retros-recursively](https://hub.bitsafe.finance/11-retros-recursively) | drafting |

## Authoring conventions

Expand Down
49 changes: 49 additions & 0 deletions scripts/check-readme-articles.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# check-readme-articles.sh — fail if any docs/articles/NN-*.md is not indexed in the READMEs.
#
# Why: Aki — "make sure the README is updated when we push new content." A new article
# file that isn't listed in the index is invisible to readers; this is the freshness guard
# that makes "added a file but forgot the index" a hard failure instead of a silent gap.
#
# Checks every article source file is referenced in BOTH:
# - README.md (the public series table)
# - docs/articles/README.md (the source-file index)
#
# Usage: scripts/check-readme-articles.sh (run from repo root or anywhere inside the repo)
# Exit: 0 = all articles indexed; 1 = one or more missing from an index.
set -euo pipefail

# Resolve repo root so the hook works from any CWD.
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"

TOP_README="README.md"
ARTICLES_README="docs/articles/README.md"
ARTICLES_DIR="docs/articles"

missing=0

shopt -s nullglob
for path in "$ARTICLES_DIR"/[0-9][0-9]-*.md; do
fname="$(basename "$path")"
# README.md is not an article; skip defensively (pattern already excludes it).
[ "$fname" = "README.md" ] && continue

if ! grep -qF "$fname" "$TOP_README"; then
echo "ERROR: $fname is not listed in $TOP_README" >&2
missing=1
fi
if ! grep -qF "$fname" "$ARTICLES_README"; then
echo "ERROR: $fname is not listed in $ARTICLES_README" >&2
missing=1
fi
done

if [ "$missing" -ne 0 ]; then
echo "" >&2
echo "Freshness guard failed: add the article(s) above to the README index/table(s)." >&2
echo "See CONTRIBUTING.md → 'Article style' and the article table in README.md." >&2
exit 1
fi

echo "check-readme-articles: OK — all $ARTICLES_DIR/NN-*.md files are indexed in both READMEs."
6 changes: 6 additions & 0 deletions scripts/git-hooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# pre-commit — runs the README-freshness guard before allowing a commit.
# Installed via scripts/install-git-hooks.sh. Bypass (rarely) with: git commit --no-verify
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
exec "$REPO_ROOT/scripts/check-readme-articles.sh"
13 changes: 13 additions & 0 deletions scripts/install-git-hooks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# install-git-hooks.sh — install the repo's pre-commit hook into .git/hooks.
# Idempotent: re-running overwrites the managed hook. Run once after cloning.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
HOOKS_DIR="$(git rev-parse --git-path hooks)"
SRC="$REPO_ROOT/scripts/git-hooks/pre-commit"

mkdir -p "$HOOKS_DIR"
cp "$SRC" "$HOOKS_DIR/pre-commit"
chmod +x "$HOOKS_DIR/pre-commit"
echo "Installed pre-commit hook -> $HOOKS_DIR/pre-commit"
echo "It runs scripts/check-readme-articles.sh on every commit (bypass: git commit --no-verify)."
32 changes: 32 additions & 0 deletions scripts/test-check-readme-articles.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# test-check-readme-articles.sh — fixture test for the README-freshness guard.
# Proves: (1) the current repo passes, (2) an unlisted article makes it FAIL.
set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
CHECK="$REPO_ROOT/scripts/check-readme-articles.sh"

pass=0
fail=0
ok() { echo " PASS: $1"; pass=$((pass+1)); }
bad() { echo " FAIL: $1"; fail=$((fail+1)); }

echo "Case 1: current repo state passes the guard"
if bash "$CHECK" >/dev/null 2>&1; then ok "guard exits 0 on a fully-indexed repo"; else bad "guard rejected a clean repo"; fi

echo "Case 2: an unlisted article makes the guard fail"
FIXTURE="$REPO_ROOT/docs/articles/99-fixture-unlisted-$$.md"
cleanup() { rm -f "$FIXTURE"; }
trap cleanup EXIT
printf -- '---\ntitle: "Fixture"\n---\n# Fixture\n' > "$FIXTURE"
if bash "$CHECK" >/dev/null 2>&1; then
bad "guard passed despite an unlisted article (99-fixture)"
else
ok "guard exits non-zero when an article is missing from the index"
fi
cleanup
trap - EXIT

echo ""
echo "Results: $pass passed, $fail failed"
[ "$fail" -eq 0 ]
Loading