A persistent, single-writer knowledge base. Lessons learned here carry over to future tasks, so the same mistakes are not made twice and past decisions do not need to be re-derived.
- faq.md — recurring pitfalls and their solutions (RabbitMQ Stream
plugin, E2E broker ports,
ghdefault limits,socket_selectloop gotchas, server-push frame key rule). Entry idsFAQ-NNN. - decisions.md — project decisions with rationale (protocol
conventions, exception hierarchy, coverage policy, naming) and a reference
to the issue/PR/commit that introduced them. Entry ids
DEC-NNN.
Both files are linted by bin/kb-lint.php,
which runs inside composer lint.
- Read before you start — the index, not the file. Every file opens with
a generated tag index. Load the index, pick the tags matching the
files in your diff, then read only those
###entries. Reading either file end to end is a waste of context and is not what the index is for. - Only the retro step writes here.
coder/coder-highandreview/review-criticalpropose candidate entries in their report — title, suggested tags, trigger, one paragraph — and the retro step decides what lands and commits it. Two writers gave us duplicates, unlabelled entries and a file nobody could load selectively; the retro step (step 14 ofdocs/workflow.md) is the single writer. A subagent that "just appends" outside that step is doing the wrong thing. - One topic per entry. The problem, the solution/decision, optionally an
issue/PR/commit reference. Do not restate what
AGENTS.mdalready says —AGENTS.mdalready covers naming conventions, the "Implementing a New Protocol Command" template and the server-push frame table, so link to it instead of duplicating it. - Prefer a gate over an entry. If an automated check (regression test, PHPStan rule, PHPCS rule) could catch the class of defect, add the check. The knowledge base is a buffer for what cannot be automated yet, not a destination. PHPStan runs at level 9 here — a missing type or unreachable branch is a static-analysis catch, not a knowledge-base entry.
- In doubt, ask. If it is unclear whether something belongs here (or where), ask the user.
Markdown has no per-section front matter, so every entry carries a
single-line HTML comment immediately after its ### heading. It renders
as nothing and parses with one regex:
### Some entry title
<!-- id=FAQ-001 date=2025-01-01 tags=foo,bar trigger="when X" hits=0 status=active -->
The body of the entry.Grammar: <key>=<value>, all on one line. A value is either bare (no
whitespace) or double-quoted. The comment must be the very next line after
the heading. No value may contain -->, quoted or not: an HTML comment ends
at its first one, so the rest of the entry would render as visible text.
| Key | Required | Meaning |
|---|---|---|
id |
yes | FAQ-NNN in faq.md, DEC-NNN in decisions.md; unique across both files and never reused |
date |
yes | ISO-8601 YYYY-MM-DD — when the entry was learned (the referenced issue/PR, or the commit that introduced it) |
tags |
yes | comma-separated [a-z0-9-] tags, no spaces; these feed the tag index |
trigger |
yes | quoted phrase answering "when should an agent load this?" |
hits |
yes | how many cycles actually used the entry; the retro increments it |
status |
yes | active | promoted | stale |
gate |
only for promoted |
the test/rule that replaced the entry |
Each file has a generated index between the markers
<!-- kb-index:start --> and <!-- kb-index:end -->, mapping every tag to
the entry ids that carry it. It is regenerated by:
php bin/kb-lint.php --fixcomposer lint fails when the index is out of sync with the entries, so it
is never stale. Loading one entry looks like this:
sed -n '/^<!-- kb-index:start/,/^<!-- kb-index:end/p' docs/helpers/faq.md # the index
awk '/^### /{p=0} /id=FAQ-002/{p=1} p' docs/helpers/faq.md # one entryThe knowledge base is capped at 300 lines per file (the generated index
does not count, nor does the ## Tag index heading and the blank lines
around it). bin/kb-lint.php warns above that; the retro step brings it back
under by applying two rules:
promoted— the lesson is now encoded as a test, a PHPStan rule or a lint rule. The entry collapses to a one-liner naming the gate ingate="…"; the detail lives in the check itself.kb-lintenforces the collapse (at most two body lines) and refuses apromotedentry without agate.stale— 0hitsover 20 cycles.kb-lint.phpdoes not compute this fromhitsitself: the retro is the one that judges an entry stale (0 hits overSTALE_AFTER_CYCLEScycles) and setsstatus=stale;kb-lintthen only lists entries already marked that way, in a machine-checkable form the retro can act on, and removes them at the next pass. If nobody loaded it in 20 cycles it is not knowledge, it is sediment.
An entry is never edited in place to say something different: change the
content, change the date, and say what changed and why.