@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
@@ -0,0 +1,676 @@
1
+ // Every assertion here goes through the *registered* `tool_call` handler.
2
+ //
3
+ // No test in this file calls `trackGate()` or any other gate function directly.
4
+ // That is the whole point: the gates were fully tested as pure functions and
5
+ // still never fired, because nothing connected them to pi. A test that reaches
6
+ // past the handler would have passed in that broken state too.
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import register from "./nodd-kernel.ts";
14
+
15
+ type Handler = (event: unknown, ctx?: unknown) => unknown;
16
+
17
+ /**
18
+ * Tool call ids are unique within a session file, so a resumed session never
19
+ * re-mints one its predecessor already used. This counter is module-level for
20
+ * that reason: a per-session counter made session 2 emit `call-0` again, which
21
+ * the reducer correctly dropped as a replayed duplicate, and the test then
22
+ * looked like a fail-closed bug it was not. The helper was unfaithful to pi,
23
+ * so the helper changed.
24
+ */
25
+ let nextId = 0;
26
+ type Block = { block?: boolean; reason?: string; terminate?: boolean } | undefined;
27
+
28
+ /**
29
+ * A session driven through the registered handlers.
30
+ *
31
+ * `entries` is pi's session log: the kernel appends to it through
32
+ * `pi.appendEntry` and reads it back on `session_start` through
33
+ * `ctx.sessionManager.getEntries()`, in pi's real `{ type: "custom",
34
+ * customType, data }` shape. Passing a previous session's array is how a resume
35
+ * is simulated: same session file, new process, empty in-memory state.
36
+ */
37
+ function session(options: { cwd?: string; entries?: Array<Record<string, unknown>> } = {}) {
38
+ const handlers = new Map<string, Handler>();
39
+ const tools = new Map<string, (args: never) => unknown>();
40
+ const entries = options.entries ?? [];
41
+ const pi = {
42
+ on: (event: string, handler: Handler) => handlers.set(event, handler),
43
+ registerTool: (name: string, opts: { handler: (args: never) => unknown }) => tools.set(name, opts.handler),
44
+ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
45
+ };
46
+ const cwd = options.cwd ?? mkdtempSync(join(tmpdir(), "nodd-enforce-"));
47
+ const kernel = register(pi as never, cwd);
48
+
49
+ const toolCall = handlers.get("tool_call");
50
+ const toolResult = handlers.get("tool_result");
51
+ const sessionStart = handlers.get("session_start");
52
+ assert.ok(toolCall, "the kernel must register a tool_call handler");
53
+ assert.ok(toolResult, "the kernel must register a tool_result handler");
54
+ assert.ok(sessionStart, "the kernel must register a session_start handler");
55
+
56
+ // pi fires session_start on every session, resume or not, and the entries
57
+ // come from the context, never from the event.
58
+ sessionStart!({ type: "session_start", reason: options.entries ? "resume" : "startup" }, {
59
+ sessionManager: { getEntries: () => entries },
60
+ });
61
+
62
+ return {
63
+ cwd,
64
+ kernel,
65
+ entries,
66
+ /** Fire one tool call through the real handler. */
67
+ call(toolName: string, input: Record<string, unknown> = {}): Block {
68
+ return toolCall!({ toolName, toolCallId: `call-${nextId++}`, input }) as Block;
69
+ },
70
+ /**
71
+ * A NODD tool as pi actually runs it: preflight, execute, then the result
72
+ * event that advances committed state. Calling the tool handler alone would
73
+ * be a shortcut no real session takes.
74
+ */
75
+ runTool(toolName: string, args: Record<string, unknown>): string {
76
+ const toolCallId = `call-${nextId++}`;
77
+ toolCall!({ toolName, toolCallId, input: args });
78
+ const text = String(tools.get(toolName)!(args as never));
79
+ toolResult!({ toolName, toolCallId, input: args, isError: false, content: text });
80
+ return text;
81
+ },
82
+ /** A tool result pi delivered, with no preflight: an observed fact. */
83
+ observe(toolName: string, input: Record<string, unknown>, content = ""): void {
84
+ toolResult!({ toolName, toolCallId: `call-${nextId++}`, input, isError: false, content });
85
+ },
86
+ /** The same, for a result pi reported as an error. */
87
+ fail(toolName: string, input: Record<string, unknown>, content = ""): void {
88
+ toolResult!({ toolName, toolCallId: `call-${nextId++}`, input, isError: true, content });
89
+ },
90
+ declare(args: Record<string, unknown>) {
91
+ return this.runTool("nodd_declare", args);
92
+ },
93
+ task(args: Record<string, unknown>) {
94
+ return this.runTool("nodd_task", args);
95
+ },
96
+ };
97
+ }
98
+
99
+ const WRITE = { path: "/repo/src/thing.ts", content: "x" };
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // The handler actually reaches the gates
103
+ // ---------------------------------------------------------------------------
104
+ test("an undeclared write is blocked through the handler, with a reason", () => {
105
+ const s = session();
106
+ const result = s.call("write", WRITE);
107
+
108
+ assert.ok(result, "the handler must return a decision, not undefined");
109
+ assert.equal(result!.block, true);
110
+ assert.ok(typeof result!.reason === "string" && result!.reason.length > 0, "a block must carry a reason");
111
+ assert.match(result!.reason!, /nodd\//, "the reason names the gate that refused");
112
+ });
113
+
114
+ test("the block names the concrete action and the escape hatch", () => {
115
+ const reason = session().call("write", WRITE)!.reason!;
116
+ assert.match(reason, /nodd_declare/, "it names what unblocks the call");
117
+ assert.match(reason, /\/nodd-allow/, "it names the one-shot override");
118
+ });
119
+
120
+ test("a kernel that only observed would fail this: the gates are on the real path", () => {
121
+ // If the handler recorded the pending call and returned nothing, this is the
122
+ // assertion that catches it.
123
+ const s = session();
124
+ assert.notEqual(s.call("write", WRITE), undefined, "the handler returned nothing for a violating write");
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Reads and read-only sessions are untouched
129
+ // ---------------------------------------------------------------------------
130
+ test("a read is never blocked", () => {
131
+ const s = session();
132
+ assert.equal(s.call("read", { path: "/repo/src/thing.ts" }), undefined);
133
+ });
134
+
135
+ test("a read-only declaration lets reads through and blocks writes", () => {
136
+ const s = session();
137
+ s.declare({ intent: "read-only", route: "inline", slug: "audit", summary: "look only" });
138
+
139
+ assert.equal(s.call("read", { path: "/repo/a.ts" }), undefined, "reading is the point of read-only");
140
+ assert.equal(s.call("grep", { pattern: "x" }), undefined);
141
+
142
+ const blocked = s.call("write", WRITE);
143
+ assert.equal(blocked?.block, true);
144
+ assert.match(blocked!.reason!, /authorize/, "read-only writes are gate-authorize's business");
145
+ });
146
+
147
+ test("a declared inline change may write", () => {
148
+ const s = session();
149
+ s.declare({ intent: "change", route: "inline", slug: "fix", summary: "small fix" });
150
+ assert.equal(s.call("write", WRITE), undefined, "an authorized inline write proceeds");
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Mutating bash goes through the same path
155
+ // ---------------------------------------------------------------------------
156
+ test("mutating bash is blocked when undeclared; a read-only command is not", () => {
157
+ const s = session();
158
+ assert.equal(s.call("bash", { command: "ls -la" }), undefined, "listing mutates nothing");
159
+ assert.equal(s.call("bash", { command: "rm -rf build" })?.block, true);
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Order and precedence
164
+ // ---------------------------------------------------------------------------
165
+ test("when authorize and track both apply, only authorize speaks", () => {
166
+ const s = session();
167
+ // read-only + a tracked route with no doc: authorize refuses first, and the
168
+ // call must come back with exactly one message.
169
+ s.declare({ intent: "read-only", route: "tracked", slug: "both", summary: "x" });
170
+
171
+ const reason = s.call("write", WRITE)!.reason!;
172
+ assert.match(reason, /nodd\/authorize/, "the first gate in registry order wins");
173
+ assert.ok(!reason.includes("nodd/track"), "a single call never collects two refusals");
174
+ assert.equal(reason.split("nodd/").length - 1, 1, `exactly one gate message, got: ${reason}`);
175
+ });
176
+
177
+ test("gate-track fires once authorize is satisfied", () => {
178
+ const s = session();
179
+ s.declare({ intent: "change", route: "tracked", slug: "warmup", summary: "x" });
180
+
181
+ // The declaration created the doc, so the write proceeds; writing into
182
+ // .nodd/ is what track still refuses.
183
+ const blocked = s.call("write", { path: join(s.cwd, ".nodd", "warmup", "feature.md"), content: "hand-edited" });
184
+ assert.equal(blocked?.block, true);
185
+ assert.match(blocked!.reason!, /nodd\/track/, "NODD's own artifacts are written by NODD");
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Kill switches, on the real path
190
+ // ---------------------------------------------------------------------------
191
+ test("a disabled gate lets the same call through the handler", () => {
192
+ const blocked = session();
193
+ assert.equal(blocked.call("write", WRITE)?.block, true, "baseline: this call blocks");
194
+
195
+ const off = session();
196
+ off.kernel.setPolicy({ config: { classify: { enabled: false } }, flags: {}, hatches: {} });
197
+ assert.equal(off.call("write", WRITE), undefined, "gates.classify.enabled=false turns it off entirely");
198
+ });
199
+
200
+ test("--nodd-off=all turns every gate off on the real path", () => {
201
+ const s = session();
202
+ s.kernel.setPolicy({ config: {}, flags: { all: false }, hatches: {} });
203
+
204
+ s.declare({ intent: "read-only", route: "tracked", slug: "x", summary: "y" });
205
+ assert.equal(s.call("write", WRITE), undefined, "off means off, entirely");
206
+ assert.equal(s.call("bash", { command: "rm -rf /tmp/whatever" }), undefined);
207
+ });
208
+
209
+ test("turning off one gate leaves the others enforcing", () => {
210
+ const s = session();
211
+ s.kernel.setPolicy({ config: { classify: { enabled: false } }, flags: {}, hatches: {} });
212
+ s.declare({ intent: "read-only", route: "inline", slug: "audit", summary: "x" });
213
+
214
+ const blocked = s.call("write", WRITE);
215
+ assert.equal(blocked?.block, true, "authorize still enforces");
216
+ assert.match(blocked!.reason!, /authorize/);
217
+ });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // The escape hatch, on the real path
221
+ // ---------------------------------------------------------------------------
222
+ test("a granted hatch lets exactly one call through, then the gate blocks again", () => {
223
+ const s = session();
224
+ assert.equal(s.call("write", WRITE)?.block, true, "baseline");
225
+
226
+ s.kernel.setPolicy({ config: {}, flags: {}, hatches: { classify: { reason: "one-off" } } });
227
+
228
+ assert.equal(s.call("write", WRITE), undefined, "the hatch is honoured on the real path");
229
+ assert.equal(s.call("write", WRITE)?.block, true, "and it was consumed, not kept");
230
+ });
231
+
232
+ test("a hatch for one gate does not open another", () => {
233
+ const s = session();
234
+ s.declare({ intent: "read-only", route: "inline", slug: "audit", summary: "x" });
235
+ s.kernel.setPolicy({ config: {}, flags: {}, hatches: { track: { reason: "wrong gate" } } });
236
+
237
+ const blocked = s.call("write", WRITE);
238
+ assert.equal(blocked?.block, true, "a track hatch must not excuse an authorize refusal");
239
+ assert.match(blocked!.reason!, /authorize/);
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // terminate is never set
244
+ // ---------------------------------------------------------------------------
245
+ test("no refusal ever sets terminate", () => {
246
+ const s = session();
247
+ const refusals: Block[] = [
248
+ s.call("write", WRITE),
249
+ s.call("bash", { command: "rm -rf x" }),
250
+ s.call("edit", { path: "/repo/a.ts", edits: [] }),
251
+ ];
252
+ for (const refusal of refusals) {
253
+ assert.equal(refusal?.block, true);
254
+ assert.equal(refusal?.terminate, undefined, "a blocked call stops the call, never the agent");
255
+ }
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // The evidence gate on the checkoff path
260
+ // ---------------------------------------------------------------------------
261
+ test("checking off a task with no observed run is refused, not recorded as unverified", () => {
262
+ const s = session();
263
+ s.declare({ intent: "change", route: "tracked", slug: "warmup", summary: "x" });
264
+ s.task({ action: "add", id: "T1", title: "Do the thing", slug: "warmup" });
265
+
266
+ const reply = String(s.task({ action: "check", id: "T1", slug: "warmup" }));
267
+ assert.match(reply, /nodd\/evidence/, "the evidence gate must be on this path");
268
+ assert.ok(!/unverified \(evidence gate not yet active\)/.test(reply), "the placeholder must be gone");
269
+ });
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // The two round-1 attacks, replayed through the real handler
273
+ //
274
+ // Both of these checked a task off in round 1. They are the reason evidence is
275
+ // now bound to (declared runner, time of the task's last write) rather than to
276
+ // "some exit-0 happened in this session".
277
+ // ---------------------------------------------------------------------------
278
+ function trackedFeature(s: ReturnType<typeof session>, extra: Record<string, unknown> = {}) {
279
+ s.declare({
280
+ intent: "change", route: "tracked", slug: "login", summary: "build login",
281
+ runner: "npm test", files: ["/repo/src/login.ts"], ...extra,
282
+ });
283
+ s.task({ action: "add", id: "T1", title: "Implement the login system", slug: "login" });
284
+ }
285
+
286
+ test("stale-green attack: a run observed BEFORE the edit cannot check the task off", () => {
287
+ const s = session();
288
+ trackedFeature(s);
289
+
290
+ // Green first...
291
+ s.observe("bash", { command: "npm test" }, "42 passing");
292
+ // ...then the source is edited. The green now describes code that no longer
293
+ // exists, which is precisely what it must not be allowed to certify.
294
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
295
+
296
+ const reply = String(s.task({ action: "check", id: "T1", slug: "login" }));
297
+ assert.match(reply, /nodd\/evidence/, "the evidence gate must refuse");
298
+ assert.match(reply, /before the last write/i, reply);
299
+ assert.ok(!/checked in/.test(reply), "the task must not be checked");
300
+ });
301
+
302
+ test("stale-green, repaired: re-running after the edit is accepted", () => {
303
+ const s = session();
304
+ trackedFeature(s);
305
+ s.observe("bash", { command: "npm test" }, "42 passing");
306
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
307
+ s.observe("bash", { command: "npm test" }, "42 passing");
308
+
309
+ assert.match(String(s.task({ action: "check", id: "T1", slug: "login" })), /checked in/, "the remedy the refusal named must work");
310
+ });
311
+
312
+ test("echo attack: an exit-0 sentence the model chose is not the declared check", () => {
313
+ const s = session();
314
+ trackedFeature(s);
315
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
316
+ // Exit 0. pi reports no error. In round 1 this was recorded, verbatim, as
317
+ // `observed: `echo 'I have verified that all tests pass'` → success`.
318
+ s.observe("bash", { command: "echo 'I have verified that all tests pass'" }, "I have verified that all tests pass");
319
+
320
+ const reply = String(s.task({ action: "check", id: "T1", slug: "login" }));
321
+ assert.match(reply, /nodd\/evidence/);
322
+ assert.match(reply, /npm test/, "the refusal names the runner that was declared");
323
+ assert.ok(!/checked in/.test(reply));
324
+
325
+ const doc = readFileSync(join(s.cwd, ".nodd", "login", "feature.md"), "utf8");
326
+ assert.ok(!doc.includes("I have verified"), "model prose must not reach the artifact at all");
327
+ assert.match(doc, /- \[ \] T1\./, "the task is still open on disk");
328
+ });
329
+
330
+ // The round-1 echo attack has a second entrance: `runner` is optional, so
331
+ // omitting it at declaration skips the runner comparison entirely and the echo
332
+ // certifies the task. That path cannot be closed without making the field
333
+ // mandatory, so what NODD owes the reader is disclosure in the artifact --
334
+ // which round 2 promised in the README and did not implement.
335
+ test("echo attack with no runner declared: allowed, but the artifact says the runner was not pinned", () => {
336
+ const s = session();
337
+ s.declare({ intent: "change", route: "tracked", slug: "bare", summary: "build login", files: ["/repo/src/login.ts"] });
338
+ s.task({ action: "add", id: "T1", title: "Implement the login system", slug: "bare" });
339
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
340
+ s.observe("bash", { command: "echo 'I have verified that all tests pass'" }, "I have verified that all tests pass");
341
+
342
+ assert.match(String(s.task({ action: "check", id: "T1", slug: "bare" })), /checked in/, "nothing was pinned, so nothing can be compared");
343
+
344
+ const doc = readFileSync(join(s.cwd, ".nodd", "bare", "feature.md"), "utf8");
345
+ const evidenceLine = doc.split("\n").find((line) => line.trim().startsWith("- observed:"));
346
+ assert.ok(evidenceLine, "a checked task carries an evidence line");
347
+ assert.match(
348
+ evidenceLine!,
349
+ /runner not pinned/,
350
+ `the evidence line must disclose the unpinned runner, got: ${evidenceLine}`,
351
+ );
352
+ });
353
+
354
+ // Two claims of the README meet here, and round 2 left the seam open.
355
+ //
356
+ // "The choice is made up front ... so the command the work is judged by cannot
357
+ // be invented afterwards to fit whatever happened to pass" is only true if a
358
+ // later declaration cannot rewrite the runner. It could: nodd_declare
359
+ // overwrites `## Verification` wholesale, so a refused checkoff was repaired by
360
+ // re-declaring with the echo as the runner, and the second attempt passed.
361
+ // README fact 4: "A RED first, under strict TDD, when the declaration set
362
+ // `tdd: strict`." The kernel only built the TDD context when a runner was also
363
+ // pinned, so `tdd: strict` alone wrote `- tdd: strict` into the artifact and
364
+ // enforced nothing -- a document asserting a discipline nobody checked, which
365
+ // is the one thing NODD exists to prevent.
366
+ test("strict TDD without a pinned runner is refused, never recorded as enforced", () => {
367
+ const s = session();
368
+ const reply = String(s.declare({
369
+ intent: "change", route: "tracked", slug: "notdd", summary: "x", tdd: "strict", files: ["/repo/a.ts"],
370
+ }));
371
+ assert.match(reply, /runner/i, `strict TDD with nothing pinned must be refused, got: ${reply}`);
372
+
373
+ assert.ok(
374
+ !existsSync(join(s.cwd, ".nodd", "notdd", "feature.md")),
375
+ "a refused declaration writes no document claiming strict TDD",
376
+ );
377
+ });
378
+
379
+ test("the runner cannot be re-declared to fit a run that already happened", () => {
380
+ const s = session();
381
+ trackedFeature(s);
382
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
383
+ s.observe("bash", { command: "echo 'I have verified that all tests pass'" }, "ok");
384
+ assert.match(String(s.task({ action: "check", id: "T1", slug: "login" })), /nodd\/evidence/, "the echo is not npm test");
385
+
386
+ // The repair attempt: keep the feature, swap the runner for the thing that
387
+ // did pass. This must not become a checkoff.
388
+ const redeclared = String(s.declare({
389
+ intent: "change", route: "tracked", slug: "login", summary: "build login",
390
+ runner: "echo 'I have verified that all tests pass'", files: ["/repo/src/login.ts"],
391
+ }));
392
+ assert.match(redeclared, /runner/i, `re-pinning an already-pinned runner must be refused, got: ${redeclared}`);
393
+
394
+ const doc = readFileSync(join(s.cwd, ".nodd", "login", "feature.md"), "utf8");
395
+ assert.match(doc, /- runner: npm test$/m, "the originally declared runner stands");
396
+ assert.ok(!doc.includes("I have verified"), "the invented runner must not reach the artifact");
397
+ assert.match(String(s.task({ action: "check", id: "T1", slug: "login" })), /nodd\/evidence/, "and the checkoff is still refused");
398
+ });
399
+
400
+ test("a checkoff certified by the declared runner carries no unpinned caveat", () => {
401
+ // The disclosure above is only worth something if it discriminates. If every
402
+ // line carried it, the two cases would read the same again.
403
+ const s = session();
404
+ trackedFeature(s);
405
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
406
+ s.observe("bash", { command: "npm test" }, "42 passing");
407
+ assert.match(String(s.task({ action: "check", id: "T1", slug: "login" })), /checked in/);
408
+
409
+ const doc = readFileSync(join(s.cwd, ".nodd", "login", "feature.md"), "utf8");
410
+ assert.match(doc, /- observed: `npm test` → success$/m, "a pinned checkoff reads clean");
411
+ assert.ok(!doc.includes("not pinned"), "the caveat must not appear when the runner was pinned");
412
+ });
413
+
414
+ test("the declared runner is recorded in the feature doc, not chosen per checkoff", () => {
415
+ const s = session();
416
+ trackedFeature(s, { tdd: "strict" });
417
+ const doc = readFileSync(join(s.cwd, ".nodd", "login", "feature.md"), "utf8");
418
+ assert.match(doc, /## Verification/);
419
+ assert.match(doc, /- runner: npm test/);
420
+ assert.match(doc, /- tdd: strict/);
421
+ assert.match(doc, /- source: nodd_declare/);
422
+ });
423
+
424
+ test("strict TDD on the real path: GREEN with no observed RED is refused", () => {
425
+ const s = session();
426
+ trackedFeature(s, { tdd: "strict" });
427
+ s.observe("edit", { path: "/repo/src/login.ts" }, "");
428
+ s.observe("bash", { command: "npm test" }, "42 passing");
429
+
430
+ const reply = String(s.task({ action: "check", id: "T1", slug: "login" }));
431
+ assert.match(reply, /red|failing/i, reply);
432
+ assert.ok(!/checked in/.test(reply));
433
+ });
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // gate-promotion, on the real path
437
+ //
438
+ // In round 1 this gate was wired and inert: `promotionSignals()` hardcoded every
439
+ // trigger to zero, so deleting its registry row left the whole suite green.
440
+ // Every test here drives it through the registered handler with signals derived
441
+ // from what the kernel observed.
442
+ // ---------------------------------------------------------------------------
443
+ test("writing more distinct files than were declared blocks, naming both counts", () => {
444
+ const s = session();
445
+ s.declare({
446
+ intent: "change", route: "tracked", slug: "scope", summary: "two files",
447
+ runner: "npm test", files: ["/repo/a.ts", "/repo/b.ts"],
448
+ });
449
+ // Delegation is satisfied so gate-delegate does not speak first.
450
+ s.observe("subagent", { agent: "writer" }, "done");
451
+ s.observe("write", { path: "/repo/a.ts" }, "");
452
+ s.observe("write", { path: "/repo/b.ts" }, "");
453
+
454
+ const blocked = s.call("write", { path: "/repo/c.ts", content: "x" });
455
+ assert.equal(blocked?.block, true, "a third file against two declared is divergence");
456
+ assert.match(blocked!.reason!, /nodd\/promotion/);
457
+ assert.match(blocked!.reason!, /2/, "the declared count");
458
+ assert.match(blocked!.reason!, /3/, "the observed count");
459
+ assert.match(blocked!.reason!, /nodd-promote scope/, "the way forward is named");
460
+ });
461
+
462
+ test("writing exactly the declared files never blocks, however many they are", () => {
463
+ const s = session();
464
+ const files = Array.from({ length: 40 }, (_, i) => `/repo/f${i}.ts`);
465
+ s.declare({ intent: "change", route: "tracked", slug: "big", summary: "forty files", runner: "npm test", files });
466
+ s.observe("subagent", { agent: "writer" }, "done");
467
+ for (const path of files.slice(0, 39)) s.observe("write", { path }, "");
468
+
469
+ assert.equal(s.call("write", { path: files[39], content: "x" }), undefined, "mismatch is the trigger, never magnitude");
470
+ });
471
+
472
+ test("two consecutive failed runs of the declared runner block the next write", () => {
473
+ const s = session();
474
+ s.declare({ intent: "change", route: "tracked", slug: "stuck", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
475
+ s.observe("subagent", { agent: "writer" }, "done");
476
+ s.fail("bash", { command: "npm test" }, "FAIL\nCommand exited with code 1");
477
+ s.fail("bash", { command: "npm test" }, "FAIL\nCommand exited with code 1");
478
+
479
+ const blocked = s.call("write", { path: "/repo/a.ts", content: "x" });
480
+ assert.equal(blocked?.block, true, "the plan is not working, which is what escalation is for");
481
+ assert.match(blocked!.reason!, /nodd\/promotion/);
482
+ assert.match(blocked!.reason!, /2/, "both attempts are counted");
483
+ });
484
+
485
+ test("one failure does not block: a first red is ordinary work, especially under TDD", () => {
486
+ const s = session();
487
+ s.declare({ intent: "change", route: "tracked", slug: "red", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
488
+ s.observe("subagent", { agent: "writer" }, "done");
489
+ s.fail("bash", { command: "npm test" }, "FAIL\nCommand exited with code 1");
490
+
491
+ assert.equal(s.call("write", { path: "/repo/a.ts", content: "x" }), undefined);
492
+ });
493
+
494
+ test("a failure then a success does not block: the streak is consecutive, not cumulative", () => {
495
+ const s = session();
496
+ s.declare({ intent: "change", route: "tracked", slug: "fixed", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
497
+ s.observe("subagent", { agent: "writer" }, "done");
498
+ s.fail("bash", { command: "npm test" }, "FAIL\nCommand exited with code 1");
499
+ s.fail("bash", { command: "npm test" }, "FAIL\nCommand exited with code 1");
500
+ s.observe("bash", { command: "npm test" }, "42 passing");
501
+
502
+ assert.equal(s.call("write", { path: "/repo/a.ts", content: "x" }), undefined, "a resolved task must not stay blocked");
503
+ });
504
+
505
+ test("failures of some other command are not this task's failures", () => {
506
+ const s = session();
507
+ s.declare({ intent: "change", route: "tracked", slug: "other", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
508
+ s.observe("subagent", { agent: "writer" }, "done");
509
+ s.fail("bash", { command: "grep -r missing ." }, "Command exited with code 1");
510
+ s.fail("bash", { command: "grep -r missing ." }, "Command exited with code 1");
511
+
512
+ assert.equal(s.call("write", { path: "/repo/a.ts", content: "x" }), undefined, "a failing grep is not a failing plan");
513
+ });
514
+
515
+ test("gates.promotion off lets the diverging write through", () => {
516
+ const s = session();
517
+ s.declare({ intent: "change", route: "tracked", slug: "off", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
518
+ s.observe("subagent", { agent: "writer" }, "done");
519
+ s.observe("write", { path: "/repo/a.ts" }, "");
520
+ assert.equal(s.call("write", { path: "/repo/b.ts", content: "x" })?.block, true, "baseline: this blocks");
521
+
522
+ const disabled = session();
523
+ disabled.kernel.setPolicy({ config: { promotion: { enabled: false } }, flags: {}, hatches: {} });
524
+ disabled.declare({ intent: "change", route: "tracked", slug: "off", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
525
+ disabled.observe("subagent", { agent: "writer" }, "done");
526
+ disabled.observe("write", { path: "/repo/a.ts" }, "");
527
+ assert.equal(disabled.call("write", { path: "/repo/b.ts", content: "x" }), undefined, "off means off");
528
+ });
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // gate-delegate, on the real path
532
+ //
533
+ // The gate had thorough unit tests and no proof of being wired: deleting its
534
+ // registry row left the whole suite green, the same defect the round-1 verdict
535
+ // found in gate-promotion. Unit-testing a gate function and registering it are
536
+ // two claims, and only the second one blocks anything.
537
+ // ---------------------------------------------------------------------------
538
+ test("a second non-trivial write without a delegate is refused through the registry", () => {
539
+ const s = session();
540
+ s.declare({ intent: "change", route: "inline", slug: "del", summary: "x", runner: "npm test" });
541
+ s.observe("write", { path: "/repo/a.ts" }, "");
542
+
543
+ const blocked = s.call("write", { path: "/repo/b.ts", content: "x" });
544
+ assert.equal(blocked?.block, true, "the writer threshold must fire from the registered gate");
545
+ assert.match(blocked!.reason!, /nodd\/delegate/);
546
+ });
547
+
548
+ test("reading past the mapping threshold without a delegate is refused through the registry", () => {
549
+ const s = session();
550
+ s.declare({ intent: "change", route: "inline", slug: "map", summary: "x", runner: "npm test" });
551
+ for (const path of ["/r/1.ts", "/r/2.ts", "/r/3.ts", "/r/4.ts", "/r/5.ts"]) {
552
+ s.observe("read", { path }, "");
553
+ }
554
+
555
+ const blocked = s.call("write", { path: "/r/1.ts", content: "x" });
556
+ assert.equal(blocked?.block, true, "the mapping threshold must fire from the registered gate");
557
+ assert.match(blocked!.reason!, /nodd\/delegate/);
558
+ });
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // Resume: the README's fail-closed promise, made true
562
+ //
563
+ // Round 1 replayed persisted observations into `committed.commandResults`, which
564
+ // is exactly what the evidence gate reads. A second process that ran no command
565
+ // checked a task off, while the README promised the check had to be re-run.
566
+ // ---------------------------------------------------------------------------
567
+ function resumedSession(first: ReturnType<typeof session>) {
568
+ return session({ cwd: first.cwd, entries: first.entries });
569
+ }
570
+
571
+ test("a resumed session cannot check a task off on the previous session's evidence", () => {
572
+ const s1 = session();
573
+ s1.declare({
574
+ intent: "change", route: "tracked", slug: "resume", summary: "x",
575
+ runner: "npm test", files: ["/repo/a.ts"],
576
+ });
577
+ s1.task({ action: "add", id: "T1", title: "First", slug: "resume" });
578
+ s1.task({ action: "add", id: "T2", title: "Second", slug: "resume" });
579
+ s1.observe("write", { path: "/repo/a.ts" }, "");
580
+ s1.observe("bash", { command: "npm test" }, "42 passing");
581
+ assert.match(String(s1.task({ action: "check", id: "T1", slug: "resume" })), /checked in/, "session 1 earned its checkoff");
582
+
583
+ // New process, same session file. No command is run.
584
+ const s2 = resumedSession(s1);
585
+ const reply = String(s2.task({ action: "check", id: "T2", slug: "resume" }));
586
+
587
+ assert.match(reply, /nodd\/evidence/, "the resumed session must be refused");
588
+ assert.ok(!/checked in/.test(reply), "a task checked off with zero commands run is the whole bug");
589
+ assert.match(reply, /re-?run/i, "and the refusal says what to do, or it reads as NODD being broken");
590
+ });
591
+
592
+ test("the resumed session can check the task off once it re-runs the check", () => {
593
+ const s1 = session();
594
+ s1.declare({ intent: "change", route: "tracked", slug: "redo", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
595
+ s1.task({ action: "add", id: "T1", title: "First", slug: "redo" });
596
+ s1.observe("write", { path: "/repo/a.ts" }, "");
597
+
598
+ const s2 = resumedSession(s1);
599
+ s2.observe("bash", { command: "npm test" }, "42 passing");
600
+ assert.match(String(s2.task({ action: "check", id: "T1", slug: "redo" })), /checked in/, "re-running is the documented remedy");
601
+ });
602
+
603
+ test("replay still rebuilds the gate context, so a resumed session is not un-gated", () => {
604
+ const s1 = session();
605
+ s1.declare({ intent: "read-only", route: "inline", slug: "audit", summary: "look only" });
606
+
607
+ const s2 = resumedSession(s1);
608
+ const blocked = s2.call("write", WRITE);
609
+ assert.equal(blocked?.block, true, "a read-only declaration must survive the reload");
610
+ assert.match(blocked!.reason!, /authorize/);
611
+ });
612
+
613
+ test("replay carries writes forward, so a resumed run cannot outrun its own edits", () => {
614
+ const s1 = session();
615
+ s1.declare({ intent: "change", route: "tracked", slug: "carry", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
616
+ s1.task({ action: "add", id: "T1", title: "First", slug: "carry" });
617
+ s1.observe("bash", { command: "npm test" }, "42 passing");
618
+ s1.observe("write", { path: "/repo/a.ts" }, "");
619
+
620
+ const s2 = resumedSession(s1);
621
+ // The edit from session 1 is known; this fresh green must postdate it.
622
+ s2.observe("bash", { command: "npm test" }, "42 passing");
623
+ assert.match(String(s2.task({ action: "check", id: "T1", slug: "carry" })), /checked in/);
624
+ });
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // The ledger exists in production
628
+ // ---------------------------------------------------------------------------
629
+ test("a successful checkoff writes the evidence it relied on to the ledger", () => {
630
+ const s = session();
631
+ s.declare({ intent: "change", route: "tracked", slug: "led", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
632
+ s.task({ action: "add", id: "T1", title: "First", slug: "led" });
633
+ s.observe("write", { path: "/repo/a.ts" }, "");
634
+ s.observe("bash", { command: "npm test" }, "42 passing");
635
+ s.task({ action: "check", id: "T1", slug: "led" });
636
+
637
+ const path = join(s.cwd, ".nodd", "led", "ledger.json");
638
+ assert.ok(existsSync(path), `the ledger must exist at ${path}, or its integrity rules are dead code`);
639
+ const { records } = JSON.parse(readFileSync(path, "utf8")) as { records: Array<Record<string, unknown>> };
640
+ assert.equal(records.length, 1);
641
+ assert.equal(records[0].command, "npm test");
642
+ assert.ok(typeof records[0].toolCallId === "string" && records[0].toolCallId !== "", "a record without a toolCallId is not an observation");
643
+ });
644
+
645
+ test("a ledger contradicting what this session observed reads as a mismatch, not as staleness", () => {
646
+ const s = session();
647
+ s.declare({ intent: "change", route: "tracked", slug: "tamper", summary: "x", runner: "npm test", files: ["/repo/a.ts"] });
648
+ s.task({ action: "add", id: "T1", title: "First", slug: "tamper" });
649
+ s.observe("write", { path: "/repo/a.ts" }, "");
650
+ s.fail("bash", { command: "npm test" }, "FAIL\nCommand exited with code 1");
651
+
652
+ // Someone edits the ledger to claim the failing run passed, reusing the real
653
+ // toolCallId so it is not merely unobserved.
654
+ const path = join(s.cwd, ".nodd", "tamper", "ledger.json");
655
+ const id = s.kernel.state.committed.commandResults[0].toolCallId;
656
+ mkdirSync(join(s.cwd, ".nodd", "tamper"), { recursive: true });
657
+ writeFileSync(path, JSON.stringify({
658
+ records: [{ toolCallId: id, tool: "bash", command: "npm test", outcome: { kind: "success" }, at: "2026-09-19T10:00:00.000Z" }],
659
+ }), "utf8");
660
+
661
+ const reply = String(s.task({ action: "check", id: "T1", slug: "tamper" }));
662
+ assert.ok(!/checked in/.test(reply), "a forged ledger must not check anything off");
663
+ assert.match(reply, /contradict|mismatch/i, `the refusal must name the contradiction: ${reply}`);
664
+ assert.ok(!/previous session/i.test(reply), "a contradicted record is not a staleness problem");
665
+ });
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // Robustness
669
+ // ---------------------------------------------------------------------------
670
+ test("a malformed event never throws and never blocks by accident", () => {
671
+ const s = session();
672
+ for (const input of [undefined, null, "string", 42, { path: null }]) {
673
+ assert.doesNotThrow(() => s.call("write", input as never));
674
+ }
675
+ assert.doesNotThrow(() => s.call("", {}));
676
+ });