@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,185 @@
1
+ // Generating NODD's own pi-subagents agent files.
2
+ //
3
+ // Pattern from `sdd-agents.ts:94-130,220-270`. Three agents, one per model-backed
4
+ // canonical step; the four mechanism steps get none, because they run no model.
5
+ // They land under `~/.pi/agent/agents/nodd/` and never under `agents/zero/` —
6
+ // forge's namespace is forge's.
7
+ //
8
+ // ## Why this is safe to ship (the spike's answer)
9
+ //
10
+ // `spike/subagent-enforcement/RESULT.md` measured it on pi 0.84.2: an extension
11
+ // loaded as an **installed package** does load inside pi-subagent children, and
12
+ // the child's `write` was blocked by the child's own copy of the gate. So
13
+ // delegating through these agents does not route work around NODD's enforcement,
14
+ // which was the open question that gated this task.
15
+ //
16
+ // Two consequences are built into the renderer rather than left to documentation:
17
+ //
18
+ // 1. **No `extensions:` line.** An agent definition that declares one makes
19
+ // pi-subagents launch the child with `--no-extensions`
20
+ // (`pi-args.ts:472-474`), which would strip NODD out of NODD's own
21
+ // sub-agent. A test asserts the line is absent.
22
+ // 2. **The advisory travels.** `routing.go:95`'s ~400-line figure is a planning
23
+ // heuristic, and its anti-gaming sentence is forwarded verbatim into every
24
+ // body, so a child does not optimize for a number the parent is only
25
+ // estimating with.
26
+ //
27
+ // Provisioning is best-effort per file: one failure never blocks the others and
28
+ // never breaks a pi session.
29
+
30
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { noddConfigPath } from "../src/config.ts";
34
+
35
+ /** Forwarded verbatim into every generated body (`routing.go:95`). */
36
+ export const ANTI_GAMING =
37
+ "Never delete blank lines or comments for cosmetic savings, never minify, " +
38
+ "never omit tests, never add gratuitous abstractions, and never split work " +
39
+ "artificially to fit the number.";
40
+
41
+ export type NoddAgent = {
42
+ /** The configurable slot this agent's model comes from. */
43
+ slot: "explore" | "resolve-uncertainty" | "implement";
44
+ description: string;
45
+ tools: string[];
46
+ purpose: string;
47
+ };
48
+
49
+ export const NODD_AGENTS: readonly NoddAgent[] = Object.freeze([
50
+ {
51
+ slot: "explore",
52
+ description: "NODD explore step: map existing code and requirements, read-only",
53
+ tools: ["read", "grep", "ls", "bash"],
54
+ purpose:
55
+ "Map the existing code and requirements for the task you are given. You are read-only: " +
56
+ "report what is there, where it is, and what it implies. Do not write, edit or delegate a writer.",
57
+ },
58
+ {
59
+ slot: "resolve-uncertainty",
60
+ description: "NODD resolve-uncertainty step: research one named uncertainty, read-only",
61
+ tools: ["read", "grep", "ls", "bash"],
62
+ purpose:
63
+ "Research the one named uncertainty you were given. Prefer primary sources, attribute every claim " +
64
+ "to a URL or a code location, and distinguish verified facts from assumptions. Research is read-only " +
65
+ "and does not authorize implementation.",
66
+ },
67
+ {
68
+ slot: "implement",
69
+ description: "NODD implement step: change the code for one task and verify it",
70
+ tools: ["read", "grep", "ls", "write", "edit", "bash"],
71
+ purpose:
72
+ "Implement the one task you were given and verify it by running the project's own checks. " +
73
+ "Report the observed result of those checks verbatim, including failures — never a summary that " +
74
+ "claims a pass you did not see.",
75
+ },
76
+ ]);
77
+
78
+ export function agentsDir(home: string = homedir()): string {
79
+ return join(home, ".pi", "agent", "agents", "nodd");
80
+ }
81
+
82
+ /** The advisory block every generated body carries. */
83
+ function advisory(): string {
84
+ return [
85
+ "## Scale advisory (not a rule)",
86
+ "",
87
+ "About 400 authored changed lines per task is a **planning heuristic, advisory only**.",
88
+ "It is not an acceptance criterion, not a hard cap, not a counter-trigger, not an",
89
+ "automatic stop, not a forced split and not a review trigger. If the correct solution",
90
+ "naturally exceeds it, say so briefly and continue.",
91
+ "",
92
+ ANTI_GAMING,
93
+ ].join("\n");
94
+ }
95
+
96
+ /** The enforcement note, from the spike's measured result. */
97
+ function enforcementNote(): string {
98
+ return [
99
+ "## NODD gates apply here",
100
+ "",
101
+ "NODD is installed as a pi package, so its gates load in this child process too and",
102
+ "will block a write that violates one. A refusal names what was observed, the action",
103
+ "that unblocks it, and a one-shot `/nodd-allow <gate>` override. Treat a block as",
104
+ "information, not as an obstacle to work around.",
105
+ ].join("\n");
106
+ }
107
+
108
+ /** Pure: agent definition plus model in, file text out. */
109
+ export function buildAgentFile(agent: NoddAgent, model: string | undefined): string {
110
+ const front = ["---", `name: nodd-${agent.slot}`, `description: ${agent.description}`];
111
+ // Omitted rather than emitted empty: a blank `model:` is a value pi cannot
112
+ // resolve, and an unresolvable model fails at the moment it is needed.
113
+ if (model) front.push(`model: ${model}`);
114
+ front.push(
115
+ `tools: ${agent.tools.join(", ")}`,
116
+ "systemPromptMode: replace",
117
+ "inheritProjectContext: false",
118
+ "inheritSkills: false",
119
+ "defaultContext: fresh",
120
+ "---",
121
+ );
122
+
123
+ const body = [
124
+ `# NODD · ${agent.slot}`,
125
+ "",
126
+ agent.purpose,
127
+ "",
128
+ advisory(),
129
+ "",
130
+ enforcementNote(),
131
+ ].join("\n");
132
+
133
+ return `${front.join("\n")}\n\n${body}\n`;
134
+ }
135
+
136
+ function modelFor(config: Record<string, unknown>, slot: string): string | undefined {
137
+ const models = config.models;
138
+ if (typeof models !== "object" || models === null) return undefined;
139
+ const map = models as Record<string, unknown>;
140
+ const specific = map[slot];
141
+ if (typeof specific === "string" && specific !== "") return specific;
142
+ const fallback = map.default;
143
+ return typeof fallback === "string" && fallback !== "" ? fallback : undefined;
144
+ }
145
+
146
+ export type ProvisionResult = { written: string[]; failed: string[] };
147
+
148
+ export function provisionAgents(home: string, config: Record<string, unknown>): ProvisionResult {
149
+ const result: ProvisionResult = { written: [], failed: [] };
150
+ const dir = agentsDir(home);
151
+
152
+ try {
153
+ mkdirSync(dir, { recursive: true });
154
+ } catch {
155
+ return { written: [], failed: NODD_AGENTS.map((agent) => `nodd-${agent.slot}`) };
156
+ }
157
+
158
+ for (const agent of NODD_AGENTS) {
159
+ const name = `nodd-${agent.slot}`;
160
+ try {
161
+ writeFileSync(join(dir, `${name}.md`), buildAgentFile(agent, modelFor(config, agent.slot)), "utf8");
162
+ result.written.push(name);
163
+ } catch {
164
+ // One agent failing must not block the other two.
165
+ result.failed.push(name);
166
+ }
167
+ }
168
+
169
+ return result;
170
+ }
171
+
172
+ export default function register(_pi?: unknown): void {
173
+ try {
174
+ let config: Record<string, unknown> = {};
175
+ try {
176
+ config = JSON.parse(readFileSync(noddConfigPath(), "utf8")) as Record<string, unknown>;
177
+ } catch {
178
+ // No config, or an unreadable one: the agents are still worth having, just
179
+ // without an explicit model.
180
+ }
181
+ provisionAgents(homedir(), config);
182
+ } catch {
183
+ // Provisioning must never break a pi session.
184
+ }
185
+ }
@@ -0,0 +1,75 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { GATE_IDS } from "../src/gates/registry.ts";
4
+ import { consumeHatch, emptyPolicy, refuse } from "../src/gates/policy.ts";
5
+ import { runAllowCommand } from "./nodd-allow.ts";
6
+
7
+ function session() {
8
+ const entries: Array<{ type: string; data: any }> = [];
9
+ return {
10
+ entries,
11
+ policy: emptyPolicy(),
12
+ appendEntry(type: string, data: unknown) { entries.push({ type, data: data as any }); },
13
+ };
14
+ }
15
+
16
+ /** A gate that always refuses, wrapped so a granted hatch can clear it once. */
17
+ function attempt(policy: ReturnType<typeof emptyPolicy>, gate: (typeof GATE_IDS)[number]) {
18
+ const consumed = consumeHatch(policy, gate);
19
+ if (consumed) return { blocked: false, policy: consumed.policy };
20
+ refuse(gate, "observed", "do the thing");
21
+ return { blocked: true, policy };
22
+ }
23
+
24
+ test("after /nodd-allow track the next track refusal passes and the one after blocks", () => {
25
+ const s = session();
26
+ const granted = runAllowCommand("track I know what I am doing", s.policy, s);
27
+ assert.equal(granted.ok, true);
28
+
29
+ const first = attempt(granted.policy, "track");
30
+ assert.equal(first.blocked, false, "the override clears the next refusal");
31
+
32
+ const second = attempt(first.policy, "track");
33
+ assert.equal(second.blocked, true, "the override was one-shot");
34
+ });
35
+
36
+ test("an override for one gate does not affect another", () => {
37
+ const s = session();
38
+ const granted = runAllowCommand("track", s.policy, s);
39
+ assert.equal(attempt(granted.policy, "classify").blocked, true, "classify is untouched");
40
+ assert.equal(attempt(granted.policy, "track").blocked, false);
41
+ });
42
+
43
+ test("one audit entry is appended with gate, timestamp and reason", () => {
44
+ const s = session();
45
+ runAllowCommand("delegate the count is wrong here", s.policy, s, () => "2026-09-19T10:00:00.000Z");
46
+ assert.equal(s.entries.length, 1);
47
+ assert.equal(s.entries[0].type, "nodd:allow");
48
+ assert.deepEqual(s.entries[0].data, {
49
+ gate: "delegate",
50
+ reason: "the count is wrong here",
51
+ at: "2026-09-19T10:00:00.000Z",
52
+ });
53
+ });
54
+
55
+ test("a reason is optional and recorded as empty, never invented", () => {
56
+ const s = session();
57
+ runAllowCommand("track", s.policy, s, () => "t");
58
+ assert.equal(s.entries[0].data.reason, "");
59
+ });
60
+
61
+ test("an unknown gate id lists the valid ids and grants nothing", () => {
62
+ const s = session();
63
+ const result = runAllowCommand("nonsense", s.policy, s);
64
+ assert.equal(result.ok, false);
65
+ for (const id of GATE_IDS) assert.ok(result.text.includes(id), `must list ${id}`);
66
+ assert.deepEqual(result.policy.hatches, {}, "nothing was granted");
67
+ assert.deepEqual(s.entries, [], "a rejected request is not audited as a grant");
68
+ });
69
+
70
+ test("an empty invocation explains itself instead of granting silently", () => {
71
+ const s = session();
72
+ const result = runAllowCommand("", s.policy, s);
73
+ assert.equal(result.ok, false);
74
+ assert.deepEqual(result.policy.hatches, {});
75
+ });
@@ -0,0 +1,76 @@
1
+ // `/nodd-allow <gate> [reason]` — the escape hatch.
2
+ //
3
+ // A gate that is wrong is worse than no gate, so every gate ships with a way
4
+ // past it from day one. The constraints are what keep it from becoming a
5
+ // silent opt-out: one shot, consumed by the next refusal of that one gate,
6
+ // audited through `pi.appendEntry`, never implicit and never permanent.
7
+ //
8
+ // Turning a gate off for good is `/nodd-gates disable`, which is a different
9
+ // decision and says so.
10
+
11
+ import { GATE_IDS, isGateId } from "../src/gates/registry.ts";
12
+ import { grantHatch, type Policy } from "../src/gates/policy.ts";
13
+
14
+ export const ALLOW_ENTRY = "nodd:allow";
15
+
16
+ type Session = { appendEntry(type: string, data: unknown): void };
17
+
18
+ export type AllowResult = { ok: boolean; text: string; policy: Policy };
19
+
20
+ export function runAllowCommand(
21
+ args: string,
22
+ policy: Policy,
23
+ session: Session,
24
+ now: () => string = () => new Date().toISOString(),
25
+ ): AllowResult {
26
+ const trimmed = (args ?? "").trim();
27
+ const [gate, ...rest] = trimmed.split(/\s+/);
28
+
29
+ if (!gate) {
30
+ return {
31
+ ok: false,
32
+ text: `nodd-allow: usage — /nodd-allow <gate> [reason]. Valid gates: ${GATE_IDS.join(", ")}`,
33
+ policy,
34
+ };
35
+ }
36
+ if (!isGateId(gate)) {
37
+ return {
38
+ ok: false,
39
+ text: `nodd-allow: unknown gate "${gate}". Valid gates: ${GATE_IDS.join(", ")}`,
40
+ policy,
41
+ };
42
+ }
43
+
44
+ const reason = rest.join(" ");
45
+ const at = now();
46
+ try {
47
+ session.appendEntry(ALLOW_ENTRY, { gate, reason, at });
48
+ } catch {
49
+ // The audit trail is best effort; the override itself still stands.
50
+ }
51
+
52
+ return {
53
+ ok: true,
54
+ text: `nodd: one-shot override granted for ${gate}. It is consumed by the next ${gate} refusal.`,
55
+ policy: grantHatch(policy, gate, reason, at),
56
+ };
57
+ }
58
+
59
+ type PiApi = {
60
+ registerCommand?(name: string, options: { description?: string; handler: (args: string, ctx: unknown) => void }): void;
61
+ appendEntry?(type: string, data?: unknown): void;
62
+ };
63
+
64
+ export default function register(pi?: PiApi, policyRef: { current: Policy } = { current: { config: {}, flags: {}, hatches: {} } }): void {
65
+ pi?.registerCommand?.("nodd-allow", {
66
+ description: "Grant a one-shot override of a single NODD gate: /nodd-allow <gate> [reason]",
67
+ handler: (args: string, ctx: unknown) => {
68
+ const notify = (ctx as { ui?: { notify?(m: string, t?: string): void } })?.ui?.notify;
69
+ const result = runAllowCommand(args ?? "", policyRef.current, {
70
+ appendEntry: (type, data) => pi?.appendEntry?.(type, data),
71
+ });
72
+ policyRef.current = result.policy;
73
+ notify?.(result.text, result.ok ? "info" : "warning");
74
+ },
75
+ });
76
+ }