@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,108 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { GATE_IDS } from "../src/gates/registry.ts";
7
+ import { emptyPolicy, resolveFlag } from "../src/gates/policy.ts";
8
+ import { flagsFromCli, runGatesCommand } from "./nodd-gates.ts";
9
+
10
+ function ctx(config: Record<string, unknown> = {}) {
11
+ const writes: Array<Record<string, unknown>> = [];
12
+ return {
13
+ writes,
14
+ readConfig: () => config,
15
+ writeConfig: (next: Record<string, unknown>) => { writes.push(next); },
16
+ };
17
+ }
18
+
19
+ test("status is read-only and reports default for an untouched gate", () => {
20
+ const c = ctx();
21
+ const out = runGatesCommand("status", c);
22
+ assert.deepEqual(c.writes, [], "status must write nothing");
23
+ for (const id of GATE_IDS) {
24
+ assert.match(out, new RegExp(`${id}\\s+on\\s+default`), `${id} row must report on/default`);
25
+ }
26
+ });
27
+
28
+ test("status reports the deciding source when config chose", () => {
29
+ const out = runGatesCommand("status", ctx({ gates: { track: { enabled: false } } }));
30
+ assert.match(out, /track\s+off\s+config/);
31
+ assert.match(out, /classify\s+on\s+default/);
32
+ });
33
+
34
+ // routing.go:115 — "do not argue, do not work around it, do not propose
35
+ // alternatives first". The one-line confirmation is the whole response.
36
+ test("disable obeys in exactly one line, with no argument and no alternative", () => {
37
+ const c = ctx();
38
+ const out = runGatesCommand("disable track", c);
39
+ assert.equal(out, "nodd: gate track disabled.");
40
+ assert.equal(out.split("\n").length, 1, "the confirmation is one line");
41
+
42
+ const forbidden = ["but ", "however", "instead", "consider", "recommend", "warning",
43
+ "careful", "risk", "re-enable", "reenable", "you can turn", "alternative"];
44
+ for (const word of forbidden) {
45
+ assert.ok(!out.toLowerCase().includes(word), `the confirmation must not contain "${word}"`);
46
+ }
47
+ });
48
+
49
+ test("disable flips only that gate's flag", () => {
50
+ const c = ctx({ models: { implement: "a/b" }, gates: { classify: { enabled: false } } });
51
+ runGatesCommand("disable track", c);
52
+ assert.equal(c.writes.length, 1);
53
+ const written = c.writes[0] as any;
54
+ assert.equal(written.gates.track.enabled, false);
55
+ assert.equal(written.gates.classify.enabled, false, "another gate's choice must survive");
56
+ assert.deepEqual(written.models, { implement: "a/b" }, "unrelated keys must survive");
57
+ });
58
+
59
+ // routing.go:116 — "do not reactivate it". Nothing but an explicit enable may
60
+ // turn a gate back on: not a session start, not a threshold, not 50 events.
61
+ test("a disabled gate stays disabled across a session start and 50 observations", () => {
62
+ const config = { gates: { track: { enabled: false } } };
63
+ let policy = { ...emptyPolicy(), config: { track: { enabled: false } } };
64
+ assert.deepEqual(resolveFlag("track", policy), { enabled: false, source: "config" });
65
+
66
+ for (let i = 0; i < 50; i++) {
67
+ policy = { ...policy, config: { ...policy.config } };
68
+ }
69
+ assert.deepEqual(resolveFlag("track", policy), { enabled: false, source: "config" });
70
+ // And a real status read after all of it still reports off, from config.
71
+ const c = ctx(config);
72
+ assert.match(runGatesCommand("status", c), /track\s+off\s+config/);
73
+ assert.deepEqual(c.writes, [], "nothing re-enabled it and nothing was written");
74
+ });
75
+
76
+ test("enable is the only way back on", () => {
77
+ const c = ctx({ gates: { track: { enabled: false } } });
78
+ const out = runGatesCommand("enable track", c);
79
+ assert.equal(out, "nodd: gate track enabled.");
80
+ assert.equal((c.writes[0] as any).gates.track.enabled, true);
81
+ });
82
+
83
+ test("an unknown gate id lists the valid ids", () => {
84
+ const out = runGatesCommand("disable nonsense", ctx());
85
+ assert.match(out, /nonsense/);
86
+ for (const id of GATE_IDS) assert.ok(out.includes(id), `must list ${id}`);
87
+ });
88
+
89
+ test("`enabled: true` is written from exactly one handler", () => {
90
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "nodd-gates.ts"), "utf8");
91
+ const occurrences = src.match(/enabled:\s*true/g) ?? [];
92
+ assert.equal(occurrences.length, 1, `expected exactly one 'enabled: true' writer, found ${occurrences.length}`);
93
+ });
94
+
95
+ test("--nodd-off=<id> disables one gate and --nodd-off=all disables every gate", () => {
96
+ assert.deepEqual(flagsFromCli("track"), { track: false });
97
+ assert.deepEqual(flagsFromCli("all"), { all: false });
98
+ assert.deepEqual(flagsFromCli("nonsense"), {}, "an unknown id disables nothing");
99
+ assert.deepEqual(flagsFromCli(undefined), {});
100
+
101
+ // The flag outranks config, and the status output says so.
102
+ const one = runGatesCommand("status", ctx(), flagsFromCli("track"));
103
+ assert.match(one, /track\s+off\s+flag/);
104
+ assert.match(one, /classify\s+on\s+default/);
105
+
106
+ const every = runGatesCommand("status", ctx(), flagsFromCli("all"));
107
+ for (const id of GATE_IDS) assert.match(every, new RegExp(`${id}\\s+off\\s+flag`));
108
+ });
@@ -0,0 +1,121 @@
1
+ // `/nodd-gates enable|disable|status [gate]` — the kill switch.
2
+ //
3
+ // The semantics are ODD's, inherited verbatim from `routing.go:106-118`, and
4
+ // the reason ODD gives is the reason NODD adopts them: "a switch the agent
5
+ // cannot name does not exist for the user, who would otherwise ask to stop
6
+ // using receipt-driven development and be argued with instead of obeyed."
7
+ // A gate that resists being turned off is the same product failure as a gate
8
+ // that does not exist — and NODD ships six of them.
9
+ //
10
+ // So: `status` writes nothing and names the deciding source. `disable` obeys in
11
+ // one line, with no counter-argument, no consequence warning and no alternative
12
+ // offered. Nothing re-enables a gate but the explicit `enable` handler, which
13
+ // is why `enabled: true` appears exactly once in this file and a test counts it.
14
+ //
15
+ // The one inversion from ODD is the default: NODD's gates default *on*, because
16
+ // enforcement is the product where RDD is opt-in. Everything else is unchanged.
17
+
18
+ import { readFileSync, writeFileSync } from "node:fs";
19
+ import { GATE_IDS, isGateId } from "../src/gates/registry.ts";
20
+ import { mergeConfig, noddConfigPath, parseConfig } from "../src/config.ts";
21
+ import { resolveFlag, type Policy } from "../src/gates/policy.ts";
22
+
23
+ type ConfigIo = {
24
+ readConfig(): Record<string, unknown>;
25
+ writeConfig(next: Record<string, unknown>): void;
26
+ };
27
+
28
+ export function fileConfigIo(path: string = noddConfigPath()): ConfigIo {
29
+ return {
30
+ readConfig() {
31
+ try {
32
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
33
+ } catch {
34
+ return {};
35
+ }
36
+ },
37
+ writeConfig(next) {
38
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
39
+ },
40
+ };
41
+ }
42
+
43
+ function policyFrom(raw: Record<string, unknown>, flags: Record<string, boolean>): Policy {
44
+ const { config } = parseConfig(JSON.stringify(raw));
45
+ return { config: config.gates, flags, hatches: {} };
46
+ }
47
+
48
+ function renderStatus(raw: Record<string, unknown>, flags: Record<string, boolean>): string {
49
+ const policy = policyFrom(raw, flags);
50
+ const width = Math.max(...GATE_IDS.map((id) => id.length));
51
+ const rows = GATE_IDS.map((id) => {
52
+ const { enabled, source } = resolveFlag(id, policy);
53
+ return ` ${id.padEnd(width)} ${(enabled ? "on" : "off").padEnd(3)} ${source}`;
54
+ });
55
+ return [`nodd gates (gate, effective mode, deciding source):`, ...rows].join("\n");
56
+ }
57
+
58
+ function setEnabled(io: ConfigIo, gate: string, value: boolean): void {
59
+ const raw = io.readConfig();
60
+ const gates = { ...(raw.gates as Record<string, unknown> | undefined) };
61
+ gates[gate] = { enabled: value };
62
+ io.writeConfig(mergeConfig(raw, { gates }));
63
+ }
64
+
65
+ export function runGatesCommand(args: string, io: ConfigIo, flags: Record<string, boolean> = {}): string {
66
+ const [verb, gate] = args.trim().split(/\s+/);
67
+
68
+ if (!verb || verb === "status") return renderStatus(io.readConfig(), flags);
69
+
70
+ if (verb !== "enable" && verb !== "disable") {
71
+ return `nodd-gates: usage — /nodd-gates [status|enable <gate>|disable <gate>]`;
72
+ }
73
+ if (!gate || !isGateId(gate)) {
74
+ return `nodd-gates: unknown gate "${gate ?? ""}". Valid gates: ${GATE_IDS.join(", ")}`;
75
+ }
76
+
77
+ if (verb === "disable") {
78
+ setEnabled(io, gate, false);
79
+ // One line. No argument, no consequence warning, no alternative, no hint
80
+ // about turning it back on. This shape is asserted by test because
81
+ // "do not argue" is a behaviour, and behaviours drift.
82
+ return `nodd: gate ${gate} disabled.`;
83
+ }
84
+
85
+ // The only place in NODD that turns a gate on. Reached only from an explicit
86
+ // `/nodd-gates enable` — never from a session start, a threshold or another
87
+ // gate, and never on the user's behalf.
88
+ setEnabled(io, gate, true);
89
+ return `nodd: gate ${gate} enabled.`;
90
+ }
91
+
92
+ type PiApi = {
93
+ registerCommand?(name: string, options: { description?: string; handler: (args: string, ctx: unknown) => void }): void;
94
+ registerFlag?(name: string, options: unknown): void;
95
+ };
96
+
97
+ export default function register(pi?: PiApi): void {
98
+ pi?.registerFlag?.("nodd-off", {
99
+ description: "Disable one NODD gate for this session, or all of them: --nodd-off=track | --nodd-off=all",
100
+ type: "string",
101
+ });
102
+
103
+ pi?.registerCommand?.("nodd-gates", {
104
+ description: "Read or set NODD's per-gate switches: /nodd-gates [status|enable <gate>|disable <gate>]",
105
+ handler: (args: string, ctx: unknown) => {
106
+ const notify = (ctx as { ui?: { notify?(m: string, t?: string): void } })?.ui?.notify;
107
+ try {
108
+ notify?.(runGatesCommand(args ?? "", fileConfigIo()), "info");
109
+ } catch (err) {
110
+ notify?.(`nodd-gates: ${err instanceof Error ? err.message : String(err)}`, "error");
111
+ }
112
+ },
113
+ });
114
+ }
115
+
116
+ /** Parse `--nodd-off=<id|all>` into the policy's flag map. */
117
+ export function flagsFromCli(value: string | undefined): Record<string, boolean> {
118
+ if (!value) return {};
119
+ if (value === "all") return { all: false };
120
+ return isGateId(value) ? { [value]: false } : {};
121
+ }
@@ -0,0 +1,114 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import register, { createKernel } from "./nodd-kernel.ts";
7
+
8
+ type Handler = (event: any, ctx?: any) => any;
9
+
10
+ function fakePi() {
11
+ const handlers = new Map<string, Handler>();
12
+ const entries: Array<{ type: string; data: unknown }> = [];
13
+ return {
14
+ handlers,
15
+ entries,
16
+ on(event: string, handler: Handler) { handlers.set(event, handler); },
17
+ registerTool() {},
18
+ appendEntry(type: string, data: unknown) { entries.push({ type, data }); },
19
+ emit(event: string, payload: any, ctx?: unknown) { return handlers.get(event)?.(payload, ctx); },
20
+ };
21
+ }
22
+
23
+ test("a read-only session of read/grep/ls produces zero blocks", () => {
24
+ const pi = fakePi();
25
+ register(pi as never);
26
+ const calls = [
27
+ { toolName: "read", toolCallId: "c1", input: { path: "a.ts" } },
28
+ { toolName: "grep", toolCallId: "c2", input: { pattern: "x" } },
29
+ { toolName: "ls", toolCallId: "c3", input: { path: "." } },
30
+ { toolName: "read", toolCallId: "c4", input: { path: "b.ts" } },
31
+ ];
32
+ for (const call of calls) {
33
+ const decision = pi.emit("tool_call", call);
34
+ assert.ok(decision === undefined || decision.block !== true, `${call.toolName} must not be blocked`);
35
+ pi.emit("tool_result", { ...call, isError: false, content: "" });
36
+ }
37
+ });
38
+
39
+ test("tool_call inserts into pending and tool_result moves it into committed", () => {
40
+ const kernel = createKernel();
41
+ kernel.onToolCall({ toolName: "write", toolCallId: "c1", input: { path: "a.ts" } });
42
+ assert.equal(kernel.state.pending.size, 1);
43
+ assert.equal(kernel.state.committed.filesWritten.size, 0, "a pending write is not a written file");
44
+
45
+ kernel.onToolResult({ toolName: "write", toolCallId: "c1", input: { path: "a.ts" }, isError: false, content: "" });
46
+ assert.equal(kernel.state.pending.size, 0);
47
+ assert.deepEqual([...kernel.state.committed.filesWritten.keys()], ["a.ts"]);
48
+ });
49
+
50
+ test("evidence helpers take Committed, so a pending sibling is structurally unreachable", () => {
51
+ const kernel = createKernel();
52
+ // Same assistant batch: two siblings preflighted, neither finished.
53
+ kernel.onToolCall({ toolName: "bash", toolCallId: "c1", input: { command: "npm test" } });
54
+ kernel.onToolCall({ toolName: "nodd_task", toolCallId: "c2", input: { action: "check", id: "T1" } });
55
+
56
+ const evidence = kernel.evidenceView();
57
+ assert.equal(evidence.commandResults.length, 0, "a pending sibling must not be visible as evidence");
58
+ assert.ok(!("pending" in evidence), "the evidence view has no pending half at all");
59
+ });
60
+
61
+ test("session replay is idempotent by toolCallId", () => {
62
+ const kernel = createKernel();
63
+ const entry = { toolName: "read", toolCallId: "c1", input: { path: "a.ts" }, isError: false, content: "" };
64
+ kernel.onToolResult(entry);
65
+ kernel.onToolResult(entry);
66
+ assert.equal(kernel.state.committed.toolCalls, 1);
67
+ });
68
+
69
+ // This test used to emit `{ entries: [...] }` on the event and read
70
+ // `entry.type`. pi's SessionStartEvent has no `entries` field at all
71
+ // (`dist/core/extensions/types.d.ts:416-422`) — the log is reached through
72
+ // `ctx.sessionManager.getEntries()`, and a custom entry is
73
+ // `{ type: "custom", customType, data }` (`dist/core/session-manager.d.ts:69-73`).
74
+ // So the old test passed against a shape production never receives, and the
75
+ // replay path it "covered" was dead code. It now uses pi's real contract.
76
+ test("session_start replays the session log's nodd entries into committed state", () => {
77
+ const pi = fakePi();
78
+ const kernel = register(pi as never);
79
+ const entries = [
80
+ { type: "custom", customType: "nodd:observation", data: { toolName: "read", toolCallId: "r1", input: { path: "a.ts" }, isError: false, resultText: "", at: "t" } },
81
+ { type: "custom", customType: "nodd:observation", data: { toolName: "write", toolCallId: "w1", input: { path: "b.ts" }, isError: false, resultText: "", at: "t" } },
82
+ { type: "custom", customType: "something:else", data: { nope: true } },
83
+ { type: "message", role: "user" },
84
+ ];
85
+ pi.emit("session_start", { type: "session_start", reason: "resume" }, { sessionManager: { getEntries: () => entries } });
86
+
87
+ assert.deepEqual([...kernel.state.committed.filesRead], ["a.ts"]);
88
+ assert.deepEqual([...kernel.state.committed.filesWritten.keys()], ["b.ts"]);
89
+ });
90
+
91
+ test("replay restores context but never evidence: a run another process saw is not ours", () => {
92
+ const pi = fakePi();
93
+ const kernel = register(pi as never);
94
+ const entries = [
95
+ { type: "custom", customType: "nodd:observation", data: { toolName: "write", toolCallId: "w1", input: { path: "b.ts" }, isError: false, resultText: "", at: "t" } },
96
+ { type: "custom", customType: "nodd:observation", data: { toolName: "bash", toolCallId: "b1", input: { command: "npm test" }, isError: false, resultText: "42 passing", at: "t" } },
97
+ ];
98
+ pi.emit("session_start", { type: "session_start", reason: "resume" }, { sessionManager: { getEntries: () => entries } });
99
+
100
+ assert.deepEqual([...kernel.state.committed.filesWritten.keys()], ["b.ts"], "context comes back");
101
+ assert.deepEqual(kernel.state.committed.commandResults, [], "evidence does not");
102
+ });
103
+
104
+ test("a session_start with no session manager never throws", () => {
105
+ const pi = fakePi();
106
+ register(pi as never);
107
+ pi.emit("session_start", { type: "session_start", reason: "startup" });
108
+ pi.emit("session_start", { type: "session_start", reason: "startup" }, {});
109
+ });
110
+
111
+ test("terminate is never assigned anywhere in the extension", () => {
112
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "nodd-kernel.ts"), "utf8");
113
+ assert.ok(!/terminate\s*[:=]/.test(src), "blocking one sibling must never abort the batch");
114
+ });