@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,61 @@
1
+ // The single source of truth for NODD's numeric thresholds and its visible
2
+ // protocol. The values are copied from gentle's capability manifest
3
+ // (`internal/agents/capabilitymanifest/manifest.go:203-216`) and from the
4
+ // long-session backstop, which lives in gentle only as prose
5
+ // (`internal/components/agentguidance/routing.go:82`).
6
+ //
7
+ // The difference from gentle is the consumer: there these numbers feed
8
+ // `fmt.Fprintf`, here they feed a comparator (`src/gates/delegate.ts`). No gate
9
+ // module may restate a threshold literal.
10
+
11
+ export const THRESHOLDS = Object.freeze({
12
+ /** Direct inline: decide or verify from 1–3 files. */
13
+ minUnderstandingFiles: 1,
14
+ maxUnderstandingFiles: 3,
15
+ /** One mechanical, already-understood file change stays inline. */
16
+ maxMechanicalWriteFiles: 1,
17
+ /** Mapping trigger: 4+ distinct understanding files. */
18
+ mappingMinUnderstandingFiles: 4,
19
+ /** Writer trigger: 2+ non-trivial writer files. */
20
+ writerMinNonTrivialFiles: 2,
21
+ /** Long-session backstop: about 20 tool calls without a delegation. */
22
+ longSessionToolCalls: 20,
23
+ });
24
+
25
+ /** The seven ODD steps, in protocol order. This is NODD's visible contract. */
26
+ export const CANONICAL_STEPS = Object.freeze([
27
+ "authorize",
28
+ "explore",
29
+ "resolve-uncertainty",
30
+ "classify",
31
+ "track",
32
+ "implement",
33
+ "close",
34
+ ] as const);
35
+
36
+ export type CanonicalStep = (typeof CANONICAL_STEPS)[number];
37
+
38
+ /**
39
+ * The four steps NODD implements as mechanism — gates, comparators and file
40
+ * writers. They stay visible so the protocol is legible, but they run no model,
41
+ * so giving them a slot would be decoration.
42
+ */
43
+ export const MECHANISM_STEPS = Object.freeze([
44
+ "authorize",
45
+ "classify",
46
+ "track",
47
+ "close",
48
+ ] as const);
49
+
50
+ export type MechanismStep = (typeof MECHANISM_STEPS)[number];
51
+
52
+ /** The five slots `/nodd-models` can assign: two global, three model-backed steps. */
53
+ export const CONFIGURABLE_SLOTS = Object.freeze([
54
+ "default",
55
+ "orchestrator",
56
+ "explore",
57
+ "resolve-uncertainty",
58
+ "implement",
59
+ ] as const);
60
+
61
+ export type ConfigurableSlot = (typeof CONFIGURABLE_SLOTS)[number];
@@ -0,0 +1,125 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { CANONICAL_STEPS, MECHANISM_STEPS } from "../manifest.ts";
4
+ import { SLOT_ROWS, isConfigurableSlot, slotRow } from "./slots.ts";
5
+ import { assignmentPatch, groupByProvider, parseAssignment, validateAssignment } from "./assign.ts";
6
+
7
+ const registry = groupByProvider([
8
+ { provider: "anthropic", id: "claude-sonnet-4-5" },
9
+ { provider: "anthropic", id: "claude-opus-4-1" },
10
+ { provider: "openai-codex", id: "gpt-5-codex" },
11
+ { provider: "opencode-go", id: "claude-sonnet-4-5" },
12
+ ]);
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Slots: all seven steps visible, only five assignable.
16
+ // ---------------------------------------------------------------------------
17
+ test("the slot table displays all seven canonical steps in order", () => {
18
+ const stepRows = SLOT_ROWS.filter((row) => row.kind !== "global");
19
+ assert.deepEqual(stepRows.map((row) => row.id), [...CANONICAL_STEPS]);
20
+ });
21
+
22
+ test("the four mechanism steps are present and not assignable", () => {
23
+ for (const step of MECHANISM_STEPS) {
24
+ const row = slotRow(step);
25
+ assert.ok(row, `${step} must be displayed`);
26
+ assert.equal(row!.kind, "mechanism");
27
+ assert.equal(isConfigurableSlot(step), false, `${step} must not be assignable`);
28
+ }
29
+ });
30
+
31
+ test("the two global slots are assignable and are not canonical steps", () => {
32
+ for (const slot of ["default", "orchestrator"]) {
33
+ assert.equal(isConfigurableSlot(slot), true);
34
+ assert.equal(slotRow(slot)!.kind, "global");
35
+ assert.ok(!(CANONICAL_STEPS as readonly string[]).includes(slot));
36
+ }
37
+ });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // A mechanism step rejects assignment, saying it is a mechanism.
41
+ // ---------------------------------------------------------------------------
42
+ test("classify=x/y is rejected as a mechanism step, not as an unknown model", () => {
43
+ const result = validateAssignment(parseAssignment("classify=anthropic/claude-sonnet-4-5")!, registry);
44
+ assert.equal(result.ok, false);
45
+ assert.ok(result.ok === false && /mecanismo/.test(result.message), result.ok === false ? result.message : "");
46
+ assert.ok(result.ok === false && result.message.includes("classify"));
47
+ });
48
+
49
+ test("every mechanism step rejects assignment", () => {
50
+ for (const step of MECHANISM_STEPS) {
51
+ const result = validateAssignment({ slot: step, provider: "anthropic", model: "claude-opus-4-1" }, registry);
52
+ assert.equal(result.ok, false, `${step} must reject assignment`);
53
+ }
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Model validation against the registry.
58
+ // ---------------------------------------------------------------------------
59
+ test("implement=unknown/model is rejected against the registry", () => {
60
+ const unknownProvider = validateAssignment(parseAssignment("implement=nope/some-model")!, registry);
61
+ assert.equal(unknownProvider.ok, false);
62
+ assert.ok(unknownProvider.ok === false && unknownProvider.message.includes("nope"));
63
+ assert.ok(unknownProvider.ok === false && unknownProvider.message.includes("anthropic"), "valid providers are listed");
64
+
65
+ const unknownModel = validateAssignment(parseAssignment("implement=anthropic/gpt-9")!, registry);
66
+ assert.equal(unknownModel.ok, false);
67
+ assert.ok(unknownModel.ok === false && unknownModel.message.includes("gpt-9"));
68
+ });
69
+
70
+ test("an ambiguous bare model id is rejected with qualified suggestions", () => {
71
+ const result = validateAssignment(parseAssignment("implement=claude-sonnet-4-5")!, registry);
72
+ assert.equal(result.ok, false);
73
+ if (result.ok !== false) return;
74
+ assert.ok(/ambiguo/.test(result.message), result.message);
75
+ assert.ok(result.message.includes("anthropic/claude-sonnet-4-5"));
76
+ assert.ok(result.message.includes("opencode-go/claude-sonnet-4-5"));
77
+ });
78
+
79
+ test("an unambiguous bare model id resolves to its only provider", () => {
80
+ const result = validateAssignment(parseAssignment("implement=gpt-5-codex")!, registry);
81
+ assert.equal(result.ok, true);
82
+ assert.equal(result.ok === true && result.provider, "openai-codex");
83
+ });
84
+
85
+ test("a qualified valid assignment is accepted", () => {
86
+ const result = validateAssignment(parseAssignment("implement=anthropic/claude-opus-4-1")!, registry);
87
+ assert.equal(result.ok, true);
88
+ assert.equal(result.ok === true && result.provider, "anthropic");
89
+ });
90
+
91
+ test("an empty registry is permissive: a headless context must not be unusable", () => {
92
+ const result = validateAssignment(parseAssignment("implement=whatever/model")!, new Map());
93
+ assert.equal(result.ok, true);
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Parsing and the config patch.
98
+ // ---------------------------------------------------------------------------
99
+ test("parseAssignment reads slot=provider/model and slot=model", () => {
100
+ assert.deepEqual(parseAssignment("implement=anthropic/claude-opus-4-1"), {
101
+ slot: "implement", provider: "anthropic", model: "claude-opus-4-1",
102
+ });
103
+ assert.deepEqual(parseAssignment("default=gpt-5-codex"), {
104
+ slot: "default", provider: null, model: "gpt-5-codex",
105
+ });
106
+ assert.equal(parseAssignment("implement"), null, "no `=` is not an assignment");
107
+ assert.equal(parseAssignment("=x/y"), null, "an empty slot is not an assignment");
108
+ assert.equal(parseAssignment("implement="), null, "an empty model is not an assignment");
109
+ });
110
+
111
+ test("a valid assignment produces a patch touching only models.<slot>", () => {
112
+ const patch = assignmentPatch({ models: { explore: "anthropic/claude-opus-4-1" }, unrelated: 42 } as never, {
113
+ slot: "implement", provider: "anthropic", model: "claude-opus-4-1",
114
+ });
115
+
116
+ assert.deepEqual(patch, {
117
+ models: { explore: "anthropic/claude-opus-4-1", implement: "anthropic/claude-opus-4-1" },
118
+ });
119
+ assert.ok(!("unrelated" in patch), "the patch names only what it changes; the merge preserves the rest");
120
+ });
121
+
122
+ test("a bare model resolved to one provider is written qualified", () => {
123
+ const patch = assignmentPatch({}, { slot: "implement", provider: "openai-codex", model: "gpt-5-codex" });
124
+ assert.deepEqual(patch, { models: { implement: "openai-codex/gpt-5-codex" } });
125
+ });
@@ -0,0 +1,138 @@
1
+ // Validating a model assignment against pi's live registry.
2
+ //
3
+ // Adapted from `zero-models.ts:411-460,505-525` — adapted, never imported: NODD
4
+ // is a standalone package and a shared import would couple two products that
5
+ // only share a home directory.
6
+ //
7
+ // The rule that matters: the names written to `~/.pi/nodd.json` must be names pi
8
+ // can actually resolve, so a configured slot never fails at the moment it is
9
+ // needed. That means the registry is the authority, not a hardcoded catalog. An
10
+ // ambiguous bare id is refused with the qualified forms rather than guessed —
11
+ // picking a provider on the user's behalf is how a config silently points at the
12
+ // wrong model.
13
+ //
14
+ // A mechanism step refuses assignment saying *why*: "unknown model" would be a
15
+ // misleading error for `classify=x/y`, where the model is fine and the slot does
16
+ // not exist.
17
+
18
+ import { isConfigurableSlot, slotRow } from "./slots.ts";
19
+ import type { ConfigurableSlot } from "../manifest.ts";
20
+
21
+ export type RegistryModel = { provider: string; id: string };
22
+
23
+ export type Assignment = {
24
+ slot: string;
25
+ /** null when the user gave a bare model id. */
26
+ provider: string | null;
27
+ model: string;
28
+ };
29
+
30
+ export type Validation =
31
+ | { ok: true; provider: string | null }
32
+ | { ok: false; message: string };
33
+
34
+ export function groupByProvider(models: RegistryModel[]): Map<string, string[]> {
35
+ const groups = new Map<string, string[]>();
36
+ for (const model of models) {
37
+ const existing = groups.get(model.provider);
38
+ if (existing) existing.push(model.id);
39
+ else groups.set(model.provider, [model.id]);
40
+ }
41
+ return groups;
42
+ }
43
+
44
+ /** `slot=provider/model` or `slot=model`. */
45
+ export function parseAssignment(text: string): Assignment | null {
46
+ const match = /^([^=\s]+)=(\S+)$/.exec(text.trim());
47
+ if (!match) return null;
48
+
49
+ const [, slot, value] = match;
50
+ const slash = value.indexOf("/");
51
+ if (slash < 0) return { slot, provider: null, model: value };
52
+
53
+ const provider = value.slice(0, slash);
54
+ const model = value.slice(slash + 1);
55
+ if (!provider || !model) return null;
56
+ return { slot, provider, model };
57
+ }
58
+
59
+ function providersOwning(groups: Map<string, string[]>, model: string): string[] {
60
+ return [...groups.entries()].filter(([, ids]) => ids.includes(model)).map(([provider]) => provider);
61
+ }
62
+
63
+ function suggest(groups: Map<string, string[]>, model: string): string[] {
64
+ const needle = model.toLowerCase();
65
+ const out: string[] = [];
66
+ for (const [provider, ids] of groups) {
67
+ for (const id of ids) {
68
+ const hay = id.toLowerCase();
69
+ if (hay.includes(needle) || needle.includes(hay)) out.push(`${provider}/${id}`);
70
+ }
71
+ }
72
+ return out.slice(0, 5);
73
+ }
74
+
75
+ export function validateAssignment(assignment: Assignment, groups: Map<string, string[]>): Validation {
76
+ const row = slotRow(assignment.slot);
77
+ if (!row) {
78
+ return { ok: false, message: `slot desconocido: ${assignment.slot}` };
79
+ }
80
+ if (!isConfigurableSlot(assignment.slot)) {
81
+ return {
82
+ ok: false,
83
+ message: `${assignment.slot} es un mecanismo, no un paso con modelo: es código determinista y no admite asignación`,
84
+ };
85
+ }
86
+
87
+ // An empty registry means we cannot check; refusing everything would make a
88
+ // headless context unusable, so the permissive fallback is deliberate.
89
+ if (groups.size === 0) return { ok: true, provider: assignment.provider };
90
+
91
+ if (assignment.provider) {
92
+ const ids = groups.get(assignment.provider);
93
+ if (!ids) {
94
+ return {
95
+ ok: false,
96
+ message: `provider desconocido: ${assignment.provider}. Usá uno de: ${[...groups.keys()].sort().join(", ")}`,
97
+ };
98
+ }
99
+ if (!ids.includes(assignment.model)) {
100
+ const hints = suggest(groups, assignment.model);
101
+ return {
102
+ ok: false,
103
+ message: `modelo desconocido para ${assignment.provider}: ${assignment.model}${hints.length > 0 ? `. Quizás: ${hints.join(", ")}` : ""}`,
104
+ };
105
+ }
106
+ return { ok: true, provider: assignment.provider };
107
+ }
108
+
109
+ const owners = providersOwning(groups, assignment.model);
110
+ if (owners.length === 1) return { ok: true, provider: owners[0] };
111
+ if (owners.length > 1) {
112
+ return {
113
+ ok: false,
114
+ message: `modelo ambiguo: ${assignment.model}. Usá provider/model: ${owners.map((p) => `${p}/${assignment.model}`).join(", ")}`,
115
+ };
116
+ }
117
+
118
+ const hints = suggest(groups, assignment.model);
119
+ return {
120
+ ok: false,
121
+ message: `modelo desconocido: ${assignment.model}${hints.length > 0 ? `. Quizás: ${hints.join(", ")}` : ""}`,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * The config patch for a validated assignment: only `models`, and inside it only
127
+ * the one slot. The merge in `src/config.ts` preserves everything else, so an
128
+ * unrelated key the user put in the file survives.
129
+ */
130
+ export function assignmentPatch(
131
+ existing: { models?: Record<string, string> },
132
+ assignment: Assignment & { provider: string | null },
133
+ ): { models: Record<string, string> } {
134
+ const qualified = assignment.provider ? `${assignment.provider}/${assignment.model}` : assignment.model;
135
+ return {
136
+ models: { ...(existing.models ?? {}), [assignment.slot as ConfigurableSlot]: qualified },
137
+ };
138
+ }
@@ -0,0 +1,141 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { CANONICAL_STEPS } from "../manifest.ts";
5
+ import { MECHANISM_PLACEHOLDER } from "./slots.ts";
6
+ import { createPickerState, enter, moveCursor, renderRows, type PickerState } from "./picker.ts";
7
+
8
+ const groups = new Map([
9
+ ["anthropic", ["claude-sonnet-4-5", "claude-opus-4-1"]],
10
+ ["openai-codex", ["gpt-5-codex"]],
11
+ ]);
12
+
13
+ function open(models: Record<string, string> = {}): PickerState {
14
+ return createPickerState({ models, groups });
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Display: seven steps, four of them mechanisms.
19
+ // ---------------------------------------------------------------------------
20
+ test("all seven canonical steps are displayed in protocol order", () => {
21
+ const rows = renderRows(open());
22
+ const stepRows = rows.filter((row) => row.kind === "step" || row.kind === "mechanism");
23
+ assert.deepEqual(stepRows.map((row) => row.id), [...CANONICAL_STEPS]);
24
+ assert.equal(stepRows.length, 7);
25
+ });
26
+
27
+ test("the four mechanism rows say mecanismo · sin modelo and are not selectable", () => {
28
+ const mechanisms = renderRows(open()).filter((row) => row.kind === "mechanism");
29
+ assert.deepEqual(mechanisms.map((row) => row.id), ["authorize", "classify", "track", "close"]);
30
+ for (const row of mechanisms) {
31
+ assert.equal(row.value, MECHANISM_PLACEHOLDER);
32
+ assert.equal(row.selectable, false, `${row.id} must not be selectable`);
33
+ }
34
+ });
35
+
36
+ test("an assigned slot displays its model, an unassigned one its placeholder", () => {
37
+ const rows = renderRows(open({ implement: "anthropic/claude-opus-4-1" }));
38
+ assert.equal(rows.find((row) => row.id === "implement")!.value, "anthropic/claude-opus-4-1");
39
+ assert.equal(rows.find((row) => row.id === "explore")!.value, "sin asignar");
40
+ });
41
+
42
+ test("there is a save row and a quit row, and they are selectable", () => {
43
+ const rows = renderRows(open());
44
+ const actions = rows.filter((row) => row.kind === "action");
45
+ assert.deepEqual(actions.map((row) => row.id), ["save", "quit"]);
46
+ for (const row of actions) assert.equal(row.selectable, true);
47
+ });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Navigation skips the non-selectable rows.
51
+ // ---------------------------------------------------------------------------
52
+ test("the cursor starts on the first selectable row", () => {
53
+ const rows = renderRows(open());
54
+ assert.equal(rows[open().cursor].selectable, true);
55
+ assert.equal(rows[open().cursor].id, "default");
56
+ });
57
+
58
+ test("navigation skips mechanism rows entirely", () => {
59
+ let state = open();
60
+ const visited: string[] = [];
61
+ for (let i = 0; i < 12; i++) {
62
+ visited.push(renderRows(state)[state.cursor].id);
63
+ state = moveCursor(state, 1);
64
+ }
65
+ for (const mechanism of ["authorize", "classify", "track", "close"]) {
66
+ assert.ok(!visited.includes(mechanism), `the cursor must never land on ${mechanism}`);
67
+ }
68
+ assert.deepEqual([...new Set(visited)], ["default", "orchestrator", "explore", "resolve-uncertainty", "implement", "save", "quit"]);
69
+ });
70
+
71
+ test("the cursor wraps at both ends and never leaves a selectable row", () => {
72
+ let state = open();
73
+ const up = moveCursor(state, -1);
74
+ assert.equal(renderRows(up)[up.cursor].id, "quit", "moving up from the top wraps to the last row");
75
+
76
+ for (let i = 0; i < 30; i++) {
77
+ state = moveCursor(state, i % 2 === 0 ? 1 : -1);
78
+ assert.equal(renderRows(state)[state.cursor].selectable, true);
79
+ }
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Outcomes: quit writes nothing, save returns the assignment set.
84
+ // ---------------------------------------------------------------------------
85
+ test("quit returns no changes", () => {
86
+ let state = open({ implement: "anthropic/claude-opus-4-1" });
87
+ state = { ...state, staged: { ...state.staged, explore: "openai-codex/gpt-5-codex" } };
88
+
89
+ const rows = renderRows(state);
90
+ state = { ...state, cursor: rows.findIndex((row) => row.id === "quit") };
91
+
92
+ assert.deepEqual(enter(state), { type: "quit" });
93
+ });
94
+
95
+ test("save returns the staged assignment set", () => {
96
+ let state = open({ implement: "anthropic/claude-opus-4-1" });
97
+ state = { ...state, staged: { ...state.staged, explore: "openai-codex/gpt-5-codex" } };
98
+
99
+ const rows = renderRows(state);
100
+ state = { ...state, cursor: rows.findIndex((row) => row.id === "save") };
101
+
102
+ assert.deepEqual(enter(state), {
103
+ type: "save",
104
+ models: { implement: "anthropic/claude-opus-4-1", explore: "openai-codex/gpt-5-codex" },
105
+ });
106
+ });
107
+
108
+ test("entering a slot row opens that slot's model list", () => {
109
+ const state = open();
110
+ const result = enter(state);
111
+ assert.equal(result.type, "choose");
112
+ if (result.type !== "choose") return;
113
+ assert.equal(result.slot, "default");
114
+ assert.deepEqual(result.options, ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-1", "openai-codex/gpt-5-codex"]);
115
+ });
116
+
117
+ test("staging a choice does not write and is visible in the rows", () => {
118
+ let state = open();
119
+ state = { ...state, staged: { ...state.staged, default: "anthropic/claude-opus-4-1" } };
120
+ assert.equal(renderRows(state).find((row) => row.id === "default")!.value, "anthropic/claude-opus-4-1");
121
+ assert.deepEqual(open().staged, {}, "the initial state stages nothing");
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // The module boundary is the point: no fs, no pi, no TUI.
126
+ // ---------------------------------------------------------------------------
127
+ test("the picker imports no node:fs, no pi and no TUI package", () => {
128
+ // Comments are free to name what the module refuses to import; executable
129
+ // lines are not.
130
+ const source = readFileSync(new URL("./picker.ts", import.meta.url), "utf8")
131
+ .split("\n")
132
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
133
+ .join("\n");
134
+ assert.ok(!source.includes("node:fs"), "the picker must not touch the filesystem");
135
+ assert.ok(!source.includes("@earendil-works/"), "the picker must not import pi or its TUI package");
136
+ const imports = [...source.matchAll(/from "([^"]+)";/g)].map((m) => m[1]);
137
+ assert.ok(imports.length > 0, "the scan must actually find the imports");
138
+ for (const specifier of imports) {
139
+ assert.ok(specifier.startsWith("."), `unexpected external import: ${specifier}`);
140
+ }
141
+ });
@@ -0,0 +1,98 @@
1
+ // The picker's decisions, as a pure state machine.
2
+ //
3
+ // Dependency-free by design, after `zero-models-picker.ts:5-11`: no `node:fs`,
4
+ // no pi, and above all no TUI package — not even as a type. The host in
5
+ // `extensions/nodd-models.ts` owns rendering and keystrokes and holds one
6
+ // `PickerState`; every navigation and staging decision is here, where
7
+ // `node --test` can reach it without a terminal.
8
+ //
9
+ // Two properties the tests pin down, because both are easy to lose in a
10
+ // refactor: the four mechanism rows are displayed but never selectable (a cursor
11
+ // that can land on `classify` implies a slot that does not exist), and this
12
+ // module writes nothing. It returns outcomes; the command decides.
13
+
14
+ import { SLOT_ROWS, type SlotRow } from "./slots.ts";
15
+
16
+ export type Row = SlotRow & {
17
+ /** `action` rows are save/quit; the rest come from the slot table. */
18
+ kind: SlotRow["kind"] | "action";
19
+ /** The model to show, or the row's placeholder. */
20
+ value: string;
21
+ selectable: boolean;
22
+ };
23
+
24
+ export type PickerState = {
25
+ cursor: number;
26
+ /** slot -> `provider/model`, as read from config. */
27
+ models: Record<string, string>;
28
+ /** Edits not yet saved. Empty until the user chooses something. */
29
+ staged: Record<string, string>;
30
+ groups: Map<string, string[]>;
31
+ };
32
+
33
+ export type EnterResult =
34
+ | { type: "choose"; slot: string; options: string[] }
35
+ | { type: "save"; models: Record<string, string> }
36
+ | { type: "quit" };
37
+
38
+ const ACTION_ROWS: Row[] = [
39
+ { id: "save", kind: "action", placeholder: "", value: "— guardar y salir —", selectable: true },
40
+ { id: "quit", kind: "action", placeholder: "", value: "— salir sin guardar —", selectable: true },
41
+ ];
42
+
43
+ export function createPickerState(input: {
44
+ models: Record<string, string>;
45
+ groups: Map<string, string[]>;
46
+ }): PickerState {
47
+ const state: PickerState = { cursor: 0, models: { ...input.models }, staged: {}, groups: input.groups };
48
+ // Start on a row the user can actually act on, whatever the table's shape.
49
+ return { ...state, cursor: nextSelectable(state, 0, 1) };
50
+ }
51
+
52
+ /** The full display, in order: globals, the seven steps, then the actions. */
53
+ export function renderRows(state: PickerState): Row[] {
54
+ const assigned = { ...state.models, ...state.staged };
55
+ const slots = SLOT_ROWS.map((row): Row => ({
56
+ ...row,
57
+ value: row.kind === "mechanism" ? row.placeholder : assigned[row.id] ?? row.placeholder,
58
+ selectable: row.kind !== "mechanism",
59
+ }));
60
+ return [...slots, ...ACTION_ROWS];
61
+ }
62
+
63
+ /** Every `provider/model` pi can resolve, qualified, in registry order. */
64
+ export function modelOptions(groups: Map<string, string[]>): string[] {
65
+ const out: string[] = [];
66
+ for (const [provider, ids] of groups) {
67
+ for (const id of ids) out.push(`${provider}/${id}`);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function nextSelectable(state: PickerState, from: number, step: number): number {
73
+ const rows = renderRows(state);
74
+ for (let i = 0; i < rows.length; i++) {
75
+ const index = (((from + i * step) % rows.length) + rows.length) % rows.length;
76
+ if (rows[index].selectable) return index;
77
+ }
78
+ return from;
79
+ }
80
+
81
+ /** Move the cursor, wrapping at both ends and skipping mechanism rows. */
82
+ export function moveCursor(state: PickerState, step: number): PickerState {
83
+ const rows = renderRows(state);
84
+ const start = (((state.cursor + step) % rows.length) + rows.length) % rows.length;
85
+ return { ...state, cursor: nextSelectable(state, start, step >= 0 ? 1 : -1) };
86
+ }
87
+
88
+ export function enter(state: PickerState): EnterResult {
89
+ const row = renderRows(state)[state.cursor];
90
+ if (row.id === "quit") return { type: "quit" };
91
+ if (row.id === "save") return { type: "save", models: { ...state.models, ...state.staged } };
92
+ return { type: "choose", slot: row.id, options: modelOptions(state.groups) };
93
+ }
94
+
95
+ /** Stage a choice. Nothing is written until the user picks the save row. */
96
+ export function stage(state: PickerState, slot: string, model: string): PickerState {
97
+ return { ...state, staged: { ...state.staged, [slot]: model } };
98
+ }