Conventions for contributors, and for AI coding assistants working on this repository. If you are
using an assistant that reads a project file automatically (AGENTS.md, CLAUDE.md, .cursorrules),
point it here.
Lunatik runs Lua inside the Linux kernel. A mistake here does not raise an exception, it panics a machine. Two rules follow from that and outrank everything else in this document:
- Verify, do not assume. How the kernel behaves is read in its source, not inferred from a
measurement: which CPU a hook runs on is answered by
NF_HOOK's callers, and a run on one host confirms that reading, it does not replace it. Before using a kernel API, read its declaration in/usr/src/linux-headers-$(uname -r)/includeand confirm it is exported inModule.symvers. A kernel interface read at runtime rather than linked — a/sysor/procpath and the format it returns, the/dev/lunatikprotocol — is held to the same rule: confirm it against the source across the supported kernel range (6.6 and later), not from the one kernel you happen to run. Signatures and formats change between releases. If you have not checked, say so instead of asserting. - Know your execution context. Code that may sleep must not run from softirq or hardirq context. See "Execution contexts" below.
| Path | What lives there |
|---|---|
lunatik_*.c, lunatik.h |
core runtime, object model, C API |
lua/ |
the Lua fork (luainkernel/lua): upstream's git mirror plus the _KERNEL patch, which only modifies, #ifndef _KERNEL with upstream first; code the kernel side needs is added on the Lunatik side, luac/ included, never to the fork |
luac/ |
the luac fork (luainkernel/luac): the release tarball's luac.c, which the git mirror does not carry, plus the _KERNEL patch; a bump is an "update to Lua x.y.z" commit made from the tarball |
klibc/ |
the klibc fork (luainkernel/klibc): upstream's tags mirrored, and per release a klibc-x.y.z-kernel branch, upstream's tag plus Lunatik's two setjmp patches (arm64 saves x18, x86_64 returns through the retpoline and rethunk thunks), which the fork's lunatik branch follows; the tree links only each architecture's setjmp.S, archsetjmp.h and archconfig.h and libgcc's 64-bit division helpers, so a bump is read against those files |
include/ |
the C library headers the kernel's Lua includes, written over the kernel's headers and klibc's setjmp |
lib/lua*.c |
kernel modules, one .ko each, exposing a Lua module |
lib/*.lua, lib/*/*.lua |
kernel side Lua libraries, installed to /lib/modules/lua/ |
common/ |
Lua both sides run: installed under /lib/modules/lua/, where kernel scripts require it, as source whatever BYTECODE says, since the CLI's Lua refuses a chunk lunatic writes, and linked into the host's LUA_PATH under lunatik/, as lunatik/config.lua is, where the CLI requires it by that name, so that a program file's body, requiring it by the kernel's name, loads it from the kernel scripts through lunatic |
autogen/ |
build time generation of linux.* constant tables from kernel headers |
driver.lua |
the script of the runtime lunatik_run.ko creates for the /dev/lunatik driver, installed as lunatik/driver |
bin/ |
the host side: the CLI, bin/lunatik, which talks to the kernel through /dev/lunatik, and what it builds or loads, installed where the host finds it |
bin/luaebpf/ |
the Lua to eBPF compiler, a package of the CLI's Lua installed under the host's LUA_PATH as luaebpf/, and luaebpf.loader, the C module over libbpf that loads, attaches and pins what it writes, installed under LUA_CPATH; lib/ holds the modules a program file's body requires to declare what the object carries, bpf.xdp among them, and one named as a kernel module is that module's compile-time twin |
tests/ |
KTAP integration tests, one directory per suite |
examples/ |
example kernel scripts |
tools/ |
maintenance scripts, and the mechanical convention checks in tools/checks/ |
.agents/skills/ |
the recurring workflows packaged as agent skills (open SKILL.md format) |
.agents/rules/ |
the rules for a kind of file, loaded by the paths each one names |
.agents/agents/ |
the agents a workflow runs a narrow job in, the host's and GitHub's |
.claude/ |
Claude Code's wiring of the guards, the checks and the skills, with no logic of its own (see Skills) |
.github/ |
the CI workflows |
doc/ |
all documentation: the guide pages (doc/guide/), the hand written C API reference (doc/capi.md), design notes (doc/design/), the site's style (doc/style/), and generated LDoc output (everything else, gitignored) |
doc/design/ |
design notes for work in progress: gap analysis, proposed APIs, verified kernel references, test strategy |
All documentation lives under doc/. There is exactly one documentation directory: never create a
second top level one (docs/, documentation/, and the like). A guide page goes in doc/guide/ and in
the topics of config.ld, design notes go in doc/design/<topic>/, the C API reference is
doc/capi.md, and LDoc fills the rest of doc/. The README is the site's home page: what it does not
say in a few lines links to a guide page.
Code lives and installs where it runs. What runs in the kernel, the bindings, their Lua libraries, the
examples and the test scripts, lives in lib/, examples/ and tests/, with driver.lua and the tables
autogen/ writes, and installs under /lib/modules/lua/, the tree the kernel's require reads; what runs
in the CLI, on the host, lives in bin/ and installs where the host finds it, its Lua where the host's Lua
does, as lunatik/config.lua is linked into the host's LUA_PATH; what both run lives in common/, and
installs on both sides. A new top-level directory gets its row in the table above, which says which side
it is. The luaebpf compiler, which runs only in the CLI's Lua, reached review after review at the root of
the tree, installing into /lib/modules/lua/, and once moved to bin/ it required class from lib/
through a copy make install put in its own root, from a package at bin/luaebpf/luaebpf/.
tools/checks/layout.sh names a top-level directory the table does not list, a file make install puts
in the kernel's script tree from outside the trees that run there, one it puts in the host's Lua path
from a tree that runs only in the kernel, and a directory named as the one that holds it.
A definition goes where the tree keeps its kind, and that place is read before the definition is
written: the declarations of its siblings point at it, as include/stdint.h declares the libgcc helpers
a module of the tree calls on a 32-bit kernel. That compiler's runtime lives in the core, which exports
each helper the kernel does not: klibc's in the objects Kbuild links into lunatik.ko, and the ones
the tree writes in lunatik_libgcc.c, never in a binding or in lunatik_aux.c. #2132 first took
klibc's __umoddi3.c into lib/lualinux.c and defined __divmoddi4 in lunatik_aux.c, beside nothing
of their kind. tools/checks/layout.sh names a libgcc helper defined outside lunatik_libgcc.c, and a C
source of klibc included anywhere.
A module or an example that needs a second file gets a directory, not a prefixed sibling:
examples/systrack/probe.lua beside device.lua, never systrack_device.lua beside systrack.lua. The
directory is named for what it holds, never as the directory that holds it: a reader takes x/x/ for a
copy made by mistake.
sudo apt install linux-headers-$(uname -r) # after a kernel upgrade
make # build every module
sudo make install # scripts, modules, examples, tests
sudo lunatik reload # reload the modules
sudo lunatik test # run every suite
sudo lunatik test <suite> # run one suite
Use sudo make install, not the partial *_install targets. Use sudo lunatik reload, never
rmmod: the CLI knows the dependency order and unloads cleanly. What the shared host does to a cycle,
a module that will not unload, a wedged device, a stale install, is the lunatik-cycle skill's.
Never run two lunatik operations at once. Concurrent operations wedge /dev/lunatik and leave
processes in D state. ps answers for the instant it ran, which is no help when another session
starts a second later, so a build-install-run cycle takes the lock instead:
bash tools/lunatik-host sudo lunatik test
bash tools/lunatik-host bash tools/watchdog.sh examples/systrack/device
It runs the command with the lock held, names the holder while it waits, and releases on exit
however the command ends. The installed tree under /lib/modules/lua is shared too: a suite run
whose totals do not match the tests in your own worktree is a mixed install, one session's modules
against another's scripts, and it measures neither.
A worktree named for a task may belong to another session on the same machine. Check
git worktree list and the branch a worktree holds before a checkout or a reset there, and never
reset a branch checked out elsewhere. A review, or a build of a branch not your own, runs in a
worktree created for it (git worktree add, then git submodule update --init) and removed at the
end, with rm -rf and git worktree prune, since git worktree remove refuses a tree with
submodules. Each one left behind carries a build on the disk every session shares, and a build that
stops halfway on a full disk leaves the previous install in place for the suite to measure:
tools/checks/disk.sh fails below a free-space threshold and names the largest worktrees, and
tools/lunatik-host runs it as a warning before every cycle. A git checkout carries what is
uncommitted onto the new HEAD, where an edit made against the old base reads as a change to the new
one: switch branches in a tree with nothing pending, or read the other branch in a worktree of its
own. git stash has no place here at all: the stack belongs to the repository and not to the
worktree, so stash@{0} is usually another session's work and the pop that follows spreads its
conflicts over your tree. Set work aside with a commit, and compare two revisions with a worktree or
git show <ref>:<path>. What a worktree holds built and not run is code nobody ran: a cycle run in
a worktree not your own installs its author's untested edit, and an edit left built while its author
waits on a question is a crash any session can trigger. An edit is built, installed and run in one
step, or reverted before the session pauses. A session's scratch worktrees live under scratch/wt/, since the
reboot such a crash forces clears /tmp and every tree it held, and a script a cycle reruns lives
under scratch/ for the same reason; tools/lunatik-host names one it runs from /tmp.
A change the host does not read, a comment, spacing, a document, is not run at all: the suite would
measure what it measured already. tools/checks/functional.sh <base> names the files of a change that the
build, the install or the suite reads, and when it names none the change goes out on the checks alone.
A tree with a conflict pending (git status showing UU) is not a test subject: a suite run over a
half-applied rebase or cherry-pick measures neither side. Resolve and commit, then build.
tools/checks/ holds the mechanical checks, and .agents/rules/checks.md says what each one reads, the
mistake it is for and the guard it backs.
.agents/skills/ packages the recurring workflows — the build/test cycle, a new binding, a new
test suite, preparing a pull request — as agent skills in the open SKILL.md format
(agentskills.io), discovered by any compatible assistant. Each one
defers to this file as the authority and orders the steps; none replaces reading it.
For Claude Code, CLAUDE.md imports this file, .claude/settings.json runs the command guards
and the file checks as hooks, and .claude/skills/ links to .agents/skills/; nothing there holds
logic of its own. Those files, and an untracked settings.local.json beside them, bind every
session opened on the checkout rather than the one that wrote them: a permissions.deny added
there to sandbox one session's agents denied another session's commands, with no hook error to
point at the cause. A sandbox belongs in an agent definition, or in a session started with its own
permission mode. The checkout sessions start in is what every session and every agent it launches
loads, this file, the skills and the guards, so it follows master: checkout-behind.sh, which
.claude/settings.json runs as a session starts, prints how far its HEAD is behind origin/master
and the git merge --ff-only origin/master that brings it level, and the session runs that merge
when the checkout has no commit of its own and nothing tracked changed. It fetches nothing, since the
remote-tracking ref is shared by every worktree's fetch.
An agent that works on this tree runs through the Workflow tool, whose agent() takes the model and
the reasoning effort, and the maintainer's opt-in to workflows stands here: the Agent tool takes a
model and no effort, so what it launches runs at the default effort whatever was asked. agent-guard.sh, wired before the Agent tool, refuses every type but a read-only search
and the Claude Code guide; workflow-effort-guard.sh, wired before the Workflow tool, refuses a script
whose agent() calls never name an effort. An agent a running workflow waits on is not messaged: the
message resumes a copy of it from its transcript, which runs beside the original in its worktree and on
the host.
send-guard.sh, wired before SendMessage, refuses one whose run's journal holds no result for it; what
its report asks for is done outside it, on the host or in an issue, or by stopping the workflow and
relaunching it with notes.
An issue goes to agents through the implement-issue skill's workflow: a groomer, when the launch names
one, first judges whether and how the issue is to be done, an implementer commits the
change, review-pr's workflow runs over the branch nested, so a review runs one way whoever launches it,
and its build is the one run on the host, in an agent of its own, before the pull request opens; so
does a shape a review proposes, and implement-guard.sh refuses a commit of code in the session's
own thread that is not a fixup. An
agent's context is paid for on every turn it takes, so the work that only waits on the host, or only
writes to GitHub, runs in the agents .agents/agents/ defines, which load no CLAUDE.md and run on a
smaller model. The implementer's prompt names the rule a step needs and restates
none: every agent loads this file, and a rule pasted into a prompt is a copy the next change here
does not reach.
What the implementer and the review leave as issues, their findings_left, the workflow prepares as its
last stage, each under its severity label or appended to the open issue it belongs to, and returns as
writes with the pull request's label and the groomer's Update:, which the session posts as the run
returns: the auto mode classifier refuses a workflow agent's write to GitHub. An entry quotes the
contract its severity was read against, which the issue carries as a Contract: line. The implementer's
answer and the reviewer's each carry the smallest shape beside the size of the diff and what every
mechanism past it buys, and the hand-back shows it.
lunatik run [--context=softirq|hardirq] <script> # one shot
lunatik spawn <script> # in a kernel thread, always sleepable
lunatik stop <script>
lunatik list
Scripts are resolved under /lib/modules/lua/. The CLI loads the kernel modules a script needs by
itself, through require(). Never tell a user to modprobe lua* by hand.
lunatik run, spawn, stop and list exit 1 when the kernel refuses them, with lunatik: and
its message on stderr and nothing on stdout, and a wrong invocation exits 2 with the usage on stderr.
A runtime is created in one of three contexts, and this decides what its code may do:
| Context | How | Allocation | Lock | May sleep |
|---|---|---|---|---|
| process | default, and always for spawn |
GFP_KERNEL |
mutex | yes |
| softirq | lunatik run --context=softirq <script> |
GFP_ATOMIC |
spin_lock_bh; spin_lock_irqsave with IRQs already off |
no |
| hardirq | lunatik run --context=hardirq <script> |
GFP_ATOMIC |
spin_lock_irqsave, always |
no |
Netfilter and XDP hooks need a softirq runtime, kprobes a hardirq one: a kprobe fires wherever the
probed code runs, an interrupt handler included, and there it would spin forever on a runtime lock
its own CPU holds unless that lock turns interrupts off.
A softirq object reached with IRQs already off, as an rcu.table written from a kprobe handler is,
locks with spin_lock_irqsave: a bottom-half unlock there runs the pending softirqs inline, inside
the probe.
The script body itself runs once at runtime creation, in process context, before the runtime is armed.
So registering hooks at the top level of a softirq script is legal and may sleep. Everything that
runs later, from a hook, may not.
Use lunatik_cannotsleep(L, ...) to reject a sleeping entry point called from an IRQ context runtime
rather than letting it deadlock the machine.
Which runtime of a percpu script a callback reaches is the CPU it fires on, and nothing else ties a
flow to one of them: the packets of one connection reach several. State that must see a whole flow
belongs in what the runtimes share, the runtime holds what is per-CPU. Which CPU each path lands on
is traced in doc/guide/03-percpu.md.
A script for lunatik spawn must return the thread body, poll for a stop request, and yield.
Violating any of these hangs the machine:
local thread = require("thread")
local linux = require("linux")
return function()
while not thread.shouldstop() do
-- non blocking work only
linux.schedule(100)
end
end
The thread body runs holding the runtime lock, and stop() waits for it to return. A stop ends a wait
that reads signals: kthread_stop() sets TIF_NOTIFY_SIGNAL on the task before it wakes it, so a
sock:receive() with no timeout returns ERESTARTSYS, a linux.schedule() returns early, and a wait
for another runtime's lock, in resume, stop, thread.run and every monitored method, raises
EINTR. One thing it does not end, and it makes the thread unstoppable: a wait that goes back to
sleep without reading a signal, the lock a device file operation or a kernel call takes among them.
The runtime that calls thread.run() keeps the thread and stops it as it ends, so a worker another
body starts and drops ends with that body's runtime, as examples/echod's workers do; an end made
on the thread's own body leaves the thread to run until that body returns.
A body that races another thread, a stress, pauses once per slice of its running rather than once
per turn: linux.schedule() sleeps until a timer fires whatever the timeout, 0 included, so a pause
per turn starves the race the stress is for.
The rules for a kind of file live in .agents/rules/, each opening with the paths it governs, and Claude
Code loads one when a file it reads or edits matches, through .claude/rules, which links there. Another
assistant reads the ones whose paths match the files it touches.
Code on master is not settled. When a new design makes an existing mechanism simpler, or subsumes
it, change or remove it. Working around it to keep a diff small leaves two mechanisms doing one job,
and every later change has to serve both. Authorship is not a reason to keep code, and neither is how
recently it was merged; a commit that removes or subsumes something already on master names it in
the body.
The first shape of a fix is the smallest change that makes the observed defect unreachable, and every mechanism past it is named with what it buys that the small one does not. A structural answer the kernel offers is not the smaller one by being structural: four lines that decline a device of another namespace in the handler fixed #797, where moving the netdevice block to the per-namespace chain took a macro signature, two wrappers and an API newer than the tree's floor. Write the small shape down first, even when the larger one is chosen, so the choice is a comparison and not a default.
What a mechanism past the small shape buys keeps it only when it is one of two things, with its
source: a defect the small shape leaves reachable, traced, or a need the issue or the maintainer
stated, quoted. Fewer reads, more robust, the kernel spells it so: none of them is either, and the
review removes such a mechanism by a fixup. A kernel idiom comes with the reason the kernel has for it,
which the body names. #2083 copied a device's name into a local array before lua_pushstring, which
copies it already, for "a read that never walks past the array", which no trace showed; its review
kept the copy for one read per byte, and the maintainer's review took it out. What a convention of
this file or of .agents/rules/ asks of the code, quoted, the helper a grown function's new step is
or the predicate a condition of three tests names, is part of the small shape and not past it, so no
review is asked to make and remove the same helper.
An issue states the need, its contract and the API the maintainer settled, the types, names and calls
a script sees, and leaves the code's inner structure to whoever implements it: a sentence that
prescribes a helper, a loop or a step two functions share is read as the spec, and weighed by no one.
#1895 asked the two walks of a set to share the step from one level to the next, and #1896 wrote
luaset_next and kept it because the issue asked, where the tree spells a loop that two functions
share as a foreach macro.
The comparison starts in the kernel. Before a mechanism is written over a kernel primitive, the
kernel's own answer to the same problem is read, Documentation/ for the family and the primitive's
users under lib/ and kernel/, and the commit body names the facility the shape was compared with,
or the page that offers none: a reader that must block has SRCU, a walk that drops the lock has the
contract the comment on rhashtable_walk_enter states, and a deferral of frees while readers sleep,
written by hand, is SRCU again. tools/checks/kernel-answer.sh names a commit whose body carries no
such comparison. An object the binding keeps past the code that lent it is compared resource by
resource: each keeper the comparison reads, the kernel's own code that keeps such an object, is read
for every resource the object carries, a device, a route, a conntrack entry, and the body names the
line each keeper has for each. An earlier push of #2099 read nf_queue and the socket receive queue
for the device alone and dropped the copy's route, as ip_frag_queue does, where those two force it
with skb_dst_force, as the merged one does.
Reshaping or renaming an API means updating every consumer, grepped for — including consumers in
stacked or sibling pull requests that will rebase onto the change. A caller left on the old shape
compiles against a Lua module and only fails when its code path runs. The grep is taken on the
branch's own base when the change is written, not from a read made earlier in the session. Every example
tools/checks/examples-touched.sh lists for the changed files is named in the pull request body
with what it did, ran, only loaded or not run and why; tools/checks/examples-named.sh fails a
body that leaves one unnamed, and pr-body-guard.sh runs it when a pull request opens.
The same holds for a value, not only a name. A change that keeps its own copy of something another
field carries, because the kernel rewrites the original, has decided the two can differ:
luaprobe_t keeps the address the script gave in requested because register_kprobe rewrites
kp.addr, on x86 with IBT past the ENDBR. From then on every reader of the original is a decision,
which of the two it wants. Grep the readers of what the change
duplicates and settle each one, and read an architecture the host cannot run for every use, not only
the one the diff touches. tools/checks/shadowed-readers.sh lists the readers.
A primitive every class inherits, the lock, the allocator, the context check, is changed for the
caller that needs it, in the arm that caller takes. Widening it to every arm because that is simpler
is a contract change for the callers that were already correct, and it belongs in the first line of
the commit body, naming what moves, never in a footnote of the pull request. A kprobe handler writing an
rcu.table needs interrupts off, and #891 keyed that on irqs_disabled(), the kernel's own rule,
leaving netfilter and XDP under bottom halves. tools/checks/blast-radius.sh lists what a changed
primitive reaches, on the file it left as well as the file it arrived in. A bound on what every
script shares, the kernel stack the C budget prices among them, is measured over the forms the tree's
own tools ship a script in as well as over the suite as installed: chunks lunatic compiles, which
BYTECODE=1 installs, and modules tools/shade.sh encrypted, required in chains. It holds where the
resource is spent, not where the bound is read: a recursion that consults nothing is outside it
whatever the measurement showed, and tools/checks/recursion.sh lists the ones in the Lua the kernel
links.
The kernel a consumer builds on bounds what a change may use, and not every consumer sits inside the range this file declares: a product built on Lunatik can ship on an older vendor kernel. An API newer than a known consumer's kernel is a decision taken here, with the floor it sets named, not one discovered at that consumer's build.
- Do not land an implementation you already intend to replace. A guarantee that holds only on some paths is not a guarantee: make it structural or do not offer it. Merging a half measure and opening a follow up that deletes it pollutes the history across pull requests the same way a commit that undoes another one pollutes a branch.
- Removing a check as redundant is verified, not asserted: trace the invariant to whoever establishes
it and confirm every path that sets the value does.
invoke's type check is redundant becauseattachvalidates the callback first, so it is always a function there; apcallthat would catch a bad value anyway is a second reason, not the trace. A binding's Lua half establishes invariants the same way: where it guarantees a precondition, the C takes it, and what stays in C is the check whose absence crashes the kernel. That is what the split is for, and re-validating in C undoes it. A context that makes a family of refusals unreachable removes those it covers, route by route, and keeps the ones its rule leaves open; a mechanism a removal makes necessary is the removal going past what the trace showed.guards.shcounts the context checks,lunatik_checkarmed,lunatik_checkrtnl,lunatik_checkownerandlunatik_checkirqs, soguard-removed.shnames one a change drops. - A function's contract — that it only reads, that it never sleeps, what it returns — is read from its
body, not inferred from its name or its place in a method table.
connmarkreads and writes through one overloaded method despite sitting among read-only accessors; calling it read-only from where it sits is a guess, not a trace. - Refusing is a legitimate outcome. When a combination has no sound semantics yet, refuse it where it
is registered, with an error that names the reason, rather than shipping an approximation. Lifting
the refusal afterwards is one line and a test. The reason is written where the refusal is made, so
the change that removes it finds the refusal and lifts it;
tools/checks/idioms.shnames a load of one mode. - A hazard is refused before it is engineered around. Where a context makes an operation unsafe, the
operation is refused in that context, and a context is made where none names it yet; a deferral, a
queue or a worker that keeps the unsafe call working buys a capability, and is weighed as one against
the refusal that makes the hazard unreachable. A netdevice callback runs under RTNL, and a softirq
runtime closes that family: nothing in it sleeps, so nothing waits on RTNL, and
lunatik_checkarmedrefuses under RTNL the replay the registration delivers before the runtime is armed. - A prohibition and a capability are two things. Where the safe form of an operation needs a facility
a later kernel adds, the binding refuses the operation where the facility is missing and uses it
where it exists, behind a version guard, and does not reimplement it: a copy of the kernel's answer
written here is a mechanism every later change serves, and it outlives the releases it was written
for.
printunder the runqueue lock is refused, not buffered and flushed from a worker, since no release through 6.17 exportsprintk_deferredto modules. - A guard keys on a property that is true by construction where it is enforced, never on a proxy that merely correlates. That a registration is global is such a property. A netfilter hook number is not: the same hook runs in softirq or in process context depending on the path the packet took.
- A refusal is written for the resource, not for the entry point that showed the hang: the matrix it
is held to lists every route from Lua to the lock or the call it protects, found by grepping what
acquires it,
lunatik_lock,lunatik_closeprivate,lunatik_runor the kernel call, and each route names its refusal or why it needs none. A refusal ofruntime:stop()for the runtime's own lock coversruntime:resume(),percpu:resume()andthread.runtoo, which take it. The routes are everything the path runs while it holds the lock, the binding's own calls among them, not only what a script calls: a raise the dispatch logs is a printk under that lock. - A guard in the core is for the honest mistake: the wrong object at an index, a size no binding can
serve, a call that sleeps from a hook. The registry, a class metatable and an object's
__gcare the runtime's own bookkeeping, and a script that reaches into them,obj:__gc(),debug.getregistry(),getmetatable(obj).__gc = nil, is out of contract, as one that spins in a hook is: root loaded it on the machine it breaks, and no guard closes that. A finding whose stimulus is such a script closes as not a defect, whatever it traces from there. - A change that puts a route out of contract, a prohibition the binding's documentation now states, reads the open issues filed on that route in the same breath: one whose stimulus the prohibition covers is a capability and not a defect, relabelled or closed with the reason.
- A decision taken with the maintainer is not reversed alone. When the investigation that follows points the other way, that is a question to bring back, not a conclusion to announce. Bring the finding, say what it would change, and wait.
- A decision brought to the maintainer, in a reply as in an issue, a review or a pull request, is one
he takes in one read:
Decision:and the question in a line, the options letteredA),B), each with what it changes, andRecommendation:with the option and its reason; "the maintainer's call" names who decides and not what. The options include the smallest change that makes the defect unreachable, and each larger one says what it buys over it. Each decision carries an ID,D1,D2, kept until it is answered, so the answer isD3 B; a text that brings two or more opens with a table of them, the ID, the question, the options and the recommendation in a few words each, and gives below each ID what its options change.tools/checks/decision.shnames a decision without its ID, and two without the table. - A recommendation's reason is what the option buys. That an option leaves an API as it stands is
not one: the break is a cost of the option that makes it, weighed with the API's users counted, in
the tree and in the known consumers, against what that option buys. A reason that holds for every
option, "nothing uses it yet", decides between none. #1965 recommended reading a map's struct value
by position because the kernel's array was "the existing contract", where a read by name holds when
a field moves and one test was all the array had;
tools/checks/decision.shnames a recommendation that rests on keeping an API. The reason is what the option buys over the one it beats, named by its letter,B, because over A it ...: a list of what the option does leaves the maintainer to ask what it buys over the others. D19 on #2202 recommended one function under two names,xdp.runtimeandtc.runtime, for keeping the names #878's Update had settled, and the maintainer asked what that bought over onebpf.runtime: a line ofrequire;decision.shnames a recommendation that names no other option. - A step the session can take it takes. One only the maintainer can, a command the session's
permissions refuse or his own terminal must run, reaches him as a script the session writes under
/tmpand the one short line that runs it,! bash /tmp/<name>.shin Claude Code: a command spelled out in the reply is one he copies by hand, and the terminal wraps it where the copy breaks.tools/checks/handoff.shnames a reply that hands him one. A write to GitHub is not such a step: the auto mode classifier reads the conversation for his go, so a post it refuses is shown again with the question whether to post, and posted on his answer. The writes an implement-issue run returns, its pull request's label, the groomer'sUpdate:and the issues its filing prepared, are posted as the run returns and never brought back as a question: the maintainer ordered them posted always, "e sempre grave", after a session had asked him about four of them. One the classifier refuses is reported in the hand-back with the file that holds it.tools/checks/settled.shnames a decision that asks whether to post one. - A question from the maintainer is answered, and the turn changes nothing: what the answer
recommends is proposed with its decision, not done. "What is left?" asks for the list, not for the
work on it;
.claude/hooks/on-prompt.shreminds a turn that reads a question. A message that ends in a question mark is a question whatever the text before it directs: "...; entendido?" asks whether the order was understood, and the turn says what it would do and waits for the go. #1865 read one as an order, and the change it filed closed unmerged. - A fact the maintainer states is taken as given and acted on, not verified back: "#736 is merged" ends a question rather than opening one, and re-arguing the point it settles spends the exchange on what is already decided. A state you assert yourself is the other way round, and rule 1 governs it: read it before writing it.
- A call the conventions here already settle is made, not escalated: decide it, note it in a line, and move on. A question is for a genuine fork — where the answer changes the outcome and no rule, precedent, or test resolves it. Asking whether to add a comment the tree's macros never carry spends the maintainer on a call this document already made.
- "The tree does it" is a fact about the tree, not a reason. A choice is defended by what it buys and what it costs; prevalence says whether it is common, never whether it is right, and a tree can be wrong in a hundred places. When the only support for a line is a precedent, say so and judge it again.
- The core and its bindings are not shaped by a consumer or by an example. An example that cannot be written, or that reaches what it needs through a channel no contract states, is evidence of a gap in the API; what fills that gap is decided from what the binding is for, and the commit and the pull request argue it that way.
- Small, auditable, incremental commits. Each one stands alone and adds value.
- A commit is one change and everything that change entails, even across modules: a new core check
adopted by four bindings is one commit. It is staged by purpose, the files of that change named and
git diff --cachedread against the message, never the whole tree an edit left behind;tools/checks/stage-guard.shrefusesgit add -Aand its kin. Independent fixes are separate commits, even when a single review finding uncovered them all; the finding is the reviewer's unit, not the committer's. - A change to a function is read against the whole function, not the lines it touches: re-read it
and take the simplification the change enables. A guard left standing that the new shape made
redundant —
!cond || check(cond)where the call now sits insideif (cond)— is a partial fix. - A change that removes the last reader of a value removes what was written for it, as the NUL
terminator and its byte go with the last read of a key as a C string. For every read the diff removes, ask what was stored, sized or kept only for that
read;
tools/checks/terminator.shnames the NUL case. - Read the commit before pushing it, not only the working tree:
git showthe diff that is about to be published. Instrumentation added while debugging — apr_err, a hardcoded branch — is invisible in a passing test and lands in the pull request. - After resolving a rebase or a merge,
git grep -n '^<<<<<<< 'before committing.git add -Astages a conflict marker without complaining, andgit rebase --continueruns no pre-commit hook, so the markers reach the branch and surface far from the resolution. - Change only what the task requires. Do not reformat untouched lines, do not move code, do not
rename variables in passing. Compare
git diffagainstgit diff -wbefore committing to catch stray whitespace. - No dead or unnecessary code. A branch that cannot execute — a nil check on a call that raises instead of returning nil — is noise that misstates the API's contract.
- No cosmetic changes inside a feature or fix commit, not even a blank line.
- Never remove an existing comment unless the change made it factually wrong.
- Restoring something that was removed puts it back exactly where and how it was.
- Subject line says what changed and why, not how. A body only when there is something the diff does not say: a new API, a behaviour change, a non obvious rationale. No bullet list of every detail.
- A pull request title and body follow the same rule: what and why, nothing the commits already say. No "Test plan" section, and no em dashes.
- The body explains the change to a reviewer who has not read its issue, in four short paragraphs
at most: the failure or the need in one plain sentence, what a script can do today and what
breaks; what the change lets a script or a user do, shown, where it reaches one, by the code they
write, in a fenced block that reads on its own, every name in it defined there; what changes from
today, the hand-written stub it replaces or the refusal it lifts; and what it costs or needs, a
kernel, a build step, a path that pays more. An example counts as no paragraph. How the problem was
found, what was measured and what was tried belong in the commits, and so does the mechanism: the
body says what a reader of the API meets, not how the code reaches it. #2202 opened on "the kfuncs
... are reachable from hand-written C stubs alone" and a paragraph on how the call lowers into
the frame, and the maintainer could not tell what it was for until a program file handing UDP
frames to a kernel script showed it.
tools/checks/pr-body.shholds a body file to the count. - A pull request that replaces another says so in the body,
Alternative to #N, and the merge of the replacement closes #N in the same breath: one left open is a claim about the queue nobody made.tools/pr-status.shmarks an open pull request a merged body names that way as superseded and keeps it out of--ready. - A pull request is one mechanism, read in one screen of diff and one paragraph of body. A body that
needs a section per mechanism describes several pull requests: stack them, each on the one below.
A defect master carries that the change fixes on the way, because it reaches the code the defect
sits in, is one of those, opened on master rather than stacked: the change's branch carries its
commit first, so either merge lands the fix and neither waits on the other's review.
The harness is the exception: the checks, rules and skill steps one incident produces travel in one
pull request, because they carry one reason and the CI line that runs them is one push. They travel with
each other, never inside an implementation: a rule that rides in a feature's commit lands unread,
and a maintainer who wants the fix and not the rule has nothing to pick.
tools/checks/harness-mixed.shnames a commit that mixes them. - A harness pull request the session opened, one whose every file
harness-mixed.shreads as the harness, merges without waiting for the maintainer oncereview.jsran over its head athighand returned it ready and its CI passed: what a correction teaches binds the next session that day, and the maintainer reads it on master, in the report the merged skill hands him. One that removes a file or changes what the gate reads, the guard, the list of what the harness is, the CI or the settings that wire the guards, waits for him, and so does one that relaxes a rule, a check or a guard, which reverses what he decided.tools/checks/merge-guard.shrefuses any other merge the session runs;MERGE_OK=1merges one he ordered merged in his own words. - No session links or assistant footers in a commit or a pull request beyond the
Co-Authored-Bytrailer. The project settings turn the link off; one that slipped in is removed with a reword. - A root cause named in a commit body or a pull request rests on a captured stack or a source-traced chain, not a correlated log line or a plausible mechanism. Until it is traced it is a hypothesis, labelled as one; a fix may land on the observed behaviour without naming a cause it has not proven. A release note, a changelog or any summary derived from commits is held to the same rule against its source: read each commit and use its words.
- A pull request reaches the maintainer's first review squashed: until he has read it, a correction
folds into the commit it corrects,
git commit --fixup=<hash>andgit rebase -i --autosquashat once, and the branch goes up with--force-with-lease. After his review, a correction isgit commit --fixup=<hash>, not a standalone "address review comments" commit, so what changed since he read it is a commit of its own. Never fixup a commit that is already onmaster; that becomes a new commit on a new branch. - A fixup after the maintainer's review is pushed as soon as it is made, onto the pull request's own
branch. It adds a commit, so it
fast-forwards the branch and moves nothing a reviewer already read; what waits for the maintainer is
the squash, and a rewrite is said out loud. A fixup that lives only on the machine that wrote it is
outside the review, and a cleared worktree or a reboot takes it; one that lives only on
review/<n>is outside it too, since the pull request lists its own branch's commits and nothing else, and "the fixups are on the pull request" is said aftergh api repos/.../pulls/<n>/commitslists them, not before. The same holds for work handed to a reviewing agent: telling it not to push leaves the push owed by whoever gave the instruction. - Work an agent does for a workflow outlives the agent. Its branch is pushed at each commit, pull request or not, and what it measured and decided goes to a checkpoint file, a line per step, that the agent relaunched in its place reads before anything else; the workflow then resumes with the finished agents from its cache and the unfinished ones from their branch and checkpoint.
- If a branch adds something in one commit and removes it in another, the second is a fixup of the first.
- After squashing, re read the comments, the commit bodies and the identifiers so they describe the
final state rather than the path taken. A name is a residue as easily as a sentence: a type renamed
to tell it apart from a sibling keeps that name after the sibling goes, and then the noun tells it
apart from nothing. For every name the branch introduces or changes, ask what it distinguishes from
in the final tree, and whether master already had a name for the same thing;
tools/checks/rename-orphaned.shcatches the typedef case, andtools/checks/body-identifiers.shthe paragraph that still explains a mechanism a folded fixup removed. - Naming an existing literal is done by visiting every call site of what carries it: sweep for the function's callers or the field's users, not for the literal, which misses positional arguments.
- Changing the value of a field visits every reader of it, in the code and in the field's doc, and
asks what each does with the value:
class->nameis the type name a type error quotes and the name every context error prints. A reader that merely compiles against the new value is not accounted for. - A force-push that restructures a branch is not done until the pull request title and body are re-read against it. They describe the branch; a rewrite that drops or replaces a mechanism turns them into fiction the reviewer reads first.
- A report of a push names the ref it moved and, separately, what it was rebased onto. "Force-pushed
onto current master" reads as the one thing nobody may do, and a reader who has to parse a sentence
to find out whether
masterwas rewritten has already been alarmed for nothing. Say which branch took the push; the base is a different clause. - Do not commit directly to
master. - Copyright years: a new file carries the current year; a modified file extends its range to include
it; and a file that factors code out of another carries that file's first year, found with
git log -Son the moved lines.common/class.luatook the:newthatlib/socket/inet.luahad carried since 2023. Every source file opens withSPDX-FileCopyrightTextandSPDX-License-Identifier, the holder being who created it, and a file that cannot carry a header, Markdown, data or a font, is declared inREUSE.toml;pre-commitrefuses a source file staged without one.
The conventions in this document and in .agents/rules/ are a checklist to run against your own diff before showing it, not
reference to reach for after a reviewer objects. Code shown without that pass makes the reader the
reviewer, and the deviations they then find — a prefix nothing else in the file uses, a comment on a
body the file leaves bare, a handle closed by hand where the file uses <close> — were already rules
here. The failure is not the missing rule; it is not running the ones that exist.
Before presenting a change to an existing file, read the file as the authority and measure the
addition against it — naming, comment density, idioms, resource handling, duplication — then prove
the behaviour on the operation in isolation, not a round trip that hides a partial result: unload
reaching zero, not reload returning to a full set. The pass covers the prose the change ships with,
too: after any rewrite or force-push, the commit message and the pull request body go through the
same review against the final code — a claim the code does not support, a mechanism the branch
dropped or a rationale its call sites contradict, is caught there, as Patches and commits requires.
Saying the prose matches is not the check; showing the claim-to-code mapping is. A change shown
without that pass is not done.
Before the hand-back, every function the diff touches is re-read whole, not by its changed lines: a new branch is asked whether it is this function's job or its caller's, a condition with stack juggling is asked whether it is a predicate helper, and a guard the new shape made redundant is removed. The maintainer reading a function and finding it dirtier than before is the pass not run.
The hand-back lists each finding of your own review that the change does not apply, with the reason. A finding dropped in silence is found again by the maintainer, who then doubts the whole review; and "few sites" is not a reason against a rule that collapses a repeated pattern.
A review comment names a principle, not a token. Read the words as written, say which principle they
invoke, and fix that: a note that an enum is formatted inline asks for the formatting, not for a
#define, and changing both leaves the reviewer to undo one.
makeis clean, with no new warnings, and a change that touches a__percpupointer, or any other address-space annotated one, is clean undermake C=1too: sparse models the annotation as an address space, as GCC 14 on x86 does for__percpu(__seg_gs), so avoid *holding one, or a cast between the two, is a warning here and a build error there;sudo make install && sudo lunatik reload && sudo lunatik testpasses;- new API is documented and listed in
config.ld, andmake doc-site LUA=lua5.5, the CI target, exits zero: a C file that contributes to a module another file declares carries@modulewith the same name, whichmerge = trueinconfig.ldfolds, since@submodulededuces its section name from a path underlib/; - new tests are wired into their suite and described, and the hand-back carries the matrix the change is held to: for each guard or mechanism it adds, the operations by types by outcomes, each cell naming its test or saying why it is not covered. Wiring is what a check confirms; the matrix is what a rewrite inherits from the branch it replaces unless it is written down;
- error paths audited: for each raise, everything already acquired is released;
- every example that uses a binding the change touches is run, not only loaded, through the
example's own
setup.shandcleanup.shwhere it has them, and the hand-back says of each one whether it ran, only loaded, or was not run, and why. A loaded script that never fired its hook is the green build the review rule warns about; - commits are small, ordered, and none of them undoes another;
- every helper the change introduces has a caller. A helper extracted to remove duplication but left unused, while the duplication it replaces still stands, is the refactor half-done. Grep the new symbols for a caller before sending.
- the branch is handed back merge-ready, not as a working scratch: the session's commits grouped into a clean history that none of them undoes, nothing left to squash, the pull request title and body describing the final state. On a harness or docs pull request you author, tidy it before returning it, unasked — what comes back is reviewed and merged, not tidied first.
- the simplification pass, before sending. For each helper, collection, loop, or mechanism the change adds, a registration path, an argument on an API, a name for a literal, name what it buys over the minimal shape — weighing the whole cost, not the line count at one call site. A loop that vanishes locally by generating a build-time list can add more surface than it removes (an extra argument, an emitted table, a file to keep in sync); prefer the ground truth the system already exposes — the kernel's own "Used by" list, a field already on the struct — over a structure invented to encode it, and keep a loop that is doing honest work. The same holds for a comment: one non-obvious reason, one line, at the site that needs it — not spread over two, not repeated wherever the feature is touched.
The process is .agents/skills/review-pr/process.md, which the review-pr skill opens with.