Use cases
What a base is actually for. Each shows the CLI; the MCP reference has the equivalent tool call.
Recording a decision
A decision is the one thing a later pass cannot recover: the diff shows what
changed, not which alternative was rejected. write-decision takes
alternative and impact as fields rather than free sections, because what
was rejected is the part a writer leaves empty when it is a heading.
strauss-kb write-decision <<'JSON'
{
"slug": "cas-not-lock",
"title": "Compare-and-swap rather than a lock",
"why": "A stale lock hold blocks every later writer.",
"alternative": "A lock file, which adds a stale-hold failure mode.",
"impact": "Read-modify-write callers must handle a conflict and retry.",
"anchors": [{ "file": "src/kb-store.ts", "symbol": "KbStore.setStatus" }]
}
JSON
{
"conceptId": "decision.cas-not-lock",
"action": "created",
"supersededIds": []
}
What belongs in one:
- Record a decision when a later reader would otherwise simplify the constraint away. If the diff already answers the question, there is nothing to write.
alternativeis what you turned down and why, not everything considered.- Material you read goes in
sources; code goes inanchors; another record goes inrelatedConceptIds.
Other types use write with a section map keyed by the type's own headings —
call strauss-kb types first, since a heading the type does not define is
rejected.
strauss-kb write fact <<'JSON'
{
"slug": "cache-key-includes-region",
"title": "The cache key includes the region",
"why": "A region-less key serves another region's data.",
"sections": { "Claim": "Every key is prefixed with the region." },
"anchors": [{ "file": "src/cache/order-cache.ts", "symbol": "OrderCache.get" }]
}
JSON
Judgment the tool cannot enforce:
- An unsourced claim is an
assumptionwithassumption: true, never afactwith a vague source. - When two records conflict, say so in a
risk, anopen-question, or a supersedingdecision. Quietly picking a winner destroys the disagreement. - Prefer a new record over overloading an existing one, and keep each short.
When there was nothing to decide
strauss-kb no-decision "Renamed a private helper; the diff answers it."
Idempotent, writing decision.none, so a workflow gate can ask "did you
answer?" rather than "did you write a decision?".
Querying before deciding
Search first: a duplicate concept id is rejected rather than overwritten, and the same knowledge filed twice under different slugs is how a base rots.
strauss-kb query cache key region
Every match comes back with its standing, never filtered:
[
{
"conceptId": "decision.cas-not-lock",
"title": "Compare-and-swap rather than a lock",
"standing": "current",
"supersededBy": [],
"warnings": [{ "kind": "unverified" }],
"body": "## Decision\n\n…"
}
]
standing is one of current, superseded, rejected, unsettled, open.
Read it before acting on body.
load, catalog, query, pack, and trace are the supported ways to read a
base. A raw file read bypasses supersession resolution and returns replaced
records as if current.
A query cannot tell you that nothing was decided — it returns its nearest hit
whatever the distance. When the question is what exists, use catalog.
Superseding
A record whose meaning changed is superseded, never edited. Link two existing records:
strauss-kb supersede decision.cursor-v1 decision.cursor-v2
Or declare it while writing the replacement, which is the usual path:
strauss-kb write-decision <<'JSON'
{
"slug": "cursor-v2",
"title": "Cursor pagination keyed on (created_at, id)",
"why": "Offset pagination skipped rows under concurrent inserts.",
"alternative": "Keeping offsets and accepting the skew.",
"supersedes": ["decision.cursor-v1"]
}
JSON
{
"conceptId": "decision.cursor-v2",
"action": "superseded-prior",
"supersededIds": ["decision.cursor-v1"]
}
supersededIds holds only the ids actually marked; an id naming a record
that does not exist yet is legal and left for validate to report. Both
directions are written, so validate drops to catching hand-edits:
strauss-kb validate || echo "errors above" # warnings alone still exit 0
"What breaks if I change this?"
Ask before superseding, contradicting, or narrowing a record. The answer is
the set of records whose claims were written assuming the current one holds. The
edges come from strauss_links, written on the record that makes the claim:
strauss-kb write fact <<'JSON'
{
"slug": "region-key",
"title": "The cache key is prefixed with the region",
"why": "A region-less key serves another region's data.",
"sections": { "Claim": "Every key is prefixed with the region." },
"links": [{ "target": "requirement.tenant-isolation", "rel": "satisfies" }]
}
JSON
strauss-kb impact fact.region-key
{
"root": "fact.region-key",
"impacted": [
{
"conceptId": "decision.single-cache-namespace",
"standing": "current",
"depth": 1,
"via": [
{
"source": "decision.single-cache-namespace",
"target": "fact.region-key",
"rel": "depends_on"
}
]
}
],
"stopped": ["decision.cache-v1"],
"truncated": false,
"unexpanded": []
}
Three things to read carefully: via shows the edge as written, not as
walked; stopped names where the walk halted, since a superseded or rejected
record is reported and not walked through; and truncated says whether
--depth N cut it, with unexpanded naming what was skipped.
Which direction each rel runs is the whole subtlety — see the
direction table. Naming related_to,
or a rel that does not exist, in --rels is an error rather than an empty
result: "nothing breaks" is the one answer you must never receive from a typo.
For the flat question — who points at this, one hop, every rel — use
backlinks.
strauss-kb backlinks fact.region-key
Moving a status, answering a question
status moves a record's status and nothing else, with a compare-and-swap.
answer resolves an open-question: it sets the status, stamps
strauss_answered, and appends an Answer section.
strauss-kb status requirement.export-csv accepted
strauss-kb answer open-question.retry-budget "Three retries with full jitter."
If the answer overturns an assumption or a decision, that is a supersession — do it explicitly.
Verification
verify appends one verified[] event. The --note is required and must say
what the check found — "verified" on its own is not an event worth appending.
STRAUSS_KB_ACTOR="human:assaf" strauss-kb verify decision.cas-not-lock \
--note "Re-read KbStore.setStatus; the digest check is still before publish."
A record's own generator is refused unless the actor is human:-prefixed:
re-reading your own output is not an independent check. The refusal lands in the
log as verify:refused.
Keeping anchors honest
An anchor names where a record attaches in the code. Stamp it with a hash once the change has settled, and the base can tell you later when the code moved out from under it.
strauss-kb anchor-resolve decision.cas-not-lock --repo-root /repo
{
"conceptId": "decision.cas-not-lock",
"results": [
{
"file": "src/kb-store.ts",
"state": "stamped",
"currentHash": "sha256:9f2c…"
}
]
}
Run it again later and the states become the answer.
It works as a CI gate — a run that leaves drift unsettled exits non-zero
(the exact rule) — and a green run writes
nothing at all. You do not have
to run it to see drift: load and query re-resolve hash-carrying anchors as
they read and attach a drifted warning — or unchecked, for an anchor in
another repository whose remote was not in the cache.
Repointing a record after a refactor
A refactor renames the anchored function and extracts a helper. The record's
claim still holds; its pointers do not. doctor --drifted names the record:
# decision.export-retention — Exports are deleted thirty days after they are produced
type: decision standing: current
why: Keeping an export longer holds customer data past what the contract allows.
## Anchors (1)
- src/cleanup.mjs:shouldDeleteExport — gone (symbol-not-found)
Default: rationale-may-survive — the reasoning may outlive the code that implemented it; check whether it does.
Read the code first. No tool can say that isExportExpired replaces
shouldDeleteExport; a name that looks like a rename is the thing to check, not
the evidence. Then send the anchors back with the rename applied — the same
objects you read, so each keeps the hash it was stamped with:
strauss-kb anchor-set decision.export-retention <<'JSON'
{
"reason": "Reviewed the refactor: isExportExpired replaces shouldDeleteExport; retentionDays owns the shared setting.",
"anchors": [
{ "file": "src/cleanup.mjs", "symbol": "isExportExpired",
"hash": "sha256:5c7242b8…", "hash_kind": "ast", "lines": 3,
"resolved_at": "2026-09-17T20:06:25.829Z", "resolver": "tree-sitter" },
{ "file": "src/download.mjs", "symbol": "canDownloadExport",
"hash": "sha256:b4be493b…", "hash_kind": "ast", "lines": 3,
"resolved_at": "2026-09-17T20:06:25.830Z", "resolver": "tree-sitter" },
{ "file": "src/retention.mjs", "symbol": "retentionDays" }
]
}
JSON
An agent calls kb_anchor_set with the same object as input. Only symbol
moved on the first anchor; the second is the one it read, back unchanged; the
third is new and unstamped. The command reports what that amounted to:
{
"changes": [
{
"op": "move",
"from": { "file": "src/cleanup.mjs", "symbol": "shouldDeleteExport" },
"to": { "file": "src/cleanup.mjs", "symbol": "isExportExpired" }
},
{
"op": "add",
"to": { "file": "src/retention.mjs", "symbol": "retentionDays" }
}
],
"baseline": "unchanged",
"note": "pointers only: nothing was resolved, rebaselined or verified. Run anchor-resolve to check the new pointers, --rebaseline to accept the code, and verify separately."
}
Leave the hashes off and anchor-resolve stamps the rewritten body as the new
baseline — right if you read it, not if you did not. Carrying them keeps the
drift visible until you do.
The pointer moved; the baseline did not. So the next check reports the drift the rename had been hiding — the body really did change — and the helper is stamped for the first time:
strauss-kb anchor-resolve decision.export-retention
{
"results": [
{
"file": "src/cleanup.mjs",
"symbol": "isExportExpired",
"state": "drifted",
"diffSize": 0
},
{
"file": "src/download.mjs",
"symbol": "canDownloadExport",
"state": "match"
},
{
"file": "src/retention.mjs",
"symbol": "retentionDays",
"state": "stamped"
}
]
}
Hashes and hashKind elided. Read that body, and only then accept it:
strauss-kb anchor-resolve decision.export-retention --rebaseline
A re-run now reports match for all three and reassess has nothing to hand a
reader:
decision.export-retention: nothing to reassess.
Three acts, three commands, on purpose: anchor-set says where the code is,
--rebaseline says the code is still right, and verify says
somebody read it. The move is in the log with the reason attached:
{
"at": "2026-09-17T20:07:11.204Z",
"by": "agent:author",
"operation": "anchor-set",
"conceptId": "decision.export-retention",
"reason": "Reviewed the refactor: isExportExpired replaces shouldDeleteExport; retentionDays owns the shared setting.",
"anchors": [
{
"op": "move",
"from": { "file": "src/cleanup.mjs", "symbol": "shouldDeleteExport" },
"to": { "file": "src/cleanup.mjs", "symbol": "isExportExpired" }
},
{
"op": "add",
"to": { "file": "src/retention.mjs", "symbol": "retentionDays" }
}
]
}
What this does not settle: a risk about EXPORT_RETENTION_DAYS overriding the
thirty days stays open once the anchors match, because an environment variable
changes no bytes. See
what drift does and does not see.
Sweeping a base for decay
Decay is invisible from inside a single record: a stale one reads exactly like a
live one. doctor is the question no reader thinks to ask.
strauss-kb doctor
# KB Doctor — /repo/.strauss/kb
records: 24
thresholds: expiring within 30d, unverified over 90d, aging over 90d
expired 1 past its stale_after date
expiring 0 stale_after falls within the window
unverified 3 nobody has ever confirmed it
aging 2 still open or proposed long after it was written
orphaned 0 no other record links to it
broken-supersession 0 the supersession pointers do not resolve
superseded-but-cited 1 a live record points at one that no longer holds
drifted 0 the code an anchor points at moved
unchecked 0 an anchor in another repository nothing could reach
## expired (1)
- fact.tls-cipher-list — The accepted cipher list: stale since 2026-06-01
7 findings across 4 of 9 checks.
Every group is reported even when empty, and it is read-only: every finding is a judgment somebody has to make.
strauss-kb doctor --strict # exit 1 if anything has expired
strauss-kb doctor --json | jq '.counts' # the object behind the table
strauss-kb doctor --unverified-days 30 --aging-days 45
The flags and checks are on the CLI page.
validate is the narrower neighbour: it asks only whether pointers between
records agree.
Tracing history
trace asks "why is this the way it is", and answers as a timeline ordered by
when each record was written, deliberately including rejected, draft, and
superseded records — in a history those are the content.
strauss-kb trace decision.cursor-v2
[
{
"conceptId": "decision.cursor-v1",
"at": "2026-07-04T14:20:00Z",
"status": "superseded",
"depth": 1,
"via": ["supersession"]
},
{
"conceptId": "decision.cursor-v2",
"at": "2026-08-16T09:14:00Z",
"status": "accepted",
"depth": 0,
"via": []
}
]
Narrow the walk by naming edges — typed-link, supersession, anchor,
source:
strauss-kb trace decision.cursor-v2 supersession
related_to is excluded because it can reach most of a bundle from anywhere,
which suits a bounded pack but floods a timeline.
Loading a base into agent context
Two tiers, because long sessions lose a base twice over: attention decays, and compaction summarises away both the records and the instruction to consult them.
Tier 1 — a small index at every context birth
strauss-kb pin docs/kb # mark a base every session should see
strauss-kb context # emit the pinned index block
strauss-kb sync-instructions AGENTS.md # or keep it in an instruction file
context emits concept ids, titles and standing — an index, not the content —
nothing at all when nothing is pinned, and a refusal listing the bases and their
sizes rather than a truncation past its budget.
Pins live in .strauss/kb-pins.json, committed with the repo, plus
.strauss/kb-pins.local.json (personal, gitignored) and ~/.strauss/kb-pins.json
(every workspace). Nearest layer wins per base, unpin removes from all three,
and a malformed layer is skipped on read and refused on write. Per-pin flags are
on the CLI page.
Budgets are named profiles — session-start (full-under 1500), compact and
turn (budget 2500) — with per-repo overrides in the manifest, so hook commands
never carry numbers:
{
"pins": [{ "path": "docs/adr", "mode": "full" }],
"context": { "compact": { "budgetTokens": 1500 } }
}
Flags beat the manifest, the manifest beats the built-ins, and an invalid value
falls back to a default instead of silencing the index. sync-instructions
keeps the same block between sentinels in AGENTS.md or CLAUDE.md, for
runtimes without a reliable post-compaction hook.
Tier 2 — bodies fetched when a question needs them
strauss-kb load # the whole base, each record with its standing
strauss-kb load decision # one type
strauss-kb load --budget 40000 # a wider budget
strauss-kb load --all # no ceiling at all
Call it at the point of use, not once per session: never conclude nothing
was decided from a context holding no records. Superseded records arrive as
stubs; trace reaches them by id.
Place the result in the stable prefix — the system prompt, or the first turn
— and reload only when the returned
digest changes; query and pack results
belong at the tail, where they cannot invalidate the cached prefix. --all is
the deliberate-operator escape hatch, mutually exclusive with --budget.
When the load refuses
A 25,000-token budget can stop it, and
the refusal names it. The move is
not to raise the budget. It is catalog:
strauss-kb catalog
One line per record — id, type, title, standing, stale flag — at roughly thirty tokens each, and the only read path with no ceiling. A workspace can also block raw reads, so a base is only ever read through the tools:
{
"permissions": { "deny": ["Read(.strauss/kb/**)", "Read(**/.strauss/kb/**)"] }
}
Packing a subgraph
pack is the middle rung between load and query: when the work centres on a
record you can name, it hands over that record's bounded neighbourhood.
strauss-kb pack decision.cursor-v2 --hops 2 --max-nodes 4
# KB Pack — decision.cursor-v2
bundle: /repo/.strauss/kb
budget: ~358 of 25000 tokens, 4 records
packed: 2026-08-31T22:54:18.986Z
## Records (3)
### decision.cursor-v2 — Cursor pagination keyed on (created_at, id) [current]
warnings: unverified
anchors: src/api/list.ts#listOrders
## Decision
Cursor pagination keyed on (created_at, id)
## Superseded (1)
- decision.cursor-v1 → decision.cursor-v2 (2026-08-31T22:54:18.430Z)
## Excluded (1)
- open-question.pagination
The header's record count includes the superseded stub; Excluded names what
--max-nodes cut before adjudication, named but not counted. Three properties
make it usable as an artifact: every cut record is named, so a gap is
knowable; it refuses rather than truncating past its token budget, reporting
the record count and every already-cut id; and everything below the header is
byte-identical across runs over an unchanged base, so two packs can be diffed.
The walk follows all four edge kinds with the whole
rel vocabulary. With neither a size problem nor a root record in hand, the
question is a point lookup — query.
Promoting review records to an ADR base at merge
A review base holds what a pull request settled, and most of it stops mattering
once the branch is gone. promote --list names
what does not — decisions off review, constraints still proposed, contracts,
requirements something satisfies, blocking risks — and promote <ids> --to <base> --source <pr-url> copies those into the base that outlives the branch:
settled, review tags gone, the pull request recorded as a source, and both bases
logged. Links to records left behind are dropped and named, since a typed
edge cannot cross bases. For a repository that keeps ADRs in-tree,
export --format madr renders the promoted
decisions into docs/adr afterwards.
Writing from several worktrees at once
Nothing special is required. One record per file means parallel writers never
merge records — they only choose distinct names, and a collision is a 409 the
caller answers. The one shared file is log.jsonl, and the store handles it by
writing a
merge=union driver
into the base's .gitattributes on the first append. log sorts entries by
timestamp and drops exact duplicates, so a union merge's reordering is never
yours to handle.
Catching a hand-edit
The store's guarantees hold because everything goes through one door, and a
hand-edit is the case that does not. The plugin's
two hooks close it from the outside: a
PreToolUse deny on the generated files sitting directly in a bundle, and an
advisory PostToolUse validate that reports what a manual edit broke in the
same turn. Neither is wired by the plugin — a workspace opts in by copying the
script and adding the entry, per that page. To turn the validate hook off for a
session:
export STRAUSS_KB_NO_VALIDATE_HOOK=1