@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,186 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ RESERVED_PROFILE_NAMES,
5
+ applyProfileCommand,
6
+ isValidProfileName,
7
+ mirrorToActiveProfile,
8
+ readActiveProfile,
9
+ readProfiles,
10
+ } from "./profiles.ts";
11
+
12
+ const withProfiles = () => ({
13
+ models: { implement: "anthropic/claude-opus-4-1" },
14
+ profiles: {
15
+ fast: { models: { implement: "openai-codex/gpt-5-codex" } },
16
+ careful: { models: { implement: "anthropic/claude-opus-4-1", explore: "anthropic/claude-sonnet-4-5" } },
17
+ },
18
+ activeProfile: "fast",
19
+ });
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Names
23
+ // ---------------------------------------------------------------------------
24
+ test("reserved verb names are rejected, so `profile save` stays unambiguous", () => {
25
+ for (const reserved of RESERVED_PROFILE_NAMES) {
26
+ assert.equal(isValidProfileName(reserved), false, `${reserved} must be reserved`);
27
+ }
28
+ });
29
+
30
+ test("invalid characters are rejected and ordinary names accepted", () => {
31
+ for (const bad of ["Fast", "my profile", "prof/ile", "", "über", "a.b"]) {
32
+ assert.equal(isValidProfileName(bad), false, `${bad} must be rejected`);
33
+ }
34
+ for (const good of ["fast", "careful-2", "my_profile", "x"]) {
35
+ assert.equal(isValidProfileName(good), true, `${good} must be accepted`);
36
+ }
37
+ });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Reading, tolerantly
41
+ // ---------------------------------------------------------------------------
42
+ test("a corrupt profiles value parses to an empty set with a defect note", () => {
43
+ for (const corrupt of ["not an object", 42, null, ["a"]]) {
44
+ const result = readProfiles({ profiles: corrupt });
45
+ assert.deepEqual(result.profiles, {});
46
+ assert.ok(result.defects.length > 0, `${JSON.stringify(corrupt)} must be reported, not silently dropped`);
47
+ }
48
+ });
49
+
50
+ test("malformed individual entries are discarded and reported, valid siblings survive", () => {
51
+ const result = readProfiles({
52
+ profiles: { fast: { models: { implement: "anthropic/x" } }, "BAD NAME": { models: {} }, broken: 7 },
53
+ });
54
+ assert.deepEqual(Object.keys(result.profiles), ["fast"]);
55
+ assert.equal(result.defects.length, 2);
56
+ assert.ok(result.defects.some((d) => d.includes("BAD NAME")));
57
+ assert.ok(result.defects.some((d) => d.includes("broken")));
58
+ });
59
+
60
+ test("an absent profiles key is not a defect: it is just unset", () => {
61
+ const result = readProfiles({});
62
+ assert.deepEqual(result.profiles, {});
63
+ assert.deepEqual(result.defects, []);
64
+ });
65
+
66
+ test("activeProfile is only honoured when the profile exists", () => {
67
+ assert.equal(readActiveProfile(withProfiles()), "fast");
68
+ assert.equal(readActiveProfile({ ...withProfiles(), activeProfile: "ghost" }), null);
69
+ assert.equal(readActiveProfile({ activeProfile: 42 }), null);
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // The four verbs, as pure transforms
74
+ // ---------------------------------------------------------------------------
75
+ test("new creates from the current config and activates it", () => {
76
+ const result = applyProfileCommand({ models: { implement: "anthropic/claude-opus-4-1" } }, { kind: "new", name: "fresh" });
77
+ assert.equal(result.ok, true);
78
+ if (result.ok !== true) return;
79
+ assert.deepEqual(result.data.profiles, { fresh: { models: { implement: "anthropic/claude-opus-4-1" } } });
80
+ assert.equal(result.data.activeProfile, "fresh");
81
+ assert.match(result.message, /fresh/);
82
+ });
83
+
84
+ test("new refuses to overwrite an existing profile", () => {
85
+ const result = applyProfileCommand(withProfiles(), { kind: "new", name: "fast" });
86
+ assert.equal(result.ok, false);
87
+ assert.ok(result.ok === false && result.message.includes("fast"));
88
+ });
89
+
90
+ test("new with an invalid name is refused", () => {
91
+ assert.equal(applyProfileCommand({}, { kind: "new", name: "use" }).ok, false);
92
+ assert.equal(applyProfileCommand({}, { kind: "new", name: "Bad Name" }).ok, false);
93
+ });
94
+
95
+ test("save stores the current config under a name", () => {
96
+ const data = { ...withProfiles(), models: { implement: "anthropic/new-model" } };
97
+ const result = applyProfileCommand(data, { kind: "save", name: "careful" });
98
+ assert.equal(result.ok, true);
99
+ if (result.ok !== true) return;
100
+ assert.deepEqual((result.data.profiles as Record<string, unknown>).careful, {
101
+ models: { implement: "anthropic/new-model" },
102
+ });
103
+ assert.deepEqual(
104
+ (result.data.profiles as Record<string, unknown>).fast,
105
+ { models: { implement: "openai-codex/gpt-5-codex" } },
106
+ "an unrelated profile is untouched",
107
+ );
108
+ });
109
+
110
+ test("save without a name consolidates the active profile", () => {
111
+ const data = { ...withProfiles(), models: { implement: "anthropic/edited" } };
112
+ const result = applyProfileCommand(data, { kind: "save" });
113
+ assert.equal(result.ok, true);
114
+ if (result.ok !== true) return;
115
+ assert.deepEqual((result.data.profiles as Record<string, unknown>).fast, { models: { implement: "anthropic/edited" } });
116
+ });
117
+
118
+ test("save without a name and no active profile is refused", () => {
119
+ const result = applyProfileCommand({ models: {} }, { kind: "save" });
120
+ assert.equal(result.ok, false);
121
+ assert.ok(result.ok === false && /activo|nombre/.test(result.message));
122
+ });
123
+
124
+ test("use activates a profile and applies its models", () => {
125
+ const result = applyProfileCommand(withProfiles(), { kind: "use", name: "careful" });
126
+ assert.equal(result.ok, true);
127
+ if (result.ok !== true) return;
128
+ assert.equal(result.data.activeProfile, "careful");
129
+ assert.deepEqual(result.data.models, {
130
+ implement: "anthropic/claude-opus-4-1",
131
+ explore: "anthropic/claude-sonnet-4-5",
132
+ });
133
+ });
134
+
135
+ test("use on an unknown profile is refused and lists what exists", () => {
136
+ const result = applyProfileCommand(withProfiles(), { kind: "use", name: "ghost" });
137
+ assert.equal(result.ok, false);
138
+ if (result.ok !== false) return;
139
+ assert.ok(result.message.includes("ghost"));
140
+ assert.ok(result.message.includes("careful") && result.message.includes("fast"));
141
+ });
142
+
143
+ test("delete removes a profile and clears activeProfile when it was active", () => {
144
+ const result = applyProfileCommand(withProfiles(), { kind: "delete", name: "fast" });
145
+ assert.equal(result.ok, true);
146
+ if (result.ok !== true) return;
147
+ assert.deepEqual(Object.keys(result.data.profiles as object), ["careful"]);
148
+ assert.equal(result.data.activeProfile, null);
149
+ assert.deepEqual(result.data.models, { implement: "anthropic/claude-opus-4-1" }, "the flat config survives a delete");
150
+ });
151
+
152
+ test("deleting a non-active profile leaves the active one alone", () => {
153
+ const result = applyProfileCommand(withProfiles(), { kind: "delete", name: "careful" });
154
+ assert.equal(result.ok, true);
155
+ assert.equal(result.ok === true && result.data.activeProfile, "fast");
156
+ });
157
+
158
+ test("every verb is pure: the input object is never mutated", () => {
159
+ const data = withProfiles();
160
+ const snapshot = JSON.stringify(data);
161
+ for (const command of [
162
+ { kind: "new" as const, name: "fresh" },
163
+ { kind: "save" as const, name: "fast" },
164
+ { kind: "use" as const, name: "careful" },
165
+ { kind: "delete" as const, name: "fast" },
166
+ ]) {
167
+ applyProfileCommand(data, command);
168
+ }
169
+ assert.equal(JSON.stringify(data), snapshot, "the transforms return new data; the command decides to write");
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Mirroring
174
+ // ---------------------------------------------------------------------------
175
+ test("mirroring writes the current config into the active profile", () => {
176
+ const data = { ...withProfiles(), models: { implement: "anthropic/just-edited" } };
177
+ const mirrored = mirrorToActiveProfile(data);
178
+ assert.deepEqual((mirrored.profiles as Record<string, unknown>).fast, {
179
+ models: { implement: "anthropic/just-edited" },
180
+ });
181
+ });
182
+
183
+ test("mirroring with no active profile changes nothing", () => {
184
+ const data = { models: { implement: "anthropic/x" }, profiles: {}, activeProfile: null };
185
+ assert.deepEqual(mirrorToActiveProfile(data), data);
186
+ });
@@ -0,0 +1,162 @@
1
+ // Named slot-assignment sets, in `~/.pi/nodd.json` only.
2
+ //
3
+ // Adapted from `zero-models-profiles.ts:46-68,170-177,255-299`. NODD's profiles
4
+ // are never shared with forge's: the two products keep separate files, and the
5
+ // isolation test in `src/config.test.ts` asserts no NODD source even names the
6
+ // other one.
7
+ //
8
+ // Every verb is a pure transform returning the new data plus a message. The
9
+ // command decides whether to write — which is what makes "quit writes nothing"
10
+ // enforceable rather than a promise about a code path.
11
+ //
12
+ // Malformed profile data is discarded *and reported*. Silently dropping it would
13
+ // leave the user staring at a profile that vanished with no explanation, and
14
+ // throwing would take the whole command down over a hand-edit.
15
+
16
+ export type Profile = { models: Record<string, string> };
17
+
18
+ /** Sub-command verbs, which therefore cannot be profile names. */
19
+ export const RESERVED_PROFILE_NAMES = ["list", "new", "save", "use", "delete", "rm", "from"] as const;
20
+
21
+ export type ProfileCommand =
22
+ | { kind: "new"; name: string; from?: string }
23
+ | { kind: "save"; name?: string }
24
+ | { kind: "use"; name: string }
25
+ | { kind: "delete"; name: string };
26
+
27
+ export type ProfileResult =
28
+ | { ok: true; data: Record<string, unknown>; message: string }
29
+ | { ok: false; message: string };
30
+
31
+ export function isValidProfileName(name: string): boolean {
32
+ if (!/^[a-z0-9_-]+$/.test(name)) return false;
33
+ return !(RESERVED_PROFILE_NAMES as readonly string[]).includes(name);
34
+ }
35
+
36
+ function isObject(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ function stringMap(value: unknown): Record<string, string> {
41
+ const out: Record<string, string> = {};
42
+ for (const [key, raw] of Object.entries(isObject(value) ? value : {})) {
43
+ if (typeof raw === "string") out[key] = raw;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export type ReadProfiles = { profiles: Record<string, Profile>; defects: string[] };
49
+
50
+ export function readProfiles(data: Record<string, unknown>): ReadProfiles {
51
+ const raw = data.profiles;
52
+ if (raw === undefined) return { profiles: {}, defects: [] };
53
+ if (!isObject(raw)) {
54
+ return { profiles: {}, defects: ["`profiles` is not an object; it was discarded and no profile is available"] };
55
+ }
56
+
57
+ const profiles: Record<string, Profile> = {};
58
+ const defects: string[] = [];
59
+ for (const [name, value] of Object.entries(raw)) {
60
+ if (!isValidProfileName(name)) {
61
+ defects.push(`profile \`${name}\` has an invalid name and was discarded`);
62
+ continue;
63
+ }
64
+ if (!isObject(value)) {
65
+ defects.push(`profile \`${name}\` is not an object and was discarded`);
66
+ continue;
67
+ }
68
+ profiles[name] = { models: stringMap(value.models) };
69
+ }
70
+ return { profiles, defects };
71
+ }
72
+
73
+ /** The active profile, but only when it actually exists. */
74
+ export function readActiveProfile(data: Record<string, unknown>): string | null {
75
+ const active = data.activeProfile;
76
+ if (typeof active !== "string") return null;
77
+ return readProfiles(data).profiles[active] ? active : null;
78
+ }
79
+
80
+ /** The flat config as a profile snapshot. */
81
+ function snapshot(data: Record<string, unknown>): Profile {
82
+ return { models: stringMap(data.models) };
83
+ }
84
+
85
+ /**
86
+ * Editing a slot while a profile is active edits that profile, so the two never
87
+ * drift apart without the user asking for it. No active profile: unchanged.
88
+ */
89
+ export function mirrorToActiveProfile(data: Record<string, unknown>): Record<string, unknown> {
90
+ const active = readActiveProfile(data);
91
+ if (active === null) return data;
92
+ const { profiles } = readProfiles(data);
93
+ return { ...data, profiles: { ...profiles, [active]: snapshot(data) } };
94
+ }
95
+
96
+ export function applyProfileCommand(data: Record<string, unknown>, command: ProfileCommand): ProfileResult {
97
+ const { profiles } = readProfiles(data);
98
+ const names = Object.keys(profiles).sort();
99
+
100
+ if (command.kind === "new") {
101
+ if (!isValidProfileName(command.name)) {
102
+ return { ok: false, message: `nombre de perfil inválido: ${command.name}` };
103
+ }
104
+ if (profiles[command.name]) {
105
+ return { ok: false, message: `el perfil ${command.name} ya existe; usá save para sobrescribirlo` };
106
+ }
107
+ const source = command.from ? profiles[command.from] : snapshot(data);
108
+ if (!source) return { ok: false, message: `no existe el perfil ${command.from}` };
109
+ return {
110
+ ok: true,
111
+ data: { ...data, profiles: { ...profiles, [command.name]: source }, activeProfile: command.name },
112
+ message: `perfil ${command.name} creado y activado`,
113
+ };
114
+ }
115
+
116
+ if (command.kind === "save") {
117
+ const target = command.name ?? readActiveProfile(data);
118
+ if (!target) {
119
+ return { ok: false, message: "no hay perfil activo: pasá un nombre para guardar" };
120
+ }
121
+ if (!isValidProfileName(target)) {
122
+ return { ok: false, message: `nombre de perfil inválido: ${target}` };
123
+ }
124
+ return {
125
+ ok: true,
126
+ data: { ...data, profiles: { ...profiles, [target]: snapshot(data) } },
127
+ message: `perfil ${target} guardado`,
128
+ };
129
+ }
130
+
131
+ if (command.kind === "use") {
132
+ const profile = profiles[command.name];
133
+ if (!profile) {
134
+ return {
135
+ ok: false,
136
+ message: `no existe el perfil ${command.name}${names.length > 0 ? `. Perfiles: ${names.join(", ")}` : ""}`,
137
+ };
138
+ }
139
+ return {
140
+ ok: true,
141
+ data: { ...data, models: { ...profile.models }, activeProfile: command.name },
142
+ message: `perfil ${command.name} activado`,
143
+ };
144
+ }
145
+
146
+ const remaining = { ...profiles };
147
+ if (!remaining[command.name]) {
148
+ return {
149
+ ok: false,
150
+ message: `no existe el perfil ${command.name}${names.length > 0 ? `. Perfiles: ${names.join(", ")}` : ""}`,
151
+ };
152
+ }
153
+ delete remaining[command.name];
154
+ // The flat config stays as it is: deleting a profile must not silently change
155
+ // which models are in use.
156
+ const active = readActiveProfile(data);
157
+ return {
158
+ ok: true,
159
+ data: { ...data, profiles: remaining, activeProfile: active === command.name ? null : active },
160
+ message: `perfil ${command.name} borrado`,
161
+ };
162
+ }
@@ -0,0 +1,48 @@
1
+ // What `/nodd-models` shows, and what it lets you change.
2
+ //
3
+ // All seven canonical ODD steps are displayed, because the visible protocol is
4
+ // part of what NODD inherits: hiding `authorize`, `classify`, `track` and
5
+ // `close` would make the mechanized half of ODD invisible. But those four run no
6
+ // model — they are comparators and file writers — so they get no slot. A
7
+ // decorative slot on a step that never calls a model is a lie with a dropdown.
8
+ //
9
+ // Assignable: the two global slots plus the three model-backed steps.
10
+
11
+ import { CANONICAL_STEPS, CONFIGURABLE_SLOTS, MECHANISM_STEPS, type ConfigurableSlot } from "../manifest.ts";
12
+
13
+ export type SlotRow = {
14
+ id: string;
15
+ /** `global` and `step` are assignable; `mechanism` is displayed only. */
16
+ kind: "global" | "step" | "mechanism";
17
+ /** What the row shows when it carries no model. */
18
+ placeholder: string;
19
+ };
20
+
21
+ export const MECHANISM_PLACEHOLDER = "mecanismo · sin modelo";
22
+
23
+ const GLOBAL_SLOTS = ["default", "orchestrator"] as const;
24
+
25
+ function isMechanism(step: string): boolean {
26
+ return (MECHANISM_STEPS as readonly string[]).includes(step);
27
+ }
28
+
29
+ /**
30
+ * The display model: the two global slots first, then the seven canonical steps
31
+ * in protocol order. The order is the contract — a test asserts it.
32
+ */
33
+ export const SLOT_ROWS: readonly SlotRow[] = Object.freeze([
34
+ ...GLOBAL_SLOTS.map((id): SlotRow => ({ id, kind: "global", placeholder: "sin asignar" })),
35
+ ...CANONICAL_STEPS.map((id): SlotRow =>
36
+ isMechanism(id)
37
+ ? { id, kind: "mechanism", placeholder: MECHANISM_PLACEHOLDER }
38
+ : { id, kind: "step", placeholder: "sin asignar" },
39
+ ),
40
+ ]);
41
+
42
+ export function slotRow(id: string): SlotRow | undefined {
43
+ return SLOT_ROWS.find((row) => row.id === id);
44
+ }
45
+
46
+ export function isConfigurableSlot(id: string): id is ConfigurableSlot {
47
+ return (CONFIGURABLE_SLOTS as readonly string[]).includes(id);
48
+ }
@@ -0,0 +1,61 @@
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 { observation, pendingCall } from "./observations.ts";
7
+ import { emptyState } from "./state.ts";
8
+
9
+ test("an observation carries the raw observed fields", () => {
10
+ const obs = observation({
11
+ toolCallId: "call_1",
12
+ toolName: "bash",
13
+ input: { command: "npm test" },
14
+ isError: false,
15
+ resultText: "12 passing",
16
+ at: "2026-09-19T10:00:00.000Z",
17
+ });
18
+ assert.equal(obs.toolCallId, "call_1");
19
+ assert.equal(obs.toolName, "bash");
20
+ assert.deepEqual(obs.input, { command: "npm test" });
21
+ assert.equal(obs.isError, false);
22
+ assert.equal(obs.resultText, "12 passing");
23
+ });
24
+
25
+ test("an observation cannot be constructed without a toolCallId", () => {
26
+ const missing = { toolCallId: "", toolName: "bash", input: {}, isError: false, resultText: "", at: "t" };
27
+ assert.throws(() => observation(missing), /toolCallId/);
28
+ // @ts-expect-error — the field is required by type, and rejected at runtime too.
29
+ assert.throws(() => observation({ toolName: "bash", input: {}, isError: false, resultText: "", at: "t" }), /toolCallId/);
30
+ });
31
+
32
+ test("a pending call is keyed by toolCallId and carries no result", () => {
33
+ const pending = pendingCall({ toolCallId: "call_2", toolName: "write", input: { path: "a.ts" } });
34
+ assert.equal(pending.toolCallId, "call_2");
35
+ assert.ok(!("resultText" in pending), "a pending call has no result yet");
36
+ assert.ok(!("isError" in pending), "a pending call has no outcome yet");
37
+ });
38
+
39
+ test("state splits committed from pending", () => {
40
+ const state = emptyState();
41
+ assert.ok(state.pending instanceof Map);
42
+ assert.equal(state.pending.size, 0);
43
+ assert.equal(state.committed.toolCalls, 0);
44
+ assert.equal(state.committed.declaration, null);
45
+ });
46
+
47
+ // The ledger stores what was observed. Interpretation (success/failure) is
48
+ // derived later by src/outcome.ts, so evidence stays re-derivable and is never
49
+ // pre-collapsed into a boolean a caller could trust blindly.
50
+ test("no raw observation field is a boolean verdict", () => {
51
+ const obs = observation({
52
+ toolCallId: "call_3", toolName: "bash", input: {}, isError: true, resultText: "boom", at: "t",
53
+ });
54
+ for (const key of ["ok", "passed", "success", "failed"]) {
55
+ assert.ok(!(key in obs), `Observation must not carry a '${key}' verdict field`);
56
+ }
57
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "observations.ts"), "utf8");
58
+ for (const key of ["ok:", "passed:", "success:"]) {
59
+ assert.ok(!src.includes(key), `observations.ts must not define '${key}'`);
60
+ }
61
+ });
@@ -0,0 +1,51 @@
1
+ // What NODD saw, as it saw it.
2
+ //
3
+ // An `Observation` is a fact from a real pi `tool_result` event: the tool, its
4
+ // input, whether pi reported an error, and the rendered result text. Nothing
5
+ // here interprets that raw material — `src/outcome.ts` derives the five command
6
+ // outcomes from it, and it does so on demand. Storing the interpretation
7
+ // instead of the observation is how "Evidence: tests pass" became worthless in
8
+ // ODD: once collapsed, the evidence can no longer be re-checked.
9
+
10
+ export type Observation = {
11
+ toolCallId: string;
12
+ toolName: string;
13
+ input: Record<string, unknown>;
14
+ /** pi's own error flag from the tool result. Not a verdict — an observation. */
15
+ isError: boolean;
16
+ /** The rendered result text, including the status line pi's bash appends. */
17
+ resultText: string;
18
+ /** ISO timestamp of when the result was observed. */
19
+ at: string;
20
+ };
21
+
22
+ /**
23
+ * A tool call preflighted in the current assistant batch whose result is not
24
+ * known yet. It deliberately has no result fields: pi preflights siblings
25
+ * sequentially and runs them concurrently, so a gate that read a result from
26
+ * here would read evidence that does not exist.
27
+ */
28
+ export type PendingCall = {
29
+ toolCallId: string;
30
+ toolName: string;
31
+ input: Record<string, unknown>;
32
+ };
33
+
34
+ export function observation(fields: Observation): Observation {
35
+ if (!fields?.toolCallId) {
36
+ throw new Error("an observation requires a toolCallId: it is what makes replay idempotent");
37
+ }
38
+ return {
39
+ toolCallId: fields.toolCallId,
40
+ toolName: fields.toolName,
41
+ input: fields.input ?? {},
42
+ isError: fields.isError,
43
+ resultText: fields.resultText ?? "",
44
+ at: fields.at,
45
+ };
46
+ }
47
+
48
+ export function pendingCall(fields: PendingCall): PendingCall {
49
+ if (!fields?.toolCallId) throw new Error("a pending call requires a toolCallId");
50
+ return { toolCallId: fields.toolCallId, toolName: fields.toolName, input: fields.input ?? {} };
51
+ }
@@ -0,0 +1,125 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { CANONICAL_STEPS } from "./manifest.ts";
5
+ import { GATE_IDS } from "./gates/registry.ts";
6
+ import { ODD_PROSE, proseForStep, type ProseEntry } from "./odd-prose.ts";
7
+
8
+ // The `(P)`-bearing rows of `REQ: odd-parity-matrix`. Listed here by number so
9
+ // that adding a row to the matrix without adding its clause fails loudly.
10
+ const P_ROWS = [4, 5, 6, 7, 11, 19, 28, 29, 32, 33, 34, 35, 36, 38, 39, 41, 44, 49];
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // One entry per (P) row
14
+ // ---------------------------------------------------------------------------
15
+ test("the corpus has exactly one entry per (P) row of the parity matrix", () => {
16
+ assert.deepEqual(ODD_PROSE.map((entry) => entry.row).sort((a, b) => a - b), P_ROWS);
17
+ assert.equal(new Set(ODD_PROSE.map((entry) => entry.row)).size, ODD_PROSE.length, "no duplicated row");
18
+ });
19
+
20
+ test("every entry carries its routing.go line, a step tag, a clause and a reason", () => {
21
+ for (const entry of ODD_PROSE) {
22
+ assert.match(entry.line, /^:\d+/, `row ${entry.row} must cite a routing.go line`);
23
+ assert.ok(CANONICAL_STEPS.includes(entry.step), `row ${entry.row} has step ${entry.step}`);
24
+ assert.ok(entry.clause.length > 20, `row ${entry.row} needs a real clause`);
25
+ assert.ok(entry.reason.length > 20, `row ${entry.row} needs a real reason`);
26
+ }
27
+ });
28
+
29
+ test("no reason is the non-reason the matrix forbids", () => {
30
+ for (const entry of ODD_PROSE) {
31
+ assert.ok(
32
+ !/did not get to|todo|later|not yet implemented/i.test(entry.reason),
33
+ `row ${entry.row}: "we did not get to it" is not a reason`,
34
+ );
35
+ }
36
+ });
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // The two clauses that must survive verbatim
40
+ // ---------------------------------------------------------------------------
41
+ test("the preparation trigger is carried under explore", () => {
42
+ const entry = ODD_PROSE.find((e) => e.row === 28);
43
+ assert.ok(entry, "row 28 must exist");
44
+ assert.equal(entry!.step, "explore");
45
+ assert.match(entry!.clause, /prepar/i);
46
+ assert.equal(entry!.line, ":81");
47
+ });
48
+
49
+ test("the ~400-line advisory is carried under implement, marked as advisory only", () => {
50
+ const entry = ODD_PROSE.find((e) => e.row === 38);
51
+ assert.ok(entry);
52
+ assert.equal(entry!.step, "implement");
53
+ assert.match(entry!.clause, /400/);
54
+ for (const negation of ["acceptance criterion", "hard cap", "automatic stop", "forced split"]) {
55
+ assert.ok(entry!.clause.includes(negation), `the advisory must deny being a ${negation}`);
56
+ }
57
+ });
58
+
59
+ test("the anti-gaming sentence is carried verbatim and names everything it forbids", () => {
60
+ const entry = ODD_PROSE.find((e) => e.row === 39);
61
+ assert.ok(entry);
62
+ assert.equal(entry!.step, "implement");
63
+ for (const forbidden of ["blank lines", "comments", "minify", "tests", "split"]) {
64
+ assert.ok(entry!.clause.includes(forbidden), `the anti-gaming clause must name ${forbidden}`);
65
+ }
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Selection by step
70
+ // ---------------------------------------------------------------------------
71
+ test("selecting a step returns that step's entries and nothing else", () => {
72
+ for (const step of CANONICAL_STEPS) {
73
+ for (const entry of proseForStep(step)) {
74
+ assert.equal(entry.step, step, `${entry.row} leaked into ${step}`);
75
+ }
76
+ }
77
+ });
78
+
79
+ test("every entry is reachable through some step, so nothing is stranded", () => {
80
+ const reachable = CANONICAL_STEPS.flatMap((step) => proseForStep(step).map((entry) => entry.row));
81
+ assert.deepEqual(reachable.sort((a, b) => a - b), P_ROWS);
82
+ });
83
+
84
+ test("implement carries the two line-heuristic clauses together", () => {
85
+ const rows = proseForStep("implement").map((entry) => entry.row);
86
+ assert.ok(rows.includes(38) && rows.includes(39), "the advisory and its anti-gaming sentence travel together");
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // The absent mechanisms are verified absent
91
+ // ---------------------------------------------------------------------------
92
+ test("no gate id contains `prepar`: the preparation trigger stayed prose", () => {
93
+ const gates = readdirSync(new URL("./gates/", import.meta.url)).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
94
+ for (const file of gates) {
95
+ assert.ok(!/prepar/i.test(file), `${file} implies a preparation mechanism that must not exist`);
96
+ }
97
+ for (const id of GATE_IDS) assert.ok(!/prepar/i.test(id), `gate ${id} must not be a preparation trigger`);
98
+ });
99
+
100
+ test("no gate consults a line count: the 400-line figure stayed advisory", () => {
101
+ const dir = new URL("./gates/", import.meta.url);
102
+ for (const file of readdirSync(dir)) {
103
+ if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
104
+ const source = readFileSync(new URL(file, dir), "utf8")
105
+ .split("\n")
106
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
107
+ .join("\n");
108
+ for (const forbidden of ["countAuthoredLines", "lineCount", "linesChanged", "400"]) {
109
+ assert.ok(!source.includes(forbidden), `${file} must not consult ${forbidden}`);
110
+ }
111
+ }
112
+ });
113
+
114
+ test("the corpus is data, not behaviour: it reads no file and imports no gate", () => {
115
+ const source = readFileSync(new URL("./odd-prose.ts", import.meta.url), "utf8")
116
+ .split("\n")
117
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
118
+ .join("\n");
119
+ assert.ok(!source.includes("node:fs"));
120
+ assert.ok(!source.includes("./gates/"));
121
+ });
122
+
123
+ test("the corpus is frozen, so no caller can ratchet it at runtime", () => {
124
+ assert.throws(() => (ODD_PROSE as ProseEntry[]).push({} as ProseEntry));
125
+ });