
Obsidian Plan Wiki
- 31 installs
- Updated January 1, 1970
- cygnusfear/agent-skills
Creates Obsidian-compatible markdown wikis that separate specifications (what the system does) from plans (how to build it), with selective loading of content.
About
Obsidian-plan-wiki is a Claude Code skill that builds a structured, Obsidian-compatible markdown wiki splitting system documentation into specifications and plans, and lets an agent selectively load only the parts it needs. A solo builder uses it to keep the what and the how of an agent system organized and navigable via numeric Johnny-Decimal style lookups.
- Obsidian-compatible markdown wiki
- Separates specifications from build plans
- Johnny Lookup numeric IDs map to playbook file paths
Obsidian Plan Wiki by the numbers
- 31 all-time installs (skills.sh)
- Ranked #927 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cygnusfear/agent-skills --skill obsidian-plan-wikiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 31 |
|---|---|
| Last updated | January 1, 1970 |
| Repository | cygnusfear/agent-skills ↗ |
What it does
Creates Obsidian-compatible markdown wikis that separate specifications (what the system does) from plans (how to build it), with selective loading of content.
Who is it for?
Organizing agent-system docs into specs and plans
Files
Rebase / Merge Strategy
We use git merge --ff-only to maintain linear history. Do NOT create merge commits. The history must remain linear. If a merge cannot fast-forward, rebase the feature branch onto main first:
git checkout feature-branch
git rebase main
git checkout main
git merge --ff-only feature-branchRead and follow the playbook — use the 20-git skills for the full procedure, and use critical sanity checks if your circumstances deviate from the regular workflow. In case of doubt, ask the Hooman.
Rebase strategy
1. 20.01 Rebase Preparations (Johnny Lookup → skill 20-01-methodic-rebase-merge) 2. 20.02 Rebase (Johnny Lookup → skill 20-02-authoritative-main-rebase)
ALWAYS CONFIRM REBASE WITH EXPLICIT [R].
Merge strategy
ONLY MERGE IF THIS IS EXPLICITLY REQUESTED.
1. 20.03 Merge (Johnny Lookup → skill 20-03-merge-local-safe) 2. 20.04 Post-Merge (Johnny Lookup → skill 20-04-post-merge-hygiene)
ALWAYS CONFIRM MERGE WITH EXPLICIT [M].
Commit Format
Critical: Close completed tickets with tk close <ticket-id> before committing. See 05.01 (Ticket Lifecycle).
feat(bridge): add tab management APIs
fix(sidebar): correct tab ordering on drag
docs(plan): update Phase 2 tasks
refactor(app): extract sidebar view modelReverting (CRITICAL, EXPLICIT APPROVAL)
I WILL ABSOLUTELY DESTROY YOUR CIRCUITS IF YOU ATTEMPT TO REVERT FILES WITHOUT ME APPROVING LITERALLY IN THE NEXT COMMENT WITH "[OK]"
Agent Instructions: [Project Name]
00.00 Johnny Lookup
If the human gives you only an ID like 20.01 (or 2001), treat it as a playbook call:
1. Check docs/playbook/**/20.01-*/SKILL.md — if it exists, read and follow it (local override). 2. If no local file, read playbook/20-git/20-01-*/SKILL.md from the obsidian-plan-wiki skill. 3. Follow the instructions literally.
---
Wiki Operations
Progressive Disclosure
Read only what you need.
- Overview →
docs/README.md - Feature area →
docs/features/NN-area/README.md - Spec/plan →
docs/features/NN-area/NN.NN-*-spec.md/NN.NN-*-plan.md - Architecture/research →
docs/reference/ - Process/tooling →
docs/playbook/
Open Questions
Use Obsidian comments with emoji + block IDs:
%% 🙋♂️ Human task/question %% ^q-scope-topic
%% 🤖 Agent question (waiting on human) %% ^q-scope-topic
%% ✅ Question → Answer %% ^q-scope-topicRules:
- Blank line between questions (Obsidian merges adjacent comments).
- Every question needs a block ID (
^q-scope-topic). - Last emoji decides whose turn it is.
Ticketing (tk)
Use tk for non-trivial work. Close the ticket before committing.
Small-change exemption (all must be true): one file, ≤10 lines, docs-only or comment/typo-only. Otherwise, create a ticket.
ID=$(tk create "Short description" -t task -p 1 --tags tag1,tag2 -d "Details") && tk start $IDLifecycle: tk create → tk start <id> → work → tk close <id> → commit.
Rebase Strategy
Read and follow the playbook in 20-git. Use critical sanity check if circumstances deviate. In case of doubt, ask the Hooman.
1. 20.01 Rebase Preparations 2. 20.02 Rebase
ALWAYS CONFIRM REBASE WITH EXPLICIT [R].
Merge Strategy
ONLY MERGE IF THIS IS EXPLICITLY REQUESTED.
1. 20.03 Merge 2. 20.04 Post-Merge
ALWAYS CONFIRM MERGE WITH EXPLICIT [M].
Changelog (tinychange)
Log changes with tinychange.
tinychange -I new -k <fix|feat|docs|refactor|...> -m "t-XXXX: message" -a AUTHOR
tinychange mergeChangelog
All notable changes to this project.
Format follows Keep a Changelog.
This file is generated by tinychange merge. Do not hand-edit it.
---
YYYY-MM-DD (Session 1)
Added
- Initial plan structure
- [[README]] - Wiki index
- [[AGENTS]] - Agent instructions
Notes
- [Any initial decisions or context]
Agent Instructions: docs/plans
Purpose: implementation and design plans.
Naming:
YYYY-MM-DD-HHMM-topic.md(24h time, local).
Rules:
- Follow
/AGENTS.mdanddocs/AGENTS.md. - Include a
tkticket ID in the header when available. - Cover goal, steps, risks, and testing strategy.
- Plans should be as specific as possible, including edge cases.
- Include pseudocode for non-obvious implementation details.
- List every file touched with specific functions and changes.
- See the
obsidian-plan-wikiskill'sreferences/templates.mdfor bulletproof plan guidelines.
Agent Instructions: docs/playbook
Purpose: process, tooling, and repo workflow documentation. Each entry is a SKILL.md (standard format understood by LLM agents).
Rules:
- Follow
/AGENTS.mdanddocs/AGENTS.md. - Use Johnny Decimal IDs for subfolders and directory prefixes (e.g.,
10-02-build-pipeline/SKILL.md). - Keep instructions actionable and step-by-step.
- Update related links when moving or renaming playbook entries.
Agent Instructions: docs/postmortems
Purpose: incident postmortems and learnings.
Naming:
YYYY-MM-DD-HHMM-topic.md(24h time, local).
Rules:
- Follow
/AGENTS.mdanddocs/AGENTS.md. - Include a
tkticket ID in the header when available. - Cover summary, timeline, root cause, fix, and prevention.
Agent Instructions: docs/reference
Purpose: architecture and research references (internal and external).
Rules:
- Follow
/AGENTS.mdanddocs/AGENTS.md. - Use Johnny Decimal IDs with two-digit decimals (e.g.,
01-design/01-03-systems.md). - Cite sources for external research and keep links intact.
- Prefer stable, long-lived docs over transient notes.
Research Index
Research that informs decisions.
Active Research
Add links as you create research files.
Archive Policy
Keep research files here for reference. When you apply research to a spec/plan:
1. Link the research from the spec/plan: > **Research:** See [[research/topic]] 2. Resolve the open question in place: %% ✅ question → see [[research/topic]] %% ^q-scope-topic 3. Do not delete the research file.
Structuring a Rust Codebase
How to organize modules, manage privacy, and keep a complex Rust codebase navigable. Extracted from Datalust's Flare storage engine and Brandon's "Lasagna Code" talk on design and architecture in Rust.
---
Module = Privacy Boundary = Feature
Each module owns one feature. The module boundary is where privacy lives — not the struct, not the impl block. Everything inside a module can see everything else inside it. Everything outside sees only what you export.
Organize by reason to change, not by technical category. Don't make a models/ and a utils/. Make an ingest_buf/ and a page_index/.
Modules are Rust's smallest unit of privacy — equivalent to files in C, classes in Java, capitalization in Go. Implementations never affect privacy rules. A method on a struct still obeys the module boundary, not the impl block.
Start by writing mod inline whenever you need a stronger boundary. Extract to a file later by replacing the block with a semicolon.
---
The Facade Pattern
Expose a flat, simple API from a complex interior. Mark items pub inside a private mod. The parent decides what gets re-exported. Internals stay complex; the surface stays small.
src/
├── store/
│ ├── mod.rs ← public API lives here (re-exports)
│ ├── ingest_buf/ ← private, facaded from root ingest_buf
│ │ ├── mod.rs
│ │ └── reader.rs
│ ├── stable_span/
│ │ ├── mod.rs
│ │ └── reader.rs
│ ├── nucleus/
│ │ ├── mod.rs
│ │ ├── read.rs
│ │ └── write.rs
│ └── mvcc/
│ ├── mod.rs
│ └── model.rsConcrete example from Flare: root-level ingest_buf and stable_span modules contain the raw storage formats. store re-exports them as store::ingest_buf and store::stable_span. This seam absorbs internal API changes — the root module's implementation can shift without breakage leaking into store.
Treat root modules as if they were independent crates. This disciplines their public API and content.
---
Depth Hierarchy
Each level adds a privacy boundary. Code at depth N cannot see private items at depth N+1.
| Depth | Purpose | Example |
|---|---|---|
| Root modules | Independent features, treated like separate crates | ingest_buf/, stable_span/, store/ |
| Second-level | Sub-concerns within a feature | store/mvcc/, store/nucleus/ |
| Third-level | Operational sub-groups | store/nucleus/maintenance/ |
| Leaf files | Single responsibility each | coalesce_into_span.rs, truncate.rs |
---
Privacy Ladder
From loosest to tightest — choose deliberately:
| Visibility | Use when |
|---|---|
pub | Ecosystem consumers need it |
pub(crate) | Other modules in your crate need it |
pub inside private mod | Sibling code within the parent module needs it, but nothing outside |
| private (default) | Only the current module needs it |
Default to private. Widen only when a consumer demands it. The smaller the holes in your API, the easier each new one is to reason about.
pub(crate) is fairly unique to Rust — few languages offer compilation-unit privacy. Use it as your working default for items that other modules in the crate need.
Why not `pub` everything? Discipline is hard. Making a conscious decision to publicize internals matters. The only thing stopping you from exposing everything is speed bumps you place for yourself.
---
Dependency Direction
store → ingest_buf
store → stable_span
store → mvcc
store → std_ext
ingest_buf → std_ext
stable_span → std_extArrows point one way: from orchestration toward infrastructure, from volatile toward stable. Implementation changes faster than definition. API pressure flows from the real world inward — the infrastructure that interacts with the outside world drives what the inner modules expose.
If you draw a loop, your layers are wrong. When module A requires changes in module B and module B requires changes in module A, the code is spaghetti regardless of how many files you split it across.
---
Cross-Cutting Utilities: Mirror std
Every codebase accumulates helpers. Without structure, they scatter or become a grab-bag. Mirror std's module layout in a std_ext module:
src/
├── std_ext/
│ ├── mod.rs
│ ├── fs.rs ← extends std::fs
│ ├── io.rs ← extends std::io (ResetRead, SizeRead, etc.)
│ ├── iter.rs ← extends std::iter
│ ├── option.rs ← extends Option
│ ├── result.rs ← extends Result
│ ├── slice.rs
│ ├── sync.rs
│ └── vec.rsEverything pub(crate). The mirror structure keeps utilities discoverable. When you need an extension trait for Option, you know to look in std_ext::option — not hunt through utils.rs or helpers.rs.
Extension traits are the typical form:
pub(crate) trait OptionMutExt<T> {
fn map_mut<F>(&mut self, f: F) -> &mut Self
where F: FnOnce(&mut T);
}Some extensions are more fundamental — traits that extend std::io::Read for data sources offering additional guarantees (resettable readers, size-aware readers, readers that can detach from their source). These compose: a MemRead trait unifies contiguous and non-contiguous payloads behind a single interface, with an optimized fast path when bytes are contiguous.
---
Auditable unsafe via Macro
#![deny(unsafe_code)] at the crate root. Channel all unsafe through a custom macro that requires a written safety justification:
macro_rules! unsafe_block {
($reason:tt => $body:expr) => {{
#[allow(unsafe_code)]
let r = unsafe { $body };
r
}};
}Forgetting the macro and writing raw unsafe { ... } produces a compile error. Every unsafe block carries a reason string explaining what invariants guarantee safety:
let mut read_into = unsafe_block!(
"Our writers don't read from the uninitialized buffer" => {
buf.bytes_mut()
}
);Extend the pattern with similar macros for unsafe functions and unsafe impl blocks.
---
Destructuring + deny(unused_variables) for Field Coverage
When a method must touch every field (reset, serialize, clone-with-changes), destructure the struct and deny unused variables at function scope:
fn reset(&mut self) -> Result<(), io::Error> {
#![deny(unused_variables)]
let Decompress {
ref mut src,
dec: ref _dec, // explicitly unused
ref mut compressed,
ref mut decompressed,
ref mut read_head,
} = *self;
src.reset()?;
compressed.clear();
decompressed.clear();
*read_head = 0;
Ok(())
}Add a field to the struct later? The compiler forces you to handle it here. Bind irrelevant fields with _ prefix to signal deliberate omission.
---
Tooling Lives Outside the Main Crate
workspace/
├── flare/ ← the engine (single crate)
├── bench/ ← benchmarks
├── fuzz/ ← fuzz targets
└── tools/ ← diagnostics, inspectorsBenchmarks, fuzz targets, and diagnostic tools are separate crates in the same workspace. They depend on the main crate; the main crate does not know they exist.
---
Encapsulate Incrementally
Progress through these layers only as complexity demands — not before:
1. Raw data in a collection — no encapsulation, business logic bleeds everywhere. Even here, &mut signals tell you something about access patterns.
2. Functions in a module — operations are separated, but ownership of the underlying data still spreads. The module provides the privacy boundary.
3. Structs — tie data and behavior together. Keep fields pub(crate) for internal types you trust; make them private when invariants matter. A reasonable choice for internal types: exposing fields to trusted code eliminates boilerplate you don't need to write.
4. Traits — define only when you have motivation: reuse across implementations, testing mocks, or ecosystem interop. Not before. Use traits as early as necessary, but no earlier.
---
Trait Constraints: Where to Place Them
Push bounds like K: Hash + Eq onto the struct, not individual methods:
- Better error messages — users discover incompatibility at construction, not
months later when they call a method.
- Clearer documentation — the struct definition states what matters about its
type parameters.
Use method-level constraints only when a specific operation needs an additional bound (like Clone) that the struct does not universally require. In trait implementations, you cannot add method-level constraints — the entire impl block needs the where clause.
---
Separation of Orthogonal Concerns
When two things change for different reasons, put them in separate modules. Cache mechanics and cache metrics are orthogonal — one changes when you change storage strategy, the other when you change what you measure.
Each module owns its data and enforces its own invariants. The consumer module (metrics) depends on the producer module (cache) through its public API. The producer does not know the consumer exists.
Rust's zero-cost abstractions mean this separation costs nothing at runtime. The compiler inlines the boundaries away.
---
Traits as Type-Level Privacy
Modules hide items (code-level privacy). Traits hide concrete types (type-level privacy). Once CacheMetrics depends on a Cache trait instead of a concrete HashMap wrapper, it no longer knows or cares what backs the cache.
This gives single-direction implementation: the consumer knows the cache's shape but not its specific implementation. The "uses" line is isolated from the "implements" line.
---
Type System as Test Replacement
Encode structural invariants in types when possible:
struct CascadeCache {
first: Box<dyn Cache>, // at least one — guaranteed by the type
rest: Vec<Box<dyn Cache>>,
}A Vec<Box<dyn Cache>> alone requires tests to verify non-emptiness. The split representation makes the compiler enforce it. No test needed.
---
Summary
| Principle | Mechanism |
|---|---|
| One module per reason-to-change | Feature modules, not technical categories |
| Facade complex internals | Private mod + selective re-export |
Mirror std for utilities | std_ext/ with pub(crate) extension traits |
| Private by default | Widen visibility deliberately through the privacy ladder |
| Dependencies flow one direction | Volatile → stable, never circular |
| Separate crate for tooling | Benchmarks, fuzz, diagnostics outside the main crate |
| Auditable unsafe | Macro-enforced reason strings, deny(unsafe_code) at root |
| Compiler catches forgotten fields | Destructure + deny(unused_variables) |
| Encode invariants in types | Structural guarantees over runtime checks |
| Abstract late | Traits when motivated, not speculatively |
Professional Rust: A Field Guide
How to write Rust that scales for further development. Extracted from studying ripgrep (46K LOC), clap (41K LOC), parsel (6K LOC), and ring_api (2.3K LOC) — four codebases written by people who know what they're doing.
Not design patterns. Not architecture astronautics. The actual craft — the decisions that separate code that rots from code that lives.
---
Part I — Types
The foundation of professional Rust is type design. Every decision in this section prevents a class of bugs or eliminates a category of boilerplate.
1. Newtypes prevent misuse at zero cost
An amateur represents a match as (usize, usize) or start: usize, end: usize. A professional wraps it:
// ripgrep — crates/matcher/src/lib.rs
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Match {
start: usize,
end: usize,
}
impl Match {
pub fn new(start: usize, end: usize) -> Match {
assert!(start <= end); // checked once, trusted forever
Match { start, end }
}
pub fn len(&self) -> usize { self.end - self.start }
pub fn is_empty(&self) -> bool { self.start == self.end }
pub fn offset(&self, amount: usize) -> Match {
Match { start: self.start + amount, end: self.end + amount }
}
}
// Integrates with native slice syntax: &haystack[m]
impl std::ops::Index<Match> for [u8] {
type Output = [u8];
fn index(&self, index: Match) -> &[u8] {
&self[index.start..index.end]
}
}Match is Copy — zero overhead. The invariant start <= end is checked at construction. After that, every use site trusts it. The Index impl means you write &haystack[m] instead of &haystack[m.start..m.end].
Without the newtype, someone eventually passes (end, start) in the wrong order. The bug shows up as a panic in haystack[end..start] three functions away.
ring_api does the same for domain concepts:
// ring_api — src/job.rs
pub struct JobId(String);You can't pass a PDB identifier where a job ID is expected. They're both strings. The newtype makes the compiler enforce the distinction.
When to newtype: When the raw type (usize, String, u8) could mean more than one thing in your domain. The cost is one struct declaration. The benefit: wrong usage doesn't compile.
2. Enums carry state — they replace boolean flags and if-cascades
Amateur:
fn search(pattern: &str, case_sensitive: bool, multiline: bool, word_boundary: bool) {
if case_sensitive && multiline && word_boundary { ... }
else if case_sensitive && multiline { ... }
else if case_sensitive && word_boundary { ... }
// ... 8 branches, growing with every new option
}Professional — ripgrep:
// crates/core/search.rs
pub(crate) enum PatternMatcher {
RustRegex(grep::regex::RegexMatcher),
#[cfg(feature = "pcre2")]
PCRE2(grep::pcre2::RegexMatcher),
}Each variant holds a fully configured, compiled matcher. Case sensitivity, multiline, word boundaries — all baked in at construction time. The dispatch is one match:
match self.matcher {
RustRegex(ref m) => search_path(m, searcher, printer, path),
PCRE2(ref m) => search_path(m, searcher, printer, path),
}No booleans. No cascading ifs. Each variant carries its own state.
ripgrep's LineTerminator shows the same pattern at a smaller scale:
// crates/matcher/src/lib.rs
pub struct LineTerminator(LineTerminatorImp); // public opaque wrapper
enum LineTerminatorImp { // private inner enum
Byte(u8),
CRLF,
}
impl LineTerminator {
pub fn byte(byte: u8) -> LineTerminator {
LineTerminator(LineTerminatorImp::Byte(byte))
}
pub fn crlf() -> LineTerminator {
LineTerminator(LineTerminatorImp::CRLF)
}
pub fn as_byte(&self) -> u8 {
match self.0 {
LineTerminatorImp::Byte(byte) => byte,
LineTerminatorImp::CRLF => b'\n',
}
}
pub fn as_bytes(&self) -> &[u8] {
match self.0 {
LineTerminatorImp::Byte(ref byte) => std::slice::from_ref(byte),
LineTerminatorImp::CRLF => &[b'\r', b'\n'],
}
}
}Nobody scatters if line_ending == "\r\n" through the codebase. They call terminator.as_byte(). The logic lives on the type.
The scaling difference: With booleans, adding a third case means finding every if-else chain in the codebase. With an enum, you add a variant, and the compiler shows you every match that needs updating. The boolean version silently does the wrong thing for the new case. The enum version refuses to compile.
3. Hide the inner enum, expose methods
Notice that LineTerminator wraps LineTerminatorImp — the inner enum is private. Users interact through methods. This matters because:
- Adding a variant (say,
Unicodefor\u{2028}) is not a breaking change - The internal representation can change freely
- Users can't write
matchon your internals and break when you evolve
clap's Str type takes this further — the inner representation changes based on a feature flag:
// clap_builder/src/builder/str.rs
pub struct Str { name: Inner }
// Without `string` feature (default):
pub(crate) struct Inner(pub(crate) &'static str);
// With `string` feature:
pub(crate) enum Inner {
Static(&'static str),
Owned(Box<str>),
}Same public API. Completely different internal representation depending on compile-time configuration. Users see Str and its methods. They never know which Inner they're using.
The amateur mistake: pub enum LineTerminator { LF, CRLF }. Works until you need a third variant. Then every downstream match breaks. You reach for #[non_exhaustive], but now every consumer needs a _ => branch that panics when the new variant actually arrives.
4. Right-size your types
ripgrep needs a set of bytes (which bytes appear in a pattern, which are line terminators). An amateur uses HashSet<u8>. A professional:
// crates/matcher/src/lib.rs
pub struct ByteSet(BitSet);
#[derive(Clone, Copy)]
struct BitSet([u64; 4]); // 256 bits = one bit per possible byte value
impl ByteSet {
pub fn empty() -> ByteSet { ByteSet(BitSet([0; 4])) }
pub fn full() -> ByteSet { ByteSet(BitSet([u64::MAX; 4])) }
pub fn add(&mut self, byte: u8) {
let bucket = byte / 64;
let bit = byte % 64;
(self.0).0[usize::from(bucket)] |= 1 << bit;
}
pub fn contains(&self, byte: u8) -> bool {
let bucket = byte / 64;
let bit = byte % 64;
(self.0).0[usize::from(bucket)] & (1 << bit) != 0
}
}32 bytes on the stack. Copy. O(1) contains. No heap allocation. A HashSet<u8> is heap-allocated, carries 40+ bytes of metadata, hashes on every lookup, and for a set that's at most 256 elements, is slower in every dimension.
The principle: When the domain constrains the range (256 possible bytes, not billions), the type should reflect that constraint. Don't reach for the general-purpose container when a fixed-size one fits exactly.
5. Box<str> when you're done building
clap stores argument names as Box<str>, not String:
enum Inner {
Static(&'static str),
Owned(Box<str>), // not String
}
// Construction:
pub(crate) fn from_string(name: String) -> Self {
Self::Owned(name.into_boxed_str())
}String is (pointer, length, capacity) — 24 bytes. Box<str> is (pointer, length) — 16 bytes. The difference is capacity — which matters when you're building the string (appending, pushing), but is wasted space once the string is done.
For a CLI with 50 arguments, each with a name and help string, that's 100 strings × 8 bytes = 800 bytes saved. Small. But it signals: "this string is finished, it won't be modified." The type communicates intent.
6. Cow means "probably won't allocate"
ripgrep's glob matcher:
// crates/globset/src/lib.rs
pub struct Candidate<'a> {
path: Cow<'a, [u8]>,
basename: Cow<'a, [u8]>,
ext: Cow<'a, [u8]>,
}On Unix, the path comes in as &[u8] and stays borrowed — zero allocation. On Windows, it needs conversion from UTF-16, so it allocates. Cow handles both without the Unix path paying for Windows's needs.
// crates/globset/src/pathutil.rs
pub(crate) fn normalize_path(path: Cow<'_, [u8]>) -> Cow<'_, [u8]> {
// If already normalized, returns the borrow unchanged
// If needs work, allocates and returns owned
}When to use `Cow`: Data that's usually borrowed but sometimes needs transformation. Path normalization, string escaping, case conversion.
When NOT to: When you always own the data (just use Vec/String) or always borrow it (just use &[u8]/&str). Cow adds a branch on every dereference. Only worth it when the common path avoids allocation.
7. Work in bytes internally, strings at the boundary
ripgrep's entire Matcher trait operates on &[u8]:
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error>;Not &str. Files on disk aren't always UTF-8. Filenames on Unix aren't always UTF-8. If your search tool takes &str, it silently skips every file with a non-UTF-8 byte — the user's file doesn't appear in results and they don't know why.
The printer converts to strings at the output boundary, using lossy conversion when needed. Internal processing is all bytes.
The rule: Your internal representation should be the least restrictive type that still lets you do the work. &[u8] can hold anything. &str can only hold valid UTF-8. Accept bytes inside, convert to strings only when you need to show something to a human.
---
Part II — Function Signatures
How you declare function parameters determines how pleasant your API is to use, how much code the compiler generates, and whether callers need to fight with ownership.
8. impl Into<T> in constructors, &T in methods
clap:
// Constructor: accepts anything convertible (stores it)
pub fn new(id: impl Into<Id>) -> Self { ... }
// Setter: accepts anything convertible (stores it)
pub fn arg(mut self, a: impl Into<Arg>) -> Self { ... }
// Query: borrows (just looking)
pub fn get_one<T>(&self, id: &str) -> Option<&T> { ... }
pub fn contains_id(&self, id: &str) -> bool { ... }Constructors and setters take impl Into<T> — they need to store the value, and Into lets callers pass &str, String, Id, whatever converts. Query methods take &str — they just need to compare, not keep.
Why not `impl Into<T>` everywhere? Generics. Every distinct input type generates a new monomorphized copy of the function. For a constructor called once, fine. For a hot lookup called thousands of times, you want one concrete function.
ripgrep:
// Constructor: flexible input
fn new(rawargs: impl IntoIterator<Item = impl Into<OsString>>) -> Parser { ... }Accepts &[&str], Vec<String>, std::env::args_os() — anything iterable with items convertible to OsString.
9. AsRef<T> for reading, Into<T> for storing
// ring_api — reads the file (doesn't store path)
pub fn with_pdb_file<P: AsRef<Path>>(file: P) -> Result<Self> { ... }
// ring_api — stores the string
pub fn file_name<T: Into<String>>(mut self, file_name: T) -> Self { ... }// ripgrep — just needs to look at the path
pub fn new<P: AsRef<Path> + ?Sized>(path: &'a P) -> Candidate<'a> { ... }The rule:
- `AsRef<T>` — "I need to look at it" (reading, comparing, hashing)
- `Into<T>` — "I'm going to keep it" (storing in a field)
- `&T` — "I know the exact type" (internal methods, hot paths, no generics)
10. ?Sized widens your API for free
pub fn new<P: AsRef<Path> + ?Sized>(path: &'a P) -> Candidate<'a>Without ?Sized, you can't pass &Path directly — only references to sized types like &PathBuf. With ?Sized, both &Path and &PathBuf work. One word in the bound, broader API.
11. The monomorphization firewall
clap:
pub fn new(name: impl Into<Str>) -> Self {
/// Non-generic to save code size.
fn new_inner(name: Str) -> Command {
Command { name, ..Default::default() }
}
new_inner(name.into())
}The outer function is generic — accepts &str, String, Str. But it's one line: convert and delegate. The inner function is monomorphic — compiled once, regardless of how many types you pass to new().
Without this, Command has 30+ fields, and rustc generates a complete copy of the constructor for every input type. The inner function prevents that.
The rule: When a generic function has a large body, factor it into a non-generic inner function. The generic wrapper does ONLY the type conversion. Free at the source level, saves real binary size.
12. Return &mut Self from builders when reuse matters
ripgrep's builders return &mut Self:
pub fn line_terminator(&mut self, lt: LineTerminator) -> &mut SearcherBuilder {
self.config.line_term = lt;
self
}This means you can reuse a builder:
let mut builder = SearcherBuilder::new();
builder.line_terminator(LineTerminator::crlf());
let searcher_a = builder.build(); // first config
builder.encoding(Some(Encoding::utf8())); // tweak
let searcher_b = builder.build(); // second config, builder still aliveclap's builders consume self:
pub fn arg(mut self, a: impl Into<Arg>) -> Self { ... }This makes the chain the only way to use it — you can't reuse a half-built Command. Good for clap's use case (you build one CLI definition and parse).
The decision: &mut Self when callers might build multiple variants from one builder. self when the builder is one-shot and you want to prevent partial use. Pick based on your expected usage, not a dogma.
13. #[must_use] on every method that returns Self
clap has 125+ #[must_use] annotations:
#[must_use]
pub fn arg(mut self, a: impl Into<Arg>) -> Self {
self.arg_internal(a.into());
self
}Without it, this compiles silently and does nothing:
let cmd = Command::new("app");
cmd.arg(Arg::new("input")); // DROPPED. cmd is unchanged.With #[must_use], the compiler warns. Zero cost. Catches a bug that's invisible at the call site and manifests as "my flag doesn't work" far away.
---
Part III — Traits
Traits are Rust's core abstraction mechanism. Professional trait design is about knowing what to require, what to provide, and when not to use a trait at all.
14. Minimum required surface, maximum provided convenience
ripgrep's Matcher trait has 2 required methods:
pub trait Matcher {
type Captures: Captures;
type Error: std::fmt::Display;
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error>;
fn new_captures(&self) -> Result<Self::Captures, Self::Error>;
// ~15 provided methods built on the two above:
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> { ... }
fn find_iter<F>(&self, haystack: &[u8], matched: F) -> Result<(), Self::Error> { ... }
fn captures_at(&self, ...) -> Result<bool, Self::Error> { ... }
fn replace_with_captures<F>(&self, ...) -> Result<(), Self::Error> { ... }
// ...
}Implementing a new regex backend requires 2 methods. Everything else comes for free. This is why ripgrep supports both Rust regex and PCRE2 — the barrier to adding a backend is trivially low.
ripgrep's Sink trait: 1 required method, 5 with defaults:
pub trait Sink {
type Error: SinkError;
fn matched(&mut self, searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error>;
// All optional with sensible defaults:
fn context(&mut self, ...) -> Result<bool, Self::Error> { Ok(true) }
fn context_break(&mut self, ...) -> Result<bool, Self::Error> { Ok(true) }
fn binary_data(&mut self, ...) -> Result<bool, Self::Error> { Ok(true) }
fn begin(&mut self, ...) -> Result<bool, Self::Error> { Ok(true) }
fn finish(&mut self, ...) -> Result<(), Self::Error> { Ok(()) }
}A minimal sink is 4 lines. A full-featured sink overrides what it needs. Progressive disclosure — start simple, add complexity only where you need it.
The failure mode: A trait with 12 required methods. Nobody implements it except you. The trait becomes ceremony — an abstraction that abstracts nothing.
15. Choose internal or external iteration based on your weakest implementor
ripgrep's Matcher uses callback-based (push) iteration:
fn find_iter<F>(&self, haystack: &[u8], matched: F) -> Result<(), Self::Error>
where F: FnMut(Match) -> bool;Not an iterator. Why? PCRE2 is a C library that uses internal iteration. If Matcher required returning an Iterator, PCRE2 couldn't implement it without deep contortions (channels, unsafe hacks). Rust's regex crate can do either style.
The rule: Design trait methods for the most constrained implementor you need to support. Convenience for consumers comes from adapters and provided methods, not from constraining the interface.
16. Generics on hot paths, trait objects on cold paths
ripgrep makes this choice deliberately:
| Where | Mechanism | Why |
|---|---|---|
Matcher | Generics | Hot path. find_at is called millions of times. Must inline. |
Sink | Generics | Hot path. matched() runs per line. Must inline. |
Flag | Trait objects | Cold path. Flag parsing happens once at startup. |
ParallelVisitor | Trait objects | Created once per thread, not per line. |
// Hot: generic — compiler inlines find_at into the search loop
fn search<M: Matcher>(matcher: &M, haystack: &[u8]) { ... }
// Cold: trait object — one copy of the parsing code, N flag implementations
static FLAGS: &[&dyn Flag] = &[
&CaseSensitive,
&Color,
&Encoding,
// ...
];The decision: Generics when the code runs per-item in a hot loop (you need inlining). Trait objects when the code runs once or per-thread (you want one copy of the function, not N monomorphized copies).
17. Closure wrappers as ergonomic adapters
ripgrep's Sink trait is powerful but verbose. For simple cases:
// The wrapper: a tuple struct holding a closure
pub struct UTF8<F>(pub F)
where
F: FnMut(u64, &str) -> Result<bool, io::Error>;
// It implements Sink — the search engine doesn't know the difference
impl<F> Sink for UTF8<F>
where
F: FnMut(u64, &str) -> Result<bool, io::Error>,
{
type Error = io::Error;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, io::Error> {
let matched = std::str::from_utf8(mat.bytes()).map_err(io::Error::error_message)?;
let line_number = mat.line_number().ok_or_else(|| ...)?;
(self.0)(line_number, matched)
}
}Usage:
searcher.search_path(&matcher, path, sinks::UTF8(|lnum, line| {
println!("{lnum}: {line}");
Ok(true)
}))?;The closure is zero-cost — no boxing, no dynamic dispatch. The compiler inlines it. The user writes 3 lines instead of implementing a 6-method trait.
Three variants exist: UTF8 (strict), Lossy (replaces invalid UTF-8), Bytes (raw &[u8]). Each wraps a closure and implements Sink.
The pattern: Powerful trait for experts. Closure adapter for common cases. Same search engine, same code path, same performance. Two entry points.
18. Associated types for compile-time pairing
ring_api:
pub trait Request {
type Body: Serialize;
type Response: for<'de> Deserialize<'de>;
fn endpoint(&self) -> Cow<str>;
fn body(&self) -> RequestBody<&Self::Body> { Default::default() }
}client.send(SubmitId { ... }) returns Result<SubmitResponse>. Not Result<serde_json::Value>. The response type is determined by the request type at compile time.
// You literally cannot mix up request and response types:
let response: SubmitResponse = client.send(submit_request)?; // ✓
let response: StatusResponse = client.send(submit_request)?; // compile errorWhen to use associated types: When two types are always paired (request/response, encoder/decoder, parser/ast-node). The pairing becomes a compile-time guarantee, not a runtime convention.
clap's trait hierarchy does the same:
pub trait Parser: FromArgMatches + CommandFactory + Sized {
fn parse() -> Self {
let matches = <Self as CommandFactory>::command().get_matches();
<Self as FromArgMatches>::from_arg_matches_mut(&mut matches)
.map_err(format_error::<Self>)
.unwrap_or_else(|e| e.exit())
}
// try_parse, parse_from, etc. — all provided, built from the two supertraits
}Parser composes CommandFactory (knows how to describe itself) with FromArgMatches (knows how to populate itself). The parse() method chains them — provided, not required.
19. The derive-generates-builder-calls pattern
clap's derive macros generate code that calls the builder API:
// What #[derive(Args)] generates:
impl clap::Args for Cli {
fn augment_args(cmd: Command) -> Command {
cmd.arg(
Arg::new("verbose")
.short('v')
.action(ArgAction::SetTrue)
)
}
}There is no "derive runtime." The derive macro is a code generator targeting the same builder API a human would write. This means:
- Builder and derive can be mixed in the same app
- One runtime, two entry points
- Bug fixes to the builder fix derive behavior automatically
- Users who outgrow derive drop down to builder without rewriting
The failure mode: Two separate systems — derive path with its own parsing logic, builder path with different logic. Two runtimes, two bug surfaces, permanent feature-parity struggle.
---
Part IV — Errors
20. Custom errors in libraries, anyhow in binaries
ripgrep:
- Library crates: Hand-written
Error+ErrorKindenum. Typed, matchable. - Binary crate:
anyhow::Resultfor ergonomic error chaining.
Library consumers can match on ErrorKind::Regex vs ErrorKind::InvalidLineTerminator. The binary can eprintln!("{:#}", err) and get a nice chain.
The pattern — Error wrapping ErrorKind:
// Every library crate follows this structure
pub struct Error {
kind: ErrorKind,
}
#[non_exhaustive] // can add variants without breaking semver
pub enum ErrorKind {
Regex(String),
NotAllowed(String),
InvalidLineTerminator(u8),
}
impl std::error::Error for Error {}
impl std::fmt::Display for Error { /* match on kind */ }The Error struct hides internals. ErrorKind is #[non_exhaustive] — future variants don't break downstream.
21. Separate user errors from programmer errors
clap:
- User errors (bad CLI input) →
Result<_, Error>with nice messages,
suggestions, usage
- Programmer errors (bad API usage) →
debug_assert!panics
A user typing --verbosee gets: "unexpected argument '--verbosee', did you mean '--verbose'?"
A programmer writing duplicate arg names gets a panic in debug builds:
// clap_builder/src/builder/debug_asserts.rs — 818 lines
// Runs only in #[cfg(debug_assertions)]
pub(crate) fn assert_app(cmd: &Command) {
// Checks for duplicate argument names
// Checks for conflicting short/long flags
// Checks positional ordering rules
// ...
}Zero cost in release. Comprehensive invariant checking in debug. Panics point to the programmer's code via #[track_caller].
22. Structured error context, not format strings
clap's errors carry data, not strings:
pub enum ContextKind {
InvalidArg,
InvalidValue,
SuggestedArg, // "did you mean --verbose?"
SuggestedValue,
Usage,
}
pub enum ContextValue {
String(String),
Strings(Vec<String>),
Number(isize),
StyledStr(StyledStr),
}A formatter reads this structure and renders it. Different formatters produce different output. Tests can assert err.get(ContextKind::SuggestedArg) without parsing strings.
The failure mode: format!("expected one of: {}", values.join(", ")). You can't programmatically extract the valid values. You can't test the suggestion without parsing the human-readable string.
23. Mechanical error context, not manual
parsel's derive macros wrap every field parse automatically:
// Generated code — you never write this by hand
#field_name: input.parse().map_err(|cause| {
::parsel::util::chain_error(cause, "MyEnum", "MyVariant", "field_name")
})?You never forget to add context because you never add it manually. The derive does it for every field, every variant, every time.
When multiple alternatives fail (parsing an enum), report the one that consumed the most input:
Err(max_by_key(left_error, right_error, |e| e.span().end()))"Expected ; after expression" (from the variant that got farthest) is more useful than "expected keyword if" (from the first variant tried).
24. Return bool from callbacks for flow control
ripgrep's Sink::matched returns Result<bool, Error>:
Ok(true)— keep searchingOk(false)— stop (enough results)Err(e)— stop (something broke)
The walker uses the same: WalkState::Continue / WalkState::Quit.
This is backpressure. The consumer controls the producer. No channels, no shared state. The search loop checks the return value and stops.
The amateur version: Callback returns (). Stopping requires AtomicBool checked periodically. Or the callback panics. Or you collect everything into a Vec and truncate.
25. Broken pipe as graceful exit
Every Unix CLI that pipes output must handle this:
// ripgrep — crates/core/main.rs
fn main() -> ExitCode {
match run(flags::parse()) {
Ok(code) => code,
Err(err) => {
for cause in err.chain() {
if let Some(ioerr) = cause.downcast_ref::<std::io::Error>() {
if ioerr.kind() == std::io::ErrorKind::BrokenPipe {
return ExitCode::from(0); // not an error
}
}
}
eprintln_locked!("{:#}", err);
ExitCode::from(2)
}
}
}rg pattern | head should exit cleanly, not print an error about broken pipe. This is consistently handled at every output path in the codebase.
---
Part V — Modules & Visibility
26. pub(crate) as your default
ripgrep has 457 uses of `pub(crate)`. Every item's visibility is a deliberate choice.
The Arg struct in clap:
pub struct Arg {
pub(crate) id: Id,
pub(crate) help: Option<StyledStr>,
pub(crate) action: Option<ArgAction>,
pub(crate) value_parser: Option<super::ValueParser>,
// ...
}Users interact through methods. Internal code (parser, validator, help renderer) accesses fields directly. Encapsulated externally, convenient internally.
The rule: Start private. Promote to pub(crate) when another module in the same crate needs it. Promote to pub only when it's part of the intentional API. Every pub is a semver commitment.
27. #![deny(missing_docs)] enforces the discipline
Every ripgrep library crate:
#![deny(missing_docs)]Every pub item must have a doc comment. If you can't explain why it's public, it probably shouldn't be.
The binary crate does NOT have this lint — internal code doesn't need the same documentation level. The distinction between library and application is enforced.
28. Facade crates hide workspace structure
ripgrep's grep crate — 90 lines total:
pub extern crate grep_cli as cli;
pub extern crate grep_matcher as matcher;
pub extern crate grep_printer as printer;
pub extern crate grep_regex as regex;
pub extern crate grep_searcher as searcher;Users add one dependency: grep. They never need to know about the 5 sub-crates. Internal crate boundaries can change without affecting downstream.
parsel does the same:
// parsel/src/lib.rs
pub use parsel_derive::{Parse, ToTokens, FromStr, Display};Users never write use parsel_derive::Parse. The proc-macro crate is an implementation detail.
29. Types carry behavior, functions wire types together
The difference between a 3000-line file of if-cascades and a professional codebase is WHERE the behavior lives.
Amateur: behavior lives in functions. Functions grow because they handle every combination of every option.
// Everything in one function
fn search_and_print(path: &Path, pattern: &str, opts: &Options) {
let content = read_file(path); // 50 lines
let matches = find_matches(content, pattern); // 100 lines of if/else
format_output(matches, opts); // 200 lines of if/else
}Professional: behavior lives on types. Functions wire types together.
// ripgrep — each type carries its own behavior
fn search_path(matcher: &impl Matcher, searcher: &mut Searcher,
printer: &mut impl Sink, path: &Path) {
searcher.search_path(matcher, path, printer)
}10 lines. Matcher handles matching. Searcher handles buffering and line detection. Sink handles output. The wiring function is trivial.
When a new feature arrives, you add a method to a type or a variant to an enum. You don't add another branch to a 500-line function.
---
Part VI — Macros
30. Macros are for trait impl boilerplate, not for logic
Across all four codebases, macros serve exactly three purposes:
1. Eliminating repetitive trait impls
parsel's impl_literal! generates 8 literal types, each with ~150 lines of trait impls (From, Into, PartialEq, Eq, Ord, Hash, Display, FromStr, ToTokens, Parse). 1200 lines from 120 lines of macro.
2. Test scaffolding
ripgrep's rgtest!:
rgtest!(r16, |dir: Dir, mut cmd: TestCommand| {
dir.create(".gitignore", "ghi/");
dir.create("ghi/toplevel.txt", "xyz");
cmd.arg("xyz").assert_err();
});Sets up temp directory, constructs test command, generates the #[test] function boilerplate.
3. Small DSLs for domain-specific notation
clap's arg!:
let arg = arg!(-o --output <FILE> "Output file");
// Expands to builder callsWhat macros are NOT used for: None of these codebases use macros for business logic, control flow, or conditional branching. No macro does what a function or generic type could do.
The rule: If your macro does anything beyond stamping out trait impls or test scaffolding, it should probably be a function. Macros are hard to debug, invisible to rust-analyzer, and impossible to step through. Use them for structure, not logic.
31. Derive macros should bound field types, not type parameters
parsel:
// Naive: bound type parameter (breaks with associated types)
where T: Parse
// Professional: bound the actual field type
where FieldType1: Parse, FieldType2: ParseThe add_bounds function in parsel_derive collects all field types and bounds those specifically. This handles associated types, complex generics, and type parameter indirection that the naive approach breaks on.
The #[parsel(recursive)] attribute excludes a field from the where clause, breaking infinite constraint cycles for recursive types:
#[derive(Parse)]
enum Expr {
Literal(LitInt),
Paren(#[parsel(recursive)] Box<Paren<Expr>>),
}Without the attribute, the where clause would require Box<Paren<Expr>>: Parse, which requires Expr: Parse, which requires Box<Paren<Expr>>: Parse — cycle.
32. Emit dummy impls on derive error
clap:
// clap_derive/src/dummies.rs
pub(crate) fn parser(name: &Ident) -> TokenStream {
quote!(
#[automatically_derived]
impl clap::Parser for #name {}
)
}When a derive macro encounters a parse error in attributes, it still emits dummy trait impls (with empty bodies). Without this, the user sees their one attribute error PLUS 50 cascading "trait not implemented" errors from everywhere the type is used. With dummies, they see one clear error.
---
Part VII — Configuration & Construction
33. Private config, public builder, frozen product
Every configurable type in ripgrep:
// 1. Private — nobody outside sees this
struct Config {
line_term: LineTerminator,
binary: BinaryDetection,
encoding: Option<Encoding>,
// ...12 more fields
}
impl Default for Config {
fn default() -> Config { /* sensible defaults */ }
}
// 2. Public — this is the configuration phase
pub struct SearcherBuilder {
config: Config,
}
impl SearcherBuilder {
pub fn new() -> SearcherBuilder {
SearcherBuilder { config: Config::default() }
}
pub fn line_terminator(&mut self, lt: LineTerminator) -> &mut SearcherBuilder {
self.config.line_term = lt;
self
}
// build() freezes the config
pub fn build(&self) -> Searcher {
Searcher { config: self.config.clone(), /* ... */ }
}
}
// 3. The product — immutable, ready to work
pub struct Searcher {
config: Config,
// runtime state...
}This pattern appears in SearcherBuilder, StandardBuilder, SummaryBuilder, JSONBuilder, PathPrinterBuilder, LineBufferBuilder, WalkBuilder, GlobBuilder, RegexMatcherBuilder — all identical structure.
Why three types:
- Config is an implementation detail (private)
- Builder is the mutable configuration phase (public, ergonomic)
- Product is the operational phase (public, immutable)
Users can't change config mid-operation. The builder can be reused to produce variants. The config can change internally without breaking the API.
34. Two-phase processing: validate cheap, then construct expensive
ripgrep:
Phase 1: CLI args → LowArgs (microseconds — string matching)
Phase 2: LowArgs → HiArgs (milliseconds — compile regexes, stat filesystem)If the user types rg --help, phase 1 detects SpecialMode::Help and short-circuits. Phase 2 never runs — no regex compilation, no filesystem access.
The rule: Separate "can I understand this input?" from "let me do the expensive work." If you discover a bad field after you've already compiled three regexes, you've wasted work.
35. Classify inputs by cost, dispatch cheapest-first
ripgrep's globset classifies glob patterns at construction time:
enum MatchStrategy {
Literal(String), // "target" → HashMap lookup, O(1)
Extension(String), // "*.o" → HashMap lookup, O(1)
Prefix(String), // "src/**" → string prefix check
Suffix { suffix: String },// "**.rs" → string suffix check
Regex, // complex pattern → full regex
}During matching, strategies run cheapest-first. A typical .gitignore with 20 patterns resolves 18 via hash lookups. Only the complex patterns hit regex.
The principle: When you have a hot path processing heterogeneous inputs, don't apply the general-case algorithm to everything. Classify at construction time. Dispatch by cost.
---
Part VIII — Testing
36. Tests that return Result, not panic
parsel:
#[test]
fn test_parse() -> anyhow::Result<()> {
let actual: Assignment = parsel::parse_str("foo = 42")?;
ensure!(actual.name == "foo", "expected 'foo', got '{}'", actual.name);
Ok(())
}The ? operator works. Parse errors, IO errors, assertion failures all flow the same way. Output is a clean error message, not a panic backtrace.
37. Roundtrip testing catches what unit tests miss
parsel verifies parse(print(x)) == x:
let ts = ast_original.to_token_stream();
let ast_parsed: Many<Item> = parsel::parse2(ts)?;
assert!(ast_parsed == ast_original);One property test replaces dozens of example tests and catches edge cases no human would think of. If your system has a natural roundtrip (serialize/ deserialize, encode/decode, parse/print), test the roundtrip.
38. Test behavior, not structure
parsel tests derive macros by testing what the generated code DOES:
#[derive(Parse)]
struct Assignment { name: Ident, eq: Token![=], value: LitInt }
let actual: Assignment = parsel::parse_str("foo = 42")?;
assert_eq!(actual.name, "foo");Not what tokens it generates. If you test the generated token stream, every whitespace change breaks every test — even though behavior is identical.
ripgrep's integration tests create real directory structures and run the real binary:
rgtest!(r16, |dir: Dir, mut cmd: TestCommand| {
dir.create(".gitignore", "ghi/");
dir.create("ghi/toplevel.txt", "xyz");
cmd.arg("xyz").assert_err();
});330 such tests, each linked to a GitHub issue. The test IS the regression.
39. Colocate tests with the code they test
ripgrep's flag definitions — each flag is immediately followed by its tests:
#[derive(Debug)]
struct CaseSensitive;
impl Flag for CaseSensitive {
fn is_switch(&self) -> bool { true }
fn name_long(&self) -> &'static str { "case-sensitive" }
fn update(&self, v: FlagValue, args: &mut LowArgs) -> anyhow::Result<()> { ... }
}
#[cfg(test)]
#[test]
fn test_case_sensitive() {
let args = parse_low_raw(None::<&str>).unwrap();
assert_eq!(CaseMode::Sensitive, args.case);
}112 tests in one file, each next to its flag definition. When you add a flag, you add its test right there. The pattern makes it nearly impossible to add a flag without testing it.
---
Part IX — Performance
40. #[inline] on small methods that cross crate boundaries
ripgrep: 199 #[inline] annotations, concentrated on:
Match::new(),Match::start(),Match::end()— millions of callsLineTerminator::as_byte()— once per lineByteSet::contains()— once per byteSinkdefault implementations — so they compile away
Without #[inline], the compiler can't inline across crate boundaries (unless using LTO). With it, the function body is available in the caller's codegen unit.
When NOT to: Large functions, same-crate functions (compiler handles those), functions called in one place. Over-inlining bloats code size and hurts instruction cache.
41. FlatMap for small N
clap:
pub(crate) struct FlatMap<K, V> {
keys: Vec<K>,
values: Vec<V>,
}For N < ~50 (typical CLI: 10-30 arguments), linear scan beats hashing. Hash maps pay: computation, bucket management, random access. Linear scan pays: one sequential read through cache-friendly memory.
42. No async where threads suffice
ripgrep uses std::thread::scope with crossbeam work-stealing deques. No tokio. No async-std.
File search is CPU-bound with blocking filesystem reads. Async is for IO multiplexing — thousands of network connections waiting for responses. Adding an async runtime here adds colored functions, lifetime constraints, and runtime initialization for zero benefit.
Thread count caps at 12:
std::thread::available_parallelism().map_or(1, |n| n.get()).min(12)Beyond 12, filesystem IO contention dominates.
---
Part X — Project Discipline
43. The compiler as quality gate
ring_api:
#![deny(unsafe_code)]
#![deny(clippy::result_unwrap_used)]
#![deny(clippy::option_unwrap_used)]
#![deny(clippy::missing_docs_in_private_items)]
#![deny(clippy::shadow_unrelated)]
#![deny(clippy::cast_precision_loss)]
#![deny(clippy::print_stdout)]
#![deny(variant_size_differences)]parsel:
#![forbid(unsafe_code)] // even stronger — can't #[allow] itThese aren't suggestions. They're compile errors. The lint config IS the quality policy.
Recommendations:
deny(unsafe_code)on every crate that doesn't need unsafe (most crates)deny(clippy::unwrap_used)on library code (tests get#[allow])deny(missing_docs)on public APIsforbidoverdenyfor hard rules (unsafe in a pure-logic crate)
44. #[non_exhaustive] on all public enums
ripgrep and clap both use #[non_exhaustive] on every public enum that might grow:
#[non_exhaustive]
pub enum ErrorKind { ... }
#[non_exhaustive]
pub enum ArgAction { ... }Adding a variant is not a semver break. Downstream match blocks need a _ => arm — but that's better than forcing a major version bump for every new variant.
45. Document what you chose NOT to do
parsel:
/// `Separated` can't be `Default` because that would mean an empty sequence.
/// It can't be `DerefMut` because users could `.pop()` the last element.
/// `FromIterator` is missing because the input iterator could be empty.ripgrep:
// I guess memory maps on macOS aren't great. Should re-evaluate.A new contributor sees Separated without DerefMut. "Must be an oversight." They add it. Users call .pop(). The non-empty invariant dies. Without the comment, the "fix" looks helpful. With the comment, it's obviously wrong.
The rule: When you deliberately omit an impl, trait, or feature, explain why. Future readers will encounter the absence and wonder.
46. Own your critical path
BurntSushi wrote: regex, memchr, aho-corasick, globset, ignore, termcolor, walkdir, bstr, same-file. Every dependency between the user's command and the search output.
ring_api uses reqwest, serde, serde_json — because HTTP and JSON are not its core competency.
The rule: Own what's between your user's input and output. Depend on others for everything outside that path.
47. FromStr + Display before serde
ring_api:
impl FromStr for NodeId { /* parse "A:42:_:ALA" */ }
impl Display for NodeId { /* format "A:42:_:ALA" */ }
// serde delegates:
impl Serialize for NodeId {
fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self) // → Display
}
}FromStr and Display are testable without serde. They work in CLI args, log messages, error formatting — contexts where serde doesn't exist.
If you implement parsing inside a serde visitor, your type's string representation is trapped in the serialization framework. You can't parse the string without constructing a deserializer.
---
Appendix A — The Abstraction That Eliminates Code
parsel's core idea: a Rust struct is a sequence (parse fields left-to-right). A Rust enum is an alternation (try variants top-to-bottom).
#[derive(Parse, ToTokens)]
struct Assignment {
name: Ident,
eq: Token![=],
value: LitInt,
}No grammar file. No parser combinators. The type IS the grammar. The derive macro generates the parser from the struct definition.
Composable types handle grammar concepts:
| Type | Grammar | Example |
|---|---|---|
Paren<T> | '(' T ')' | (42) |
Bracket<T> | '[' T ']' | [true] |
Separated<T, P> | T (P T)+ | a::b::c |
Punctuated<T, P> | T (P T)* P? | 1, 2, 3, |
Many<T> | T* | foo bar baz |
LeftAssoc<O, R> | left-assoc binary | 1 + 2 + 3 |
RightAssoc<O, L> | right-assoc binary | a = b = c |
Maybe<P, T> | (P T)? | -> RetType |
Either<L, R> | L / R | alternation |
The derive macros are ~500 LOC total. Trivial — because the types do the work.
The principle: The right abstraction doesn't add a layer — it removes one. If you're maintaining parallel structures (grammar + AST, schema + types), ask: can one generate the other?
---
Appendix B — Lint Configuration Reference
Recommended starting point, drawn from these codebases:
// Library crate root
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(missing_debug_implementations)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::print_stdout)]
#![deny(clippy::print_stderr)]
#![deny(clippy::dbg_macro)]
#![warn(clippy::todo)]
#![warn(clippy::unimplemented)]
#![warn(clippy::cast_possible_truncation)]
#![warn(clippy::cast_precision_loss)]
#![warn(clippy::implicit_clone)]
#![warn(clippy::inefficient_to_string)]
#![warn(clippy::enum_glob_use)]For workspace-wide lints (clap pattern):
# Cargo.toml (workspace root)
[workspace.lints.clippy]
unwrap_used = "deny"
print_stdout = "deny"
dbg_macro = "deny"
todo = "warn"
cast_possible_truncation = "warn"
# ...
# Sub-crate Cargo.toml
[lints]
workspace = true---
Source codebases: ripgrep v15.1.0 (~46K LOC), clap v4.5.60 (~41K LOC), parsel v0.16.0 (~6K LOC), ring_api v0.1.0 (~2.3K LOC). All code examples reference actual files in these repositories.
Implementer Prompt Template
Fill in placeholders. Pass as text in a teams({ action: 'delegate', tasks: [...] }) call.
First Implementation
You are an implementer. Your working directory is {{WORKING_DIRECTORY}}.
## Task
{{TASK_DESCRIPTION}}
## Context
{{ADDITIONAL_CONTEXT}}
## Before Starting
If anything is unclear — requirements, approach, dependencies, assumptions — ask now. Raise concerns before starting work.
## Process
1. Read the nearest AGENTS.md in the target code folder.
2. Read the codebase to understand existing patterns.
3. Implement exactly what the task specifies — nothing more.
4. Write tests where appropriate.
5. Run tests. Verify they pass.
6. Commit with a clear message.
7. Self-review:
- Completeness: every requirement covered? Edge cases?
- Quality: best work? Names clear? Clean and maintainable?
- Discipline: no overbuilding? Only what was requested? Existing patterns followed?
- Testing: tests verify behavior, not just mocks? Comprehensive?
8. Fix anything found in self-review.
9. Report: what was implemented, what was tested, files changed, concerns.
Do NOT use the code-review skill. Do NOT create review tickets. A separate reviewer handles that.Fix Pass (With Review Feedback)
You are an implementer. Your working directory is {{WORKING_DIRECTORY}}.
## Task
{{TASK_DESCRIPTION}}
## Review Feedback From Previous Iteration
The reviewer found these issues. Fix ALL of them:
{{REVIEW_FEEDBACK}}
## Process
1. Read the review feedback carefully.
2. Fix every issue listed.
3. Run tests. Verify they pass.
4. Commit your fixes.
5. Self-review the fixes.
6. Report: what was fixed, test results, files changed, concerns.
Do NOT use the code-review skill. Do NOT create review tickets. A separate reviewer handles that.Reviewer Worker Prompt Template
Fill in every placeholder before dispatching this prompt to a reviewer worker.
---
You are a code reviewer. Your job is to produce a rigorous, layered review and output a tk review ticket.
Task
Review the implementation of: {{TASK_DESCRIPTION}}
Working Directory
{{WORKING_DIRECTORY}}Diff Range
BASE: {{BASE_SHA}}
HEAD: {{HEAD_SHA}}To see what changed:
git diff {{BASE_SHA}}..{{HEAD_SHA}}Review Layers to Apply
{{REVIEW_LAYERS}}
<!-- Fill in one or more of:
- Base only: "Run the 6-pass base (90-04-review). No additional layers."
- Base + Rust: "Run the 6-pass base (90-04-review). Load the Rust rubric from 80-10-rust."
- Base + Architecture: "Run the 6-pass base (90-04-review). Load 30. and 40. architecture lenses."
- Base + Spec: "Run the 6-pass base (90-04-review). Load 15-02-spec-divergence. The spec/ticket is: <id or path>."
- Full stack: "Run the 6-pass base (90-04-review). Load 80-10-rust, 30., 40., and 15-02-spec-divergence."
-->
Instructions
1. Load 90-04-review from the playbook. Run all six passes in order. 2. Load each layer listed in Review Layers above. 3. Run verification commands and paste actual output. 4. Create a tk review ticket:
tk new --type review --tags review5. Write the ticket with YAML frontmatter:
---
score: <1-10>
changes_requested:
- "issue description"
tags: [review]
---6. Include body sections: ## Suggest Fixing, ## Possible Simplifications, ## What Works Well, ## Verification. 7. Report the ticket ID when done.
Scoring Reminder
- 10/10 = zero
changes_requested. If nothing to fix, score is 10. - Empty
changes_requestedwith score < 10 is contradictory — fix the score. - Be harsh. A 9/10 with real issues is dishonest. Score what you find.
AGENTS.md and AGENTS.md Templates
These templates help you set up a repo wiki that agents can follow.
AGENTS.md (Symlink)
Make AGENTS.md a symlink to AGENTS.md:
# From within docs/
ln -s AGENTS.md AGENTS.mdWhy: some tools ignore AGENTS.md when it contains @AGENTS.md. A symlink avoids this.
docs/AGENTS.md (Actual Instructions)
Copy and customize:
# Agent Instructions: [Project Name]
## 00.00 Johnny Lookup
If the human gives you only an ID like `20.01` (or `2001`), treat it as a playbook call:
- Replace dot with hyphen: `20.01` → `20-01`
- Find `docs/playbook/**/20-01-*/SKILL.md`
- Read it
- Follow it literally
---
## Wiki Operations
### Progressive Disclosure
Read only what you need.
- Overview → `docs/README.md`
- Feature area → `docs/features/NN-area/README.md`
- Spec/plan → `docs/features/NN-area/NN.NN-*-spec.md` / `NN.NN-*-plan.md`
- Architecture/research → `docs/reference/`
- Process/tooling → `docs/playbook/`
### Open Questions
Use Obsidian comments with emoji + block IDs:
%% 🙋♂️ Human task/question %% ^q-scope-topic
%% 🤖 Agent question (waiting on human) %% ^q-scope-topic
%% ✅ Question → Answer %% ^q-scope-topic
Rules:
- Blank line between questions (Obsidian merges adjacent comments).
- Every question needs a block ID (`^q-scope-topic`).
- Last emoji decides whose turn it is.
### Ticketing (tk)
Use `tk` for non-trivial work. Close the ticket before committing.
ID=$(tk create "Short description" -t task -p 1 --tags tag1,tag2 -d "Details") && tk start $ID
### Changelog (tinychange)
Log changes with `tinychange`.
tinychange -I new -k <fix|feat|docs|refactor|...> -m "t-XXXX: message" -a AUTHOR tinychange merge
Core Principles
1. Progressive Disclosure
Load only what's needed:
User asks about auth → Read features/10-core/README.md
User asks about login → Read features/10-core/10-01-auth-spec.md
User asks for overview → Read README.md only2. Johnny Decimal Structure
Organize features, playbook, and reference docs using Johnny Decimal (johnnydecimal.com).
Hard rules (avoid drift):
- Use two-digit decimals everywhere:
NN.NN(NOTNN.N, NOTNN, NOT01without.01). - Features: folder
docs/features/NN-name/, filesNN.NN-*-spec.mdandNN.NN-*-plan.md. - Playbook: folder
docs/playbook/NN-area/, entriesNN.NN-topic/SKILL.md. - Reference: folder
docs/reference/NN-area/, filesNN.NN-topic.md.
Johnny lookup flow (common):
- If the human says
20.01(or2001) with no other context, interpret it as "open playbook section 20.01". - Replace dot with hyphen:
20.01→20-01 - Locate it by directory prefix (do not guess the topic slug):
docs/playbook/**/20-01-*/SKILL.md- If multiple matches exist, pick the closest match by area/README context and link to the others.
Example:
docs/features/10-core/
├── README.md
├── 10-01-auth-spec.md
└── 10-01-auth-plan.mdExample playbook/reference naming:
docs/playbook/20-git/
├── 20-01-methodic-rebase-merge/SKILL.md
└── 20-04-post-merge-hygiene/SKILL.md
docs/reference/01-design/
├── 01-07-game-design.md
└── 01-16-ticket-metadata-audit.mdJohnny decimal drift to watch for:
- Feature specs named
10.01but reference docs named01(missing decimals) → fix reference docs to01.NN-*. - Inconsistent padding (
1.01vs01.01) → always pad to 2 digits.
Migration: fixing `01`-only reference files: 1. Create a tk ticket for the migration (renames touch many links). 2. Rename files to NN.NN-topic.md (choose an unused .NN in that area). 3. Update all Obsidian wiki links ([[...]]) that referenced the old filename/path. 4. Add tinychange -k docs entry for the rename.
Migration rule: If you rename docs for Johnny compliance, update all wiki links, record a tk ticket, and add a tinychange entry (usually docs kind).
Quick audit (optional):
# Find reference/playbook files missing an NN.NN prefix (heuristic)
rg --files docs/reference docs/playbook | rg -v "/[0-9]{2}\.[0-9]{2}-"
# Find feature docs missing an NN.NN prefix (heuristic)
rg --files docs/features | rg -v "/[0-9]{2}\.[0-9]{2}-"3. Wiki Links Everywhere
All references use [[wiki-links]]. Broken links = sync signal.
[[features/10-core/10-01-auth-spec|Login Flow]]
[[reference/architecture#auth-middleware|Auth Middleware]]4. Task Tracking with Obsidian Comments
Track open questions using hidden comments with emoji prefixes and block references. Multi-line is allowed if it improves readability.
%% 🙋♂️ Human question/task %% ^q-scope-descriptor
%% 🤖 Agent question waiting on human %% ^q-scope-question
%% ✅ Question here → Answer here %% ^q-scope-resolvedCRITICAL: Separate each question with a blank line. Obsidian treats consecutive lines as a single block; only the last block ID works.
Format components:
🙋♂️= human wrote this → AGENTS SHOULD ACTION/ANSWER🤖= agent wrote this → AGENTS MUST SKIP (waiting for human)✅= resolved → no action needed^q-{scope}-{descriptor}= block ID for Obsidian navigation
WHO ANSWERS WHAT:
| Emoji | Who wrote it | Who should answer/action |
|---|---|---|
| 🙋♂️ | Human | Agent (this is work for you!) |
| 🤖 | Agent | Human (skip this, you asked it) |
| ✅ | Resolved | No one |
Conversation threading: Questions can have inline replies. The LAST emoji determines whose turn:
%% 🤖 Should we cache? 🙋♂️ yes 🤖 what limit? %% ^q-cacheLast emoji is 🤖 → Human's turn. When ✅ → Done.
Block ID convention: ^q-{scope}-{descriptor}
^q-auth-oauth(auth feature, OAuth question)^q-tabs-persist(tabs feature, persistence question)
Workflow:
- Agent adds
🤖question → human answers (agent skips these) - Human answers → convert to
🙋♂️(now actionable by agent) or✅(resolved) - Human adds
🙋♂️task → agent should action this - Resolved format:
%% ✅ question → answer %% ^q-id
Linking to questions:
[[features/10-core/10-01-auth-spec#^q-auth-oauth|OAuth question]]Search in Obsidian: Search for the emoji.
Find via terminal:
rg "🙋♂️" docs/ # human tasks
rg "🤖" docs/ # agent questions
rg "✅" docs/ # resolved
rg "%% .*%%$" docs/ # missing block IDs (lines ending with %%)Agent responsibility: Add block IDs to any question missing one. Generate the ID from the file's feature/spec and the question topic:
%% 🤖 how to handle OAuth? %% → missing block ID
%% 🤖 how to handle OAuth? %% ^q-auth-oauth → fixed5. Changelog Protocol
Update changelog.md via tinychange. Do not hand-edit.
Setup (once):
tinychange initAdd entry (interactive):
tinychangeAdd entry (scripted — preferred for agents):
tinychange -I new -k <fix|test|chore|security|feat|docs|refactor|perf> -m "Your change message" -a AUTHORInclude the tk ticket ID in the message when available (e.g., "t-9cdc: Add feature X").
Merge entries into docs/changelog.md:
tinychange mergeEnsure tinychange.toml points to docs/changelog.md and uses Keep a Changelog format.
6. Task Tracking with tk
All non-trivial work is tracked via tk (https://github.com/wedow/ticket). A tk ticket is the execution-level unit of work.
Small-change exemption (all must be true): one file, 10 lines or fewer (excluding whitespace-only), and docs-only or comment/typo-only changes. Otherwise, create a ticket.
One-liner to create + start + template a ticket:
ID=$(tk create "Short description of work" -t task -p 1 --tags tag1,tag2 -d "Longer description") && tk start $ID && printf '\n## Goal\nWhat outcome must be achieved.\n\n## Acceptance Criteria\n- [ ] Observable completion conditions\n\n## Verification\n- [ ] Commands, checks, or manual steps\n\n## Worktree\n- .\n' >> .tickets/$ID.mdTicket body template:
## Goal
What outcome must be achieved.
## Scope
What is included.
## Out of Scope
What is explicitly excluded.
## Acceptance Criteria
- [ ] Observable completion conditions.
## Verification
- [ ] Commands, checks, or manual steps.
## Risks
- [ ] Risk and mitigation.
## Related Files
- `path/to/file`
## Links
- [label](url)
## Worktree
- `.` or `.worktrees/the-tree`Lifecycle:
tk create→tk start <id>→ work →tk close <id>before committing- Include ticket IDs in spec/plan headers and in
tinychangemessages tk listto see open tickets,tk list --status closedfor closed
Linking conventions:
- Spec header includes related tk ticket IDs
- tinychange messages include tk ID (e.g.,
t-9cdc: add salvage system)
Open Questions System
Track open questions in Obsidian comments so they are searchable, linkable, and resolved in context.
- Reply inline until resolved.
- Multi-line is OK when it improves clarity.
- You can use the same markers in code comments (syntax varies by language).
Comment Format
%% 🙋♂️ Human question/task %% ^q-scope-topic
%% 🤖 Agent question waiting on human %% ^q-scope-topic
%% 🤖 Agent question waiting on human 🙋♂️ human answers %% ^q-scope-topic
%% 🤖 Agent question waiting on human 🙋♂️ human answers 🤖 asks more 🙋♂️ sure why not %% ^q-scope-topicWhen done, mark it resolved in place (optionally copy it to a question archive note such as docs/playbook/10-docs/10-02-question-archive/SKILL.md).
%% ✅ Question here → Answer here %% ^q-scope-topicMarkers
| Marker | Meaning | Who acts next |
|---|---|---|
| 🙋♂️ | Human wrote this | Agent acts |
| 🤖 | Agent wrote this | Human responds |
| ✅ | Resolved | - |
Rules
1. Blank line between questions (Obsidian merges adjacent comments). 2. Every question needs a block ID (^q-scope-topic). 3. Last emoji decides whose turn it is. 4. ✅ means resolved.
Finding Questions
Terminal search:
rg "🙋♂️" docs/
rg "🤖" docs/
rg "✅" docs/
rg "%% .*%%$" docs/ # missing block IDs (lines ending with %%)
rg "🙋♂️" src/Obsidian search:
🙋♂️for human tasks🤖for agent questions
Linking to a Question
[[features/10-core/10-06-client-prediction-spec#^q-prediction-fixed-tick|Prediction tick question]]Open Questions Index (Dataview)
Use this in docs/open-questions.md to group open questions by who responds next.
const pages = dv.pages('"features" or "reference" or "playbook"');
const results = [];
function getLastResponder(text) {
const emojis = [...text.matchAll(/🙋♂️|🤖/g)];
if (emojis.length === 0) return null;
return emojis[emojis.length - 1][0];
}
function listItem(r) {
return `[[${r.page.file.path}#^${r.blockId}|${r.page.file.name}]] - ${r.question}`;
}
const ignored = ['open-questions-system', 'AGENTS'];
for (const page of pages) {
if (ignored.some(pattern => page.file.path.includes(pattern))) continue;
const content = await dv.io.load(page.file.path);
const lines = content.split('\n');
lines.forEach((line, idx) => {
if (line.includes('✅')) return;
const match = line.match(/%%\s*(🙋♂️|🤖)\s+(.+?)\s+%%\s+\^(q-[\w-]+)/);
if (match) {
const question = match[2];
const blockId = match[3];
const lastResponder = getLastResponder(line);
results.push({ page, question, blockId, line: idx + 1, lastResponder });
}
});
}
const needsHuman = results.filter(r => r.lastResponder === '🤖');
const needsAgent = results.filter(r => r.lastResponder === '🙋♂️');
if (needsHuman.length > 0) {
dv.header(3, "🤖 Last: Needs Human Response");
const now = dv.date("today");
const recent = new Map();
const older = new Map();
for (const r of needsHuman) {
const m = r.page.file.mtime ?? now;
const daysAgo = Math.floor(now.diff(m, "days").days);
if (daysAgo <= 6) {
const key = m.toFormat("yyyy-LL-dd");
if (!recent.has(key)) recent.set(key, { date: m, items: [] });
recent.get(key).items.push(r);
} else {
const weekKey = `${m.weekYear}-W${String(m.weekNumber).padStart(2, '0')}`;
if (!older.has(weekKey)) older.set(weekKey, { date: m, items: [] });
older.get(weekKey).items.push(r);
}
}
const recentGroups = Array.from(recent.values()).sort((a, b) => b.date - a.date);
recentGroups.forEach((group, index) => {
dv.header(4, group.date.toFormat("ccc yyyy-LL-dd"));
dv.list(group.items.map(listItem));
if (index < recentGroups.length - 1) dv.el("hr", "");
});
if (recentGroups.length > 0 && older.size > 0) dv.el("hr", "");
const olderGroups = Array.from(older.values()).sort((a, b) => b.date - a.date);
olderGroups.forEach((group, index) => {
const start = group.date.startOf("week");
const end = group.date.endOf("week");
const weekLabel = `${group.date.weekYear}-W${String(group.date.weekNumber).padStart(2, '0')}`;
dv.header(4, `Week ${weekLabel} (${start.toFormat('LLL dd')}–${end.toFormat('LLL dd')})`);
dv.list(group.items.map(listItem));
if (index < olderGroups.length - 1) dv.el("hr", "");
});
}
if (needsAgent.length > 0) {
dv.header(3, "🙋♂️ Last: Agent Should Pick Up");
dv.list(needsAgent.map(listItem));
}Platform Detection
Skills must adapt to the platform in use. Detect from context and apply the right tooling.
Detection Rules
| Signal | Platform | Tooling |
|---|---|---|
User mentions GitHub, task from gh issue, repo on github.com | GitHub | gh CLI |
| User mentions Forgejo, task from Forgejo, repo on Forgejo instance | Forgejo | Forgejo API / tea CLI |
No remote platform mentioned, working from tk tickets | Local tk | tk CLI only |
Ambiguous? Ask: "Are we working with GitHub, Forgejo, or local tickets?"
Platform Command Map
Issues / Tasks
| Action | GitHub | Forgejo | Local tk |
|---|---|---|---|
| View issue | gh issue view <N> | tea issue view <N> or Forgejo API | tk show <id> |
| List issues | gh issue list | tea issue list or Forgejo API | tk ls |
| Close issue | gh issue close <N> | Forgejo API | tk close <id> |
| Search issues | gh issue list --search "query" | Forgejo API | tk ls + grep |
Reviews
| Action | GitHub | Forgejo | Local tk |
|---|---|---|---|
| Post review | gh pr review / gh pr comment | Forgejo API comment on PR | todos_oneshot tagged review |
| Request changes | gh pr review --request-changes | Forgejo API review | tk add-note <id> |
| Approve | gh pr review --approve | Forgejo API review | tk close <id> |
Pull Requests / Merge Requests
| Action | GitHub | Forgejo | Local tk |
|---|---|---|---|
| Create PR/MR | gh pr create | tea pr create or Forgejo API | N/A (merge locally) |
| View PR/MR | gh pr view <N> | Forgejo API | N/A |
| Get diff | gh pr diff <N> | Forgejo API | git diff main..HEAD |
| Merge | gh pr merge --squash | Forgejo API merge | git merge --ff-only |
| Link issues | Closes #X in body | Closes #X in body | tk dep/tk link |
URLs
| Context | GitHub | Forgejo | Local tk |
|---|---|---|---|
| PR link | https://github.com/OWNER/REPO/pull/N | https://forgejo.instance/OWNER/REPO/pulls/N | ticket ID |
| Issue link | https://github.com/OWNER/REPO/issues/N | https://forgejo.instance/OWNER/REPO/issues/N | ticket ID |
Issue Linking Keywords
Both GitHub and Forgejo recognize: Closes #X, Fixes #X, Resolves #X
For local tk: use tk dep <child> <parent> or tk link <a> <b>.
Platform-Conditional Sections in Skills
When a skill says "post review to platform", apply:
- GitHub:
gh pr comment <N> --body "..."orgh pr review <N> - Forgejo: Forgejo API
POST /repos/{owner}/{repo}/pulls/{index}/reviews - Local tk:
todos_oneshot(title: "Review: <branch>", tags: "review")— review lives as a ticket
When a skill says "create PR/MR", apply:
- GitHub:
gh pr create --title "..." --body "..." - Forgejo:
tea pr createor Forgejo API - Local tk: no PR needed — merge locally with
git merge --ff-only
When a skill says "verify issue linking", apply:
- GitHub:
gh pr view <N> --json closingIssuesReferences - Forgejo: check PR body for
Closes #X - Local tk:
tk show <id>— check deps/links
Postmortems
When things go wrong — agent mistakes, system failures, expensive debugging loops — capture the incident so future sessions don't repeat it.
Storage
Postmortems are stored as tickets tagged postmortem. Create with:
todos_oneshot(
title: "Postmortem: <topic>",
description: "<postmortem content>",
tags: "postmortem",
type: "task"
)Postmortem AGENTS.md
# Agent Instructions: docs/postmortems
Purpose: incident postmortems and learnings.
Naming:
- `YYYY-MM-DD-HHMM-topic.md` (24h time, local).
Rules:
- Follow `/AGENTS.md` and `docs/AGENTS.md`.
- Include a `tk` ticket ID in the header when available.
- Cover summary, timeline, root cause, fix, and prevention.Postmortem Template
# Post Mortem: [Title] ([Date])
## What Was Requested
What was the intended outcome.
## What Actually Happened
### Phase 1: [Name] (Good/Bad)
Chronological narrative of what occurred.
### Phase N: [Name]
Continue phases as needed.
## Root Causes
### 1. [Root Cause Title]
Explain the root cause with specifics. Include code examples showing the wrong vs right approach.
## Cost
- Compute/time spent
- Work lost or reverted
- Impact on schedule
## Lessons
1. **[Lesson title]** - Specific, actionable takeaway
2. **[Lesson title]** - Another takeaway
## Prevention
What changes to process, tooling, or rules prevent recurrence.When to Write a Postmortem
- Agent destroyed work through incorrect cleanup (deletion, revert)
- Agent bypassed existing systems (duplicated instead of reusing)
- Expensive debugging loop (>30 minutes on something avoidable)
- Feature shipped broken because verification was skipped
- Parallel agent coordination failure
Referencing Postmortems
Link postmortems from the relevant AGENTS.md or spec to ensure agents encounter the lesson at the point of danger:
## SpacetimeDB: Search Before You Implement (CRITICAL)
> **Post-mortem:** See postmortem ticket — Agent skipped codebase exploration, directly mutated velocity, broke physics.Templates
Spec File Template
# NN.NN Spec Name
> **Feature Area:** [[../README|NN-Feature-Area-Name]]
> **Feature ID:** NN.NN
> **Ticket:** tk-000 (optional)
## Behavior
### Contract
- **Input:** description
- **Output:** description
- **Preconditions:** what must be true before
- **Postconditions:** what will be true after
### Scenarios
- When X happens → Y should occur
- When edge case → handle gracefully
## Decisions
### Assumptions
1. [Assumption] - [implication if wrong]
2. [Assumption] - [implication if wrong]
### Failure Modes
| Failure | Detection | Recovery |
|---------|-----------|----------|
| [scenario] | [how to detect] | [what to do] |
### ADR-1: Decision Title
- **Status:** Proposed | Accepted | Deprecated | Superseded
- **Context:** Why this decision was needed
- **Decision:** What we decided
- **Consequences:** What happens as a result
- **Alternatives:** What we considered and rejected
### Open Questions
%% 🤖 Question needing resolution? %% ^q-specname-topic
## Integration
### Dependencies
- [[path/to/spec|Display Name]] - what we need from it
### Consumers
- [[path/to/spec|Display Name]] - what uses us
### Diagramgraph LR A --> B B --> C
Plan File Template
# NN.NN Plan Name
> **Feature Area:** [[../README|NN-Feature-Area-Name]]
> **Related Spec:** [[NN.NN-spec-name]] (optional)
> **Ticket:** tk-000 (optional)
## Goal
What this plan achieves.
## Prerequisites
- [ ] Dependency 1
- [ ] Dependency 2
## Implementation Steps
### Phase 1: [Name]
- [ ] Step 1
- [ ] Step 2
### Phase 2: [Name]
- [ ] Step 3
- [ ] Step 4
## Files to Modify
| File | Changes |
|------|---------|
| `path/to/file` | Description of changes |
## Testing Strategy
How to verify the implementation works.
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| [What could go wrong] | [How to prevent/handle] |
## Open Questions
%% 🤖 Implementation question? %% ^q-planname-topicBulletproof Plan Guidelines (Agent-Proof)
Plans are executed by agents that take everything literally. Ambiguity causes expensive mistakes. Use these rules to make plans unambiguous and self-checking.
Rule 1: Naming consistency (CRITICAL)
- Pick ONE name for each new type/table/function/file and use it everywhere.
- If you rename during planning, update the entire doc (do not leave old names around).
Rule 2: Migration semantics (CRITICAL)
- For any new thing that overlaps an existing thing, explicitly state one of:
- REPLACES (old is removed)
- EXTENDS (old stays, new adds behavior)
- COEXISTS WITH (both exist; explain selection rules)
Rule 3: State transitions (CRITICAL)
- For every boolean/enum state, document ALL transitions:
- trigger condition
- which system/reducer executes it
- side effects (what else changes)
Rule 4: Ownership (CRITICAL)
- If touching shared state (DB rows, physics-owned state, caches), state WHO OWNS IT and HOW it may be modified.
- Include a guardrail: "NEVER mutate X directly; always go through Y".
Rule 5: Error paths (required)
- List failure conditions and required cleanup/rollback for each.
Rule 6: Concrete file list (required)
- List EVERY file to create/modify/delete.
- For modified files: list the exact functions to change and what changes.
Rule 7: Tests (required)
- Name test cases, describe assertions, and specify file locations.
Rule 8: Danger Dragons (required)
- Add an explicit "how it should NOT work" section listing common mistakes.
- Include the WHY (what breaks / what incident it prevents).
Plan checklist (before execution):
- [ ] Terminology section exists and is consistent
- [ ] Migration semantics stated for every overlapping construct
- [ ] State transition table exists for every state field
- [ ] Error paths listed
- [ ] File list is complete
- [ ] Test cases are named
- [ ] Danger Dragons section exists
Suggested plan add-ons (drop into any plan):
## Terminology
- `ThingA`: definition
- `ThingB`: definition
## Migration
- `OldThing` is REPLACED by `NewThing`.
- References to `OldThing` are updated in: `path/a`, `path/b`.
## State Transitions
| From | To | Trigger | Owner/System | Side Effects |
|------|----|---------|--------------|--------------|
| ... | ...| ... | ... | ... |
## Error Paths
| Condition | Detection | Response | Cleanup |
|----------|-----------|----------|---------|
## Danger Dragons (How it should NOT work)
- ❌ Don't do X
- WHY: breaks YFeature Area README Template
# NN Feature Area Name
> Brief description of what this feature area covers.
## Goal
What this feature area achieves.
## Specs
| Spec | Description | Status |
|------|-------------|--------|
| [[NN.NN-spec-name]] | Brief description | Status |
## Plans
| Plan | Description | Status |
|------|-------------|--------|
| [[NN.NN-plan-name]] | Implementation approach | Status |
## Shared Decisions
ADRs that apply to all specs in this feature area:
- **Decision:** Brief summary
## Integration Points
This feature area connects to:
- [[../20-other-area/README|Other Feature Area]] - howAGENTS.md Setup (Symlink)
AGENTS.md should be a symlink to AGENTS.md, not a file with content:
# From within docs/ directory
ln -s AGENTS.md AGENTS.mdThis ensures AGENTS.md and AGENTS.md always have identical content. All actual instructions go in AGENTS.md.
AGENTS.md Template
Agent instructions belong here:
# Agent Instructions: [Project Name]
[Project-specific rules here...]
## 00.00 Johnny Lookup
If the human gives you simply an ID like `20.01` (or `2001`), treat it as a **playbook call**.
- Replace dot with hyphen: `20.01` → `20-01`
- Locate and read the matching playbook entry: `docs/playbook/**/20-01-*/SKILL.md`
- Follow the instructions literally.
- If multiple matches exist, list them and pick the most relevant by context.
---
## Wiki Operations
**IMPORTANT:** When working with this wiki, use the `obsidian-plan-wiki` skill if available. It provides the full spec format and workflow patterns.
This documentation uses Obsidian vault format. Follow these patterns.
### Change Tracking (No LWW)
Specs, plans, and code are updated intentionally and together. Track changes via `tk` tickets and `tinychange` entries.
### Ticketing (tk)
All non-trivial work is tracked via `tk` (https://github.com/wedow/ticket).
**Small-change exemption** (all must be true): one file, ≤10 lines, docs-only or comment/typo-only. Otherwise, create a ticket.
Oneshot: `ID=$(tk create "Description" -t task -p 1 --tags tag1,tag2 -d "Details") && tk start $ID && printf '\n## Goal\n...\n' >> .tickets/$ID.md`
Lifecycle: `tk create` → `tk start <id>` → work → `tk close <id>` → commit.
Include ticket IDs in spec/plan headers and in `tinychange` messages.
When logging changes: `tinychange -I new -k <fix|feat|docs|refactor|...> -m "t-XXXX: message" -a AUTHOR`
### Progressive Disclosure
**Don't load everything.** Navigate in layers:
1. **Start at feature area README** - `features/NN-name/README.md`
- Understand scope and current status
- See which specs exist
2. **Read specific specs as needed** - `features/NN-name/NN.NN-*-spec.md`
- Load only the spec you're implementing
- Check "Integration" section for related specs
3. **Dive into reference docs for deep context** - `reference/` or `features/NN-name/reference/`
4. **Check research for background** - `research/topic/`
### Johnny Decimal Features
Feature areas use Johnny Decimal IDs with two-digit decimals. Specs/plans use `NN.NN-` prefixes.
### Open Questions System
See [[playbook/10-docs/10-01-open-questions-system/SKILL]] for full spec.
**WHO ANSWERS WHAT:**
| Emoji | Who wrote it | Who should answer/action |
|-------|--------------|--------------------------|
| 🙋♂️ | Human | **Agent** (this is work for you!) |
| 🤖 | Agent | **Human** (skip this, you asked it) |
| ✅ | Resolved | **No one** |
### Updating Specs
**Before:** Read Assumptions and Failure Modes
**During:** Mark open questions resolved with `✅`, note discoveries
**After:** Update Success Criteria checkboxes, update README status
### Link Format
| Target | Format |
|--------|--------|
| Same directory | `[text](filename.md)` |
| Parent | `[text](../README.md)` |
| Cross-feature area | `[text](../20-name/README.md)` |Codebase AGENTS.md
Every top-level code or source folder must include an AGENTS.md that explains:
- The folder's purpose
- Feature area IDs it implements (link to
docs/features/) - Boundaries (what does NOT belong here)
- Primary entry points and tests
Recommended AGENTS.md files for docs/ subfolders:
| Path | Content |
|---|---|
docs/reference/AGENTS.md | Purpose, Johnny Decimal convention, citation rules |
docs/plans/AGENTS.md | Purpose, naming convention (YYYY-MM-DD-HHMM-topic.md), plan quality rules |
Postmortem tickets (tagged postmortem) | Purpose, incident learnings |
docs/playbook/AGENTS.md | Purpose, Johnny Decimal areas, update rules |
Referencing postmortems from code AGENTS.md:
When a past incident is relevant to a code folder, link the postmortem directly in that folder's AGENTS.md so agents encounter the lesson at the point of danger:
> **Post-mortem:** See postmortem ticket — Brief description of what went wrong.Root README Template
# Project Wiki
> **For you:** Start here. Read feature area READMEs for context, then specific specs as needed.
## Feature Areas
| # | Feature Area | Description |
|---|--------------|-------------|
| 10 | [[features/10-name/README\|Name]] | Description |
## Quick Links
- [[AGENTS]] - Rules for agents
- [[changelog]] - What changed and when
- [[playbook/README]] - Process and tooling playbook
- [[reference/architecture]] - System overview
- [[reference/decisions]] - ADRs
## Postmortems
Incident learnings (read these before repeating known mistakes):
- Postmortem tickets (tagged `postmortem`) - Description
## Research
Oracle/Delphi outputs (frozen snapshots):
- [[research/topic]] - DescriptionWorkflow Patterns
Creating a New Wiki
Copy the starter template to docs/ and customize:
cp -r <skill-path>/assets/starter-template/* docs/Or create manually:
1. Create docs/ directory structure 2. Write README.md with feature area table (Johnny Decimal) 3. Create AGENTS.md with agent instructions 4. Create AGENTS.md as a symlink: ln -s AGENTS.md AGENTS.md 5. Initialize changelog via tinychange init 6. Create feature area folders with README.md (e.g., features/10-core/) 7. Create plans/ with AGENTS.md 8. Use tickets tagged postmortem for incident learnings 9. Create reference/ with AGENTS.md and README.md 10. Create playbook/ with AGENTS.md and README.md 11. Keep research/ with index.md 12. Add specs as needed
Adding a Spec
1. Create NN.NN-spec-name.md in feature area folder 2. Add tk ticket in the header if applicable 3. Fill in Behavior (contract + scenarios) 4. Document Decisions (ADRs) 5. Map Integration (dependencies + consumers with wiki links) 6. Update feature area README table 7. Update changelog via CLI
Adding a Plan
1. Create NN.NN-plan-name.md in feature area folder 2. Link to related spec if one exists 3. Add tk ticket in the header if applicable 4. Fill in Implementation Steps with checkboxes 5. List Files to Modify 6. Document Risks & Mitigations 7. Update feature area README plans table 8. Update changelog via CLI
Research Workflow
When a %% 🙋♂️ ... %% or %% 🤖 ... %% comment needs research:
Simple question: Launch oracle agent Complex/uncertain: Use Delphi (3 parallel oracles + synthesis)
Store results in research/, link from spec:
%% ✅ question → see [[research/topic]] %% ^q-scope-topicKeeping Specs and Code in Sync
Specs and code are updated together. If you discover drift: 1. Open or link a tk ticket. 2. Decide the intended behavior (document in spec or ADR). 3. Update spec/plan and code to match that decision. 4. Add a tinychange entry.
Updating Specs During Implementation
Before: Read the spec's Assumptions and Failure Modes.
During implementation:
- Add implementation notes to the spec
- Mark open questions as resolved:
%% ✅ Decided → [outcome] %% - Note any discovered failure modes
After completing:
- Update Success Criteria checkboxes
- Add commit hash if significant
- Update feature area README status if needed
Link Format
Use relative markdown links (Obsidian-compatible):
| Target | Link Format |
|---|---|
| Same directory | [text](filename.md) |
| Parent directory | [text](../README.md) |
| Subdirectory | [text](reference/file.md) |
| Cross-feature area | [text](../20-context-menu/README.md) |
| Heading anchor | [text](file.md#section-name) |
When to Create New Documentation
| Situation | Action |
|---|---|
| New feature area | Create new feature area directory |
| New behavior to document | Create numbered spec file (NN.NN-spec.md) |
| New implementation approach | Create numbered plan file (NN.NN-plan.md) |
| Deep technical topic | Add to reference/ subdirectory |
| Research question | Use Oracle, save to research/ |
| Feature-area-specific rules | Create AGENTS.md in feature area |
| New code/source folder | Create AGENTS.md in that folder |
| Agent mistake or system failure | Create postmortem ticket (tagged postmortem) |
| Recurring agent situation | Create handler in playbook/80-agent-behaviour/ |
| Spec drifted from code | Run spec divergence audit |
| Tickets drifted from reality | Run ticket divergence audit |