@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,174 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { MECHANISM_PLACEHOLDER } from "../src/models/slots.ts";
5
+ import register, { runModelsCommand, type ConfigIo } from "./nodd-models.ts";
6
+
7
+ const registry = {
8
+ getAll: () => [
9
+ { provider: "anthropic", id: "claude-sonnet-4-5" },
10
+ { provider: "anthropic", id: "claude-opus-4-1" },
11
+ { provider: "openai-codex", id: "gpt-5-codex" },
12
+ ],
13
+ };
14
+
15
+ function memoryIo(initial: Record<string, unknown> = {}): ConfigIo & { writes: Record<string, unknown>[] } {
16
+ let data = { ...initial };
17
+ const writes: Record<string, unknown>[] = [];
18
+ return {
19
+ writes,
20
+ readConfig: () => ({ ...data }),
21
+ writeConfig: (next) => {
22
+ data = next;
23
+ writes.push(next);
24
+ },
25
+ };
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // The display
30
+ // ---------------------------------------------------------------------------
31
+ test("the no-arg output lists all seven steps with the four mechanisms marked", () => {
32
+ const io = memoryIo({ models: { implement: "anthropic/claude-opus-4-1" } });
33
+ const output = runModelsCommand("", io, registry);
34
+
35
+ for (const step of ["authorize", "explore", "resolve-uncertainty", "classify", "track", "implement", "close"]) {
36
+ assert.ok(output.includes(step), `${step} must be listed`);
37
+ }
38
+ for (const mechanism of ["authorize", "classify", "track", "close"]) {
39
+ const row = output.split("\n").find((line) => line.includes(mechanism))!;
40
+ assert.ok(row.includes(MECHANISM_PLACEHOLDER), `${mechanism} must be marked as a mechanism: ${row}`);
41
+ }
42
+ assert.ok(output.includes("anthropic/claude-opus-4-1"), "an assigned slot shows its model");
43
+ assert.equal(io.writes.length, 0, "displaying writes nothing");
44
+ });
45
+
46
+ test("the two global slots are listed alongside the steps", () => {
47
+ const output = runModelsCommand("", memoryIo(), registry);
48
+ assert.ok(output.includes("default"));
49
+ assert.ok(output.includes("orchestrator"));
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Direct assignment
54
+ // ---------------------------------------------------------------------------
55
+ test("a valid assignment writes only models.implement and preserves unrelated keys", () => {
56
+ const io = memoryIo({
57
+ models: { explore: "anthropic/claude-sonnet-4-5" },
58
+ gates: { track: { enabled: false } },
59
+ somethingElse: { deep: [1, 2, 3] },
60
+ });
61
+
62
+ const output = runModelsCommand("implement=anthropic/claude-opus-4-1", io, registry);
63
+ assert.equal(io.writes.length, 1, "a valid assignment writes exactly once");
64
+
65
+ const written = io.writes[0];
66
+ assert.deepEqual(written.models, {
67
+ explore: "anthropic/claude-sonnet-4-5",
68
+ implement: "anthropic/claude-opus-4-1",
69
+ });
70
+ assert.deepEqual(written.gates, { track: { enabled: false } }, "gate flags survive a model write");
71
+ assert.deepEqual(written.somethingElse, { deep: [1, 2, 3] }, "an unknown key survives byte-for-byte");
72
+ assert.match(output, /implement/);
73
+ });
74
+
75
+ test("classify=x/y is rejected as a mechanism and writes nothing", () => {
76
+ const io = memoryIo();
77
+ const output = runModelsCommand("classify=anthropic/claude-opus-4-1", io, registry);
78
+ assert.match(output, /mecanismo/);
79
+ assert.equal(io.writes.length, 0);
80
+ });
81
+
82
+ test("an unknown model is rejected against the live registry and writes nothing", () => {
83
+ const io = memoryIo();
84
+ assert.match(runModelsCommand("implement=unknown/model", io, registry), /provider desconocido/);
85
+ assert.match(runModelsCommand("implement=anthropic/gpt-9", io, registry), /modelo desconocido/);
86
+ assert.equal(io.writes.length, 0);
87
+ });
88
+
89
+ test("an unparseable argument reports usage and writes nothing", () => {
90
+ const io = memoryIo();
91
+ assert.match(runModelsCommand("implement", io, registry), /uso/i);
92
+ assert.equal(io.writes.length, 0);
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Profiles, through the same command
97
+ // ---------------------------------------------------------------------------
98
+ test("profile new writes once and activates the profile", () => {
99
+ const io = memoryIo({ models: { implement: "anthropic/claude-opus-4-1" } });
100
+ const output = runModelsCommand("profile new fast", io, registry);
101
+ assert.equal(io.writes.length, 1);
102
+ assert.equal(io.writes[0].activeProfile, "fast");
103
+ assert.match(output, /fast/);
104
+ });
105
+
106
+ test("profile list writes nothing", () => {
107
+ const io = memoryIo({ profiles: { fast: { models: {} } }, activeProfile: "fast" });
108
+ const output = runModelsCommand("profile list", io, registry);
109
+ assert.ok(output.includes("fast"));
110
+ assert.equal(io.writes.length, 0);
111
+ });
112
+
113
+ test("editing a slot with a profile active mirrors into that profile", () => {
114
+ const io = memoryIo({
115
+ models: { implement: "anthropic/claude-sonnet-4-5" },
116
+ profiles: { fast: { models: { implement: "anthropic/claude-sonnet-4-5" } } },
117
+ activeProfile: "fast",
118
+ });
119
+ runModelsCommand("implement=anthropic/claude-opus-4-1", io, registry);
120
+ assert.deepEqual(
121
+ (io.writes[0].profiles as Record<string, unknown>).fast,
122
+ { models: { implement: "anthropic/claude-opus-4-1" } },
123
+ );
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // The picker host
128
+ // ---------------------------------------------------------------------------
129
+ test("quitting the picker produces zero writes; saving writes once", async () => {
130
+ const quitIo = memoryIo();
131
+ await register.runPicker({ type: "quit" }, quitIo);
132
+ assert.equal(quitIo.writes.length, 0, "quit writes nothing");
133
+
134
+ const saveIo = memoryIo({ unrelated: true });
135
+ await register.runPicker({ type: "save", models: { implement: "anthropic/claude-opus-4-1" } }, saveIo);
136
+ assert.equal(saveIo.writes.length, 1);
137
+ assert.deepEqual(saveIo.writes[0].models, { implement: "anthropic/claude-opus-4-1" });
138
+ assert.equal(saveIo.writes[0].unrelated, true, "the picker's save preserves unrelated keys");
139
+ });
140
+
141
+ test("the command is registered with pi under its own name", () => {
142
+ const commands = new Map<string, unknown>();
143
+ register({ registerCommand: (name: string, options: unknown) => commands.set(name, options) } as never);
144
+ assert.ok(commands.has("nodd-models"), "the command must be /nodd-models, never /zero-models");
145
+ assert.ok(!commands.has("zero-models"));
146
+ });
147
+
148
+ test("the picker component renders rows and is a plain object, no TUI import", () => {
149
+ const component = register.createComponent(
150
+ { models: { implement: "anthropic/claude-opus-4-1" }, groups: new Map([["anthropic", ["claude-opus-4-1"]]]) },
151
+ () => {},
152
+ );
153
+ const lines = component.render(80);
154
+ assert.ok(Array.isArray(lines), "render returns string[]");
155
+ assert.ok(lines.length > 7, "every slot row is rendered");
156
+ assert.ok(lines.some((line) => line.includes(MECHANISM_PLACEHOLDER)), "mechanism rows are visible in the picker");
157
+ assert.ok(lines.some((line) => line.includes("anthropic/claude-opus-4-1")));
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // The absolute rule
162
+ // ---------------------------------------------------------------------------
163
+ test("this extension imports no TUI package, as a value or as a type", () => {
164
+ const source = readFileSync(new URL("./nodd-models.ts", import.meta.url), "utf8");
165
+ const forbidden = `@earendil-works/${["pi", "tui"].join("-")}`;
166
+ assert.ok(!source.includes(forbidden), "not a value import, not an import type, not a string");
167
+ assert.ok(/interface Component/.test(source), "the component contract is declared locally");
168
+ });
169
+
170
+ test("nothing in this extension names forge's config file", () => {
171
+ const source = readFileSync(new URL("./nodd-models.ts", import.meta.url), "utf8");
172
+ assert.ok(!source.includes(`zero${"."}json`), "NODD never opens forge's config");
173
+ assert.ok(source.includes("nodd.json") || source.includes("noddConfigPath"), "it uses its own");
174
+ });
@@ -0,0 +1,253 @@
1
+ // `/nodd-models` — a deterministic handler, not a prompt.
2
+ //
3
+ // Adapted from `zero-models.ts:929-1161`. It owns its own command name and its
4
+ // own file: forge's phase models stay `/zero-models`' business, and
5
+ // `~/.pi/nodd.json` is the only file this touches.
6
+ //
7
+ // **The no-TUI rule is absolute** (`zero-models.ts:22-32`): this file does not
8
+ // import pi's TUI package at all — not a value import, not an `import type`, and
9
+ // not even as a string, which is why the name is not written here.
10
+ // `ctx.ui.custom`'s factory only needs an object exposing
11
+ // `render(width): string[]`, so the contract is declared locally below. A stray
12
+ // ambient specifier would crash `node --test` with `ERR_MODULE_NOT_FOUND`, and
13
+ // the test file imports this module directly.
14
+
15
+ /** What `ctx.ui.custom()`'s factory must return. Declared locally on purpose. */
16
+ interface Component {
17
+ render(width: number): string[];
18
+ handleInput?(data: string): void;
19
+ invalidate?(): void;
20
+ }
21
+
22
+ import { readFileSync, writeFileSync } from "node:fs";
23
+ import { mergeConfig, noddConfigPath } from "../src/config.ts";
24
+ import { SLOT_ROWS } from "../src/models/slots.ts";
25
+ import { assignmentPatch, groupByProvider, parseAssignment, validateAssignment, type RegistryModel } from "../src/models/assign.ts";
26
+ import { applyProfileCommand, isValidProfileName, mirrorToActiveProfile, readActiveProfile, readProfiles } from "../src/models/profiles.ts";
27
+ import { createPickerState, enter, moveCursor, renderRows, stage, type EnterResult, type PickerState } from "../src/models/picker.ts";
28
+
29
+ export type ConfigIo = {
30
+ readConfig(): Record<string, unknown>;
31
+ writeConfig(next: Record<string, unknown>): void;
32
+ };
33
+
34
+ export function fileConfigIo(path: string = noddConfigPath()): ConfigIo {
35
+ return {
36
+ readConfig() {
37
+ try {
38
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
39
+ } catch {
40
+ return {};
41
+ }
42
+ },
43
+ writeConfig(next) {
44
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
45
+ },
46
+ };
47
+ }
48
+
49
+ type ModelRegistry = { getAll(): RegistryModel[] };
50
+
51
+ const USAGE =
52
+ "uso: /nodd-models · /nodd-models <slot>=<provider>/<modelo> · /nodd-models profile [list|new <n>|save [n]|use <n>|delete <n>]";
53
+
54
+ function groupsFrom(registry: ModelRegistry | undefined): Map<string, string[]> {
55
+ try {
56
+ const all = registry?.getAll?.();
57
+ return all && all.length > 0 ? groupByProvider(all) : new Map();
58
+ } catch {
59
+ // A registry that throws is a registry we do not have. Validation degrades
60
+ // to permissive rather than making the command unusable.
61
+ return new Map();
62
+ }
63
+ }
64
+
65
+ function currentModels(raw: Record<string, unknown>): Record<string, string> {
66
+ const models = raw.models;
67
+ const out: Record<string, string> = {};
68
+ for (const [key, value] of Object.entries(typeof models === "object" && models !== null ? models : {})) {
69
+ if (typeof value === "string") out[key] = value;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The slot table: every canonical step, with the mechanisms marked. */
75
+ function renderTable(raw: Record<string, unknown>): string {
76
+ const models = currentModels(raw);
77
+ const width = Math.max(...SLOT_ROWS.map((row) => row.id.length));
78
+ const rows = SLOT_ROWS.map((row) => {
79
+ const value = row.kind === "mechanism" ? row.placeholder : models[row.id] ?? row.placeholder;
80
+ return ` ${row.id.padEnd(width)} → ${value}`;
81
+ });
82
+
83
+ const active = readActiveProfile(raw);
84
+ const header = active ? `nodd · modelos (perfil activo: ${active})` : "nodd · modelos";
85
+ return [header, ...rows].join("\n");
86
+ }
87
+
88
+ function renderProfileList(raw: Record<string, unknown>): string {
89
+ const { profiles, defects } = readProfiles(raw);
90
+ const names = Object.keys(profiles).sort();
91
+ if (names.length === 0) {
92
+ return "nodd · no hay perfiles todavía. Creá el primero con: /nodd-models profile new <nombre>";
93
+ }
94
+ const active = readActiveProfile(raw);
95
+ const rows = names.map((name) => (name === active ? ` * ${name} (activo)` : ` ${name}`));
96
+ return ["nodd · perfiles:", ...rows, ...defects.map((d) => ` ! ${d}`)].join("\n");
97
+ }
98
+
99
+ function runProfile(args: string[], io: ConfigIo): string {
100
+ const [verb, name, from, fromName] = args;
101
+
102
+ if (!verb || verb === "list") return renderProfileList(io.readConfig());
103
+
104
+ const command =
105
+ verb === "new" ? { kind: "new" as const, name: name ?? "", ...(from === "from" && fromName ? { from: fromName } : {}) }
106
+ : verb === "save" ? { kind: "save" as const, ...(name ? { name } : {}) }
107
+ : verb === "use" ? { kind: "use" as const, name: name ?? "" }
108
+ : verb === "delete" || verb === "rm" ? { kind: "delete" as const, name: name ?? "" }
109
+ : null;
110
+
111
+ if (!command) return USAGE;
112
+ if (command.kind !== "save" && !isValidProfileName(command.name)) {
113
+ return `nodd · nombre de perfil inválido: ${command.name || "(vacío)"}`;
114
+ }
115
+
116
+ const result = applyProfileCommand(io.readConfig(), command);
117
+ if (!result.ok) return `nodd · ${result.message}`;
118
+ io.writeConfig(result.data);
119
+ return `nodd · ${result.message}`;
120
+ }
121
+
122
+ /**
123
+ * The deterministic handler. Display writes nothing; a valid assignment writes
124
+ * once, through the merge that preserves unrelated keys.
125
+ */
126
+ export function runModelsCommand(args: string, io: ConfigIo, registry?: ModelRegistry): string {
127
+ const words = args.trim().split(/\s+/).filter((word) => word !== "");
128
+
129
+ if (words.length === 0) return renderTable(io.readConfig());
130
+ if (words[0] === "profile") return runProfile(words.slice(1), io);
131
+
132
+ const assignment = parseAssignment(words[0]);
133
+ if (!assignment) return USAGE;
134
+
135
+ const validation = validateAssignment(assignment, groupsFrom(registry));
136
+ if (!validation.ok) return `nodd · ${validation.message}`;
137
+
138
+ const raw = io.readConfig();
139
+ const patch = assignmentPatch({ models: currentModels(raw) }, { ...assignment, provider: validation.provider });
140
+ // Mirror after the write so editing a slot under an active profile edits that
141
+ // profile too, rather than leaving the two silently divergent.
142
+ io.writeConfig(mirrorToActiveProfile(mergeConfig(raw, patch)));
143
+
144
+ return `nodd · ${assignment.slot} → ${patch.models[assignment.slot]}`;
145
+ }
146
+
147
+ /** Persist a picker outcome. Quit writes nothing; save writes once. */
148
+ function runPicker(result: EnterResult, io: ConfigIo): void {
149
+ if (result.type !== "save") return;
150
+ const raw = io.readConfig();
151
+ io.writeConfig(mirrorToActiveProfile(mergeConfig(raw, { models: result.models })));
152
+ }
153
+
154
+ /**
155
+ * The `ctx.ui.custom` component. It holds one `PickerState` and forwards
156
+ * keystrokes to the pure state machine; every decision lives there.
157
+ */
158
+ function createComponent(
159
+ input: { models: Record<string, string>; groups: Map<string, string[]> },
160
+ done: (result: EnterResult) => void,
161
+ requestRender: () => void = () => {},
162
+ ): Component {
163
+ let state: PickerState = createPickerState(input);
164
+ let choosing: { slot: string; options: string[]; cursor: number } | null = null;
165
+
166
+ return {
167
+ render(width: number): string[] {
168
+ const inner = Math.max(20, width - 4);
169
+ const lines = choosing
170
+ ? [
171
+ `elegí un modelo para ${choosing.slot}:`,
172
+ ...choosing.options.map((option, i) => `${i === choosing!.cursor ? "❯" : " "} ${option}`),
173
+ ]
174
+ : [
175
+ "nodd · modelos (↑↓ mover · enter elegir · q salir)",
176
+ ...renderRows(state).map((row, i) => {
177
+ const marker = i === state.cursor ? "❯" : " ";
178
+ return `${marker} ${row.id.padEnd(20)} → ${row.value}`;
179
+ }),
180
+ ];
181
+ return lines.map((line) => line.slice(0, inner));
182
+ },
183
+
184
+ handleInput(data: string): void {
185
+ if (choosing) {
186
+ if (data === "\r" || data === "\n") {
187
+ state = stage(state, choosing.slot, choosing.options[choosing.cursor]);
188
+ choosing = null;
189
+ } else if (data === "\u001b[A") choosing.cursor = Math.max(0, choosing.cursor - 1);
190
+ else if (data === "\u001b[B") choosing.cursor = Math.min(choosing.options.length - 1, choosing.cursor + 1);
191
+ else if (data === "q" || data === "\u001b") choosing = null;
192
+ requestRender();
193
+ return;
194
+ }
195
+
196
+ if (data === "\u001b[A") state = moveCursor(state, -1);
197
+ else if (data === "\u001b[B") state = moveCursor(state, 1);
198
+ else if (data === "q" || data === "\u001b") done({ type: "quit" });
199
+ else if (data === "\r" || data === "\n") {
200
+ const result = enter(state);
201
+ if (result.type === "choose") choosing = { slot: result.slot, options: result.options, cursor: 0 };
202
+ else done(result);
203
+ }
204
+ requestRender();
205
+ },
206
+ };
207
+ }
208
+
209
+ type PiApi = {
210
+ registerCommand?(name: string, options: { description?: string; handler: (args: string, ctx: unknown) => unknown }): void;
211
+ };
212
+
213
+ type PiCtx = {
214
+ ui?: {
215
+ notify?(message: string, tone?: string): void;
216
+ custom?<T>(factory: (tui: { requestRender(): void }, theme: unknown, keys: unknown, done: (result: T) => void) => Component): Promise<T>;
217
+ };
218
+ modelRegistry?: ModelRegistry;
219
+ };
220
+
221
+ function register(pi?: PiApi): void {
222
+ pi?.registerCommand?.("nodd-models", {
223
+ description: "Configurar los modelos de NODD: /nodd-models [<slot>=<provider>/<modelo>|profile …]",
224
+ handler: async (args: string, rawCtx: unknown) => {
225
+ const ctx = rawCtx as PiCtx;
226
+ const notify = ctx?.ui?.notify;
227
+ const io = fileConfigIo();
228
+
229
+ try {
230
+ // No arguments plus a custom-UI host: open the picker. Otherwise the
231
+ // deterministic text path, which is the whole command in a headless run.
232
+ if (args.trim() === "" && typeof ctx?.ui?.custom === "function") {
233
+ const groups = groupsFrom(ctx.modelRegistry);
234
+ const result = await ctx.ui.custom<EnterResult>((tui, _theme, _keys, done) =>
235
+ createComponent({ models: currentModels(io.readConfig()), groups }, done, () => tui.requestRender()),
236
+ );
237
+ runPicker(result, io);
238
+ notify?.(result.type === "save" ? "nodd · modelos guardados" : "nodd · sin cambios", "info");
239
+ return;
240
+ }
241
+
242
+ notify?.(runModelsCommand(args ?? "", io, ctx?.modelRegistry), "info");
243
+ } catch (err) {
244
+ notify?.(`nodd-models: ${err instanceof Error ? err.message : String(err)}`, "error");
245
+ }
246
+ },
247
+ });
248
+ }
249
+
250
+ register.runPicker = runPicker;
251
+ register.createComponent = createComponent;
252
+
253
+ export default register;
@@ -0,0 +1,150 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { emptyDoc, renderFeatureDoc, type FeatureDoc } from "../src/feature-doc.ts";
7
+ import register, { runPromote } from "./nodd-promote.ts";
8
+
9
+ function workspace(): string {
10
+ return mkdtempSync(join(tmpdir(), "nodd-promote-"));
11
+ }
12
+
13
+ function sampleDoc(): FeatureDoc {
14
+ return {
15
+ ...emptyDoc({ slug: "cache-warmup", title: "Warm the cache on boot" }),
16
+ objective: "Serve the first request from a warm cache.",
17
+ constraints: "No new dependencies.",
18
+ tasks: [
19
+ {
20
+ id: "T001",
21
+ title: "Read the boot sequence",
22
+ checked: true,
23
+ evidence: { command: "node --test src/boot.test.ts", outcome: "success" },
24
+ candidate: "a1b2c3d",
25
+ },
26
+ { id: "T002", title: "Preload the hot keys", checked: false },
27
+ ],
28
+ };
29
+ }
30
+
31
+ function seedRun(root: string, doc: FeatureDoc = sampleDoc()): string {
32
+ const dir = join(root, ".nodd", doc.slug);
33
+ mkdirSync(dir, { recursive: true });
34
+ writeFileSync(join(dir, "feature.md"), renderFeatureDoc(doc), "utf8");
35
+ return root;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Exactly one file
40
+ // ---------------------------------------------------------------------------
41
+ test("promotion writes requirements.md and nothing else under .sdd/", () => {
42
+ const root = seedRun(workspace());
43
+ const result = runPromote("cache-warmup", root);
44
+
45
+ assert.equal(result.ok, true, result.message);
46
+ const sddDir = join(root, ".sdd", "cache-warmup");
47
+ assert.deepEqual(readdirSync(sddDir), ["requirements.md"], "exactly one file");
48
+ assert.ok(!existsSync(join(sddDir, "design.md")), "design.md must be absent so forge resumes at plan");
49
+ assert.ok(!existsSync(join(sddDir, "tasks.md")), "tasks.md must be absent so forge resumes at plan");
50
+ });
51
+
52
+ test("the written requirements carry the objective and the resolved task's evidence", () => {
53
+ const root = seedRun(workspace());
54
+ runPromote("cache-warmup", root);
55
+
56
+ const written = readFileSync(join(root, ".sdd", "cache-warmup", "requirements.md"), "utf8");
57
+ assert.ok(written.includes("Serve the first request from a warm cache."));
58
+ assert.ok(written.includes("node --test src/boot.test.ts"));
59
+ assert.match(written, /not be redone/i);
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Never an overwrite
64
+ // ---------------------------------------------------------------------------
65
+ test("an existing requirements.md is refused, not overwritten", () => {
66
+ const root = seedRun(workspace());
67
+ const target = join(root, ".sdd", "cache-warmup", "requirements.md");
68
+ mkdirSync(join(root, ".sdd", "cache-warmup"), { recursive: true });
69
+ writeFileSync(target, "HAND WRITTEN, DO NOT CLOBBER\n", "utf8");
70
+
71
+ const result = runPromote("cache-warmup", root);
72
+ assert.equal(result.ok, false);
73
+ assert.match(result.message, /ya existe|already exists/i);
74
+ assert.equal(readFileSync(target, "utf8"), "HAND WRITTEN, DO NOT CLOBBER\n", "byte-identical after the refusal");
75
+ });
76
+
77
+ test("a missing run is reported, not thrown", () => {
78
+ const root = workspace();
79
+ const result = runPromote("no-such-run", root);
80
+ assert.equal(result.ok, false);
81
+ assert.ok(result.message.includes("no-such-run"));
82
+ });
83
+
84
+ test("an empty slug is refused with usage", () => {
85
+ assert.equal(runPromote("", workspace()).ok, false);
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Forge is optional
90
+ // ---------------------------------------------------------------------------
91
+ test("with forge absent the artifact is still written and the manual step reported", () => {
92
+ const root = seedRun(workspace());
93
+ const result = runPromote("cache-warmup", root, { forgeAvailable: false });
94
+
95
+ assert.equal(result.ok, true);
96
+ assert.ok(existsSync(join(root, ".sdd", "cache-warmup", "requirements.md")), "the artifact is written regardless");
97
+ assert.match(result.message, /forge --continue cache-warmup/, "it names the exact command to run by hand");
98
+ });
99
+
100
+ test("with forge present the message still names the continue command and never runs it", () => {
101
+ const root = seedRun(workspace());
102
+ const result = runPromote("cache-warmup", root, { forgeAvailable: true });
103
+ assert.equal(result.ok, true);
104
+ assert.match(result.message, /forge --continue cache-warmup/);
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // REQ: no-sdd-artifacts-off-route
109
+ // ---------------------------------------------------------------------------
110
+ test("this is the only module in the package that writes under .sdd/", () => {
111
+ const roots = ["../src", "../src/gates", "../src/models", "."];
112
+ const offenders: string[] = [];
113
+
114
+ for (const root of roots) {
115
+ const dir = new URL(`${root}/`, import.meta.url);
116
+ for (const name of readdirSync(dir)) {
117
+ if (!name.endsWith(".ts") || name.endsWith(".test.ts")) continue;
118
+ if (name === "nodd-promote.ts") continue;
119
+ const source = readFileSync(new URL(name, dir), "utf8")
120
+ .split("\n")
121
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
122
+ .join("\n");
123
+ if (source.includes(".sdd")) offenders.push(`${root}/${name}`);
124
+ }
125
+ }
126
+
127
+ assert.deepEqual(offenders, [], "only /nodd-promote may write under .sdd/");
128
+ });
129
+
130
+ test("the tracked route leaves .sdd/ untouched across many file writes", () => {
131
+ const root = seedRun(workspace());
132
+ mkdirSync(join(root, ".sdd"), { recursive: true });
133
+ writeFileSync(join(root, ".sdd", "sentinel.md"), "untouched\n", "utf8");
134
+
135
+ // A `tracked` run touching ten files: the feature doc is rewritten each time
136
+ // and nothing reaches .sdd/.
137
+ const { writeFeatureDoc } = register;
138
+ for (let i = 0; i < 10; i++) {
139
+ writeFeatureDoc(root, { ...sampleDoc(), objective: `revision ${i}` });
140
+ }
141
+
142
+ assert.deepEqual(readdirSync(join(root, ".sdd")), ["sentinel.md"], ".sdd/ gained nothing");
143
+ assert.equal(readFileSync(join(root, ".sdd", "sentinel.md"), "utf8"), "untouched\n");
144
+ });
145
+
146
+ test("the command registers as /nodd-promote", () => {
147
+ const commands = new Map<string, unknown>();
148
+ register({ registerCommand: (name: string, options: unknown) => commands.set(name, options) } as never);
149
+ assert.ok(commands.has("nodd-promote"));
150
+ });
@@ -0,0 +1,96 @@
1
+ // `/nodd-promote <slug>` — the single door from NODD into forge.
2
+ //
3
+ // It writes **exactly one file**: `.sdd/<slug>/requirements.md`. No `design.md`,
4
+ // no `tasks.md`, no checklist, so forge's resume algorithm lands on `no-plan`
5
+ // and restarts at `plan` (`orchestrator.md:102-105`).
6
+ //
7
+ // This is also the enforcement point for `REQ: no-sdd-artifacts-off-route`: it is
8
+ // the only module in the package that names `.sdd` in executable code, and a
9
+ // test in `nodd-promote.test.ts` scans every other source file to keep it that
10
+ // way. A tracked run can rewrite the feature doc ten times and leave `.sdd/`
11
+ // byte-identical.
12
+ //
13
+ // Forge is an **optional** dependency (`clarifications.md`). When it is absent
14
+ // the artifact is still written and the message names the exact command to run
15
+ // by hand — promotion must never fail because the consumer is not installed. The
16
+ // command never launches forge itself; handing control to another orchestrator
17
+ // is the user's decision.
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { parseFeatureDoc, renderFeatureDoc, type FeatureDoc } from "../src/feature-doc.ts";
22
+ import { writeVerified } from "../src/io.ts";
23
+ import { promotedRequirements } from "../src/promote.ts";
24
+
25
+ export type PromoteResult = { ok: boolean; message: string };
26
+
27
+ function featurePath(root: string, slug: string): string {
28
+ return join(root, ".nodd", slug, "feature.md");
29
+ }
30
+
31
+ function requirementsPath(root: string, slug: string): string {
32
+ return join(root, ".sdd", slug, "requirements.md");
33
+ }
34
+
35
+ /** Writes the feature doc under `.nodd/`, which is the only place NODD's own
36
+ * route writes. Exposed so the `.sdd/`-untouched test can drive a real
37
+ * multi-write run rather than assert against a mock. */
38
+ function writeFeatureDoc(root: string, doc: FeatureDoc): void {
39
+ writeVerified(featurePath(root, doc.slug), renderFeatureDoc(doc));
40
+ }
41
+
42
+ export function runPromote(
43
+ slug: string,
44
+ root: string,
45
+ options: { forgeAvailable?: boolean } = {},
46
+ ): PromoteResult {
47
+ const trimmed = slug.trim();
48
+ if (trimmed === "") return { ok: false, message: "uso: /nodd-promote <slug>" };
49
+
50
+ const source = featurePath(root, trimmed);
51
+ if (!existsSync(source)) {
52
+ return { ok: false, message: `nodd · no existe la feature ${trimmed} (${source})` };
53
+ }
54
+
55
+ const target = requirementsPath(root, trimmed);
56
+ // Refusing beats merging: an existing requirements.md was written by forge or
57
+ // by hand, and either way NODD is not the author who gets to replace it.
58
+ if (existsSync(target)) {
59
+ return { ok: false, message: `nodd · ${target} ya existe; no se sobrescribe. Movelo o borralo y repetí.` };
60
+ }
61
+
62
+ const { doc, defects } = parseFeatureDoc(readFileSync(source, "utf8"));
63
+ const written = writeVerified(target, promotedRequirements(doc));
64
+ if (!written.ok) return { ok: false, message: `nodd · no se pudo escribir ${target}: ${written.limitation}` };
65
+
66
+ const next =
67
+ options.forgeAvailable === false
68
+ ? `forge no está instalado: corré \`/forge --continue ${trimmed}\` donde sí lo esté`
69
+ : `seguí con \`/forge --continue ${trimmed}\``;
70
+
71
+ const note = defects.length > 0 ? ` (la feature tenía ${defects.length} defecto(s): ${defects.join("; ")})` : "";
72
+ return { ok: true, message: `nodd · ${target} escrito. ${next}.${note}` };
73
+ }
74
+
75
+ type PiApi = {
76
+ registerCommand?(name: string, options: { description?: string; handler: (args: string, ctx: unknown) => unknown }): void;
77
+ };
78
+
79
+ function register(pi?: PiApi): void {
80
+ pi?.registerCommand?.("nodd-promote", {
81
+ description: "Promover una feature de NODD a forge: /nodd-promote <slug>",
82
+ handler: (args: string, rawCtx: unknown) => {
83
+ const ctx = rawCtx as { ui?: { notify?(message: string, tone?: string): void } };
84
+ try {
85
+ const result = runPromote(args ?? "", process.cwd());
86
+ ctx?.ui?.notify?.(result.message, result.ok ? "info" : "error");
87
+ } catch (err) {
88
+ ctx?.ui?.notify?.(`nodd-promote: ${err instanceof Error ? err.message : String(err)}`, "error");
89
+ }
90
+ },
91
+ });
92
+ }
93
+
94
+ register.writeFeatureDoc = writeFeatureDoc;
95
+
96
+ export default register;