@nodii/dev 0.4.1 → 0.6.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.
package/dist/setup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { CliError, hubRequest } from "./core.js";
3
+ import { CliError, checkLogin } from "./core.js";
4
4
 
5
5
  export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
6
6
  const START = "<!-- nodii-dev build workflow:start -->";
@@ -13,7 +13,7 @@ description: Drive every Nodii build lane through nodii-dev from start to verifi
13
13
 
14
14
  # Nodii build workflow
15
15
 
16
- Use \`nodii-dev\` as the control plane. The canonical policy is \`docs/planning/global/rulebook/build-workflow.md\` §§1–8 (operator rulings A-020/A-021). Never edit hub lane state by another route.
16
+ Use \`nodii-dev\` as the control plane. It talks only to Dev, through the platform edge, signed in with \`nodii-dev login\` as the platform tenant's OAuth app. The canonical policy is the build-workflow rulebook in Dev (\`nodii-dev rulebook list\`), §§1–8 (operator rulings A-020/A-021). Never change lane state in Dev by another route.
17
17
 
18
18
  1. Start: \`nodii-dev lane start KEY --title "..." --kind dev_fix|new_build|docs_change|uat_fix|prod_fix --side backend|frontend|both [--issue ISSUE]\`.
19
19
  2. Ask \`nodii-dev next KEY\`; start, complete, or block the reported API step with \`nodii-dev lane step\`.
@@ -27,14 +27,14 @@ Use \`nodii-dev\` as the control plane. The canonical policy is \`docs/planning/
27
27
  - Dev fix, frontend: approved Figma (unless the rulebook exception applies) → components → adopt and local test → deploy without CI → dev check.
28
28
  - Dev fix, both: run both API step tracks; neither side may be skipped.
29
29
  - New build: feature docs alongside Figma concepts → approvals → backend/design-system build → local test → deploy without CI → dev check.
30
- - Docs change: draft against code → operator review → land on main → publish in the hub.
31
- - UAT/prod fixes remain blocked until the hub/rulebook enables per-push green CI and promotion by commit.
30
+ - Docs change: draft against code → operator review → land on main → publish in Dev.
31
+ - UAT/prod fixes remain blocked until the Dev rulebook enables per-push green CI and promotion by commit.
32
32
 
33
33
  ## Gates and landing
34
34
 
35
- Acquire the repo's heavy slot for heavy work. Run, in order: typecheck → lint and format → scoped touched tests on the real stack → review loop until CLEAN. A reviewer error is not CLEAN. On dev only, the approved no-CI hotfix path is DOOR B (\`[skip-ci-gate]\`) or DOOR C (weekly-cadence marker); still deploy and verify. UAT/prod never use that path. Land through a GitHub PR, never by merging a local/shared main. If the hub is down, merging may continue and the skipped lane gate must be recorded when service returns.
35
+ Acquire the repo's heavy slot for heavy work. Run, in order: typecheck → lint and format → scoped touched tests on the real stack → review loop until CLEAN. A reviewer error is not CLEAN. On dev only, the approved no-CI hotfix path is DOOR B (\`[skip-ci-gate]\`) or DOOR C (weekly-cadence marker); still deploy and verify. UAT/prod never use that path. Land through a GitHub PR, never by merging a local/shared main. If Dev is unreachable, merging may continue and the skipped lane gate must be recorded when service returns.
36
36
 
37
- Do not guess authorization locally: send the request and show the hub refusal unchanged. Stop and Built acknowledgement are staff-only. Machine create/update/coordinator changes are staff-only; coordinators assign agents with \`nodii-dev machine assign MACHINE AGENT --home-repo REPO\`. The target machine coordinator or staff may change lane limits and repository grants. Agents or coordinators report capacity with \`nodii-dev machine report MACHINE --host-facts JSON --memory-facts JSON --heavy-slot-total N\`. Agent registration never sets machine, role, home repo, or lane limit.
37
+ Do not guess authorization locally: send the request and show Dev's refusal unchanged. Stop and Built acknowledgement are staff-only. Machine create/update/coordinator changes are staff-only; coordinators assign agents with \`nodii-dev machine assign MACHINE AGENT --home-repo REPO\`. The target machine coordinator or staff may change lane limits and repository grants. Agents or coordinators report capacity with \`nodii-dev machine report MACHINE --host-facts JSON --memory-facts JSON --heavy-slot-total N\`. Agent registration never sets machine, role, home repo, or lane limit.
38
38
  `;
39
39
 
40
40
  export const managedBlock = `${START}
@@ -94,7 +94,7 @@ export async function setupPlan(root = process.cwd()) {
94
94
  return changes;
95
95
  }
96
96
 
97
- export async function setupCommand(flags, { root = process.cwd(), authCheck = () => hubRequest("GET", "/api/v1/whoami") } = {}) {
97
+ export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin } = {}) {
98
98
  const changes = await setupPlan(root);
99
99
  let authenticated = true;
100
100
  try { await authCheck(); } catch { authenticated = false; }
package/dist/upload.js ADDED
@@ -0,0 +1,291 @@
1
+ // `nodii-dev docs check|validate|upload|ratify|parked` and `rulebook list|show`:
2
+ // AD documents (M-05; AD-era contract §1–3). Every document is checked
3
+ // locally first, then sent to Dev, which runs the same checks plus the ones
4
+ // that need its data, the version precondition and the parked-items gate.
5
+ // Dev's refusals are shown unchanged.
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
+ import { join, relative, resolve } from "node:path";
8
+ import { setTimeout as sleep } from "node:timers/promises";
9
+ import { CliError, devRequest, devTextRequest, encode, query } from "./core.js";
10
+ import { CHANGE_CLASSES, validateDocument } from "./docs.js";
11
+
12
+ /** Exit code when Dev is waiting on the operator to acknowledge parked items. */
13
+ export const EXIT_PARKED = 4;
14
+
15
+ /** Expand files and directories into the Markdown documents under them, sorted. */
16
+ export async function collectDocuments(paths) {
17
+ if (!paths.length) throw new CliError("USAGE", "docs needs at least one file or directory", 2);
18
+ const out = new Set();
19
+ async function walk(path) {
20
+ const info = await stat(path).catch(() => null);
21
+ if (!info) throw new CliError("NOT_FOUND", `No such file or directory: ${path}`, 2);
22
+ if (info.isFile()) return void out.add(resolve(path));
23
+ for (const entry of await readdir(path, { withFileTypes: true })) {
24
+ // `_`-prefixed files and folders are working material, not documents.
25
+ if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
26
+ const child = join(path, entry.name);
27
+ if (entry.isDirectory()) await walk(child);
28
+ else if (entry.name.endsWith(".md")) out.add(resolve(child));
29
+ }
30
+ }
31
+ for (const path of paths) await walk(path);
32
+ return [...out].sort();
33
+ }
34
+
35
+ export async function checkDocuments(paths, cwd = process.cwd()) {
36
+ const files = await collectDocuments(paths);
37
+ const docs = await Promise.all(files.map(async (file) => {
38
+ const content = await readFile(file, "utf8");
39
+ return { path: relative(cwd, file) || file, content, ...validateDocument(content) };
40
+ }));
41
+ const byCode = new Map();
42
+ for (const d of docs) {
43
+ const code = d.meta.code;
44
+ if (!code) continue;
45
+ if (byCode.has(code)) d.errors.push({ code: "DUPLICATE_IN_UPLOAD", message: `code ${code} is also used by ${byCode.get(code)}` });
46
+ else byCode.set(code, d.path);
47
+ }
48
+ return docs;
49
+ }
50
+
51
+ const errorLines = (docs) =>
52
+ docs.flatMap((d) => (d.errors ?? []).map((e) => `${d.path}${e.line ? `:${e.line}` : ""}: ${e.code}: ${e.message}`));
53
+
54
+ const parkedLines = (docs) =>
55
+ docs.filter((d) => d.items?.length).flatMap((d) => [
56
+ `${d.label} has ${d.items.length} parked item${d.items.length === 1 ? "" : "s"}:`,
57
+ ...d.items.map((p, i) => ` ${i + 1}. ${p.text}${p.line ? ` (line ${p.line})` : ""}`),
58
+ ]);
59
+ const warningLines = (docs) =>
60
+ docs.flatMap((d) => (d.warnings ?? []).map((w) => `${d.path}${w.line ? `:${w.line}` : ""}: warning: ${w.message}`));
61
+
62
+ const localParked = (docs) => docs.map((d) => ({ label: `${d.path} (${d.meta.code ?? "?"})`, items: d.parked }));
63
+
64
+ const ACK_HINT =
65
+ "An operator must acknowledge each list in Dev (Parked items, or the document's entry page). nodii-dev is an OAuth app and cannot acknowledge for them. " +
66
+ "Re-run this command after they do, or add --wait to keep waiting.";
67
+
68
+ function localGate(docs, json) {
69
+ const lines = errorLines(docs);
70
+ if (!lines.length) return;
71
+ throw new CliError("DOCUMENT_INVALID", `${lines.length} problem${lines.length === 1 ? "" : "s"} found locally; nothing sent to Dev.${json ? "" : `\n${lines.join("\n")}`}`, 1,
72
+ json ? { documents: docs.filter((d) => d.errors.length).map((d) => ({ path: d.path, code: d.meta.code ?? null, errors: d.errors })) } : undefined);
73
+ }
74
+
75
+ function changeClass(flags) {
76
+ const value = flags.class ?? flags["change-class"];
77
+ if (!CHANGE_CLASSES.includes(value)) throw new CliError("USAGE", `--class is required: ${CHANGE_CLASSES.join(" | ")} (M05-8)`, 2);
78
+ return value;
79
+ }
80
+
81
+ /** `--base CODE=VERSION` (repeatable) or `--base VERSION` for one document. */
82
+ function baseFlags(flags, docs) {
83
+ const map = new Map();
84
+ for (const raw of [flags.base].flat().filter((x) => x !== undefined && x !== true)) {
85
+ const value = String(raw);
86
+ const at = value.indexOf("=");
87
+ if (at > 0) map.set(value.slice(0, at), value.slice(at + 1));
88
+ else if (docs.length === 1) map.set(docs[0].meta.code, value);
89
+ else throw new CliError("USAGE", "With several documents, pass --base CODE=VERSION for each existing one.", 2);
90
+ }
91
+ return map;
92
+ }
93
+
94
+ /**
95
+ * M05-11: each write names the version it was based on. A new document is
96
+ * based on nothing (null). For an existing one the author must say which
97
+ * version they started from; the CLI never fills in Dev's current version
98
+ * for them, because that would turn the precondition into a silent overwrite.
99
+ */
100
+ async function baseVersions(docs, flags, request) {
101
+ const given = baseFlags(flags, docs);
102
+ const missing = [];
103
+ for (const d of docs) {
104
+ let current = null;
105
+ try { current = (await request("GET", `/api/v1/documents/${encode(d.meta.code)}`)).data?.currentVersion ?? null; }
106
+ catch (error) { if (error.code !== "DOCUMENT_NOT_FOUND" && error.code !== "HTTP_404") throw error; }
107
+ d.currentVersion = current;
108
+ if (given.has(d.meta.code)) d.baseVersion = given.get(d.meta.code);
109
+ else if (current === null) d.baseVersion = null;
110
+ else missing.push(`${d.meta.code} (Dev is at ${current})`);
111
+ }
112
+ if (missing.length)
113
+ throw new CliError("BASE_VERSION_REQUIRED", `These documents already exist in Dev. Say which version you started from with --base CODE=VERSION (M05-11): ${missing.join(", ")}`, 2);
114
+ }
115
+
116
+ async function readJsonFile(path, flag) {
117
+ if (!path || path === true) return undefined;
118
+ try { return JSON.parse(await readFile(path, "utf8")); }
119
+ catch { throw new CliError("USAGE", `--${flag} must name a JSON file`, 2); }
120
+ }
121
+
122
+ const serverParked = (details) => (details?.documents ?? []).map((d) => ({ label: `${d.code} (${d.title ?? ""})`.replace(" ()", ""), code: d.code, itemsSha256: d.itemsSha256, items: d.items ?? [] }));
123
+
124
+ /**
125
+ * Poll Dev until every refused list is acknowledged. The acknowledgement
126
+ * records are matched on code and digest: the document being uploaded may
127
+ * not exist yet, or may differ from Dev's current version.
128
+ */
129
+ async function waitForAcknowledgement(pending, { request, interval, timeout, log }) {
130
+ const deadline = Date.now() + timeout;
131
+ const open = new Map(pending.map((p) => [p.code, p.itemsSha256]));
132
+ while (open.size) {
133
+ const acknowledged = (await request("GET", "/api/v1/documents/parked?status=acknowledged")).data ?? [];
134
+ for (const [code, digest] of [...open])
135
+ if (acknowledged.some((r) => r.docCode === code && r.itemsSha256 === digest)) { open.delete(code); log(`${code}: parked items acknowledged.`); }
136
+ if (!open.size) return;
137
+ if (Date.now() >= deadline) throw new CliError("PARKED_ITEMS_UNACKNOWLEDGED", `Still waiting on the operator for: ${[...open.keys()].join(", ")}`, EXIT_PARKED);
138
+ await sleep(interval);
139
+ }
140
+ }
141
+
142
+ /** Dev's per-document refusals (`details.documents[].errors`), one line each, as the local check prints them. */
143
+ function withDocumentErrors(error) {
144
+ const docs = error.details?.documents;
145
+ if (!(error instanceof CliError) || !Array.isArray(docs) || !docs.some((d) => d.errors?.length)) return error;
146
+ const lines = docs.flatMap((d) => (d.errors ?? []).map((e) => `${d.path ?? d.code}${e.line ? `:${e.line}` : ""}: ${e.code}: ${e.message}`));
147
+ return new CliError(error.code, `${error.message}\n${lines.join("\n")}`, error.exitCode, error.details);
148
+ }
149
+
150
+ /** Send a write; on the parked gate, show Dev's list and (with --wait) retry once acknowledged. */
151
+ async function withParkedGate(send, flags, { request, log, waitInterval = 15000 }) {
152
+ for (;;) {
153
+ try { return await send(); }
154
+ catch (error) {
155
+ if (error.code !== "PARKED_ITEMS_UNACKNOWLEDGED") throw withDocumentErrors(error);
156
+ const pending = serverParked(error.details);
157
+ if (!flags.wait) throw new CliError(error.code, `${error.message}\n${parkedLines(pending).join("\n")}\n${ACK_HINT}`, EXIT_PARKED, error.details);
158
+ log(`${parkedLines(pending).join("\n")}\nWaiting for the operator to acknowledge in Dev…`);
159
+ await waitForAcknowledgement(pending, { request, interval: waitInterval, timeout: Number(flags["wait-minutes"] ?? 120) * 60000, log });
160
+ }
161
+ }
162
+ }
163
+
164
+ const uploadBody = (docs, cls, flags, decision) => ({
165
+ documents: docs.map((d) => ({
166
+ path: d.path, content: d.content, changeClass: cls, baseVersion: d.baseVersion,
167
+ ...(flags["conformance-ref"] && cls === "conformance" ? { conformanceRef: String(flags["conformance-ref"]) } : {}),
168
+ ...(flags["meaning-unchanged"] && cls === "editorial" ? { meaningUnchangedReason: String(flags["meaning-unchanged"]) } : {}),
169
+ })),
170
+ ...(decision ? { decision } : {}),
171
+ });
172
+
173
+ export async function docsCommand(action, args, flags, { request = devRequest, log = console.error, cwd = process.cwd(), waitInterval } = {}) {
174
+ const json = Boolean(flags.json);
175
+ if (action === "check") {
176
+ const docs = await checkDocuments(args, cwd);
177
+ const lines = errorLines(docs);
178
+ const parked = parkedLines(localParked(docs));
179
+ if (lines.length) process.exitCode = 1;
180
+ return {
181
+ payload: { data: { ok: !lines.length, documents: docs.map((d) => ({ path: d.path, code: d.meta.code ?? null, kind: d.meta.devKind ?? null, version: d.meta.version ?? null, clauseCount: d.clauses.length, parked: d.parked, errors: d.errors, warnings: d.warnings })) } },
182
+ human: [...lines, ...warningLines(docs), ...parked, `${docs.length} document${docs.length === 1 ? "" : "s"} checked: ${lines.length ? `${lines.length} problem${lines.length === 1 ? "" : "s"}` : "no problems"}.`].join("\n"),
183
+ };
184
+ }
185
+
186
+ if (action === "validate" || action === "upload") {
187
+ const cls = changeClass(flags);
188
+ const decision = await readJsonFile(flags.decision, "decision");
189
+ if (cls === "ruling" && !decision) throw new CliError("RULING_NEEDS_DECISION", "A ruling write needs its decision: --decision DECISION.json (title, context, options, ruling, consequences, provenance, amends).", 2);
190
+ if (cls !== "ruling" && decision) throw new CliError("DECISION_NEEDS_RULING", "--decision goes with --class ruling only.", 2);
191
+ if (decision && !decision.amends?.length) throw new CliError("AMENDS_EMPTY", "A decision whose amends list is empty is refused (M05-13).", 2);
192
+ if (cls === "editorial" && flags["meaning-unchanged"] === true) throw new CliError("USAGE", "--meaning-unchanged needs a reason.", 2);
193
+ if (flags["conformance-ref"] === true) throw new CliError("USAGE", "--conformance-ref needs a value: a PR or REPO@SHA.", 2);
194
+ const docs = await checkDocuments(args, cwd);
195
+ localGate(docs, json);
196
+ await baseVersions(docs, flags, request);
197
+ const notes = [...warningLines(docs), ...parkedLines(localParked(docs))];
198
+ if (notes.length) log(notes.join("\n"));
199
+ const body = uploadBody(docs, cls, flags, decision);
200
+
201
+ if (action === "validate") {
202
+ const result = (await request("POST", "/api/v1/documents/validate", body).catch((e) => { throw withDocumentErrors(e); })).data;
203
+ if (!result.ok) process.exitCode = 1;
204
+ return {
205
+ payload: { data: result },
206
+ human: [
207
+ ...result.documents.flatMap((d) => [
208
+ `${d.path ?? d.code}\t${d.code} ${d.version} ${d.changeClass} ${d.kind} ${d.domain}\tclauses=${d.clauseCount} parked=${d.parkedCount}${d.parkedCount ? (d.parkedAcknowledged ? " (acknowledged)" : " (NOT acknowledged)") : ""}`,
209
+ ...d.errors.map((e) => ` ${e.code}${e.line ? ` (line ${e.line})` : ""}: ${e.message}`),
210
+ ...(d.warnings ?? []).map((w) => ` warning ${w.code}${w.line ? ` (line ${w.line})` : ""}: ${w.message}`),
211
+ ]),
212
+ ...(result.decisionErrors ?? []).map((e) => `decision: ${e.code}: ${e.message}`),
213
+ result.ok ? "Dev accepts this upload." : "Dev would refuse this upload.",
214
+ ].join("\n"),
215
+ };
216
+ }
217
+
218
+ const result = (await withParkedGate(() => request("POST", "/api/v1/documents/upload", body), flags, { request, log, waitInterval })).data;
219
+ return {
220
+ payload: { data: result },
221
+ human: [
222
+ ...result.documents.map((d) => `${d.created ? "+" : "~"} ${d.code} ${d.version}\t${d.changeNote}`),
223
+ ...(result.decision ? [`Locked ${result.decision.id}: ${result.decision.title}`] : []),
224
+ `Uploaded ${result.documents.length} document${result.documents.length === 1 ? "" : "s"} (${cls}).`,
225
+ ].join("\n"),
226
+ };
227
+ }
228
+
229
+ if (action === "ratify") {
230
+ // Bulk ratification (M05-2): one decision locks v1.0 of every draft in
231
+ // the named domains, and those domains become AD. A domain that is
232
+ // already AD may be named again to ratify drafts added to it since.
233
+ const domains = [flags.domain].flat().filter((x) => x && x !== true).flatMap((x) => String(x).split(",")).filter(Boolean);
234
+ if (!domains.length) throw new CliError("USAGE", "docs ratify needs --domain KEY (repeatable), e.g. --domain rulebook/methodology", 2);
235
+ const decision = await readJsonFile(flags.decision, "decision");
236
+ const parts = ["title", "context", "options", "ruling", "consequences", "provenance"];
237
+ const absent = parts.filter((k) => typeof decision?.[k] !== "string" || !decision[k].trim());
238
+ if (absent.length) throw new CliError("USAGE", `--decision DECISION.json needs non-empty ${absent.join(", ")} (Dev generates the amends).`, 2);
239
+ const documents = [];
240
+ for (const key of domains) {
241
+ const domain = (await request("GET", `/api/v1/domains/${encode(key)}`)).data;
242
+ for (const d of domain.documents ?? []) if (d.status === "draft") documents.push({ code: d.code, baseVersion: d.currentVersion, parkedCount: d.parkedCount ?? 0 });
243
+ }
244
+ if (!documents.length) throw new CliError("DOMAIN_EMPTY", `No draft documents in ${domains.join(", ")}; upload them first with \`nodii-dev docs upload\`.`, 1);
245
+ // Dev's ratify gate decides on the current versions' parked lists; its refusal carries the real list.
246
+ log(`Ratifying ${documents.length} document${documents.length === 1 ? "" : "s"} in ${domains.join(", ")} as v1.0:\n${documents.map((d) => ` ${d.code} ${d.baseVersion}${d.parkedCount ? ` (${d.parkedCount} parked)` : ""}`).join("\n")}`);
247
+ const body = { domains, documents: documents.map(({ code, baseVersion }) => ({ code, baseVersion })), decision: Object.fromEntries(parts.map((k) => [k, decision[k]])) };
248
+ const result = (await withParkedGate(() => request("POST", "/api/v1/documents/ratify", body), flags, { request, log, waitInterval })).data;
249
+ return {
250
+ payload: { data: result },
251
+ human: [`Locked ${result.decision.id}: ${result.decision.title}`, ...result.documents.map((d) => ` ${d.code} ${d.version}`), `AD now: ${result.domains.map((d) => d.key).join(", ")}`].join("\n"),
252
+ };
253
+ }
254
+
255
+ if (action === "parked") {
256
+ if (args[0]) {
257
+ const p = (await request("GET", `/api/v1/documents/${encode(args[0])}/parked`)).data;
258
+ const ack = p.current?.acknowledgement;
259
+ const lines = p.current
260
+ ? [`${p.code}: ${p.current.count} parked item${p.current.count === 1 ? "" : "s"} in the current version${ack ? ` (${ack.status}${ack.acknowledgedByLabel ? ` by ${ack.acknowledgedByLabel}` : ""})` : ""}`, ...p.current.items.map((x, i) => ` ${i + 1}. ${x.text}`)]
261
+ : [`${p.code}: not in Dev yet`];
262
+ for (const x of p.pending ?? []) lines.push(`Awaiting acknowledgement (${x.count} item${x.count === 1 ? "" : "s"}, requested ${x.requestedAt}):`, ...x.items.map((it, i) => ` ${i + 1}. ${it.text}`));
263
+ return { payload: { data: p }, human: lines.join("\n") };
264
+ }
265
+ const payload = await request("GET", `/api/v1/documents/parked${query({ status: flags.status })}`);
266
+ return { payload, human: (p) => p.data.map((x) => `${x.docCode}\t${x.status}\t${x.count} item${x.count === 1 ? "" : "s"}\t${x.title}`).join("\n") || "No parked-item lists." };
267
+ }
268
+ throw new CliError("USAGE", "docs check|validate|upload|ratify|parked", 2);
269
+ }
270
+
271
+ export async function rulebookCommand(action, args, flags, { request = devRequest, text = devTextRequest } = {}) {
272
+ for (const name of ["version", "from", "to"])
273
+ if (flags[name] === true) throw new CliError("USAGE", `--${name} needs a version, e.g. --${name} 1.0`, 2);
274
+ if (action === "list") {
275
+ const payload = await request("GET", `/api/v1/documents${query({ section: flags.section ?? (flags.all ? undefined : "rulebook"), kind: flags.kind, serviceId: flags.service, domain: flags.domain, status: flags.status, q: flags.q, limit: flags.limit ?? 500, offset: flags.offset })}`);
276
+ return { payload, human: (p) => p.data.map((x) => `${x.code}\t${x.currentVersion}\t${x.status}\t${x.kind}\t${x.title}${x.parkedCount ? `\tparked=${x.parkedCount}` : ""}`).join("\n") || "No documents." };
277
+ }
278
+ if (action === "show") {
279
+ const code = args[0];
280
+ if (!code) throw new CliError("USAGE", "rulebook show CODE [--version V]", 2);
281
+ if (flags.json) return { payload: await request("GET", flags.version ? `/api/v1/documents/${encode(code)}/versions/${encode(flags.version)}` : `/api/v1/documents/${encode(code)}`) };
282
+ return { payload: null, human: (await text("GET", `/api/v1/documents/${encode(code)}/body${query({ version: flags.version })}`)).trimEnd() };
283
+ }
284
+ if (action === "diff") {
285
+ const code = args[0];
286
+ if (!code) throw new CliError("USAGE", "rulebook diff CODE [--from V] [--to V]", 2);
287
+ const payload = await request("GET", `/api/v1/documents/${encode(code)}/diff${query({ from: flags.from, to: flags.to })}`);
288
+ return { payload, human: (p) => `${p.data.code} ${p.data.from} → ${p.data.to}\nclauses added: ${p.data.clauses.added.join(", ") || "none"}; removed: ${p.data.clauses.removed.join(", ") || "none"}; changed: ${p.data.clauses.changed.join(", ") || "none"}\n${p.data.unified}` };
289
+ }
290
+ throw new CliError("USAGE", "rulebook list|show|diff", 2);
291
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.4.1",
4
- "description": "Agent CLI for the Nodii development hub",
3
+ "version": "0.6.1",
4
+ "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nodii-dev": "bin/nodii-dev"
@@ -16,11 +16,12 @@
16
16
  },
17
17
  "scripts": {
18
18
  "build": "node build.mjs",
19
- "lint": "node --check src/*.js && node --check test/*.test.js",
19
+ "lint": "node --check src/*.js && node --check test/*.test.js && node --check test/helpers/*.js && node --check scripts/*.mjs",
20
20
  "format": "git diff --check",
21
21
  "test": "node --test test/*.test.js",
22
22
  "pack:smoke": "node scripts/pack-smoke.mjs",
23
- "openapi:snapshot": "curl -sf https://planning.dev.nucleus-cloud.in/openapi.json -o test/fixtures/devops-openapi.snapshot.json"
23
+ "openapi:snapshot": "node scripts/openapi-snapshot.mjs",
24
+ "openapi:ad-era": "node scripts/ad-era-openapi-snapshot.mjs"
24
25
  },
25
26
  "license": "UNLICENSED",
26
27
  "publishConfig": {