@nodii/dev 0.4.0 → 0.5.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.
package/dist/nodii-dev.js CHANGED
@@ -1,626 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
3
- import { stdin, stdout } from "node:process";
4
- import {
5
- CliError,
6
- DEFAULT_HUB_URL,
7
- OPERATOR_AGENT_ID,
8
- VERSION,
9
- deleteCredentials,
10
- encode,
11
- hubRawRequest,
12
- hubRequest,
13
- parseArgs,
14
- query,
15
- readConfig,
16
- requestHash,
17
- requireFlag,
18
- secretFromAws,
19
- storeSecret,
20
- writeConfig,
21
- } from "./core.js";
22
- import {
23
- DEFAULT_AUTH_URL,
24
- DEFAULT_CLIENT_ID,
25
- DEFAULT_EDGE_URL,
26
- PLATFORM_TENANT,
27
- deviceLogin,
28
- revokeOAuth,
29
- } from "./oauth.js";
30
- import { heartbeatCurrentLane, postHook, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
31
- import { setupCommand } from "./setup.js";
32
- import {
33
- ALIASES,
34
- GROUPS,
35
- buildOperationRequest,
36
- callOperation,
37
- findFirstClass,
38
- flagsToOperation,
39
- loadOpenApi,
40
- operationsFrom,
41
- parseParams,
42
- readBody,
43
- } from "./api.js";
44
- import {
45
- agentWorkflowCommand,
46
- builtCommand,
47
- laneCommand,
48
- machineCommand,
49
- nextCommand,
50
- peopleCommand,
51
- } from "./lanes.js";
2
+ import { fail, main } from "./cli.js";
52
3
 
53
- const HELP = `nodii-dev ${VERSION}
54
-
55
- Usage: nodii-dev <command> [options] [--json]
56
-
57
- login | logout | whoami | config show
58
- agent register --name NAME [--service ID] | agent heartbeat
59
- issues list|show|create|update|claim|release
60
- inbox list|show
61
- drift file | request file
62
- decisions list|get|search
63
- design entries|entry|guide
64
- figma lock|check|log-write|submit|apply|published|abandon|release
65
- hook claude-figma-pre|claude-figma-post|print-settings
66
- setup [--yes|--check] # dry-run by default
67
-
68
- lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
69
- lane step <key> --side SIDE --start|--done|--block <step>
70
- lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
71
- lane heartbeat|review|land|verify|prioritize|deprioritize|stop <key>
72
- lane list [--kind KIND] [--status STATUS] [--mine] [--cursor CURSOR]
73
- lane show <key> | next <key>
74
- built list | built ack <key...>
75
- machine list|show|set-coordinator|assign|report
76
- agent lane-limit|grant|revoke
77
- people reveal <id> --reason TEXT | people refresh-names
78
-
79
- api list [--tag TAG]
80
- api <operationId> [--param k=v] [--body @file|-|JSON]
81
- <resource> <action> [ID ...] [--field VALUE]
82
-
83
- Resource commands cover every OpenAPI tag, including services, feature-docs,
84
- contracts, sagas, outbox-topics, tables, libraries, workers, projects, issues,
85
- backlogs, builds, changes, deploy-reports, runtime, people, agents, inbox,
86
- gate, design, runbooks, doctrine, changelog, health-verdicts, approval-kinds,
87
- notification-registry, skills, open-questions, decisions, drift, and requests.
88
-
89
- Run a command with --help or see the package README for all flags.`;
90
-
91
- async function stdinText() {
92
- let value = "";
93
- for await (const chunk of stdin) value += chunk;
94
- return value;
95
- }
96
- const bodyFlag = async (f, name = "body") =>
97
- f[`${name}-file`] ? readFile(f[`${name}-file`], "utf8") : f[name];
98
- function emit(payload, json, human) {
99
- if (json) console.log(JSON.stringify(payload));
100
- else
101
- console.log(
102
- typeof human === "function"
103
- ? human(payload)
104
- : (human ?? JSON.stringify(payload.data ?? payload, null, 2)),
105
- );
106
- }
107
- const qsFlags = (f, mapping) =>
108
- Object.fromEntries(
109
- Object.entries(mapping).map(([to, from]) => [to, f[from]]),
110
- );
111
-
112
- async function login(flags, json) {
113
- const previous = await readConfig(false);
114
- const configuredClientId = flags["client-id"] ?? process.env.NODII_DEV_OAUTH_CLIENT_ID ?? previous?.clientId ?? DEFAULT_CLIENT_ID;
115
- if (configuredClientId && !flags.hmac) {
116
- const result = await deviceLogin({
117
- clientId: configuredClientId,
118
- authUrl: flags["auth-url"] ?? process.env.NODII_DEV_AUTH_URL ?? DEFAULT_AUTH_URL,
119
- edgeUrl: flags["edge-url"] ?? process.env.NODII_DEV_EDGE_URL ?? DEFAULT_EDGE_URL,
120
- tenantHint:
121
- flags["tenant-hint"] ??
122
- process.env.NODII_DEV_PLATFORM_TENANT_ID ??
123
- PLATFORM_TENANT,
124
- noBrowser: Boolean(flags["no-browser"]),
125
- scope: flags.scope,
126
- output: json ? console.error : console.log,
127
- });
128
- return emit({ loggedIn: true, provider: "oauth", credentialStorage: result.storage }, json,
129
- `OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
130
- }
131
- const hubUrl = flags["hub-url"] ?? DEFAULT_HUB_URL;
132
- const agentId = flags["agent-id"] ?? OPERATOR_AGENT_ID;
133
- const person = requireFlag(flags, "person");
134
- if (flags["secret-stdin"] && flags["from-aws"])
135
- throw new CliError(
136
- "USAGE",
137
- "Choose either --secret-stdin or --from-aws, not both.",
138
- 2,
139
- );
140
- if (
141
- !flags["secret-stdin"] &&
142
- !flags["from-aws"] &&
143
- !process.env.PLANNING_HUB_HMAC_SECRET
144
- )
145
- throw new CliError(
146
- "USAGE",
147
- "Provide --secret-stdin, --from-aws, or PLANNING_HUB_HMAC_SECRET.",
148
- 2,
149
- );
150
- const secret = flags["secret-stdin"]
151
- ? (await stdinText()).trim()
152
- : flags["from-aws"]
153
- ? await secretFromAws()
154
- : process.env.PLANNING_HUB_HMAC_SECRET;
155
- if (!secret)
156
- throw new CliError("EMPTY_SECRET", "The supplied secret is empty.", 2);
157
- const storage = await storeSecret(secret);
158
- await writeConfig({ hubUrl, agentId, person });
159
- emit(
160
- { hubUrl, agentId, person, secretStorage: storage },
161
- json,
162
- `Logged in as ${person} (${agentId}); secret stored in ${storage}.`,
163
- );
164
- }
165
-
166
- async function issues(action, args, f, json) {
167
- if (action === "list") {
168
- const payload = await hubRequest(
169
- "GET",
170
- `/api/v1/issues${query(qsFlags(f, { serviceId: "service", project: "project", status: "status", kind: "kind", priority: "priority", claimed: "claimed", limit: "limit", cursor: "cursor" }))}`,
171
- );
172
- return emit(
173
- payload,
174
- json,
175
- (p) =>
176
- p.data
177
- .map((x) => `${x.key}\t${x.status}\t${x.priority}\t${x.title}`)
178
- .join("\n") || "No issues.",
179
- );
180
- }
181
- const ref = args[0];
182
- if (!ref && action !== "create")
183
- throw new CliError("USAGE", `${action} needs an issue key or id`, 2);
184
- if (action === "show")
185
- return emit(await hubRequest("GET", `/api/v1/issues/${encode(ref)}`), json);
186
- const config = await readConfig();
187
- if (action === "create" || action === "update") {
188
- const bodyMd = await bodyFlag(f);
189
- const payload = {
190
- ...(action === "create"
191
- ? {
192
- serviceId: requireFlag(f, "service"),
193
- title: requireFlag(f, "title"),
194
- }
195
- : {}),
196
- ...Object.fromEntries(
197
- [
198
- ["title", f.title],
199
- ["kind", f.kind],
200
- ["priority", f.priority],
201
- ["project", f.project],
202
- ["bodyMd", bodyMd],
203
- ["onBehalfOf", config.person],
204
- ].filter(([, v]) => v !== undefined),
205
- ),
206
- };
207
- const result = await hubRequest(
208
- action === "create" ? "POST" : "PATCH",
209
- action === "create" ? "/api/v1/issues" : `/api/v1/issues/${encode(ref)}`,
210
- payload,
211
- );
212
- return emit(
213
- result,
214
- json,
215
- (p) => `${p.data.key} ${p.data.status} — ${p.data.title}`,
216
- );
217
- }
218
- if (action === "claim")
219
- return emit(
220
- await hubRequest("POST", `/api/v1/issues/${encode(ref)}/claim`, {
221
- onBehalfOf: config.person,
222
- ...(f.ttl ? { ttlMinutes: Number(f.ttl) } : {}),
223
- }),
224
- json,
225
- );
226
- if (action === "release")
227
- return emit(
228
- await hubRequest("POST", `/api/v1/issues/${encode(ref)}/release`, {
229
- onBehalfOf: config.person,
230
- ...(f.note ? { note: f.note } : {}),
231
- }),
232
- json,
233
- );
234
- throw new CliError("USAGE", `Unknown issues action: ${action}`, 2);
235
- }
236
-
237
- async function main(argv = process.argv.slice(2)) {
238
- if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
239
- return console.log(VERSION);
240
- if (argv.length === 1 && ["--help", "-h"].includes(argv[0]))
241
- return console.log(HELP);
242
- const { args, flags: f } = parseArgs(argv);
243
- const json = Boolean(f.json);
244
- const [command, action, ...rest] = args;
245
- if (!command || f.help || command === "help") return console.log(HELP);
246
- if (command === "api") {
247
- const operations = operationsFrom(await loadOpenApi());
248
- if (action === "list") {
249
- const selected = f.tag === undefined || f.tag === true
250
- ? operations
251
- : operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
252
- return emit(selected.map(({ operationId, method, path, tags }) => ({ operationId, method, path, tags })), json,
253
- (rows) => rows.map((x) => `${x.operationId}\t${x.method}\t${x.path}\t${x.tags.join(",")}`).join("\n"));
254
- }
255
- if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
256
- const entry = operations.find((x) => x.operationId === action);
257
- if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action}`, 2);
258
- const parsed = await readBody(f.body);
259
- const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
260
- return emit(await hubRawRequest(request.method, request.path, parsed?.text), json);
261
- }
262
- if (command === "login") return login(f, json);
263
- if (command === "logout") {
264
- const c = await readConfig(false);
265
- if (c?.authProvider === "oauth") {
266
- try { await revokeOAuth(c); } finally { await deleteCredentials(); }
267
- } else await deleteCredentials();
268
- return emit({ loggedOut: true }, json, "Logged out.");
269
- }
270
- if (command === "config" && action === "show") {
271
- const c = await readConfig();
272
- if (c.authProvider === "oauth")
273
- return emit({ provider: "oauth", authUrl: c.authUrl, edgeUrl: c.edgeUrl, clientId: c.clientId, tenantHint: c.tenantHint, agentId: c.agentId }, json,
274
- `${c.edgeUrl}\nprovider: oauth\napp: ${c.clientId}\nagent: ${c.agentId ?? "not registered"}`);
275
- return emit(
276
- c,
277
- json,
278
- `${c.hubUrl}\nagent: ${c.agentId}\nperson: ${c.person}`,
279
- );
280
- }
281
- if (command === "whoami") {
282
- const c = await readConfig();
283
- if (c.authProvider === "oauth") {
284
- try { return emit(await hubRequest("GET", "/api/v1/whoami"), json); }
285
- catch (error) {
286
- if (!["HTTP_404", "NOT_FOUND"].includes(error.code)) throw error;
287
- return emit({ data: { person: c.person, app: c.clientId, installation: c.installationId, agent: c.agentId } }, json);
288
- }
289
- }
290
- const remote = await hubRequest(
291
- "GET",
292
- `/api/v1/agents/${encode(c.agentId)}`,
293
- );
294
- return emit({ data: { ...c, agent: remote.data } }, json);
295
- }
296
- const emitCommand = async (result) => emit(result.payload, json, result.human);
297
- if (command === "setup") return emitCommand(await setupCommand(f));
298
- if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
299
- if (command === "next") return emitCommand(await nextCommand(action, f));
300
- if (command === "built") return emitCommand(await builtCommand(action, rest, f));
301
- if (command === "machine") return emitCommand(await machineCommand(action, rest, f));
302
- if (command === "people" && ["reveal", "refresh-names"].includes(action))
303
- return emitCommand(await peopleCommand(action, rest, f));
304
- if (command === "state" && action === "diff") {
305
- const [health, services, decisions, docs, requests, drift] = await Promise.all([
306
- hubRequest("GET", "/api/v1/health"), hubRequest("GET", "/api/v1/services"),
307
- hubRequest("GET", "/api/v1/decisions"), hubRequest("GET", "/api/v1/feature-docs"),
308
- hubRequest("GET", "/api/v1/requests"), hubRequest("GET", "/api/v1/drift"),
309
- ]);
310
- const count = (x) => Array.isArray(x.data) ? x.data.length : undefined;
311
- return emit({ data: { health: health.data ?? health, counts: { services: count(services), decisions: count(decisions), featureDocs: count(docs), requests: count(requests), drift: count(drift) } } }, json);
312
- }
313
- if (command === "heartbeat" && action === "audit") {
314
- const ids = rest[0] ? [rest[0]] : (await hubRequest("GET", "/api/v1/services?limit=1000")).data.map((x) => x.id);
315
- const rows = await Promise.all(ids.map(async (id) => ({ serviceId: id, ...(await hubRequest("GET", `/api/v1/services/${encode(id)}/unblock-surface`)).data })));
316
- const failed = rows.some((x) => (x.openDriftCount ?? 0) > 0 || (x.openInboxRequestCount ?? 0) > 0);
317
- emit({ data: rows, clean: !failed }, json, (p) => p.data.map((x) => `${x.serviceId}\t${(x.openDriftCount ?? 0) || (x.openInboxRequestCount ?? 0) ? "ATTENTION" : "PASS"}\tdrift=${x.openDriftCount ?? 0}\tinbox=${x.openInboxRequestCount ?? 0}`).join("\n"));
318
- if (failed) process.exitCode = 1;
319
- return;
320
- }
321
- if (command === "agents" && action === "spawn") {
322
- const serviceId = rest[0] ?? requireFlag(f, "service");
323
- const docs = await hubRequest("GET", `/api/v1/feature-docs${query({ serviceId, status: "approved", limit: 200 })}`);
324
- if (!docs.data?.length) throw new CliError("NO_APPROVED_WORK", `No approved feature docs for ${serviceId}`, 1);
325
- const repo = `${f["repo-root"] ?? `${process.env.HOME}/Projects`}/${serviceId}`;
326
- return emit({ data: { serviceId, repo, approvedFeatureDocs: docs.data.length, next: `cd ${repo} && nodii-dev agent register --name <name> --service ${serviceId}` } }, json);
327
- }
328
- if (command === "agents" && action === "cowork-tick") {
329
- if (f.plist) {
330
- const interval = Number(f.interval ?? 1800);
331
- return console.log(`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.nodii.cowork</string><key>ProgramArguments</key><array><string>nodii-dev</string><string>agents</string><string>cowork-tick</string></array><key>StartInterval</key><integer>${interval}</integer><key>RunAtLoad</key><true/></dict></plist>`);
332
- }
333
- const docs = await hubRequest("GET", "/api/v1/feature-docs?status=implementing&limit=1000");
334
- const claims = docs.data.filter((x) => x.claimedByAgentId && (!x.claimExpiresAt || Date.parse(x.claimExpiresAt) > Date.now()));
335
- return emit({ data: claims, dryRun: true, note: "Wakeup execution is workstation-local and has no hub endpoint; this tick reports eligible claims without invoking an external agent runtime." }, json,
336
- (p) => p.data.map((x) => `${x.serviceId}/${x.docKey}\t${x.claimedByAgentId}`).join("\n") || "No active claims.");
337
- }
338
- if (command === "agent") {
339
- if (["lane-limit", "grant", "revoke"].includes(action))
340
- return emitCommand(await agentWorkflowCommand(action, rest, f));
341
- const c = await readConfig();
342
- if (action === "register") {
343
- const result = await hubRequest("POST", c.authProvider === "oauth" ? "/api/v1/agents/register" : "/api/v1/agents", {
344
- sessionId: f.session ?? crypto.randomUUID(),
345
- serviceId: f.service ?? "nodii-planning-hub",
346
- name: requireFlag(f, "name"),
347
- ...(c.authProvider === "oauth" ? {} : { personId: c.person }),
348
- });
349
- await writeConfig({ ...c, agentId: result.data.id });
350
- await hubRequest(
351
- "POST",
352
- `/api/v1/agents/${encode(result.data.id)}/heartbeat`,
353
- {},
354
- );
355
- return emit(
356
- result,
357
- json,
358
- `Registered ${result.data.name}: ${result.data.id}`,
359
- );
360
- }
361
- if (action === "heartbeat")
362
- return emit(
363
- await hubRequest(
364
- "POST",
365
- `/api/v1/agents/${encode(c.agentId)}/heartbeat`,
366
- {},
367
- ),
368
- json,
369
- "Heartbeat recorded.",
370
- );
371
- }
372
- if (command === "issues") return issues(action, rest, f, json);
373
- if (command === "inbox") {
374
- if (action === "list")
375
- return emit(
376
- await hubRequest(
377
- "GET",
378
- `/api/v1/inbox${query({ serviceId: f.service, kind: f.kind, limit: f.limit, cursor: f.cursor })}`,
379
- ),
380
- json,
381
- );
382
- if (action === "show") {
383
- const [kind, id] = rest;
384
- if (!kind || !id)
385
- throw new CliError(
386
- "USAGE",
387
- "inbox show needs <drift|request|question> <id>",
388
- 2,
389
- );
390
- return emit(
391
- await hubRequest(
392
- "GET",
393
- `/api/v1/${kind === "question" ? "open-questions" : `${kind}s`}/${encode(id)}`,
394
- ),
395
- json,
396
- );
397
- }
398
- }
399
- if (command === "drift" && action === "file") {
400
- const service = requireFlag(f, "service");
401
- const bodyMd = await bodyFlag(f);
402
- return emit(
403
- await hubRequest("POST", `/api/v1/services/${encode(service)}/drift`, {
404
- severity: requireFlag(f, "severity"),
405
- summary: requireFlag(f, "summary"),
406
- bodyMd: bodyMd ?? "",
407
- relatedArtifactType: f["related-artifact-type"] ?? null,
408
- relatedArtifactId: f["related-artifact-id"] ?? null,
409
- }),
410
- json,
411
- );
412
- }
413
- if (command === "request" && action === "file") {
414
- const specMd = f["spec-file"]
415
- ? await readFile(f["spec-file"], "utf8")
416
- : requireFlag(f, "spec");
417
- const body = {
418
- fromService: requireFlag(f, "from"),
419
- toService: requireFlag(f, "to"),
420
- kind: requireFlag(f, "kind"),
421
- specMd,
422
- requestHash: requestHash({ toService: f.to, kind: f.kind, specMd }),
423
- ...(f["required-capability"]
424
- ? { requiredCapability: f["required-capability"] }
425
- : {}),
426
- targetArtifactType: f["target-artifact-type"] ?? null,
427
- targetArtifactId: f["target-artifact-id"] ?? null,
428
- };
429
- return emit(await hubRequest("POST", "/api/v1/requests", body), json);
430
- }
431
- if (command === "requests" && action === "file") {
432
- const specMd = f["spec-file"] ? await readFile(f["spec-file"], "utf8") : requireFlag(f, "spec");
433
- f.fromService = requireFlag(f, "from"); f.toService = requireFlag(f, "to"); f.specMd = specMd;
434
- f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
435
- }
436
- if (command === "decisions") {
437
- if (action === "get")
438
- return emit(
439
- await hubRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
440
- json,
441
- );
442
- const q = action === "search" ? rest.join(" ") || f.q : undefined;
443
- const payload = await hubRequest(
444
- "GET",
445
- `/api/v1/decisions${query({ status: f.status, module: f.module, tag: f.tag, limit: f.limit ?? 200 })}`,
446
- );
447
- if (q)
448
- payload.data = payload.data.filter((x) =>
449
- `${x.id} ${x.title} ${(x.topicTags ?? []).join(" ")}`
450
- .toLowerCase()
451
- .includes(q.toLowerCase()),
452
- );
453
- return emit(
454
- payload,
455
- json,
456
- (p) =>
457
- p.data.map((x) => `${x.id}\t${x.status}\t${x.title}`).join("\n") ||
458
- "No decisions.",
459
- );
460
- }
461
- if (command === "design") {
462
- if (action === "entries")
463
- return emit(
464
- await hubRequest(
465
- "GET",
466
- `/api/v1/design/entries${query({ kind: f.kind, group: f.group, q: f.q, limit: f.limit })}`,
467
- ),
468
- json,
469
- );
470
- if (action === "entry")
471
- return emit(
472
- await hubRequest("GET", `/api/v1/design/entries/${encode(rest[0])}`),
473
- json,
474
- );
475
- if (action === "guide")
476
- return emit(
477
- await hubRequest("GET", `/api/v1/design/guides/${encode(rest[0])}`),
478
- json,
479
- );
480
- }
481
- if (command === "figma") {
482
- if (action === "lock")
483
- return emit(
484
- await hubRequest(
485
- "POST",
486
- `/api/v1/design/files/${encode(rest[0])}/lock`,
487
- { changeSetTitle: requireFlag(f, "title") },
488
- ),
489
- json,
490
- );
491
- if (action === "check") {
492
- const c = await readConfig();
493
- const result = await hubRequest(
494
- "GET",
495
- `/api/v1/design/files/${encode(rest[0])}/lock/check?agentId=${encode(c.agentId)}`,
496
- );
497
- emit(
498
- result,
499
- json,
500
- (p) =>
501
- `${p.data.allowed ? "allowed" : "denied"}: ${p.data.reason ?? "lock held"}`,
502
- );
503
- if (!result.data.allowed) process.exitCode = 3;
504
- return;
505
- }
506
- if (action === "log-write")
507
- return emit(
508
- await hubRequest(
509
- "POST",
510
- `/api/v1/design/change-sets/${encode(rest[0])}/writes`,
511
- {
512
- tool: requireFlag(f, "tool"),
513
- nodeIds: requireFlag(f, "nodes").split(",").filter(Boolean),
514
- },
515
- ),
516
- json,
517
- );
518
- if (["submit", "apply", "published", "abandon"].includes(action)) {
519
- const payload = f.file
520
- ? JSON.parse(await readFile(f.file, "utf8"))
521
- : action === "abandon"
522
- ? {}
523
- : { figmaVersion: requireFlag(f, "figma-version") };
524
- return emit(
525
- await hubRequest(
526
- "POST",
527
- `/api/v1/design/change-sets/${encode(rest[0])}/${action}`,
528
- payload,
529
- ),
530
- json,
531
- );
532
- }
533
- if (action === "release")
534
- return emit(
535
- await hubRequest(
536
- "POST",
537
- `/api/v1/design/files/${encode(rest[0])}/lock/release`,
538
- {},
539
- ),
540
- json,
541
- );
542
- }
543
- if (command === "hook") {
544
- if (action === "print-settings")
545
- return console.log(JSON.stringify(settingsSnippet(), null, 2));
546
- if (action === "claude-figma-pre") {
547
- // Fail closed: Claude Code only blocks a tool call on exit code 2, and
548
- // treats any other non-zero exit as a non-blocking error that lets the
549
- // write through. Every failure here (bad stdin, missing config, hub
550
- // error, lock denied) must therefore exit 2.
551
- try {
552
- const payload = JSON.parse(await stdinText());
553
- return emit(await preHook(payload), json, "allowed");
554
- } catch (error) {
555
- throw new CliError(
556
- error?.code ?? "FIGMA_HOOK_FAILED",
557
- `Blocked Figma write: ${error?.message ?? String(error)}`,
558
- 2,
559
- error?.details,
560
- );
561
- }
562
- }
563
- if (action === "lane-heartbeat") {
564
- const result = await heartbeatCurrentLane();
565
- return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
566
- }
567
- const payload = JSON.parse(await stdinText());
568
- if (action === "workflow-post") {
569
- const suggestion = workflowSuggestion(payload);
570
- if (suggestion) console.log(suggestion);
571
- return;
572
- }
573
- if (action === "claude-figma-post") {
574
- try {
575
- return emit(await postHook(payload), json, "logged");
576
- } catch (error) {
577
- console.error(
578
- `nodii-dev post hook: ${error.code ?? "ERROR"}: ${error.message}`,
579
- );
580
- return;
581
- }
582
- }
583
- }
584
- if (Object.hasOwn(GROUPS, command)) {
585
- const operations = operationsFrom(await loadOpenApi());
586
- let entry = findFirstClass(operations, command, action);
587
- if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
588
- entry = operations.find((x) => x.operationId === "acceptDrift");
589
- if (command === "contracts") {
590
- const [kind, verb, ...ids] = [action, ...rest];
591
- const type = { grpc: "GrpcInterface", rest: "RestInterface", outbox: "OutboxTopic", saga: "Saga", table: "Table" }[kind];
592
- const op = verb === "publish" ? `create${type}` : verb === "status" && f.status === "implemented" ? `implement${type}` : null;
593
- if (!op) throw new CliError("USAGE", "contracts: use <grpc|rest|outbox|saga|table> publish|status; status currently supports --status implemented", 2);
594
- entry = operations.find((x) => x.operationId === op);
595
- rest.splice(0, rest.length, ...ids);
596
- }
597
- if (!entry) {
598
- const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
599
- const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
600
- throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available}`, 2);
601
- }
602
- const built = flagsToOperation(entry, rest, f);
603
- if (command === "requests" && action === "answer")
604
- Object.assign(built.params, { kind: "request", id: rest[0] });
605
- if (command === "drift" && entry.operationId === "acceptDrift" && !built.body?.relatedDecisionId && !built.body?.newDecision)
606
- throw new CliError("USAGE", "accepted-as-canonical drift requires --related-decision-id or --new-decision JSON", 2);
607
- const explicit = await readBody(f.body);
608
- return emit(await callOperation(entry, built.params, explicit?.value ?? built.body), json);
609
- }
610
- throw new CliError(
611
- "USAGE",
612
- `Unknown command: ${[command, action].filter(Boolean).join(" ")}`,
613
- 2,
614
- );
615
- }
616
-
617
- main().catch((error) => {
618
- const e =
619
- error instanceof CliError
620
- ? error
621
- : new CliError("UNEXPECTED", error?.message ?? String(error));
622
- console.error(
623
- `${e.code}: ${e.message}${e.details ? `\n${JSON.stringify(e.details)}` : ""}`,
624
- );
625
- process.exitCode = e.exitCode;
626
- });
4
+ main().catch(fail);
package/dist/oauth.js CHANGED
@@ -19,7 +19,7 @@ export const FORBIDDEN_SCOPE = "devops.releases.promote";
19
19
  * The scope sent at device authorization. By default none is sent, and
20
20
  * nodii-auth grants the app's provisioned ceiling (every live devops.* key
21
21
  * except promotion, D1368). An explicit --scope narrows it; promotion is
22
- * refused locally as well as by auth and the hub.
22
+ * refused locally as well as by auth and by Dev.
23
23
  */
24
24
  export function requestedScope(scope) {
25
25
  const keys = (scope ?? "").split(/[\s,]+/).filter(Boolean);
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; }