@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,80 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { emptyCommitted, fold, type Committed } from "../state.ts";
4
+ import { observation } from "../observations.ts";
5
+ import { emptyPolicy } from "./policy.ts";
6
+ import { trackGate } from "./track.ts";
7
+
8
+ function routed(route: "inline" | "tracked" | "forge", slug = "demo"): Committed {
9
+ return fold(emptyCommitted(), observation({
10
+ toolCallId: "d1", toolName: "nodd_declare",
11
+ input: { intent: "change", route, slug },
12
+ isError: false, resultText: "", at: "t",
13
+ }));
14
+ }
15
+
16
+ const write = { toolName: "write", input: { path: "src/a.ts" } };
17
+ const absent = () => false;
18
+ const present = () => true;
19
+
20
+ test("a missing feature doc blocks the first write, naming the exact path", () => {
21
+ const decision = trackGate(routed("tracked"), write, emptyPolicy(), absent);
22
+ assert.equal(decision.allow, false);
23
+ assert.ok(decision.allow === false && decision.reason.includes(".nodd/demo/feature.md"));
24
+ assert.ok(decision.allow === false && decision.remedy.action.includes("nodd_declare"));
25
+ assert.ok(decision.allow === false && decision.remedy.escapeHatch === "/nodd-allow track");
26
+ });
27
+
28
+ test("a present feature doc allows the write", () => {
29
+ assert.equal(trackGate(routed("tracked"), write, emptyPolicy(), present).allow, true);
30
+ });
31
+
32
+ test("route forge is tracked too", () => {
33
+ assert.equal(trackGate(routed("forge"), write, emptyPolicy(), absent).allow, false);
34
+ });
35
+
36
+ // routing.go:94 — small, understood work creates no durable task artifacts.
37
+ test("route inline never blocks", () => {
38
+ assert.equal(trackGate(routed("inline"), write, emptyPolicy(), absent).allow, true);
39
+ assert.equal(trackGate(routed("inline"), { toolName: "bash", input: { command: "echo x > f" } }, emptyPolicy(), absent).allow, true);
40
+ });
41
+
42
+ test("mutating bash blocks while a test command does not", () => {
43
+ const state = routed("tracked");
44
+ assert.equal(trackGate(state, { toolName: "bash", input: { command: "echo x > f" } }, emptyPolicy(), absent).allow, false);
45
+ assert.equal(trackGate(state, { toolName: "bash", input: { command: "npm test" } }, emptyPolicy(), absent).allow, true);
46
+ });
47
+
48
+ // A doc the model can rewrite is a doc the model can forge, and then evidence
49
+ // is prose again. This holds even when the doc exists and the route is inline.
50
+ test("a direct write to .nodd/** is always blocked, pointing at nodd_task", () => {
51
+ for (const state of [routed("tracked"), routed("inline"), emptyCommitted()]) {
52
+ for (const path of [".nodd/x/feature.md", ".nodd/demo/state.json", "/repo/.nodd/demo/feature.md"]) {
53
+ const decision = trackGate(state, { toolName: "write", input: { path } }, emptyPolicy(), present);
54
+ assert.equal(decision.allow, false, `${path} must be blocked`);
55
+ assert.ok(decision.allow === false && decision.remedy.action.includes("nodd_task"));
56
+ }
57
+ }
58
+ const edit = trackGate(routed("tracked"), { toolName: "edit", input: { path: ".nodd/demo/feature.md" } }, emptyPolicy(), present);
59
+ assert.equal(edit.allow, false);
60
+ });
61
+
62
+ // design.md says `.nodd/**` writes are "always blocked", but REQ: gate-framework
63
+ // says flag semantics obey the kill switch "without exception", and
64
+ // REQ: gate-kill-switch-semantics 3 says obey immediately without working
65
+ // around it. The explicit "without exception" wins: a gate the user turned off
66
+ // is off, including its doc-integrity half. Turning `track` off is the
67
+ // documented way to hand-edit a feature doc.
68
+ test("the flag off disables the whole gate, doc protection included", () => {
69
+ const policy = { ...emptyPolicy(), config: { track: { enabled: false } } };
70
+ assert.equal(trackGate(routed("tracked"), write, policy, absent).allow, true);
71
+ assert.equal(
72
+ trackGate(routed("tracked"), { toolName: "write", input: { path: ".nodd/demo/feature.md" } }, policy, present).allow,
73
+ true,
74
+ "a disabled gate does not keep enforcing half of itself",
75
+ );
76
+ });
77
+
78
+ test("an undeclared session does not block here — that is gate-classify's job", () => {
79
+ assert.equal(trackGate(emptyCommitted(), write, emptyPolicy(), absent).allow, true);
80
+ });
@@ -0,0 +1,58 @@
1
+ // gate-track — track before the first write.
2
+ //
3
+ // `routing.go:49`: for substantial authorized implementation, create the
4
+ // feature document *before the first source write*, without asking permission.
5
+ // NODD makes that literal: on a `tracked` or `forge` route, while
6
+ // `.nodd/<slug>/feature.md` is absent, the first mutation does not happen.
7
+ //
8
+ // Route `inline` never blocks. Small, understood work stays small
9
+ // (`routing.go:94`) — a gate that demanded a document for a one-line fix would
10
+ // be the bureaucracy ODD explicitly refuses.
11
+ //
12
+ // The second half of this gate protects the document itself: `write`/`edit`
13
+ // aimed at `.nodd/**` is refused and pointed at `nodd_task`. The feature doc is
14
+ // extension-owned because a doc the model can rewrite is a doc the model can
15
+ // forge, and then "Evidence: tests pass" is back, in a file, looking official.
16
+
17
+ import type { Committed } from "../state.ts";
18
+ import { allow, refuse, resolveFlag, type GateDecision, type Policy } from "./policy.ts";
19
+ import { isFileWrite, isMutation, targetPath, type GateRequest } from "./request.ts";
20
+
21
+ export function featureDocRelPath(slug: string): string {
22
+ return `.nodd/${slug}/feature.md`;
23
+ }
24
+
25
+ function targetsNoddDir(request: GateRequest): boolean {
26
+ const path = targetPath(request);
27
+ return isFileWrite(request) && path !== null && /(^|\/)\.nodd\//.test(path);
28
+ }
29
+
30
+ export function trackGate(
31
+ committed: Committed,
32
+ request: GateRequest,
33
+ policy: Policy,
34
+ docExists: (slug: string) => boolean,
35
+ ): GateDecision {
36
+ if (!resolveFlag("track", policy).enabled) return allow();
37
+
38
+ if (targetsNoddDir(request)) {
39
+ return refuse(
40
+ "track",
41
+ `${request.toolName} targets \`${targetPath(request)}\`, and NODD's own artifacts are written by NODD, not by the model`,
42
+ "use `nodd_task` to add or check off tasks, and `nodd_declare` to set the objective and route",
43
+ );
44
+ }
45
+
46
+ const declaration = committed.declaration;
47
+ if (!declaration) return allow();
48
+ if (declaration.route === "inline") return allow();
49
+ if (!isMutation(request)) return allow();
50
+ if (docExists(declaration.slug)) return allow();
51
+
52
+ const path = featureDocRelPath(declaration.slug);
53
+ return refuse(
54
+ "track",
55
+ `route \`${declaration.route}\` was declared but \`${path}\` does not exist, and ${request.toolName} would be the first write`,
56
+ `call \`nodd_declare\` with slug \`${declaration.slug}\` to create ${path}`,
57
+ );
58
+ }
package/src/io.test.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { nodeFs, writeVerified, type Fs } from "./io.ts";
7
+
8
+ function tmp(): string {
9
+ return mkdtempSync(join(tmpdir(), "nodd-io-"));
10
+ }
11
+
12
+ test("a verified write lands on disk and reports success", () => {
13
+ const dir = tmp();
14
+ const target = join(dir, "feature.md");
15
+ const result = writeVerified(target, "hello\n", { fs: nodeFs });
16
+ assert.equal(result.ok, true);
17
+ assert.equal(readFileSync(target, "utf8"), "hello\n");
18
+ assert.deepEqual(readdirSync(dir), ["feature.md"], "the temp file must not survive");
19
+ });
20
+
21
+ test("a failing writer leaves the previous file intact and reports the limitation", () => {
22
+ const dir = tmp();
23
+ const target = join(dir, "feature.md");
24
+ writeFileSync(target, "original\n", "utf8");
25
+ const failing: Fs = { ...nodeFs, writeFileSync: () => { throw new Error("ENOSPC: disk full"); } };
26
+
27
+ const result = writeVerified(target, "replacement\n", { fs: failing });
28
+ assert.equal(result.ok, false);
29
+ assert.match(result.limitation, /ENOSPC/);
30
+ assert.equal(readFileSync(target, "utf8"), "original\n", "the previous file must survive");
31
+ });
32
+
33
+ test("a read-back mismatch is reported as a failure, not as success", () => {
34
+ const dir = tmp();
35
+ const target = join(dir, "feature.md");
36
+ writeFileSync(target, "original\n", "utf8");
37
+ // A writer that silently truncates: the write "succeeds" and the content is wrong.
38
+ const lying: Fs = { ...nodeFs, readFileSync: () => "trunca" };
39
+
40
+ const result = writeVerified(target, "replacement\n", { fs: lying });
41
+ assert.equal(result.ok, false);
42
+ assert.match(result.limitation, /read-back/);
43
+ });
44
+
45
+ test("an interrupted write leaves no partial file behind", () => {
46
+ const dir = tmp();
47
+ const target = join(dir, "feature.md");
48
+ const interrupted: Fs = { ...nodeFs, renameSync: () => { throw new Error("interrupted"); } };
49
+
50
+ const result = writeVerified(target, "half", { fs: interrupted });
51
+ assert.equal(result.ok, false);
52
+ assert.deepEqual(readdirSync(dir), [], "no partial and no temp file may remain");
53
+ });
54
+
55
+ test("divergent on-disk content is preserved alongside, losing neither version", () => {
56
+ const dir = tmp();
57
+ const target = join(dir, "feature.md");
58
+ writeFileSync(target, "edited by hand\n", "utf8");
59
+
60
+ const result = writeVerified(target, "nodd version\n", {
61
+ fs: nodeFs,
62
+ expectedPrevious: "what nodd last wrote\n",
63
+ now: () => "20260919T100000",
64
+ });
65
+
66
+ assert.equal(result.ok, true);
67
+ assert.equal(result.conflictPath, join(dir, "feature.conflict-20260919T100000.md"));
68
+ assert.equal(readFileSync(target, "utf8"), "nodd version\n");
69
+ assert.equal(readFileSync(result.conflictPath!, "utf8"), "edited by hand\n");
70
+ });
71
+
72
+ test("matching previous content produces no conflict file", () => {
73
+ const dir = tmp();
74
+ const target = join(dir, "feature.md");
75
+ writeFileSync(target, "same\n", "utf8");
76
+
77
+ const result = writeVerified(target, "next\n", { fs: nodeFs, expectedPrevious: "same\n" });
78
+ assert.equal(result.ok, true);
79
+ assert.equal(result.conflictPath, undefined);
80
+ assert.deepEqual(readdirSync(dir), ["feature.md"]);
81
+ });
package/src/io.ts ADDED
@@ -0,0 +1,94 @@
1
+ // Every NODD artifact write goes through here.
2
+ //
3
+ // `routing.go:98` — "read back both writes; they are not atomic". NODD carries
4
+ // the disk half of that clause literally: write to a temp file, rename, read
5
+ // back, and compare against what we meant to write. A mismatch preserves the
6
+ // previous file and returns a limitation instead of claiming success, because a
7
+ // write NODD did not verify is exactly the kind of unearned "done" this product
8
+ // exists to refuse.
9
+ //
10
+ // If the on-disk content diverged from what NODD last wrote, both versions
11
+ // survive: the new content lands, the divergent one is kept beside it as
12
+ // `<name>.conflict-<ts>.md`. Neither is lost, and only the real conflict is
13
+ // worth asking the user about.
14
+
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { dirname, extname, join } from "node:path";
17
+
18
+ /** The file operations this module needs, injectable so tests can force failure. */
19
+ export type Fs = {
20
+ existsSync(path: string): boolean;
21
+ mkdirSync(path: string, options: { recursive: true }): void;
22
+ readFileSync(path: string, encoding: "utf8"): string;
23
+ writeFileSync(path: string, data: string, encoding: "utf8"): void;
24
+ renameSync(from: string, to: string): void;
25
+ unlinkSync(path: string): void;
26
+ };
27
+
28
+ export const nodeFs: Fs = { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync };
29
+
30
+ export type WriteResult =
31
+ | { ok: true; conflictPath?: string }
32
+ | { ok: false; limitation: string };
33
+
34
+ export type WriteOptions = {
35
+ fs?: Fs;
36
+ /**
37
+ * What NODD believes it last wrote. When the file on disk says something
38
+ * else, the divergent version is preserved rather than overwritten.
39
+ */
40
+ expectedPrevious?: string;
41
+ now?: () => string;
42
+ };
43
+
44
+ function timestamp(): string {
45
+ return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "");
46
+ }
47
+
48
+ function conflictPathFor(target: string, stamp: string): string {
49
+ const ext = extname(target);
50
+ const base = target.slice(0, target.length - ext.length);
51
+ return `${base}.conflict-${stamp}${ext}`;
52
+ }
53
+
54
+ export function writeVerified(target: string, content: string, options: WriteOptions = {}): WriteResult {
55
+ const fs = options.fs ?? nodeFs;
56
+ const stamp = (options.now ?? timestamp)();
57
+ const temp = join(dirname(target), `.${stamp}.nodd-tmp`);
58
+
59
+ let conflictPath: string | undefined;
60
+ try {
61
+ fs.mkdirSync(dirname(target), { recursive: true });
62
+
63
+ if (options.expectedPrevious !== undefined && fs.existsSync(target)) {
64
+ const onDisk = fs.readFileSync(target, "utf8");
65
+ if (onDisk !== options.expectedPrevious) {
66
+ conflictPath = conflictPathFor(target, stamp);
67
+ fs.writeFileSync(conflictPath, onDisk, "utf8");
68
+ }
69
+ }
70
+
71
+ fs.writeFileSync(temp, content, "utf8");
72
+ fs.renameSync(temp, target);
73
+ } catch (err) {
74
+ try {
75
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
76
+ } catch {
77
+ // Best effort: the temp file is already reported through the limitation.
78
+ }
79
+ return { ok: false, limitation: err instanceof Error ? err.message : String(err) };
80
+ }
81
+
82
+ // The success path is reachable only through a matching read-back.
83
+ let readBack: string;
84
+ try {
85
+ readBack = fs.readFileSync(target, "utf8");
86
+ } catch (err) {
87
+ return { ok: false, limitation: `read-back failed: ${err instanceof Error ? err.message : String(err)}` };
88
+ }
89
+ if (readBack !== content) {
90
+ return { ok: false, limitation: `read-back mismatch on ${target}: the file on disk is not what NODD wrote` };
91
+ }
92
+
93
+ return conflictPath ? { ok: true, conflictPath } : { ok: true };
94
+ }
@@ -0,0 +1,122 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { appendRecord, readLedger, classifyRecords, type LedgerRecord } from "./ledger.ts";
4
+ import type { Fs } from "./io.ts";
5
+
6
+ function memoryFs(seed: Record<string, string> = {}): Fs & { files: Map<string, string> } {
7
+ const files = new Map(Object.entries(seed));
8
+ return {
9
+ files,
10
+ readFileSync: (p: string) => {
11
+ const found = files.get(p);
12
+ if (found === undefined) { const e: any = new Error("ENOENT"); e.code = "ENOENT"; throw e; }
13
+ return found;
14
+ },
15
+ writeFileSync: (p: string, data: string) => { files.set(p, data); },
16
+ renameSync: (from: string, to: string) => {
17
+ files.set(to, files.get(from)!);
18
+ files.delete(from);
19
+ },
20
+ existsSync: (p: string) => files.has(p),
21
+ mkdirSync: () => {},
22
+ unlinkSync: (p: string) => { files.delete(p); },
23
+ };
24
+ }
25
+
26
+ const PATH = ".nodd/demo/state.json";
27
+
28
+ function rec(id: string, command = "npm test"): LedgerRecord {
29
+ return { toolCallId: id, tool: "bash", command, outcome: { kind: "success" }, at: "2026-09-19T10:00:00.000Z" };
30
+ }
31
+
32
+ test("two appends re-read in order", () => {
33
+ const fs = memoryFs();
34
+ appendRecord(PATH, rec("c1", "npm test"), fs);
35
+ appendRecord(PATH, rec("c2", "npm run build"), fs);
36
+
37
+ const { records, defects } = readLedger(PATH, fs);
38
+ assert.deepEqual(defects, []);
39
+ assert.deepEqual(records.map((r) => r.toolCallId), ["c1", "c2"]);
40
+ assert.equal(records[1].command, "npm run build");
41
+ });
42
+
43
+ test("a missing ledger is empty and is not a defect", () => {
44
+ const { records, defects } = readLedger(PATH, memoryFs());
45
+ assert.deepEqual(records, []);
46
+ assert.deepEqual(defects, []);
47
+ });
48
+
49
+ test("a truncated file yields a defect and an empty ledger without throwing", () => {
50
+ const { records, defects } = readLedger(PATH, memoryFs({ [PATH]: '{"records":[{"toolCallId":"c1"' }));
51
+ assert.deepEqual(records, [], "corrupt content is never evidence");
52
+ assert.equal(defects.length, 1);
53
+ assert.match(defects[0], /state\.json/);
54
+ });
55
+
56
+ test("a record without a toolCallId is rejected by the append API", () => {
57
+ const fs = memoryFs();
58
+ assert.throws(() => appendRecord(PATH, { tool: "bash", command: "x", outcome: { kind: "success" }, at: "t" } as any, fs),
59
+ /toolCallId/);
60
+ assert.equal(fs.files.has(PATH), false, "nothing was written");
61
+ });
62
+
63
+ test("an interrupted write leaves no partial file visible at the real path", () => {
64
+ const fs = memoryFs();
65
+ appendRecord(PATH, rec("c1"), fs);
66
+ const good = fs.files.get(PATH)!;
67
+
68
+ const failing: Fs = { ...fs, renameSync: () => { throw new Error("boom"); } };
69
+ assert.throws(() => appendRecord(PATH, rec("c2"), failing));
70
+ assert.equal(fs.files.get(PATH), good, "the previous good content is intact");
71
+ assert.deepEqual(readLedger(PATH, fs).records.map((r) => r.toolCallId), ["c1"]);
72
+ });
73
+
74
+ // --- integrity: a record is evidence only if THIS kernel observed it ---
75
+
76
+ test("records whose toolCallId this session observed are trusted", () => {
77
+ const { trusted, degraded } = classifyRecords([rec("c1"), rec("c2")], new Set(["c1", "c2"]));
78
+ assert.deepEqual(trusted.map((r) => r.toolCallId), ["c1", "c2"]);
79
+ assert.deepEqual(degraded, []);
80
+ });
81
+
82
+ // The T010 probe child rewrote a whole .nodd file because it had `write` and no
83
+ // append tool. A forged record is well-formed JSON; what it cannot be is a
84
+ // toolCallId this process saw.
85
+ test("a record the kernel never observed is degraded, not trusted", () => {
86
+ const forged = { ...rec("forged-1", "npm test"), outcome: { kind: "success" } as const };
87
+ const { trusted, degraded } = classifyRecords([rec("c1"), forged], new Set(["c1"]));
88
+
89
+ assert.deepEqual(trusted.map((r) => r.toolCallId), ["c1"]);
90
+ assert.equal(degraded.length, 1);
91
+ assert.equal(degraded[0].record.toolCallId, "forged-1");
92
+ assert.equal(degraded[0].reason, "unobserved");
93
+ assert.match(degraded[0].detail, /not observed/i);
94
+ });
95
+
96
+ test("a record whose command or outcome disagrees with what was observed is degraded", () => {
97
+ const observed = new Map([["c1", { command: "npm test", outcome: "exit 1" }]]);
98
+
99
+ const rewritten = { ...rec("c1", "npm test"), outcome: { kind: "success" } as const };
100
+ const byOutcome = classifyRecords([rewritten], new Set(["c1"]), observed);
101
+ assert.deepEqual(byOutcome.trusted, [], "a flipped outcome is not evidence");
102
+ assert.equal(byOutcome.degraded[0].reason, "mismatch");
103
+ assert.match(byOutcome.degraded[0].detail, /exit 1/);
104
+
105
+ const byCommand = classifyRecords([rec("c1", "echo pretend")], new Set(["c1"]), observed);
106
+ assert.deepEqual(byCommand.trusted, []);
107
+ assert.equal(byCommand.degraded[0].reason, "mismatch");
108
+ });
109
+
110
+ // Honest limit: a fresh process saw nothing, so everything is unverified. That
111
+ // is reported as unverified, never as proven-forged and never as success.
112
+ test("with an empty observed set every record is degraded as unverified", () => {
113
+ const { trusted, degraded } = classifyRecords([rec("c1"), rec("c2")], new Set());
114
+ assert.deepEqual(trusted, []);
115
+ assert.equal(degraded.length, 2);
116
+ for (const d of degraded) assert.equal(d.reason, "unobserved");
117
+ });
118
+
119
+ test("degradation never throws away the record, it reports it", () => {
120
+ const { degraded } = classifyRecords([rec("x")], new Set());
121
+ assert.equal(degraded[0].record.toolCallId, "x", "the record survives for the report");
122
+ });
package/src/ledger.ts ADDED
@@ -0,0 +1,133 @@
1
+ // `.nodd/<slug>/state.json` — what the extension observed.
2
+ //
3
+ // Append-only, written atomically through `writeVerified`. Every record carries
4
+ // the `toolCallId` of the call it came from: the append API refuses a record
5
+ // without one, so model-authored text cannot enter the ledger by construction.
6
+ //
7
+ // A corrupt file is a reported defect treated as empty. It is never evidence.
8
+ //
9
+ // ## Observed, not merely written
10
+ //
11
+ // The feature doc belongs to the user — hand-editing it with `track` off is
12
+ // legitimate. The ledger does not: it is the record of what the kernel saw.
13
+ // Well-formed JSON on disk proves nothing about whether anything ran, and there
14
+ // are two ordinary ways wrong records get there: a human editing the file, and
15
+ // a delegated child rewriting it whole (the T010 probe child did exactly that,
16
+ // having `write` and no append tool).
17
+ //
18
+ // So a record counts as evidence only when its `toolCallId` is one this kernel
19
+ // committed this session, with the command and outcome it actually observed.
20
+ // Everything else is degraded to unverified and reported — never assumed valid,
21
+ // never silently dropped. Otherwise "observed" decays into "was written in a
22
+ // file", and that is the single thing NODD exists to prevent.
23
+ //
24
+ // Deliberately not cryptographic. An in-memory id set answers the only question
25
+ // worth asking here — did *this* process see it happen — and a signing scheme
26
+ // would add key management without changing that answer.
27
+
28
+ import { writeVerified, nodeFs, type Fs } from "./io.ts";
29
+ import { describeOutcome, type Outcome } from "./outcome.ts";
30
+
31
+ export type LedgerRecord = {
32
+ toolCallId: string;
33
+ tool: string;
34
+ command: string;
35
+ outcome: Outcome;
36
+ at: string;
37
+ };
38
+
39
+ export type ReadResult = { records: LedgerRecord[]; defects: string[] };
40
+
41
+ export function readLedger(path: string, fs: Fs = nodeFs): ReadResult {
42
+ let raw: string;
43
+ try {
44
+ raw = fs.readFileSync(path, "utf8");
45
+ } catch {
46
+ return { records: [], defects: [] };
47
+ }
48
+
49
+ try {
50
+ const parsed = JSON.parse(raw) as { records?: unknown };
51
+ const records = Array.isArray(parsed.records) ? parsed.records : [];
52
+ const usable = records.filter(
53
+ (r): r is LedgerRecord => typeof (r as LedgerRecord)?.toolCallId === "string",
54
+ );
55
+ const defects = usable.length === records.length
56
+ ? []
57
+ : [`${path}: ${records.length - usable.length} record(s) without a toolCallId were ignored`];
58
+ return { records: usable, defects };
59
+ } catch {
60
+ return { records: [], defects: [`${path}: not valid JSON, treated as empty (it is not evidence)`] };
61
+ }
62
+ }
63
+
64
+ export function appendRecord(path: string, record: LedgerRecord, fs: Fs = nodeFs): void {
65
+ if (typeof record?.toolCallId !== "string" || record.toolCallId === "") {
66
+ throw new Error("nodd ledger: a record without a toolCallId is not an observation and cannot be appended");
67
+ }
68
+
69
+ const { records } = readLedger(path, fs);
70
+ const next = JSON.stringify({ records: [...records, record] }, null, 2);
71
+
72
+ const result = writeVerified(path, next, { fs });
73
+ if (!result.ok) throw new Error(`nodd ledger: ${result.limitation}`);
74
+ }
75
+
76
+ export type Degraded = {
77
+ record: LedgerRecord;
78
+ reason: "unobserved" | "mismatch";
79
+ detail: string;
80
+ };
81
+
82
+ /** What the kernel observed for a given call, for cross-checking the file. */
83
+ export type ObservedCall = { command: string; outcome: string };
84
+
85
+ /**
86
+ * Split the ledger into what this kernel can vouch for and what it cannot.
87
+ *
88
+ * Honest limit: a fresh process has an empty observed set, so a pre-existing
89
+ * ledger comes back entirely `unobserved`. That is "unverified", not "proven
90
+ * forged" — the two read differently in a report, and neither counts as
91
+ * success.
92
+ */
93
+ export function classifyRecords(
94
+ records: LedgerRecord[],
95
+ observedIds: Set<string>,
96
+ observedCalls: Map<string, ObservedCall> = new Map(),
97
+ ): { trusted: LedgerRecord[]; degraded: Degraded[] } {
98
+ const trusted: LedgerRecord[] = [];
99
+ const degraded: Degraded[] = [];
100
+
101
+ for (const record of records) {
102
+ if (!observedIds.has(record.toolCallId)) {
103
+ degraded.push({
104
+ record,
105
+ reason: "unobserved",
106
+ detail: `${record.toolCallId} was not observed by this session, so it is unverified and does not count as evidence`,
107
+ });
108
+ continue;
109
+ }
110
+
111
+ const observed = observedCalls.get(record.toolCallId);
112
+ if (observed) {
113
+ const outcome = describeOutcome(record.outcome);
114
+ if (observed.command !== record.command || observed.outcome !== outcome) {
115
+ degraded.push({
116
+ record,
117
+ reason: "mismatch",
118
+ detail: `${record.toolCallId} on disk says \`${record.command}\` → ${outcome}, but this session observed \`${observed.command}\` → ${observed.outcome}`,
119
+ });
120
+ continue;
121
+ }
122
+ }
123
+
124
+ trusted.push(record);
125
+ }
126
+
127
+ return { trusted, degraded };
128
+ }
129
+
130
+ /** One line per degraded record, for a refusal message or a status report. */
131
+ export function describeDegraded(degraded: Degraded[]): string[] {
132
+ return degraded.map((d) => `- ${d.detail}`);
133
+ }
@@ -0,0 +1,53 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ CANONICAL_STEPS,
5
+ CONFIGURABLE_SLOTS,
6
+ MECHANISM_STEPS,
7
+ THRESHOLDS,
8
+ } from "./manifest.ts";
9
+
10
+ // Values carried from gentle's capabilitymanifest/manifest.go:203-216 plus the
11
+ // 20-tool-call backstop, which is prose in routing.go:82. A silent edit must
12
+ // fail here.
13
+ test("thresholds carry gentle's exact values", () => {
14
+ assert.equal(THRESHOLDS.minUnderstandingFiles, 1);
15
+ assert.equal(THRESHOLDS.maxUnderstandingFiles, 3);
16
+ assert.equal(THRESHOLDS.maxMechanicalWriteFiles, 1);
17
+ assert.equal(THRESHOLDS.mappingMinUnderstandingFiles, 4);
18
+ assert.equal(THRESHOLDS.writerMinNonTrivialFiles, 2);
19
+ assert.equal(THRESHOLDS.longSessionToolCalls, 20);
20
+ });
21
+
22
+ test("the canonical step list is the seven ODD steps in order", () => {
23
+ assert.deepEqual(CANONICAL_STEPS, [
24
+ "authorize",
25
+ "explore",
26
+ "resolve-uncertainty",
27
+ "classify",
28
+ "track",
29
+ "implement",
30
+ "close",
31
+ ]);
32
+ assert.ok(Object.isFrozen(CANONICAL_STEPS));
33
+ });
34
+
35
+ test("the four mechanism steps are absent from the configurable slot set", () => {
36
+ assert.deepEqual([...MECHANISM_STEPS], ["authorize", "classify", "track", "close"]);
37
+ for (const step of MECHANISM_STEPS) {
38
+ assert.ok(!CONFIGURABLE_SLOTS.includes(step as never), `${step} must not be configurable`);
39
+ }
40
+ assert.deepEqual(CONFIGURABLE_SLOTS, [
41
+ "default",
42
+ "orchestrator",
43
+ "explore",
44
+ "resolve-uncertainty",
45
+ "implement",
46
+ ]);
47
+ });
48
+
49
+ test("every mechanism step is still displayed", () => {
50
+ for (const step of MECHANISM_STEPS) {
51
+ assert.ok(CANONICAL_STEPS.includes(step), `${step} must stay visible in the protocol`);
52
+ }
53
+ });