@gonrocca/nodd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/extensions/nodd-agents.test.ts +129 -0
- package/extensions/nodd-agents.ts +185 -0
- package/extensions/nodd-allow.test.ts +75 -0
- package/extensions/nodd-allow.ts +76 -0
- package/extensions/nodd-enforcement.test.ts +676 -0
- package/extensions/nodd-gates.test.ts +108 -0
- package/extensions/nodd-gates.ts +121 -0
- package/extensions/nodd-kernel.test.ts +114 -0
- package/extensions/nodd-kernel.ts +593 -0
- package/extensions/nodd-models.test.ts +174 -0
- package/extensions/nodd-models.ts +253 -0
- package/extensions/nodd-promote.test.ts +150 -0
- package/extensions/nodd-promote.ts +96 -0
- package/extensions/nodd-prompt.test.ts +87 -0
- package/extensions/nodd-tools.test.ts +211 -0
- package/package.json +44 -0
- package/src/bash-classifier.test.ts +114 -0
- package/src/bash-classifier.ts +69 -0
- package/src/change-acceptance.test.ts +175 -0
- package/src/change-acceptance.ts +98 -0
- package/src/config.test.ts +61 -0
- package/src/config.ts +103 -0
- package/src/delivery.test.ts +156 -0
- package/src/delivery.ts +151 -0
- package/src/feature-doc.test.ts +120 -0
- package/src/feature-doc.ts +292 -0
- package/src/gates/authorize.test.ts +62 -0
- package/src/gates/authorize.ts +32 -0
- package/src/gates/classify.test.ts +54 -0
- package/src/gates/classify.ts +45 -0
- package/src/gates/delegate.test.ts +127 -0
- package/src/gates/delegate.ts +85 -0
- package/src/gates/evidence.test.ts +281 -0
- package/src/gates/evidence.ts +209 -0
- package/src/gates/policy.test.ts +77 -0
- package/src/gates/policy.ts +90 -0
- package/src/gates/promotion.test.ts +133 -0
- package/src/gates/promotion.ts +81 -0
- package/src/gates/registry.ts +21 -0
- package/src/gates/request.ts +41 -0
- package/src/gates/track.test.ts +80 -0
- package/src/gates/track.ts +58 -0
- package/src/io.test.ts +81 -0
- package/src/io.ts +94 -0
- package/src/ledger.test.ts +122 -0
- package/src/ledger.ts +133 -0
- package/src/manifest.test.ts +53 -0
- package/src/manifest.ts +61 -0
- package/src/models/assign.test.ts +125 -0
- package/src/models/assign.ts +138 -0
- package/src/models/picker.test.ts +141 -0
- package/src/models/picker.ts +98 -0
- package/src/models/profiles.test.ts +186 -0
- package/src/models/profiles.ts +162 -0
- package/src/models/slots.ts +48 -0
- package/src/observations.test.ts +61 -0
- package/src/observations.ts +51 -0
- package/src/odd-prose.test.ts +125 -0
- package/src/odd-prose.ts +198 -0
- package/src/outcome.test.ts +75 -0
- package/src/outcome.ts +63 -0
- package/src/promote.test.ts +129 -0
- package/src/promote.ts +64 -0
- package/src/prompt.test.ts +193 -0
- package/src/prompt.ts +136 -0
- package/src/review-candidate.test.ts +118 -0
- package/src/review-candidate.ts +81 -0
- package/src/state.test.ts +153 -0
- package/src/state.ts +163 -0
- package/test/package-invariants.test.ts +66 -0
- package/test/parity-matrix.test.ts +272 -0
- package/test/readme-contract.test.ts +182 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gonzalo Rocca
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# NODD
|
|
2
|
+
|
|
3
|
+
**N**on-negotiable **O**rganic **D**riven **D**evelopment — the ODD protocol as
|
|
4
|
+
runtime mechanism for pi instead of injected prose: blocking gates fed by real
|
|
5
|
+
tool events, evidence read from observed tool results, and promotion of NODD
|
|
6
|
+
artifacts into `/forge` artifacts.
|
|
7
|
+
|
|
8
|
+
ODD's failure was promising compliance while shipping delivery. This file is
|
|
9
|
+
where NODD refuses to repeat it: everything below states what is mechanized,
|
|
10
|
+
what is only advice, and what is not carried at all.
|
|
11
|
+
|
|
12
|
+
`test/readme-contract.test.ts` holds part of that line mechanically: it fails if
|
|
13
|
+
the README names a gate, a module or a command that does not exist, drops a
|
|
14
|
+
canonical step, quotes a matrix total that disagrees with the matrix, or lets
|
|
15
|
+
the bash, enforcement-scope, resume and kill-switch sections stop stating their
|
|
16
|
+
limits. It does **not** parse English: a newly written sentence promising more
|
|
17
|
+
than the code does will not turn it red. Prose added next to a fix is therefore
|
|
18
|
+
the known way this document can drift ahead of the product, and the defence is
|
|
19
|
+
review, not the suite. Round 3 found three such sentences and closed them.
|
|
20
|
+
|
|
21
|
+
## The seven canonical steps
|
|
22
|
+
|
|
23
|
+
`authorize` → `explore` → `resolve-uncertainty` → `classify` → `track` →
|
|
24
|
+
`implement` → `close`.
|
|
25
|
+
|
|
26
|
+
Four of them — `authorize`, `classify`, `track` and `close` — are **mechanism
|
|
27
|
+
steps**: they run no model and cannot be assigned one. `/nodd-models` shows them
|
|
28
|
+
as `mecanismo · sin modelo` and refuses an assignment. The other three are the
|
|
29
|
+
steps where a model actually does work.
|
|
30
|
+
|
|
31
|
+
## The gates
|
|
32
|
+
|
|
33
|
+
| gate | fires on | refuses when |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `gate-authorize` | writes, mutating bash, delegation | intent was declared `read-only` |
|
|
36
|
+
| `gate-classify` | the first write | nothing was declared at all |
|
|
37
|
+
| `gate-track` | the first source write on a `tracked`/`forge` route | no feature doc exists yet |
|
|
38
|
+
| `gate-delegate` | writes and mutating bash | the mapping, writer or long-session threshold fired and nothing was delegated |
|
|
39
|
+
| `gate-evidence` | checking a task off | the declared runner was not observed succeeding after the task's last write |
|
|
40
|
+
| `gate-promotion` | writes | the work diverged from what was declared |
|
|
41
|
+
|
|
42
|
+
Every refusal names what was observed, the action that unblocks it, and a
|
|
43
|
+
one-shot `/nodd-allow <gate>` escape hatch. A gate that blocks without naming
|
|
44
|
+
the exit is a dead end, so `src/gates/policy.ts` makes the remedy a required
|
|
45
|
+
field rather than an encouraged one.
|
|
46
|
+
|
|
47
|
+
## The bash gate
|
|
48
|
+
|
|
49
|
+
`write` and `edit` are typed tool calls, so NODD gates them exactly. A bash call
|
|
50
|
+
arrives as a command *string* — pi's `tool_call` event carries no model of what
|
|
51
|
+
the shell will touch — so bash is gated by a denylist of mutation patterns.
|
|
52
|
+
|
|
53
|
+
A denylist is a partial mechanism. What it covers and what it does not are
|
|
54
|
+
published together, here, and both lists are generated from the classifier's own
|
|
55
|
+
pattern set (`src/bash-classifier.ts`) and asserted against by
|
|
56
|
+
`src/bash-classifier.test.ts`, so the table and the code cannot drift apart.
|
|
57
|
+
|
|
58
|
+
### Covered
|
|
59
|
+
|
|
60
|
+
| pattern | example |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| output redirection (`>`, `>>`), excluding fd-only forms like `2>&1` | `echo hi > f.txt` |
|
|
63
|
+
| `tee` | `ls \| tee out.txt` |
|
|
64
|
+
| in-place editors (`sed -i`, `perl -i`) | `sed -i 's/a/b/' f.ts` |
|
|
65
|
+
| movers and removers (`mv`, `cp`, `rm`, `rmdir`, `ln`, `install`, `dd`, `truncate`, `touch`, `mkdir`) | `rm -rf build` |
|
|
66
|
+
| permission changes (`chmod`, `chown`) | `chmod +x run.sh` |
|
|
67
|
+
| `patch` | `patch -p1 < fix.diff` |
|
|
68
|
+
| mutating `git` subcommands (`apply`, `checkout`, `restore`, `reset`, `commit`, `stash`, `clean`, `mv`, `rm`) | `git commit -m 'x'` |
|
|
69
|
+
| package installers (`npm`/`pnpm`/`yarn`/`pip`/`cargo` install or add) | `npm install lodash` |
|
|
70
|
+
| inline interpreters (`node -e`, `python -c`) | `node -e "require('fs').writeFileSync('f','x')"` |
|
|
71
|
+
|
|
72
|
+
### Not covered
|
|
73
|
+
|
|
74
|
+
These mutation vectors reach the filesystem without this classifier noticing:
|
|
75
|
+
|
|
76
|
+
- a script or build target that writes: `./build.sh`, `make`, `npm run build`
|
|
77
|
+
- compilers, formatters and codegen writing as a side effect
|
|
78
|
+
- redirection hidden behind a variable or `eval`
|
|
79
|
+
- a pre-existing background process
|
|
80
|
+
- writes performed by other extensions' or MCP tools
|
|
81
|
+
- writes performed outside pi entirely
|
|
82
|
+
- a delegated child launched with its own `extensions:` list, which pi-subagents starts with `--no-extensions`
|
|
83
|
+
|
|
84
|
+
A partial gate that says which half it holds is worth more than a total one that
|
|
85
|
+
is not.
|
|
86
|
+
|
|
87
|
+
## Enforcement scope
|
|
88
|
+
|
|
89
|
+
**Where NODD's gates run:** in the process that loaded them, and in delegated
|
|
90
|
+
pi-subagent children. This was measured, not assumed
|
|
91
|
+
(`spike/subagent-enforcement/RESULT.md`, pi 0.84.2, 2026-09-19): when NODD is
|
|
92
|
+
installed as a pi package — the documented install path — a child process
|
|
93
|
+
launched by pi-subagents performs its own ambient extension discovery, finds
|
|
94
|
+
NODD, and the child's `write` was blocked by the child's own copy of the gate.
|
|
95
|
+
Enforcement scope is therefore "parent and delegated children".
|
|
96
|
+
|
|
97
|
+
Children also inherit the parent's working directory, so they read and write the
|
|
98
|
+
same `.nodd/<slug>/` artifacts. `gate-track` transfers through the file, not
|
|
99
|
+
merely per call.
|
|
100
|
+
|
|
101
|
+
**Four vectors this does not cover:**
|
|
102
|
+
|
|
103
|
+
1. **An agent definition with its own `extensions:` list.** That sets
|
|
104
|
+
`disableAmbientExtensions` and pi-subagents launches the child with
|
|
105
|
+
`--no-extensions`; NODD is absent from that child unless listed explicitly.
|
|
106
|
+
An agent file NODD did not author can opt out of NODD. NODD's own generated
|
|
107
|
+
agents deliberately declare no `extensions:` line, and a test enforces it.
|
|
108
|
+
2. **A capability ceiling with `denyExtensions`** has the same effect.
|
|
109
|
+
3. **Grandchildren were never probed.** Only depth 1 was measured. Nothing here
|
|
110
|
+
claims anything about a child's child.
|
|
111
|
+
4. **Session state is not shared.** Each process folds its own observations; a
|
|
112
|
+
child does not see the parent's in-memory counters.
|
|
113
|
+
|
|
114
|
+
Also: this is a behaviour of how pi-subagents builds the child's argv, not a
|
|
115
|
+
documented API guarantee. It can change on upgrade.
|
|
116
|
+
|
|
117
|
+
### Counters are per-process, and there is no session total
|
|
118
|
+
|
|
119
|
+
This is the correct semantics rather than a gap, but it must be stated plainly.
|
|
120
|
+
The mapping, writer and long-session triggers exist to keep *one process's
|
|
121
|
+
context* thin enough to orchestrate. A child runs in its own process with its
|
|
122
|
+
own context window, so a child that reads four files has filled *its* context,
|
|
123
|
+
not the parent's — and it is the child that should then delegate.
|
|
124
|
+
|
|
125
|
+
What does not exist is an **aggregate whole-session total across parent and
|
|
126
|
+
children**. NODD does not claim one. What it has is a per-process count plus a
|
|
127
|
+
shared on-disk artifact.
|
|
128
|
+
|
|
129
|
+
### What actually satisfies a checkoff
|
|
130
|
+
|
|
131
|
+
An observed exit-0 is not enough. `gate-evidence` requires four facts to line
|
|
132
|
+
up, because a green command proves something only about *which* command ran and
|
|
133
|
+
*when*:
|
|
134
|
+
|
|
135
|
+
1. **A success**, parsed from the observed tool result (`src/outcome.ts`).
|
|
136
|
+
2. **The declared runner.** `nodd_declare` records a `runner` into the feature
|
|
137
|
+
doc's `## Verification` section, and only runs of that command count.
|
|
138
|
+
`npm test -- one.test.ts` counts; `echo "all tests pass"` does not, and
|
|
139
|
+
neither does any other command that merely mentions the runner. A pinned
|
|
140
|
+
runner cannot be re-pinned: re-declaring with a different one is refused, so
|
|
141
|
+
a refused checkoff cannot be repaired by renaming the check to whatever did
|
|
142
|
+
pass.
|
|
143
|
+
3. **After the task's last write.** A run observed before the edit it supposedly
|
|
144
|
+
verifies proves nothing about the edit. Ordering uses the kernel's
|
|
145
|
+
observation sequence rather than the wall clock, because a write and the run
|
|
146
|
+
after it routinely land in the same millisecond.
|
|
147
|
+
4. **A RED first, under strict TDD**, when the declaration set `tdd: strict`.
|
|
148
|
+
`tdd: strict` without a runner is refused at declaration, because a RED run
|
|
149
|
+
is a failing run *of the declared runner*: accepting it would write
|
|
150
|
+
`- tdd: strict` into the document while checking nothing. Strict is pinned
|
|
151
|
+
the same way the runner is: once set, re-declaring without it is refused, so
|
|
152
|
+
the discipline cannot be dropped after the work by leaving one field out.
|
|
153
|
+
|
|
154
|
+
The honest limit: **the model still chooses the runner.** NODD cannot know what
|
|
155
|
+
the right check for your project is. What it enforces is that the choice is made
|
|
156
|
+
up front, in a durable artifact, before the work — so the command the work is
|
|
157
|
+
judged by cannot be invented afterwards to fit whatever happened to pass.
|
|
158
|
+
|
|
159
|
+
Declaring no runner at all is still allowed, and then fact 2 cannot be checked.
|
|
160
|
+
It is skipped rather than faked, and the evidence line itself discloses it:
|
|
161
|
+
`observed: <cmd> → success (runner not pinned)`. That caveat appears only on
|
|
162
|
+
an unpinned checkoff, so a reader auditing the artifact can tell a run certified
|
|
163
|
+
by the declared runner from one certified by whatever exited 0. What NODD does
|
|
164
|
+
**not** do on that path is refuse: with nothing pinned there is nothing to
|
|
165
|
+
compare against, so an unpinned feature buys disclosure, not enforcement.
|
|
166
|
+
|
|
167
|
+
### Resume: prior evidence comes back as `unverified`
|
|
168
|
+
|
|
169
|
+
When you resume a feature in a new session, the previous session's evidence is
|
|
170
|
+
**not** enough to check a task off. The check has to be re-run.
|
|
171
|
+
|
|
172
|
+
This is expected behaviour, and the reason is one line: evidence means "observed
|
|
173
|
+
by the kernel", and a fresh process has observed nothing yet. It is fail-closed
|
|
174
|
+
on purpose. You will meet it as friction; it is not a bug.
|
|
175
|
+
|
|
176
|
+
Two outcomes that look similar and are not:
|
|
177
|
+
|
|
178
|
+
- **`unverified`** — NODD has no observation of this command in this process.
|
|
179
|
+
Nothing contradicts it; it simply was not seen here.
|
|
180
|
+
- **`mismatch`** — NODD *does* have an observation and it contradicts what the
|
|
181
|
+
ledger claims. That is a different and more serious failure, and it reads
|
|
182
|
+
differently in the refusal.
|
|
183
|
+
|
|
184
|
+
The evidence ledger is distinct from the feature document. Editing the ledger
|
|
185
|
+
from outside degrades its entries rather than being silently accepted, and a
|
|
186
|
+
degraded entry counts as no evidence at all.
|
|
187
|
+
|
|
188
|
+
## Command outcomes
|
|
189
|
+
|
|
190
|
+
A command's outcome is parsed from the observed tool result
|
|
191
|
+
(`src/outcome.ts`), from the last non-empty line only — a runner that prints
|
|
192
|
+
"Command exited with code 0" in its own stdout must not thereby rewrite its own
|
|
193
|
+
verdict.
|
|
194
|
+
|
|
195
|
+
| outcome | meaning | satisfies evidence |
|
|
196
|
+
| --- | --- | --- |
|
|
197
|
+
| `success` | the tool reported no error | **yes** |
|
|
198
|
+
| `exit <code>` | the command exited non-zero | no |
|
|
199
|
+
| `aborted` | the command was aborted | no |
|
|
200
|
+
| `timeout <n>s` | the command timed out | no |
|
|
201
|
+
| `unknown` | the result could not be parsed | **no — fail closed** |
|
|
202
|
+
|
|
203
|
+
`unknown` not counting is the whole rule. An unparseable result is an
|
|
204
|
+
unobserved result, and treating it as success is exactly how a protocol starts
|
|
205
|
+
certifying work nobody checked.
|
|
206
|
+
|
|
207
|
+
## Flags, the kill switch and the escape hatch
|
|
208
|
+
|
|
209
|
+
| control | effect |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| `--nodd-off=<gate>` | turns that gate off for the session |
|
|
212
|
+
| `--nodd-off=all` | turns every gate off |
|
|
213
|
+
| `gates.<id>.enabled: false` in `~/.pi/nodd.json` | turns that gate off persistently |
|
|
214
|
+
| `/nodd-allow <gate>` | one-shot override for the next call only |
|
|
215
|
+
| `/nodd-gates status` | read-only: effective mode and deciding source per gate |
|
|
216
|
+
|
|
217
|
+
The semantics are inherited verbatim from ODD's own kill switch:
|
|
218
|
+
|
|
219
|
+
- **Off means off, entirely.** When you ask NODD to stop, it stops. It does not
|
|
220
|
+
argue, does not work around it, does not propose an alternative first, and
|
|
221
|
+
does not re-enable itself later.
|
|
222
|
+
- **Off is not a fault to diagnose.** A disabled gate is a user decision, not a
|
|
223
|
+
broken state, and nothing in the prompt suggests turning it back on.
|
|
224
|
+
- **A disabled gate reports `disabled`, never a fabricated approval.** With
|
|
225
|
+
`gates.evidence` off, a task records `observed: none (gate disabled)` — never
|
|
226
|
+
a claim that something was verified.
|
|
227
|
+
- `status` reports the deciding source; `default` means nobody chose.
|
|
228
|
+
|
|
229
|
+
Turning off `track` is also the documented way to hand-edit a feature document.
|
|
230
|
+
|
|
231
|
+
## Deliberate divergence from ODD
|
|
232
|
+
|
|
233
|
+
ODD selects the SDD route by predicted magnitude (`routing.go:68`: file count,
|
|
234
|
+
changed lines, size, perceived risk). **NODD diverges here deliberately.**
|
|
235
|
+
`gate-promotion` reads no size, no line count and no risk score. Its triggers
|
|
236
|
+
are observables only, and there are exactly two: two consecutive non-success
|
|
237
|
+
outcomes of the runner the task declared, and a task writing more distinct
|
|
238
|
+
files than it declared.
|
|
239
|
+
|
|
240
|
+
A third trigger, "the user asked", was specified and removed. `/nodd-promote`
|
|
241
|
+
performs the promotion itself and holds no kernel state, so no gate can observe
|
|
242
|
+
the asking and still have something useful to do about it — blocking a write to
|
|
243
|
+
suggest the command you just ran is circular. It shipped once as a hardcoded
|
|
244
|
+
`false`, which is precisely the overclaim this gate exists to prevent, so it is
|
|
245
|
+
gone from the type and the docs rather than left looking operational.
|
|
246
|
+
|
|
247
|
+
A big task that succeeds is not divergent. A three-line task that fails twice
|
|
248
|
+
is. A source-scan test rejects every size-flavoured identifier in the promotion
|
|
249
|
+
path, so this divergence cannot quietly decay back into a threshold.
|
|
250
|
+
|
|
251
|
+
The ~400-line figure survives only as **advice** (see the matrix below), never
|
|
252
|
+
as a gate.
|
|
253
|
+
|
|
254
|
+
## The M/P/F parity matrix
|
|
255
|
+
|
|
256
|
+
Every clause of ODD's guidance surface is classified as exactly one of:
|
|
257
|
+
|
|
258
|
+
- **(M)** mechanized — a gate or module enforces it;
|
|
259
|
+
- **(P)** forwarded prose — kept as injected text, with a stated reason why it
|
|
260
|
+
cannot be mechanized;
|
|
261
|
+
- **(F)** out of scope — not carried, with a stated reason.
|
|
262
|
+
|
|
263
|
+
The full row-by-row matrix lives in `.sdd/nodd/requirements.md` under
|
|
264
|
+
`REQ: odd-parity-matrix`. Its `(P)` rows are implemented as data in
|
|
265
|
+
`src/odd-prose.ts` — one entry per row, tagged with the canonical step it
|
|
266
|
+
belongs to — and a test asserts the two stay in correspondence.
|
|
267
|
+
|
|
268
|
+
**A `(P)` reason may never be "we did not get to it."** A clause with no stated
|
|
269
|
+
obstacle is a mechanism someone skipped, and a test rejects that wording.
|
|
270
|
+
|
|
271
|
+
The two most load-bearing `(P)` clauses:
|
|
272
|
+
|
|
273
|
+
- **The preparation trigger** (`routing.go:81`). Reading that prepares a write
|
|
274
|
+
should be delegated too — but intent-to-write is not visible in a read event.
|
|
275
|
+
NODD ships no `prepare` gate, and a test asserts no gate id contains `prepar`:
|
|
276
|
+
a mechanism here would have to guess intent, and a gate that guesses is worse
|
|
277
|
+
than prose that admits it.
|
|
278
|
+
- **The ~400-line advisory** (`routing.go:95`), with its full anti-gaming
|
|
279
|
+
sentence. A line count measures typing, not correctness. Mechanizing it would
|
|
280
|
+
make the number an objective to optimize against — which is exactly what the
|
|
281
|
+
clause forbids. A test asserts no gate consults a line count.
|
|
282
|
+
|
|
283
|
+
### Not carried from ODD
|
|
284
|
+
|
|
285
|
+
| clause | why not |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| Engram/Cortex memory mirror | out of scope by decision; NODD's durable truth is `.nodd/<slug>/` on disk |
|
|
288
|
+
| `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 |
|
|
289
|
+
| 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 |
|
|
290
|
+
|
|
291
|
+
## The prompt budget
|
|
292
|
+
|
|
293
|
+
NODD injects two blocks per turn: block A (the session state) and block B (the
|
|
294
|
+
forwarded prose for the current step only). They are capped at **1500** and
|
|
295
|
+
**2500** characters. The budget is asserted against the *corpus* — the text
|
|
296
|
+
before any truncation — so adding a clause that does not fit turns the test red
|
|
297
|
+
instead of silently pushing an existing clause out. If a block does overflow at
|
|
298
|
+
runtime it is cut, and the cut announces itself inside the block and in the
|
|
299
|
+
rendered result's `overBudget` list, never silently.
|
|
300
|
+
|
|
301
|
+
The cap is the mechanism. gentle's guidance surface reached 105,993 bytes one
|
|
302
|
+
individually-defensible clause at a time: no single addition was wrong, the sum
|
|
303
|
+
was. Adding a clause that does not fit is therefore a decision to shorten
|
|
304
|
+
another one — the conversation that never happened in gentle.
|
|
305
|
+
|
|
306
|
+
Turning every gate off collapses block A to a single line, but block B still
|
|
307
|
+
forwards the step's prose. Turning enforcement off is not a request to stop
|
|
308
|
+
being useful.
|
|
309
|
+
|
|
310
|
+
## Commands
|
|
311
|
+
|
|
312
|
+
| command | does |
|
|
313
|
+
| --- | --- |
|
|
314
|
+
| `/nodd-gates [status\|on\|off] [<gate>]` | inspect and set gate flags |
|
|
315
|
+
| `/nodd-allow <gate>` | grant a one-shot override |
|
|
316
|
+
| `/nodd-models [<slot>=<provider>/<model>\|profile …]` | assign models per slot; no argument opens the picker |
|
|
317
|
+
| `/nodd-promote <slug>` | hand a feature to `/forge` |
|
|
318
|
+
|
|
319
|
+
`/nodd-promote` writes exactly one file, `.sdd/<slug>/requirements.md`, and
|
|
320
|
+
deliberately emits no `design.md` and no `tasks.md` so forge's resume lands on
|
|
321
|
+
`no-plan` and restarts at its plan phase. It refuses to overwrite an existing
|
|
322
|
+
`requirements.md`, and it is the only module in the package permitted to write
|
|
323
|
+
under `.sdd/` — a test scans every other source file to keep it that way.
|
|
324
|
+
|
|
325
|
+
Forge is an optional dependency. When it is absent, the artifact is still
|
|
326
|
+
written and the exact command to run by hand is reported.
|
|
327
|
+
|
|
328
|
+
## Known limitations
|
|
329
|
+
|
|
330
|
+
These are open, not fixed. They are here because a declared problem is a result
|
|
331
|
+
and a hidden one makes everything above worthless.
|
|
332
|
+
|
|
333
|
+
- **An unpinned feature is not enforced, only disclosed.** `runner` is optional
|
|
334
|
+
at declaration. Omit it and fact 2 cannot be checked, so any observed exit-0
|
|
335
|
+
after the write can check a task off. The evidence line says
|
|
336
|
+
`success (runner not pinned)` so the artifact never passes it off as a
|
|
337
|
+
certified run, but the checkoff does happen. Pin a runner to get enforcement.
|
|
338
|
+
- **`isDeclaredRunner` matches whole tokens by prefix, so a flag that redirects
|
|
339
|
+
the working directory passes.** `npm test --prefix /tmp` is accepted as a run
|
|
340
|
+
of `npm test`; the gate cannot tell a flag that narrows scope from one that
|
|
341
|
+
moves it elsewhere. It requires the model to pin a runner and then invoke it
|
|
342
|
+
with a redirecting flag.
|
|
343
|
+
- **`readLedger` reports defects that no production caller reads.** A corrupt
|
|
344
|
+
ledger is treated as empty, which is fail-closed for evidence (nothing to
|
|
345
|
+
support a checkoff) but the corruption itself is not surfaced to the user.
|
|
346
|
+
- **No test parses the English in this file.** See the note at the top: the
|
|
347
|
+
README contract checks names, sections and totals, not whether a sentence
|
|
348
|
+
promises more than the code delivers.
|
|
349
|
+
- **Enforcement in grandchildren was never measured.** Only depth 1 was probed;
|
|
350
|
+
see *Enforcement scope*.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, chmodSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ANTI_GAMING, NODD_AGENTS, agentsDir, buildAgentFile, provisionAgents } from "./nodd-agents.ts";
|
|
7
|
+
|
|
8
|
+
function tmp(): string {
|
|
9
|
+
return mkdtempSync(join(tmpdir(), "nodd-agents-"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Paths: the nodd namespace, never forge's.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
test("agent files land under agents/nodd/ and never under agents/zero/", () => {
|
|
16
|
+
const dir = agentsDir("/home/someone");
|
|
17
|
+
assert.equal(dir, "/home/someone/.pi/agent/agents/nodd");
|
|
18
|
+
assert.ok(!dir.includes("agents/zero"), "NODD must never write into forge's agent namespace");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the three model-backed steps get an agent, the four mechanisms do not", () => {
|
|
22
|
+
assert.deepEqual(NODD_AGENTS.map((agent) => agent.slot), ["explore", "resolve-uncertainty", "implement"]);
|
|
23
|
+
for (const mechanism of ["authorize", "classify", "track", "close"]) {
|
|
24
|
+
assert.ok(!NODD_AGENTS.some((agent) => agent.slot === mechanism), `${mechanism} runs no model`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// The renderer is pure and its output is pinned.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
test("the renderer matches an expected frontmatter and body exactly", () => {
|
|
32
|
+
const rendered = buildAgentFile(NODD_AGENTS[2], "anthropic/claude-opus-4-1");
|
|
33
|
+
const [, frontmatter] = rendered.split("---\n");
|
|
34
|
+
|
|
35
|
+
assert.ok(rendered.startsWith("---\n"), "the file opens with frontmatter");
|
|
36
|
+
assert.match(frontmatter, /^name: nodd-implement$/m);
|
|
37
|
+
assert.match(frontmatter, /^model: anthropic\/claude-opus-4-1$/m);
|
|
38
|
+
assert.match(frontmatter, /^tools: /m);
|
|
39
|
+
assert.match(frontmatter, /^systemPromptMode: replace$/m);
|
|
40
|
+
assert.ok(rendered.endsWith("\n"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a slot with no configured model falls back to default, then omits the line", () => {
|
|
44
|
+
const withDefault = buildAgentFile(NODD_AGENTS[0], "anthropic/fallback");
|
|
45
|
+
assert.match(withDefault, /^model: anthropic\/fallback$/m);
|
|
46
|
+
|
|
47
|
+
const withNone = buildAgentFile(NODD_AGENTS[0], undefined);
|
|
48
|
+
assert.ok(!/^model:/m.test(withNone), "no model line rather than an empty one pi cannot resolve");
|
|
49
|
+
assert.match(withNone, /^name: nodd-explore$/m);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the frontmatter declares no extensions list of its own", () => {
|
|
53
|
+
// An agent definition with `extensions:` makes pi-subagents launch the child
|
|
54
|
+
// with --no-extensions, which would strip NODD out of its own sub-agent
|
|
55
|
+
// (spike RESULT.md, uncovered vector 1). NODD must not do that to itself.
|
|
56
|
+
for (const agent of NODD_AGENTS) {
|
|
57
|
+
const rendered = buildAgentFile(agent, "anthropic/x");
|
|
58
|
+
assert.ok(!/^extensions:/m.test(rendered), `${agent.slot} must not declare its own extensions list`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// The advisory travels into every generated body.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
test("every generated body carries the advisory-only line heuristic and its anti-gaming sentence", () => {
|
|
66
|
+
for (const agent of NODD_AGENTS) {
|
|
67
|
+
const body = buildAgentFile(agent, "anthropic/x");
|
|
68
|
+
assert.match(body, /400/, `${agent.slot} must carry the line figure`);
|
|
69
|
+
assert.match(body, /advisory|orientativ/i, `${agent.slot} must mark it advisory`);
|
|
70
|
+
assert.ok(body.includes(ANTI_GAMING), `${agent.slot} must carry the anti-gaming sentence verbatim`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the anti-gaming sentence names all the things it forbids", () => {
|
|
75
|
+
for (const forbidden of ["blank lines", "comments", "minify", "tests", "split"]) {
|
|
76
|
+
assert.ok(ANTI_GAMING.includes(forbidden), `the advisory must name ${forbidden}`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("the enforcement caveat from the spike travels with every agent", () => {
|
|
81
|
+
for (const agent of NODD_AGENTS) {
|
|
82
|
+
const body = buildAgentFile(agent, "anthropic/x");
|
|
83
|
+
assert.match(body, /gate/i, "the body states that NODD's gates apply here");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Provisioning: per-file failures are swallowed.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
test("provisioning writes one file per agent, named nodd-<slot>.md", () => {
|
|
91
|
+
const home = tmp();
|
|
92
|
+
const result = provisionAgents(home, { models: { implement: "anthropic/claude-opus-4-1" } });
|
|
93
|
+
|
|
94
|
+
assert.equal(result.written.length, 3);
|
|
95
|
+
const files = readdirSync(agentsDir(home)).sort();
|
|
96
|
+
assert.deepEqual(files, ["nodd-explore.md", "nodd-implement.md", "nodd-resolve-uncertainty.md"]);
|
|
97
|
+
assert.match(readFileSync(join(agentsDir(home), "nodd-implement.md"), "utf8"), /^model: anthropic\/claude-opus-4-1$/m);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("models come from nodd.json with the default slot as fallback", () => {
|
|
101
|
+
const home = tmp();
|
|
102
|
+
provisionAgents(home, { models: { default: "anthropic/fallback", implement: "anthropic/specific" } });
|
|
103
|
+
|
|
104
|
+
const dir = agentsDir(home);
|
|
105
|
+
assert.match(readFileSync(join(dir, "nodd-implement.md"), "utf8"), /^model: anthropic\/specific$/m);
|
|
106
|
+
assert.match(readFileSync(join(dir, "nodd-explore.md"), "utf8"), /^model: anthropic\/fallback$/m);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("one failing write does not prevent the other two", () => {
|
|
110
|
+
const home = tmp();
|
|
111
|
+
const dir = agentsDir(home);
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
// Make one target unwritable by putting a read-only directory in its place.
|
|
114
|
+
mkdirSync(join(dir, "nodd-implement.md"));
|
|
115
|
+
|
|
116
|
+
const result = provisionAgents(home, {});
|
|
117
|
+
assert.equal(result.written.length, 2, "the other two are still written");
|
|
118
|
+
assert.equal(result.failed.length, 1);
|
|
119
|
+
assert.ok(result.failed[0].includes("implement"));
|
|
120
|
+
assert.ok(existsSync(join(dir, "nodd-explore.md")));
|
|
121
|
+
assert.ok(existsSync(join(dir, "nodd-resolve-uncertainty.md")));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("provisioning never throws, whatever the config holds", () => {
|
|
125
|
+
const home = tmp();
|
|
126
|
+
for (const config of [{}, { models: null }, { models: { implement: 42 } }] as never[]) {
|
|
127
|
+
assert.doesNotThrow(() => provisionAgents(home, config));
|
|
128
|
+
}
|
|
129
|
+
});
|