@gonrocca/nodd
Non-negotiable Organic Driven Development — the ODD protocol as runtime mechanism for pi: blocking gates, observed evidence, and promotion to /forge.
Package details
Install @gonrocca/nodd from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@gonrocca/nodd- Package
@gonrocca/nodd- Version
0.7.6- Published
- Sep 21, 2026
- Downloads
- 2,627/mo · 2,627/wk
- Author
- gonrocca
- License
- MIT
- Types
- extension
- Size
- 685.3 KB
- Dependencies
- 0 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./extensions/nodd-kernel.ts",
"./extensions/nodd-gates.ts",
"./extensions/nodd-allow.ts",
"./extensions/nodd-models.ts",
"./extensions/nodd-agents.ts",
"./extensions/nodd-promote.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
NODD
Non-negotiable Organic Driven Development — the ODD protocol as
runtime mechanism for pi instead of injected prose: blocking gates fed by real
tool events, evidence read from observed tool results, and promotion of NODD
artifacts into /forge artifacts.
ODD's failure was promising compliance while shipping delivery. This file is where NODD refuses to repeat it: everything below states what is mechanized, what is only advice, and what is not carried at all.
test/readme-contract.test.ts holds part of that line mechanically: it fails if
the README names a gate, a module or a command that does not exist, drops a
canonical step, quotes a matrix total that disagrees with the matrix, or lets
the bash, enforcement-scope, resume and kill-switch sections stop stating their
limits. It does not parse English: a newly written sentence promising more
than the code does will not turn it red. Prose added next to a fix is therefore
the known way this document can drift ahead of the product, and the defence is
review, not the suite. Round 3 found three such sentences and closed them.
Install
pi install npm:@gonrocca/nodd
Global installation is the path enforcement is verified on. A pi-subagent child
process does its own ambient discovery and finds installed packages, so gates
also run inside delegated work — see Enforcement scope for what that does
and does not cover. pi -e npm:@gonrocca/nodd runs the package for one session
without installing it, and in that mode children do not load it: the parent
is gated and delegated work is not.
Install into one project instead of the whole account with -l, which writes
.pi/settings.json. Remove with pi remove npm:@gonrocca/nodd.
Nothing is configured after install: every gate is on, the flags are documented
under Flags, the kill switch and the escape hatch, and model slots keep
pi's defaults until /nodd-models is run.
Using it
NODD runs on its own. A normal session looks like this:
Read-only work stays read-only. Ask a question and nothing changes. Declare
intent read-only and a write is refused by gate-authorize.
Substantial work is declared before the first write. The agent calls
nodd_declare with the slug, the route, the files and — this is the part worth
caring about — the runner: the command a checkoff will be measured against.
nodd_declare slug: dark-mode title: "Dark mode" summary: "Theme the editor"
intent: change route: tracked
runner: "npm test" tdd: strict files: [src/theme.ts]
→ .nodd/dark-mode/feature.md created with 0 tasks
Until that file exists, the first write is refused. The refusal says what to do about it.
Tasks are checked off with observed runs, not with claims. nodd_task marks
an item done only when gate-evidence finds four facts lined up: a success, of
the declared runner, after the task's last write, with a RED before it under
strict TDD. A model writing "all tests pass" changes nothing — the evidence line
in the document is rendered from the observed tool result, never from prose.
When a gate is wrong, turn it off. /nodd-gates disable track disables it
entirely and stays disabled; /nodd-allow track grants exactly one override.
Neither is argued with.
When the work outgrows NODD, promote it. /nodd-promote dark-mode writes
.sdd/dark-mode/requirements.md and /forge --continue dark-mode starts at its
plan phase with the finished work as context.
To watch it work on something disposable, run pi in an empty git repo and ask
for a two-file change: the declaration is demanded before the first write, and
the first checkoff without a real test run is refused.
The seven canonical steps
authorize → explore → resolve-uncertainty → classify → track →
implement → close.
Four of them — authorize, classify, track and close — are mechanism
steps: they run no model and cannot be assigned one. /nodd-models shows them
as mecanismo · sin modelo and refuses an assignment. The other three are the
steps where a model actually does work.
Choosing models
/nodd-models with no argument opens a picker: profiles, then slots, then the
providers and models of pi's live registry, then a thinking level. Nothing is
written until — guardar y salir —; leaving with q writes nothing at all.
A profile is a named set of assignments — rapido, barato, whatever you
call it — and the picker can create, edit, duplicate, activate and delete them.
The active profile is what the generated agents read. Editing a slot while a
profile is active edits that profile, so the two never drift apart silently.
The slot and the level both reach the generated agent's frontmatter as model:
and thinking:. Changes apply to the next pi session, not the running one:
the agent files are rewritten from the config when the extension loads, which
happens at startup.
The orchestrator slot was removed
Earlier versions let you assign a slot named orchestrator. Nothing read it:
the main session runs on the model you picked in pi, so an assignment there
looked like a choice and did nothing. It is gone from the picker.
Because the slot was assignable, real configs contain it. On load, NODD moves
orchestrator to default when default is unset, and drops it when both are
set. It rewrites your profiles to do this, since that is where the
assignments live: a profile holding orchestrator comes back holding default.
Every other slot, your active profile and the rest of the file are left alone.
The migration writes only when there is something to migrate, and takes a
timestamped backup of ~/.pi/nodd.json before touching it.
The text forms do the same without the UI:
/nodd-models implement=anthropic/claude-opus-4-1
/nodd-models profile new rapido
/nodd-models profile use rapido
The gates
| gate | fires on | refuses when |
|---|---|---|
gate-authorize |
writes, mutating bash, delegation to a writer | intent was declared read-only |
gate-classify |
the first write | nothing was declared at all |
gate-track |
the first source write on a tracked/forge route |
no feature doc exists yet |
gate-delegate |
writes and mutating bash | the mapping, writer or long-session threshold fired and nothing was delegated |
gate-evidence |
checking a task off | the declared runner was not observed succeeding after the task's last write |
gate-promotion |
writes | the work diverged from what was declared |
Every refusal names what was observed, the action that unblocks it, and a
one-shot /nodd-allow <gate> escape hatch. A gate that blocks without naming
the exit is a dead end, so src/gates/policy.ts makes the remedy a required
field rather than an encouraged one.
The bash gate
write and edit are typed tool calls, so NODD gates them exactly. A bash call
arrives as a command string — pi's tool_call event carries no model of what
the shell will touch — so bash is gated by a denylist of mutation patterns.
A denylist is a partial mechanism. What it covers and what it does not are
published together, here, and both lists are generated from the classifier's own
pattern set (src/bash-classifier.ts) and asserted against by
src/bash-classifier.test.ts, so the table and the code cannot drift apart.
Covered
| pattern | example |
|---|---|
output redirection (>, >>), excluding fd-only forms like 2>&1 |
echo hi > f.txt |
tee |
ls | tee out.txt |
in-place editors (sed -i, perl -i) |
sed -i 's/a/b/' f.ts |
movers and removers (mv, cp, rm, rmdir, ln, install, dd, truncate, touch, mkdir) |
rm -rf build |
permission changes (chmod, chown) |
chmod +x run.sh |
patch |
patch -p1 < fix.diff |
mutating git subcommands (apply, checkout, restore, reset, commit, stash, clean, mv, rm) |
git commit -m 'x' |
package installers (npm/pnpm/yarn/pip/cargo install or add) |
npm install lodash |
inline interpreters (node -e, python -c) |
node -e "require('fs').writeFileSync('f','x')" |
an interpreter running a script file as its first argument (node script.js, deno run main.ts) |
node script.js |
Not covered
These mutation vectors reach the filesystem without this classifier noticing:
- a script run without naming an interpreter, or a build target:
./build.sh,make,npm run build - an interpreter whose script follows a bare flag, or is passed as a string:
node --import=./r.mjs app.js,bash -lc '…'(flags afterrunare covered:deno run --allow-write main.ts) - a script piped into an interpreter:
cat gen.py | python3, or an argument NODD cannot see is a file:node x(no extension, no path) - an interpreter reached through a variable or an alias:
I=node; $I x.js - a wrapper that names a command instead of running it:
command -v node,xargs -I node echo(a wrapper that runs one is covered, whatever flags it carries) - compilers, formatters and codegen writing as a side effect
- redirection hidden behind a variable or
eval - a pre-existing background process
- writes performed by other extensions' or MCP tools
- writes performed outside pi entirely
- a delegated child launched with its own
extensions:list, which pi-subagents starts with--no-extensions
Several rows over-approximate on purpose. A command string cannot say what
node server.js will do, so a named script file is treated as a write even when it
only reads; and because a newline opens a command, the mover, permission, tee,
patch and installer rows read past the first line too. Data is excluded from all of
them — quoted strings, # comments and heredoc bodies — so searching for the word
install, or passing a script through cat <<'EOF', is a read. The refusal carries its remedy and a one-shot escape hatch, so that cost
is bounded; an unnoticed write is not.
What this gate is for. It catches the write a model reaches for when it takes the shortest way out after a refusal — the next obvious form, not the next clever one. It does not catch a model determined to hide a write, and it cannot: the surface of a Turing-complete shell does not close by enumeration. Four review rounds on this list each found something, and the last one found a newline. Treat the covered table as the floor, never as the boundary.
A partial gate that says which half it holds is worth more than a total one that is not.
Enforcement scope
Where NODD's gates run: in the process that loaded them, and in delegated
pi-subagent children. This was measured, not assumed
(spike/subagent-enforcement/RESULT.md, pi 0.84.2, 2026-09-19): when NODD is
installed as a pi package — the documented install path — a child process
launched by pi-subagents performs its own ambient extension discovery, finds
NODD, and the child's write was blocked by the child's own copy of the gate.
Enforcement scope is therefore "parent and delegated children".
Children also inherit the parent's working directory, so they read and write the
same .nodd/<slug>/ artifacts. gate-track transfers through the file, not
merely per call.
Four vectors this does not cover:
- An agent definition with its own
extensions:list. That setsdisableAmbientExtensionsand pi-subagents launches the child with--no-extensions; NODD is absent from that child unless listed explicitly. An agent file NODD did not author can opt out of NODD. NODD's own generated agents deliberately declare noextensions:line, and a test enforces it. - A capability ceiling with
denyExtensionshas the same effect. - Grandchildren were never probed. Only depth 1 was measured. Nothing here claims anything about a child's child.
- Session state is not shared. Each process folds its own observations; a child does not see the parent's in-memory counters.
Also: this is a behaviour of how pi-subagents builds the child's argv, not a documented API guarantee. It can change on upgrade.
Counters are per-process, and there is no session total
This is the correct semantics rather than a gap, but it must be stated plainly. The mapping, writer and long-session triggers exist to keep one process's context thin enough to orchestrate. A child runs in its own process with its own context window, so a child that reads four files has filled its context, not the parent's — and it is the child that should then delegate.
What does not exist is an aggregate whole-session total across parent and children. NODD does not claim one. What it has is a per-process count plus a shared on-disk artifact.
What actually satisfies a checkoff
An observed exit-0 is not enough. gate-evidence requires four facts to line
up, because a green command proves something only about which command ran and
when:
- A success, parsed from the observed tool result (
src/outcome.ts). - The declared runner.
nodd_declarerecords arunnerinto the feature doc's## Verificationsection, and only runs of that command count.npm test -- one.test.tscounts;echo "all tests pass"does not, and neither does any other command that merely mentions the runner. A pinned runner cannot be re-pinned: re-declaring with a different one is refused, so a refused checkoff cannot be repaired by renaming the check to whatever did pass. - After the task's last write. A run observed before the edit it supposedly verifies proves nothing about the edit. Ordering uses the kernel's observation sequence rather than the wall clock, because a write and the run after it routinely land in the same millisecond.
- A RED first, under strict TDD, when the declaration set
tdd: strict.tdd: strictwithout a runner is refused at declaration, because a RED run is a failing run of the declared runner: accepting it would write- tdd: strictinto the document while checking nothing. Strict is pinned the same way the runner is: once set, re-declaring without it is refused, so the discipline cannot be dropped after the work by leaving one field out.
The honest limit: the model still chooses the runner. NODD cannot know what the right check for your project is. What it enforces is that the choice is made up front, in a durable artifact, before the work — so the command the work is judged by cannot be invented afterwards to fit whatever happened to pass.
Declaring no runner at all is still allowed, and then fact 2 cannot be checked.
It is skipped rather than faked, and the evidence line itself discloses it:
observed: <cmd> → success (runner not pinned). That caveat appears only on
an unpinned checkoff, so a reader auditing the artifact can tell a run certified
by the declared runner from one certified by whatever exited 0. What NODD does
not do on that path is refuse: with nothing pinned there is nothing to
compare against, so an unpinned feature buys disclosure, not enforcement.
Resume: prior evidence comes back as unverified
When you resume a feature in a new session, the previous session's evidence is not enough to check a task off. The check has to be re-run.
This is expected behaviour, and the reason is one line: evidence means "observed by the kernel", and a fresh process has observed nothing yet. It is fail-closed on purpose. You will meet it as friction; it is not a bug.
Two outcomes that look similar and are not:
unverified— NODD has no observation of this command in this process. Nothing contradicts it; it simply was not seen here.mismatch— NODD does have an observation and it contradicts what the ledger claims. That is a different and more serious failure, and it reads differently in the refusal.
The evidence ledger is distinct from the feature document. Editing the ledger from outside degrades its entries rather than being silently accepted, and a degraded entry counts as no evidence at all.
Command outcomes
A command's outcome is parsed from the observed tool result
(src/outcome.ts), from the last non-empty line only — a runner that prints
"Command exited with code 0" in its own stdout must not thereby rewrite its own
verdict.
| outcome | meaning | satisfies evidence |
|---|---|---|
success |
the tool reported no error | yes |
exit <code> |
the command exited non-zero | no |
aborted |
the command was aborted | no |
timeout <n>s |
the command timed out | no |
unknown |
the result could not be parsed | no — fail closed |
unknown not counting is the whole rule. An unparseable result is an
unobserved result, and treating it as success is exactly how a protocol starts
certifying work nobody checked.
Flags, the kill switch and the escape hatch
| control | effect |
|---|---|
--nodd-off=<gate> |
turns that gate off for the session |
--nodd-off=all |
turns every gate off |
gates.<id>.enabled: false in ~/.pi/nodd.json |
turns that gate off persistently |
/nodd-allow <gate> |
one-shot override for the next call only |
/nodd-gates status |
read-only: effective mode and deciding source per gate |
The semantics are inherited verbatim from ODD's own kill switch:
- Off means off, entirely. When you ask NODD to stop, it stops. It does not argue, does not work around it, does not propose an alternative first, and does not re-enable itself later.
- Off is not a fault to diagnose. A disabled gate is a user decision, not a broken state, and nothing in the prompt suggests turning it back on.
- A disabled gate reports
disabled, never a fabricated approval. Withgates.evidenceoff, a task recordsobserved: none (gate disabled)— never a claim that something was verified. statusreports the deciding source;defaultmeans nobody chose.
Turning off track is also the documented way to hand-edit a feature document.
Deliberate divergence from ODD
ODD selects the SDD route by predicted magnitude (routing.go:68: file count,
changed lines, size, perceived risk). NODD diverges here deliberately.
gate-promotion reads no size, no line count and no risk score. Its triggers
are observables only, and there are exactly two: two consecutive non-success
outcomes of the runner the task declared, and a task writing more distinct
files than it declared.
A third trigger, "the user asked", was specified and removed. /nodd-promote
performs the promotion itself and holds no kernel state, so no gate can observe
the asking and still have something useful to do about it — blocking a write to
suggest the command you just ran is circular. It shipped once as a hardcoded
false, which is precisely the overclaim this gate exists to prevent, so it is
gone from the type and the docs rather than left looking operational.
A big task that succeeds is not divergent. A three-line task that fails twice is. A source-scan test rejects every size-flavoured identifier in the promotion path, so this divergence cannot quietly decay back into a threshold.
The ~400-line figure survives only as advice (see the matrix below), never as a gate.
The M/P/F parity matrix
Every clause of ODD's guidance surface is classified as exactly one of:
- (M) mechanized — a gate or module enforces it;
- (P) forwarded prose — kept as injected text, with a stated reason why it cannot be mechanized;
- (F) out of scope — not carried, with a stated reason.
The full row-by-row matrix lives in .sdd/nodd/requirements.md under
REQ: odd-parity-matrix. Its (P) rows are implemented as data in
src/odd-prose.ts — one entry per row, tagged with the canonical step it
belongs to — and a test asserts the two stay in correspondence.
A (P) reason may never be "we did not get to it." A clause with no stated
obstacle is a mechanism someone skipped, and a test rejects that wording.
The two most load-bearing (P) clauses:
- The preparation trigger (
routing.go:81). Reading that prepares a write should be delegated too — but intent-to-write is not visible in a read event. NODD ships nopreparegate, and a test asserts no gate id containsprepar: a mechanism here would have to guess intent, and a gate that guesses is worse than prose that admits it. - The ~400-line advisory (
routing.go:95), with its full anti-gaming sentence. A line count measures typing, not correctness. Mechanizing it would make the number an objective to optimize against — which is exactly what the clause forbids. A test asserts no gate consults a line count.
Not carried from ODD
| clause | why not |
|---|---|
| Engram/Cortex memory mirror | out of scope by decision; NODD's durable truth is .nodd/<slug>/ on disk |
gentle-ai review assess tiers, consent ceremony, preflight STATUS |
depends on the gentle-ai binary, which is deliberately not copied. A test asserts no code path invokes it. NODD's analogue is forge's veredicto phase |
| push / PR creation / merge | these remain the user's decisions. No gate and no tool in NODD pushes, opens a PR or merges, and a test asserts no code path runs them |
The prompt budget
NODD injects two blocks per turn: block A (the session state) and block B (the
forwarded prose for the current step only). They are capped at 1500 and
2500 characters. The budget is asserted against the corpus — the text
before any truncation — so adding a clause that does not fit turns the test red
instead of silently pushing an existing clause out. If a block does overflow at
runtime it is cut, and the cut announces itself inside the block and in the
rendered result's overBudget list, never silently.
The cap is the mechanism. gentle's guidance surface reached 105,993 bytes one individually-defensible clause at a time: no single addition was wrong, the sum was. Adding a clause that does not fit is therefore a decision to shorten another one — the conversation that never happened in gentle.
Turning every gate off collapses block A to a single line, but block B still forwards the step's prose. Turning enforcement off is not a request to stop being useful.
Commands
| command | does |
|---|---|
/nodd-gates [status|on|off] [<gate>] |
inspect and set gate flags |
/nodd-allow <gate> |
grant a one-shot override |
/nodd-models [<slot>=<provider>/<model>|profile …] |
assign models per slot; no argument opens the picker |
/nodd-promote <slug> |
hand a feature to /forge |
/nodd-promote writes exactly one file, .sdd/<slug>/requirements.md, and
deliberately emits no design.md and no tasks.md so forge's resume lands on
no-plan and restarts at its plan phase. It refuses to overwrite an existing
requirements.md, and it is the only module in the package permitted to write
under .sdd/ — a test scans every other source file to keep it that way.
Forge is an optional dependency. When it is absent, the artifact is still written and the exact command to run by hand is reported.
Known limitations
These are open, not fixed. They are here because a declared problem is a result and a hidden one makes everything above worthless.
- Two sessions in one repo can lose observations. NODD has no cross-process
coordination of any kind.
appendRecord(src/ledger.ts) reads.nodd/<slug>/state.json, appends, and writes the whole file back; two pi sessions interleaving that sequence leave only the second session's record, and no error is raised — the read-back guard compares the file against what that session wrote, so it cannot see a third party's change. There is no lock, no queue and no retry, and none is planned: coordinating across processes is a design problem, not a patch. Run one NODD session per repository. Pinned by a test that interleaves two appends and asserts the loss. - A delegated worker cannot declare its own route.
nodd_declareis a kernel tool and kernel state is per session, so a subagent starts with no declaration and no way to make one — its first write is refused bygate-classify. The refusal's second remedy, reporting back to the delegator, needs no tool and is always reachable, so this is not a dead end; but it costs one delegator round-trip per delegated writer. Measured: five consecutive subagents in one SDD run hit this. Exempting a directory or persisting a declaration for children to inherit were both considered and rejected —.sdd/**is the path ODD restricts most, and an inherited declaration would be authority the child's own session never observed. - There is no single switch that turns everything off.
/nodd-gates disable <gate>is per gate, so stopping NODD entirely means disabling all six. A globalallflag exists inresolveFlagbut nothing populates it, andgates.allin the config file does not work. To remove NODD completely, drop it from your pi config — the package owns no state outside~/.pi/nodd.jsonand the repo's.nodd/. - Only pi's builtin writers are gated. Enforcement sees
write,editandbash. A write performed by any other tool — a filesystem MCP server, a tool from another harness — is invisible to every gate, because NODD classifies a call by the tool that makes it. If you run an MCP that writes files, NODD does not cover that path. Pinned insrc/gates/request.test.ts. - An unpinned feature is not enforced, only disclosed.
runneris optional at declaration. Omit it and fact 2 cannot be checked, so any observed exit-0 after the write can check a task off. The evidence line sayssuccess (runner not pinned)so the artifact never passes it off as a certified run, but the checkoff does happen. Pin a runner to get enforcement. isDeclaredRunnermatches whole tokens by prefix, so a flag that redirects the working directory passes.npm test --prefix /tmpis accepted as a run ofnpm test; the gate cannot tell a flag that narrows scope from one that moves it elsewhere. It requires the model to pin a runner and then invoke it with a redirecting flag.readLedgerreports defects that no production caller reads. A corrupt ledger is treated as empty, which is fail-closed for evidence (nothing to support a checkoff) but the corruption itself is not surfaced to the user.- No test parses the English in this file. See the note at the top: the README contract checks names, sections and totals, not whether a sentence promises more than the code delivers.
- Enforcement in grandchildren was never measured. Only depth 1 was probed; see Enforcement scope.