@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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/extensions/nodd-agents.test.ts +129 -0
  4. package/extensions/nodd-agents.ts +185 -0
  5. package/extensions/nodd-allow.test.ts +75 -0
  6. package/extensions/nodd-allow.ts +76 -0
  7. package/extensions/nodd-enforcement.test.ts +676 -0
  8. package/extensions/nodd-gates.test.ts +108 -0
  9. package/extensions/nodd-gates.ts +121 -0
  10. package/extensions/nodd-kernel.test.ts +114 -0
  11. package/extensions/nodd-kernel.ts +593 -0
  12. package/extensions/nodd-models.test.ts +174 -0
  13. package/extensions/nodd-models.ts +253 -0
  14. package/extensions/nodd-promote.test.ts +150 -0
  15. package/extensions/nodd-promote.ts +96 -0
  16. package/extensions/nodd-prompt.test.ts +87 -0
  17. package/extensions/nodd-tools.test.ts +211 -0
  18. package/package.json +44 -0
  19. package/src/bash-classifier.test.ts +114 -0
  20. package/src/bash-classifier.ts +69 -0
  21. package/src/change-acceptance.test.ts +175 -0
  22. package/src/change-acceptance.ts +98 -0
  23. package/src/config.test.ts +61 -0
  24. package/src/config.ts +103 -0
  25. package/src/delivery.test.ts +156 -0
  26. package/src/delivery.ts +151 -0
  27. package/src/feature-doc.test.ts +120 -0
  28. package/src/feature-doc.ts +292 -0
  29. package/src/gates/authorize.test.ts +62 -0
  30. package/src/gates/authorize.ts +32 -0
  31. package/src/gates/classify.test.ts +54 -0
  32. package/src/gates/classify.ts +45 -0
  33. package/src/gates/delegate.test.ts +127 -0
  34. package/src/gates/delegate.ts +85 -0
  35. package/src/gates/evidence.test.ts +281 -0
  36. package/src/gates/evidence.ts +209 -0
  37. package/src/gates/policy.test.ts +77 -0
  38. package/src/gates/policy.ts +90 -0
  39. package/src/gates/promotion.test.ts +133 -0
  40. package/src/gates/promotion.ts +81 -0
  41. package/src/gates/registry.ts +21 -0
  42. package/src/gates/request.ts +41 -0
  43. package/src/gates/track.test.ts +80 -0
  44. package/src/gates/track.ts +58 -0
  45. package/src/io.test.ts +81 -0
  46. package/src/io.ts +94 -0
  47. package/src/ledger.test.ts +122 -0
  48. package/src/ledger.ts +133 -0
  49. package/src/manifest.test.ts +53 -0
  50. package/src/manifest.ts +61 -0
  51. package/src/models/assign.test.ts +125 -0
  52. package/src/models/assign.ts +138 -0
  53. package/src/models/picker.test.ts +141 -0
  54. package/src/models/picker.ts +98 -0
  55. package/src/models/profiles.test.ts +186 -0
  56. package/src/models/profiles.ts +162 -0
  57. package/src/models/slots.ts +48 -0
  58. package/src/observations.test.ts +61 -0
  59. package/src/observations.ts +51 -0
  60. package/src/odd-prose.test.ts +125 -0
  61. package/src/odd-prose.ts +198 -0
  62. package/src/outcome.test.ts +75 -0
  63. package/src/outcome.ts +63 -0
  64. package/src/promote.test.ts +129 -0
  65. package/src/promote.ts +64 -0
  66. package/src/prompt.test.ts +193 -0
  67. package/src/prompt.ts +136 -0
  68. package/src/review-candidate.test.ts +118 -0
  69. package/src/review-candidate.ts +81 -0
  70. package/src/state.test.ts +153 -0
  71. package/src/state.ts +163 -0
  72. package/test/package-invariants.test.ts +66 -0
  73. package/test/parity-matrix.test.ts +272 -0
  74. 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
+ });