@gonrocca/nodd 0.2.2 → 0.3.1
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.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import register, { createKernel } from "./nodd-kernel.ts";
|
|
@@ -20,6 +21,55 @@ function fakePi() {
|
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
test("the config is read from the given home, so the suite never reads the user's", () => {
|
|
25
|
+
// Without an injectable home, `register()` reads the real `~/.pi/nodd.json`
|
|
26
|
+
// and the suite's verdict depends on the machine running it: measured on one
|
|
27
|
+
// commit, a local config with the gates off turned 434 passes into 408.
|
|
28
|
+
const home = mkdtempSync(join(tmpdir(), "nodd-home-"));
|
|
29
|
+
mkdirSync(join(home, ".pi"), { recursive: true });
|
|
30
|
+
writeFileSync(join(home, ".pi", "nodd.json"), JSON.stringify({ gates: { classify: { enabled: false } } }));
|
|
31
|
+
|
|
32
|
+
const kernel = register(fakePi() as never, mkdtempSync(join(tmpdir(), "nodd-cwd-")), home);
|
|
33
|
+
assert.deepEqual(kernel.policy().config, { classify: { enabled: false } }, "the given home is what was read");
|
|
34
|
+
|
|
35
|
+
const other = register(fakePi() as never, mkdtempSync(join(tmpdir(), "nodd-cwd-")), mkdtempSync(join(tmpdir(), "nodd-empty-")));
|
|
36
|
+
assert.deepEqual(other.policy().config, {}, "an empty home means nobody chose, whatever the real one says");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("reloadPolicy picks up a config written after the session started", () => {
|
|
40
|
+
// `/nodd-gates off` writes the file; without a re-read the gate keeps blocking
|
|
41
|
+
// until pi restarts, while disk already says it is off. A user who turns off
|
|
42
|
+
// the kill switch and watches it keep blocking concludes it does not work.
|
|
43
|
+
const home = mkdtempSync(join(tmpdir(), "nodd-home-"));
|
|
44
|
+
mkdirSync(join(home, ".pi"), { recursive: true });
|
|
45
|
+
const kernel = register(fakePi() as never, mkdtempSync(join(tmpdir(), "nodd-cwd-")), home);
|
|
46
|
+
assert.deepEqual(kernel.policy().config, {}, "nothing configured yet");
|
|
47
|
+
|
|
48
|
+
writeFileSync(join(home, ".pi", "nodd.json"), JSON.stringify({ gates: { track: { enabled: false } } }));
|
|
49
|
+
kernel.reloadPolicy();
|
|
50
|
+
assert.deepEqual(kernel.policy().config, { track: { enabled: false } }, "the new config is in effect");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("/nodd-gates disable takes effect without restarting pi", () => {
|
|
54
|
+
// The kill switch is user-owned: it must obey at once. Reading the config
|
|
55
|
+
// only at startup left the gate blocking while disk already said it was off,
|
|
56
|
+
// and the refusal kept offering /nodd-allow as though nobody had decided —
|
|
57
|
+
// a user who turns it off and watches it keep blocking concludes it is broken.
|
|
58
|
+
const home = mkdtempSync(join(tmpdir(), "nodd-home-"));
|
|
59
|
+
mkdirSync(join(home, ".pi"), { recursive: true });
|
|
60
|
+
const pi = fakePi();
|
|
61
|
+
register(pi as never, mkdtempSync(join(tmpdir(), "nodd-cwd-")), home);
|
|
62
|
+
|
|
63
|
+
const write = { toolName: "write", toolCallId: "c1", input: { file_path: "a.ts", content: "x" } };
|
|
64
|
+
assert.equal(pi.emit("tool_call", write)?.block, true, "undeclared writes are blocked while the gate is on");
|
|
65
|
+
|
|
66
|
+
// What `/nodd-gates disable classify` writes, mid-session.
|
|
67
|
+
writeFileSync(join(home, ".pi", "nodd.json"), JSON.stringify({ gates: { classify: { enabled: false } } }));
|
|
68
|
+
|
|
69
|
+
const after = pi.emit("tool_call", { ...write, toolCallId: "c2" });
|
|
70
|
+
assert.equal(after, undefined, "the very next call sees the gate off, with no restart");
|
|
71
|
+
});
|
|
72
|
+
|
|
23
73
|
test("a read-only session of read/grep/ls produces zero blocks", () => {
|
|
24
74
|
const pi = fakePi();
|
|
25
75
|
register(pi as never);
|
|
@@ -142,17 +142,23 @@ export type Kernel = {
|
|
|
142
142
|
/** Run the ordered registry against one call. `null` means let it run. */
|
|
143
143
|
checkCall(request: GateRequest): { block: true; reason: string } | null;
|
|
144
144
|
setPolicy(policy: Policy): void;
|
|
145
|
+
/** Re-read the configured flags, keeping runtime flags and hatches. */
|
|
146
|
+
reloadPolicy(): Policy;
|
|
145
147
|
policy(): Policy;
|
|
146
148
|
};
|
|
147
149
|
|
|
148
150
|
export function createKernel(
|
|
149
151
|
now: () => string = () => new Date().toISOString(),
|
|
150
152
|
cwd: string = process.cwd(),
|
|
153
|
+
loadPolicy: () => Policy = emptyPolicy,
|
|
151
154
|
): Kernel {
|
|
152
155
|
const state = emptyState();
|
|
153
156
|
// Mutable because `/nodd-allow` grants a hatch mid-session and a refusal
|
|
154
157
|
// spends it. The policy is session state, not a constant.
|
|
155
158
|
let policy: Policy = emptyPolicy();
|
|
159
|
+
// Set by `setPolicy`: the caller owns the whole policy from then on, and
|
|
160
|
+
// `reloadPolicy` stops re-reading the file over their choice.
|
|
161
|
+
let policyIsPinned = false;
|
|
156
162
|
|
|
157
163
|
const readDoc = (slug: string): FeatureDoc | null => {
|
|
158
164
|
const path = featureDocPath(cwd, slug);
|
|
@@ -349,7 +355,20 @@ export function createKernel(
|
|
|
349
355
|
return null;
|
|
350
356
|
},
|
|
351
357
|
|
|
358
|
+
reloadPolicy() {
|
|
359
|
+
// Re-read rather than cache: `/nodd-gates off` writes the file, and a
|
|
360
|
+
// policy only read at startup leaves the gate blocking while disk already
|
|
361
|
+
// says it is off — a kill switch the user watches fail.
|
|
362
|
+
//
|
|
363
|
+
// An explicit `setPolicy` wins: it is the caller stating the whole policy,
|
|
364
|
+
// and re-reading over it would silently undo what they just set.
|
|
365
|
+
if (policyIsPinned) return policy;
|
|
366
|
+
policy = { ...loadPolicy(), flags: policy.flags, hatches: policy.hatches };
|
|
367
|
+
return policy;
|
|
368
|
+
},
|
|
369
|
+
|
|
352
370
|
setPolicy(next) {
|
|
371
|
+
policyIsPinned = true;
|
|
353
372
|
policy = next;
|
|
354
373
|
},
|
|
355
374
|
|
|
@@ -518,23 +537,32 @@ function promotionSignals(committed: Committed, request: GateRequest) {
|
|
|
518
537
|
};
|
|
519
538
|
}
|
|
520
539
|
|
|
521
|
-
/**
|
|
522
|
-
|
|
540
|
+
/**
|
|
541
|
+
* Gate flags as configured. An unreadable config means nobody chose.
|
|
542
|
+
*
|
|
543
|
+
* `home` is a parameter, not `homedir()` inside: the tests call `register()`
|
|
544
|
+
* directly, and reading the real `~/.pi/nodd.json` made the suite's verdict
|
|
545
|
+
* depend on the machine running it.
|
|
546
|
+
*/
|
|
547
|
+
function readPolicy(home?: string): Policy {
|
|
523
548
|
try {
|
|
524
|
-
const { config } = parseConfig(readFileSync(noddConfigPath(), "utf8"));
|
|
549
|
+
const { config } = parseConfig(readFileSync(noddConfigPath(home), "utf8"));
|
|
525
550
|
return { ...emptyPolicy(), config: config.gates };
|
|
526
551
|
} catch {
|
|
527
552
|
return emptyPolicy();
|
|
528
553
|
}
|
|
529
554
|
}
|
|
530
555
|
|
|
531
|
-
export default function register(pi?: PiApi, cwd: string = process.cwd()): Kernel {
|
|
532
|
-
const kernel = createKernel(undefined, cwd);
|
|
533
|
-
if (!pi || typeof pi.on !== "function")
|
|
556
|
+
export default function register(pi?: PiApi, cwd: string = process.cwd(), home?: string): Kernel {
|
|
557
|
+
const kernel = createKernel(undefined, cwd, () => readPolicy(home));
|
|
558
|
+
if (!pi || typeof pi.on !== "function") {
|
|
559
|
+
kernel.reloadPolicy();
|
|
560
|
+
return kernel;
|
|
561
|
+
}
|
|
534
562
|
|
|
535
563
|
// Flags the user set persist into the session's policy. `/nodd-allow` adds
|
|
536
564
|
// one-shot hatches on top of this at runtime.
|
|
537
|
-
kernel.
|
|
565
|
+
kernel.reloadPolicy();
|
|
538
566
|
|
|
539
567
|
pi.registerTool?.({
|
|
540
568
|
name: "nodd_declare",
|
|
@@ -552,6 +580,10 @@ export default function register(pi?: PiApi, cwd: string = process.cwd()): Kerne
|
|
|
552
580
|
pi.on("tool_call", ((event: ToolCallEvent) => {
|
|
553
581
|
let decision: { block: true; reason: string } | null = null;
|
|
554
582
|
try {
|
|
583
|
+
// Re-read the flags before deciding. `/nodd-gates disable` writes the
|
|
584
|
+
// config from another extension, and a policy read only at startup would
|
|
585
|
+
// keep blocking while disk already said the gate was off.
|
|
586
|
+
kernel.reloadPolicy();
|
|
555
587
|
decision = kernel.checkCall({ toolName: event?.toolName ?? "", input: normalizeInput(event?.input) });
|
|
556
588
|
} catch {
|
|
557
589
|
// A gate that throws must not break the session. Failing open here is
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
import { readFileSync } from "node:fs";
|
|
4
7
|
import { MECHANISM_PLACEHOLDER } from "../src/models/slots.ts";
|
|
5
8
|
import register, { runModelsCommand, type ConfigIo } from "./nodd-models.ts";
|
|
@@ -164,6 +167,73 @@ test("quitting the picker produces zero writes; saving writes once", async () =>
|
|
|
164
167
|
assert.equal(saveIo.writes[0].unrelated, true, "the picker's save preserves unrelated keys");
|
|
165
168
|
});
|
|
166
169
|
|
|
170
|
+
test("the handler opens the picker on the saved profiles, not on an empty menu", async () => {
|
|
171
|
+
// The host built the picker from `models` and `groups` only, so the profiles
|
|
172
|
+
// on disk never reached it: a user with six saved profiles opened
|
|
173
|
+
// /nodd-models and the menu offered to create the first one.
|
|
174
|
+
const commands = new Map<string, any>();
|
|
175
|
+
register({ registerCommand: (name: string, options: unknown) => commands.set(name, options) } as never);
|
|
176
|
+
|
|
177
|
+
const config = {
|
|
178
|
+
models: { implement: "cliproxy/personal/claude-opus-5" },
|
|
179
|
+
thinking: { implement: "high" },
|
|
180
|
+
profiles: {
|
|
181
|
+
rapido: { models: { implement: "cliproxy/ds/deepseek-flash" }, thinking: { implement: "low" } },
|
|
182
|
+
lento: { models: { implement: "cliproxy/personal/claude-opus-5" }, thinking: { implement: "high" } },
|
|
183
|
+
},
|
|
184
|
+
activeProfile: "rapido",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const home = mkdtempSync(join(tmpdir(), "nodd-models-"));
|
|
188
|
+
mkdirSync(join(home, ".pi"), { recursive: true });
|
|
189
|
+
writeFileSync(join(home, ".pi", "nodd.json"), JSON.stringify(config));
|
|
190
|
+
const previousHome = process.env.HOME;
|
|
191
|
+
process.env.HOME = home;
|
|
192
|
+
|
|
193
|
+
let rendered: string[] = [];
|
|
194
|
+
try {
|
|
195
|
+
await commands.get("nodd-models").handler("", {
|
|
196
|
+
ui: {
|
|
197
|
+
notify() {},
|
|
198
|
+
// Stand in for pi's custom-UI host: build the component, render it, and
|
|
199
|
+
// quit without saving.
|
|
200
|
+
async custom(build: any) {
|
|
201
|
+
let result: unknown;
|
|
202
|
+
const component = build({ requestRender() {} }, {}, {}, (r: unknown) => { result = r; });
|
|
203
|
+
rendered = component.render(80);
|
|
204
|
+
component.handleInput?.("q");
|
|
205
|
+
return result ?? { type: "quit" };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
modelRegistry: { getAll: () => [{ provider: "cliproxy", id: "personal/claude-opus-5" }] },
|
|
209
|
+
} as never);
|
|
210
|
+
} finally {
|
|
211
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
212
|
+
else process.env.HOME = previousHome;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const screen = rendered.join("\n");
|
|
216
|
+
assert.match(screen, /rapido/, "a saved profile is on the opening screen");
|
|
217
|
+
assert.match(screen, /lento/, "and so is the other one");
|
|
218
|
+
assert.doesNotMatch(screen, /modelos sin perfil/, "with profiles saved, the menu is not the no-profile one");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("the picker input carries the profiles and the active one", () => {
|
|
222
|
+
const input = register.pickerInput(
|
|
223
|
+
{
|
|
224
|
+
models: { implement: "cliproxy/personal/claude-opus-5" },
|
|
225
|
+
thinking: { implement: "high" },
|
|
226
|
+
profiles: { rapido: { models: { implement: "cliproxy/ds/deepseek-flash" } } },
|
|
227
|
+
activeProfile: "rapido",
|
|
228
|
+
},
|
|
229
|
+
new Map([["cliproxy", ["personal/claude-opus-5"]]]),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(Object.keys(input.profiles), ["rapido"], "saved profiles reach the picker");
|
|
233
|
+
assert.equal(input.activeProfile, "rapido", "and so does the active one");
|
|
234
|
+
assert.deepEqual(input.thinking, { implement: "high" }, "and the levels, so editing does not wipe them");
|
|
235
|
+
});
|
|
236
|
+
|
|
167
237
|
test("the command is registered with pi under its own name", () => {
|
|
168
238
|
const commands = new Map<string, unknown>();
|
|
169
239
|
register({ registerCommand: (name: string, options: unknown) => commands.set(name, options) } as never);
|
|
@@ -334,8 +334,12 @@ function register(pi?: PiApi): void {
|
|
|
334
334
|
// deterministic text path, which is the whole command in a headless run.
|
|
335
335
|
if (args.trim() === "" && typeof ctx?.ui?.custom === "function") {
|
|
336
336
|
const groups = groupsFrom(ctx.modelRegistry);
|
|
337
|
+
// Everything the picker needs, including the saved profiles and the
|
|
338
|
+
// active one. Passing only the models opened the picker as though
|
|
339
|
+
// nothing had ever been saved.
|
|
340
|
+
const input = pickerInput(io.readConfig(), groups);
|
|
337
341
|
const result = await ctx.ui.custom<EnterResult>((tui, _theme, _keys, done) =>
|
|
338
|
-
createComponent(
|
|
342
|
+
createComponent(input, done, () => tui.requestRender()),
|
|
339
343
|
);
|
|
340
344
|
runPicker(result, io);
|
|
341
345
|
notify?.(result.type === "save" ? "nodd · modelos guardados" : "nodd · sin cambios", "info");
|
|
@@ -352,5 +356,6 @@ function register(pi?: PiApi): void {
|
|
|
352
356
|
|
|
353
357
|
register.runPicker = runPicker;
|
|
354
358
|
register.createComponent = createComponent;
|
|
359
|
+
register.pickerInput = pickerInput;
|
|
355
360
|
|
|
356
361
|
export default register;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/nodd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Non-negotiable Organic Driven Development — the ODD protocol as runtime mechanism for pi: blocking gates, observed evidence, and promotion to /forge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|