@nodii/dev 0.3.1 → 0.4.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/README.md CHANGED
@@ -103,8 +103,71 @@ nodii-dev figma release FILE_KEY
103
103
  nodii-dev hook claude-figma-pre # reads Claude Code hook JSON on stdin
104
104
  nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
105
105
  nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
106
+
107
+ nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
108
+ --side backend|frontend|both [--repo REPO] [--issue ISSUE]
109
+ nodii-dev lane step KEY --side backend|frontend --start|--done|--block STEP
110
+ nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF
111
+ nodii-dev lane heartbeat KEY [--note TEXT]
112
+ nodii-dev lane review KEY
113
+ nodii-dev lane land KEY
114
+ nodii-dev lane verify KEY --dev-check REF
115
+ nodii-dev lane list [--kind KIND] [--status STATUS] [--machine UUID]
116
+ [--agent UUID|--mine] [--waiting-on SIDE] [--stuck true|false]
117
+ [--cursor CURSOR] [--limit N]
118
+ nodii-dev lane show KEY
119
+ nodii-dev lane prioritize|deprioritize KEY --reason TEXT
120
+ nodii-dev lane stop KEY --reason TEXT
121
+ nodii-dev next KEY
122
+
123
+ nodii-dev built list [--cursor CURSOR] [--include-acknowledged true]
124
+ nodii-dev built ack KEY [KEY ...] [--note TEXT]
125
+ nodii-dev machine list|show
126
+ nodii-dev machine set-coordinator MACHINE AGENT
127
+ nodii-dev machine assign MACHINE AGENT --home-repo REPO
128
+ nodii-dev machine report MACHINE --host-facts JSON --memory-facts JSON --heavy-slot-total N
129
+ nodii-dev agent lane-limit AGENT N
130
+ nodii-dev agent grant|revoke AGENT REPO
131
+ nodii-dev people reveal PERSON --reason TEXT
132
+ nodii-dev people refresh-names
133
+
134
+ nodii-dev setup # preview files that would change
135
+ nodii-dev setup --yes # merge and write the harness
136
+ nodii-dev setup --check # exit non-zero when it has drifted
106
137
  ```
107
138
 
139
+ ## Build lanes and repository setup
140
+
141
+ `lane start` creates and starts a lane. It infers the repository, branch and
142
+ worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
143
+ required by the hub. Lane keys are passed directly to every lane and Built
144
+ acknowledgement route. `next` reads the lane's generated
145
+ API steps and prints the exact next `lane step` command. Server refusals,
146
+ including operator-only actions, workflow blocks, lane limits and Built items
147
+ that are not on dev, retain the hub error code/message and exit non-zero.
148
+
149
+ `setup` installs the Claude Code workflow harness in the current repository.
150
+ It writes `.claude/skills/nodii-build-workflow/SKILL.md`, merges SessionStart,
151
+ Stop and Bash PostToolUse hooks into `.claude/settings.json`, and maintains one
152
+ marked section in an existing `CLAUDE.md` or `AGENTS.md` (creating `CLAUDE.md`
153
+ when neither exists). Existing settings and hooks are preserved. The default
154
+ is a read-only preview; `--yes` applies, and `--check` is suitable for drift
155
+ checks. Setup also checks `whoami` and prints `nodii-dev login` guidance without
156
+ exposing credential details.
157
+
158
+ Heartbeat notes are optional and limited by the hub to 500 characters.
159
+ Prioritize and deprioritize require a 1–500 character reason, which the CLI
160
+ sends to the hub. `lane review` records the review gate.
161
+
162
+ The CLI does not duplicate authorization policy. It sends requests and prints
163
+ the hub's refusal unchanged. Stop, Built acknowledgement, and machine
164
+ create/update/coordinator changes require a staff person login. Machine agent
165
+ assignment is for coordinators; lane-limit and repository-grant changes are for
166
+ the target agent's coordinator or staff. Agent registration does not accept
167
+ machine, role, home-repository, or lane-limit fields. Machine reports may be
168
+ sent by the machine's agent or coordinator. People name reveal requires a
169
+ reason; refreshing the names cache is staff-only.
170
+
108
171
  Every first-class resource command accepts positional path identifiers and
109
172
  OpenAPI property names as kebab-case flags. For example:
110
173
 
@@ -121,7 +184,7 @@ deploy reports, runtime, people, agents, inbox/gates, design, runbooks,
121
184
  doctrine and exceptions, changelog, health verdicts, approval kinds,
122
185
  notification registry, skills, questions, decisions, drift, requests,
123
186
  pipelines, runners, routing, usage, audit, reconciliation, cron, composite
124
- surfaces, and service edges. `api list` is the authoritative discovery view;
187
+ surfaces, service edges, lanes, swaps, ideas, and terminology. `api list` is the authoritative discovery view;
125
188
  `api OPERATION_ID` makes every operation callable even without an alias.
126
189
 
127
190
  `figma check` exits `0` when allowed and `3` when denied. The pre-hook permits
package/dist/api.js CHANGED
@@ -98,7 +98,8 @@ export const GROUPS = {
98
98
  "approval-kinds": "ApprovalKinds", "notification-registry": "Notification registry", skills: "Skills", "open-questions": "OpenQuestions",
99
99
  decisions: "Decisions", drift: "Drift", requests: "Requests", pipelines: "Pipelines", slices: "Slices", "sql-lookups": "SqlLookups",
100
100
  runners: "Runners", routing: "Routing", usage: "Usage", audit: "AuditLog", reconciliation: "Reconciliation", cron: "Cron", health: "Health",
101
- composite: "Composite", "service-edges": "ServiceEdges",
101
+ composite: "Composite", "service-edges": "ServiceEdges", lanes: "Lanes",
102
+ swaps: "Swaps", ideas: "Ideas", terminology: "Terminology",
102
103
  };
103
104
 
104
105
  export const ALIASES = {
@@ -139,6 +140,9 @@ export const ALIASES = {
139
140
  "audit:list": "listAuditLog", "health:show": "getHealth",
140
141
  "composite:refresher": "getServiceRefresher", "composite:impact": "getImpact", "composite:search": "search",
141
142
  "service-edges:list": "listServiceEdges", "service-edges:show": "getServiceEdgesForService",
143
+ "swaps:list": "listSwaps", "swaps:show": "getSwap", "swaps:create": "createSwap", "swaps:close": "closeSwap",
144
+ "ideas:list": "listIdeas", "ideas:show": "getIdea", "ideas:create": "createIdea", "ideas:status": "updateIdeaStatus",
145
+ "terminology:list": "listTerminology", "terminology:show": "getTerminologyTerm", "terminology:create": "createTerminologyTerm", "terminology:update": "updateTerminologyTerm",
142
146
  };
143
147
 
144
148
  export function findFirstClass(operations, group, action) {
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.3.1";
7
+ export const VERSION = "0.4.0";
8
8
  export const DEFAULT_HUB_URL = "https://planning.dev.nucleus-cloud.in";
9
9
  export const OPERATOR_AGENT_ID = "00000000-0000-0000-0000-000000000001";
10
10
  export const configPath = () =>
package/dist/hooks.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { CliError, hubRequest } from "./core.js";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFileAsync = promisify(execFile);
2
6
 
3
7
  const FIGMA_PREFIXES = [
4
8
  "mcp__figma__",
@@ -151,3 +155,22 @@ export const settingsSnippet = () => ({
151
155
  ],
152
156
  },
153
157
  });
158
+
159
+ export async function heartbeatCurrentLane(request = hubRequest) {
160
+ let branch;
161
+ try { branch = (await execFileAsync("git", ["branch", "--show-current"], { encoding: "utf8" })).stdout.trim(); }
162
+ catch { return { heartbeated: false, reason: "not a git worktree" }; }
163
+ if (!branch) return { heartbeated: false, reason: "detached HEAD" };
164
+ const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
165
+ const lane = lanes.find((x) => x.branch === branch && !["on_dev", "stopped"].includes(x.status));
166
+ if (!lane) return { heartbeated: false, reason: "no lane for current branch" };
167
+ await request("POST", `/api/v1/lanes/${encodeURIComponent(lane.id)}/heartbeat`, { via: "nodii-dev" });
168
+ return { heartbeated: true, key: lane.key };
169
+ }
170
+
171
+ export function workflowSuggestion(payload) {
172
+ const command = payload?.tool_input?.command ?? "";
173
+ if (/\bgit\s+push\b/.test(command)) return "nodii-dev: pushed work; record it with `nodii-dev lane evidence <key> --commit <sha>` or `--pr <url>`.";
174
+ if (/\bgh\s+pr\s+merge\b/.test(command)) return "nodii-dev: after the merge, run `nodii-dev lane land <key>`.";
175
+ return null;
176
+ }
package/dist/lanes.js ADDED
@@ -0,0 +1,160 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { CliError, encode, hubRequest, query, readConfig, requireFlag } from "./core.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const via = { via: "nodii-dev" };
7
+
8
+ const need = (value, usage) => {
9
+ if (!value) throw new CliError("USAGE", usage, 2);
10
+ return value;
11
+ };
12
+
13
+ async function git(...args) {
14
+ try {
15
+ return (await execFileAsync("git", args, { encoding: "utf8" })).stdout.trim();
16
+ } catch {
17
+ throw new CliError("GIT_REQUIRED", "Run this command inside a git worktree, or pass --repo and --branch.", 2);
18
+ }
19
+ }
20
+
21
+ export async function inferGitContext() {
22
+ const worktree = await git("rev-parse", "--show-toplevel");
23
+ const branch = await git("branch", "--show-current");
24
+ let repo;
25
+ try {
26
+ const remote = await git("remote", "get-url", "origin");
27
+ repo = remote.replace(/\.git$/, "").replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "");
28
+ } catch {
29
+ repo = worktree.split("/").filter(Boolean).at(-1);
30
+ }
31
+ return { repo, branch: need(branch, "Detached HEAD: pass --branch."), worktree };
32
+ }
33
+
34
+ const laneLine = (x) => `${x.key}\t${x.status}\t${x.kind}/${x.side}\t${x.title}${x.stuck ? "\tSTUCK" : ""}`;
35
+ const detailText = (x) => [laneLine(x), ...(x.steps ?? []).map((s) => ` ${s.side}\t${s.state}\t${s.stepKey}\t${s.label}`)].join("\n");
36
+
37
+ function evidence(flags) {
38
+ const selected = ["pr", "commit", "figma", "deploy", "dev-check"].filter((x) => flags[x] !== undefined);
39
+ if (selected.length !== 1) throw new CliError("USAGE", "Choose exactly one of --pr, --commit, --figma, --deploy, or --dev-check.", 2);
40
+ const flag = selected[0];
41
+ const value = requireFlag(flags, flag);
42
+ return { kind: flag === "figma" ? "figma_frame" : flag.replace("-", "_"), ...(URL.canParse(value) ? { url: value } : { ref: value }), ...via };
43
+ }
44
+
45
+ export async function laneCommand(action, args, flags, { request = hubRequest, config = readConfig, gitContext = inferGitContext } = {}) {
46
+ if (action === "list") {
47
+ const c = flags.mine ? await config() : null;
48
+ const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
49
+ return { payload, human: (p) => p.data.map(laneLine).join("\n") || "No lanes." };
50
+ }
51
+ if (action === "start") {
52
+ const key = need(args[0], "lane start needs <key>");
53
+ const c = await config();
54
+ const context = await gitContext();
55
+ const agent = (await request("GET", `/api/v1/agents/${encode(c.agentId)}`)).data;
56
+ if (!agent.machineId) throw new CliError("MACHINE_REQUIRED", "The current agent is not assigned to a machine.", 1);
57
+ let issueIds = [];
58
+ if (flags.issue) issueIds = [String(flags.issue)].flatMap((x) => x.split(",")).map(String);
59
+ issueIds = await Promise.all(issueIds.map(async (ref) => (await request("GET", `/api/v1/issues/${encode(ref)}`)).data.id));
60
+ await request("POST", "/api/v1/lanes", {
61
+ key, title: requireFlag(flags, "title"), kind: requireFlag(flags, "kind"), side: requireFlag(flags, "side"),
62
+ repos: [flags.repo ?? context.repo], agentId: c.agentId, machineId: agent.machineId,
63
+ branch: flags.branch ?? context.branch, worktree: flags.worktree ?? context.worktree, issueIds,
64
+ ...(flags["step-limit"] ? { stepLimitSeconds: Number(flags["step-limit"]) } : {}), ...via,
65
+ });
66
+ const payload = await request("POST", `/api/v1/lanes/${encode(key)}/start`, via);
67
+ return { payload, human: (p) => `Started ${laneLine(p.data)}` };
68
+ }
69
+ const ref = need(args[0], `lane ${action} needs <key>`);
70
+ const id = ref;
71
+ if (action === "show") {
72
+ const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
73
+ return { payload, human: (p) => detailText(p.data) };
74
+ }
75
+ if (action === "step") {
76
+ const selected = ["start", "done", "block"].filter((x) => flags[x]);
77
+ if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, or --block.", 2);
78
+ const side = requireFlag(flags, "side");
79
+ const stepRef = need(flags[selected[0]] === true ? args[1] : flags[selected[0]], "lane step needs <key> --side <side> --start|--done|--block <step>");
80
+ const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`)).data;
81
+ const step = lane.steps.find((x) => x.side === side && (x.id === stepRef || x.stepKey === stepRef));
82
+ if (!step) throw new CliError("STEP_NOT_FOUND", `No ${side} step ${stepRef} on ${ref}.`, 1);
83
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/steps/${encode(step.id)}/${selected[0]}`, via);
84
+ return { payload, human: `${ref}: ${side}/${step.stepKey} ${selected[0]}` };
85
+ }
86
+ if (action === "evidence") {
87
+ const body = evidence(flags);
88
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/evidence`, body);
89
+ return { payload, human: `${ref}: recorded ${body.kind} evidence.` };
90
+ }
91
+ if (action === "heartbeat") {
92
+ return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/heartbeat`, { ...(flags.note !== undefined ? { note: flags.note } : {}), ...via }), human: `${ref}: heartbeat recorded.` };
93
+ }
94
+ if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
95
+ if (action === "review") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/review`, via), human: `${ref}: review recorded.` };
96
+ if (action === "verify") {
97
+ const value = requireFlag(flags, "dev-check");
98
+ return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/verify`, { ...(URL.canParse(value) ? { url: value } : { ref: value }), ...via }), human: `${ref}: verified on dev.` };
99
+ }
100
+ if (["prioritize", "deprioritize"].includes(action)) {
101
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/${action}`, { reason: requireFlag(flags, "reason"), ...via });
102
+ return { payload, human: `${ref}: ${action}d.` };
103
+ }
104
+ if (action === "stop") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/stop`, { reason: requireFlag(flags, "reason"), ...via }), human: `${ref}: stopped.` };
105
+ throw new CliError("USAGE", `Unknown lane action: ${action}`, 2);
106
+ }
107
+
108
+ export async function nextCommand(ref, flags, { request = hubRequest } = {}) {
109
+ const lane = (await request("GET", `/api/v1/lanes/${encode(ref)}`)).data;
110
+ const pending = lane.steps.filter((x) => ["waiting", "current", "blocked"].includes(x.state)).sort((a, b) => a.position - b.position);
111
+ const steps = ["backend", "frontend"].map((side) => pending.find((x) => x.side === side)).filter(Boolean);
112
+ const data = steps.map((step) => ({ side: step.side, step: step.stepKey, label: step.label, state: step.state, command: `nodii-dev lane step ${lane.key} --side ${step.side} --${step.state === "current" ? "done" : "start"} ${step.stepKey}` }));
113
+ return { payload: { data }, human: data.length ? data.map((x) => `${x.side}: ${x.label}\n ${x.command}`).join("\n") : `All workflow steps are complete. Run: nodii-dev lane land ${lane.key}` };
114
+ }
115
+
116
+ export async function builtCommand(action, args, flags, { request = hubRequest } = {}) {
117
+ if (action === "list") {
118
+ const payload = await request("GET", `/api/v1/built${query({ cursor: flags.cursor, limit: flags.limit, includeAcknowledged: flags["include-acknowledged"] })}`);
119
+ return { payload, human: (p) => p.data.map(laneLine).join("\n") || "No built lanes awaiting acknowledgement." };
120
+ }
121
+ if (action === "ack") {
122
+ if (!args.length) throw new CliError("USAGE", "built ack needs one or more lane keys", 2);
123
+ const results = await Promise.all(args.map((ref) => request("POST", `/api/v1/built/${encode(ref)}/acknowledge`, { ...(flags.note !== undefined ? { note: flags.note } : {}), ...via })));
124
+ return { payload: args.length === 1 ? results[0] : { data: results.map((x) => x.data) }, human: `Acknowledged ${args.join(", ")}.` };
125
+ }
126
+ throw new CliError("USAGE", `Unknown built action: ${action}`, 2);
127
+ }
128
+
129
+ export async function machineCommand(action, args, flags, { request = hubRequest } = {}) {
130
+ if (action === "list") return { payload: await request("GET", "/api/v1/machines"), human: (p) => p.data.map((x) => `${x.id}\t${x.name}\tcoordinator=${x.coordinatorAgentId}`).join("\n") || "No machines." };
131
+ const id = need(args[0], `machine ${action} needs <machine>`);
132
+ if (action === "show") return { payload: await request("GET", `/api/v1/machines/${encode(id)}`) };
133
+ if (action === "set-coordinator") return { payload: await request("PUT", `/api/v1/machines/${encode(id)}/coordinator`, { agentId: need(args[1] ?? flags.agent, "machine set-coordinator needs <machine> <agent>") }), human: "Coordinator updated." };
134
+ if (action === "assign") return { payload: await request("POST", `/api/v1/machines/${encode(id)}/agents`, { agentId: need(args[1] ?? flags.agent, "machine assign needs <machine> <agent>"), homeRepo: requireFlag(flags, "home-repo") }), human: "Agent assigned." };
135
+ if (action === "report") return { payload: await request("POST", `/api/v1/machines/${encode(id)}/report`, { hostFacts: JSON.parse(requireFlag(flags, "host-facts")), memoryFacts: JSON.parse(requireFlag(flags, "memory-facts")), heavySlotTotal: Number(requireFlag(flags, "heavy-slot-total")) }), human: "Machine report recorded." };
136
+ throw new CliError("USAGE", `Unknown machine action: ${action}`, 2);
137
+ }
138
+
139
+ export async function peopleCommand(action, args, flags, { request = hubRequest } = {}) {
140
+ if (action === "reveal") {
141
+ const id = need(args[0], "people reveal needs <person>");
142
+ return { payload: await request("POST", `/api/v1/people/${encode(id)}/reveal-name`, { reason: requireFlag(flags, "reason") }), human: (p) => p.data.value ?? p.data.hint ?? "Name unavailable." };
143
+ }
144
+ if (action === "refresh-names") return { payload: await request("POST", "/api/v1/people/refresh-names"), human: "People names refreshed." };
145
+ throw new CliError("USAGE", `Unknown people action: ${action}`, 2);
146
+ }
147
+
148
+ export async function agentWorkflowCommand(action, args, flags, { request = hubRequest } = {}) {
149
+ const agent = need(args[0], `agent ${action} needs <agent>`);
150
+ if (action === "lane-limit") return { payload: await request("PUT", `/api/v1/agents/${encode(agent)}/lane-limit`, { laneLimit: Number(need(args[1], "agent lane-limit needs <agent> <n>")) }), human: "Lane limit updated." };
151
+ if (action === "grant") return { payload: await request("POST", `/api/v1/agents/${encode(agent)}/repo-grants`, { repo: need(args[1], `agent ${action} needs <agent> <repo>`) }), human: "Repository grant added." };
152
+ if (action === "revoke") {
153
+ const repo = need(args[1], "agent revoke needs <agent> <repo>");
154
+ const grants = (await request("GET", `/api/v1/agents/${encode(agent)}/repo-grants`)).data;
155
+ const grant = grants.find((x) => x.repo === repo && !x.revokedAt);
156
+ if (!grant) throw new CliError("REPO_GRANT_NOT_FOUND", `No active grant for ${repo}.`, 1);
157
+ return { payload: await request("POST", `/api/v1/agents/${encode(agent)}/repo-grants/${encode(grant.id)}/revoke`, {}), human: "Repository grant revoked." };
158
+ }
159
+ throw new CliError("USAGE", `Unknown agent action: ${action}`, 2);
160
+ }
package/dist/nodii-dev.js CHANGED
@@ -27,7 +27,8 @@ import {
27
27
  deviceLogin,
28
28
  revokeOAuth,
29
29
  } from "./oauth.js";
30
- import { postHook, preHook, settingsSnippet } from "./hooks.js";
30
+ import { heartbeatCurrentLane, postHook, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
31
+ import { setupCommand } from "./setup.js";
31
32
  import {
32
33
  ALIASES,
33
34
  GROUPS,
@@ -40,6 +41,14 @@ import {
40
41
  parseParams,
41
42
  readBody,
42
43
  } from "./api.js";
44
+ import {
45
+ agentWorkflowCommand,
46
+ builtCommand,
47
+ laneCommand,
48
+ machineCommand,
49
+ nextCommand,
50
+ peopleCommand,
51
+ } from "./lanes.js";
43
52
 
44
53
  const HELP = `nodii-dev ${VERSION}
45
54
 
@@ -54,6 +63,18 @@ Usage: nodii-dev <command> [options] [--json]
54
63
  design entries|entry|guide
55
64
  figma lock|check|log-write|submit|apply|published|abandon|release
56
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
57
78
 
58
79
  api list [--tag TAG]
59
80
  api <operationId> [--param k=v] [--body @file|-|JSON]
@@ -272,6 +293,14 @@ async function main(argv = process.argv.slice(2)) {
272
293
  );
273
294
  return emit({ data: { ...c, agent: remote.data } }, json);
274
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));
275
304
  if (command === "state" && action === "diff") {
276
305
  const [health, services, decisions, docs, requests, drift] = await Promise.all([
277
306
  hubRequest("GET", "/api/v1/health"), hubRequest("GET", "/api/v1/services"),
@@ -307,6 +336,8 @@ async function main(argv = process.argv.slice(2)) {
307
336
  (p) => p.data.map((x) => `${x.serviceId}/${x.docKey}\t${x.claimedByAgentId}`).join("\n") || "No active claims.");
308
337
  }
309
338
  if (command === "agent") {
339
+ if (["lane-limit", "grant", "revoke"].includes(action))
340
+ return emitCommand(await agentWorkflowCommand(action, rest, f));
310
341
  const c = await readConfig();
311
342
  if (action === "register") {
312
343
  const result = await hubRequest("POST", c.authProvider === "oauth" ? "/api/v1/agents/register" : "/api/v1/agents", {
@@ -529,7 +560,16 @@ async function main(argv = process.argv.slice(2)) {
529
560
  );
530
561
  }
531
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
+ }
532
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
+ }
533
573
  if (action === "claude-figma-post") {
534
574
  try {
535
575
  return emit(await postHook(payload), json, "logged");
package/dist/setup.js ADDED
@@ -0,0 +1,114 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { CliError, hubRequest } from "./core.js";
4
+
5
+ export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
6
+ const START = "<!-- nodii-dev build workflow:start -->";
7
+ const END = "<!-- nodii-dev build workflow:end -->";
8
+
9
+ export const workflowSkill = `---
10
+ name: nodii-build-workflow
11
+ description: Drive every Nodii build lane through nodii-dev from start to verified-on-dev and Built acknowledgement.
12
+ ---
13
+
14
+ # Nodii build workflow
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.
17
+
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
+ 2. Ask \`nodii-dev next KEY\`; start, complete, or block the reported API step with \`nodii-dev lane step\`.
20
+ 3. Record durable evidence with \`nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF\` and heartbeat long-running work with \`nodii-dev lane heartbeat KEY --note "..."\` (notes are at most 500 characters).
21
+ 4. Record the review gate with \`nodii-dev lane review KEY\`. After merge, run \`nodii-dev lane land KEY\`. Done still means deployed and checked live on dev as the right user: \`nodii-dev lane verify KEY --dev-check REF\`.
22
+ 5. Use \`nodii-dev lane prioritize|deprioritize KEY --reason "..."\` when priority changes; the reason is required. The lane stays on Built until a staff login runs \`nodii-dev built ack KEY\`. Never treat acknowledgement as testing.
23
+
24
+ ## Steps
25
+
26
+ - Dev fix, backend: identify/root cause → build and local test → deploy without CI → dev check.
27
+ - Dev fix, frontend: approved Figma (unless the rulebook exception applies) → components → adopt and local test → deploy without CI → dev check.
28
+ - Dev fix, both: run both API step tracks; neither side may be skipped.
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.
32
+
33
+ ## Gates and landing
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.
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.
38
+ `;
39
+
40
+ export const managedBlock = `${START}
41
+ ## Nodii build workflow
42
+
43
+ Follow [the nodii build workflow skill](${SKILL_PATH}) for all build work. Drive lane state, evidence, heartbeats, landing, dev verification, and Built acknowledgement through \`nodii-dev\`.
44
+ ${END}`;
45
+
46
+ export const workflowHooks = () => ({
47
+ SessionStart: [{ hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] }],
48
+ Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook lane-heartbeat" }] }],
49
+ PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "nodii-dev hook workflow-post" }] }],
50
+ });
51
+
52
+ const stable = (x) => JSON.stringify(x);
53
+ function mergeHookList(current = [], additions = []) {
54
+ const out = [...current];
55
+ for (const item of additions) if (!out.some((x) => stable(x) === stable(item))) out.push(item);
56
+ return out;
57
+ }
58
+
59
+ export function mergeSettings(current = {}) {
60
+ const hooks = { ...(current.hooks ?? {}) };
61
+ for (const [event, entries] of Object.entries(workflowHooks())) hooks[event] = mergeHookList(hooks[event], entries);
62
+ return { ...current, hooks };
63
+ }
64
+
65
+ export function updateMarkedDocument(text = "") {
66
+ const block = new RegExp(`${START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
67
+ if (block.test(text)) return `${text.replace(block, managedBlock).trimEnd()}\n`;
68
+ return `${text.trimEnd()}${text.trim() ? "\n\n" : ""}${managedBlock}\n`;
69
+ }
70
+
71
+ async function optional(path) {
72
+ try { return await readFile(path, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
73
+ }
74
+
75
+ export async function setupPlan(root = process.cwd()) {
76
+ const settingsPath = join(root, ".claude/settings.json");
77
+ const existingSettings = await optional(settingsPath);
78
+ let parsed = {};
79
+ try { parsed = existingSettings ? JSON.parse(existingSettings) : {}; }
80
+ catch { throw new CliError("INVALID_SETTINGS", `${settingsPath} is not valid JSON; fix it before setup.`, 2); }
81
+ const claude = join(root, "CLAUDE.md");
82
+ const agents = join(root, "AGENTS.md");
83
+ const documentPath = (await optional(claude)) !== null ? claude : (await optional(agents)) !== null ? agents : claude;
84
+ const desired = new Map([
85
+ [join(root, SKILL_PATH), workflowSkill],
86
+ [settingsPath, `${JSON.stringify(mergeSettings(parsed), null, 2)}\n`],
87
+ [documentPath, updateMarkedDocument((await optional(documentPath)) ?? "")],
88
+ ]);
89
+ const changes = [];
90
+ for (const [path, content] of desired) {
91
+ const before = await optional(path);
92
+ if (before !== content) changes.push({ path, relative: path.slice(resolve(root).length + 1), before, content });
93
+ }
94
+ return changes;
95
+ }
96
+
97
+ export async function setupCommand(flags, { root = process.cwd(), authCheck = () => hubRequest("GET", "/api/v1/whoami") } = {}) {
98
+ const changes = await setupPlan(root);
99
+ let authenticated = true;
100
+ try { await authCheck(); } catch { authenticated = false; }
101
+ if (flags.check) {
102
+ if (changes.length) throw new CliError("SETUP_DRIFT", `Workflow harness drift: ${changes.map((x) => x.relative).join(", ")}`, 1);
103
+ return { payload: { ok: true, changed: [], authenticated }, human: authenticated ? "Workflow harness is current." : "Workflow harness is current. Not logged in; run `nodii-dev login`." };
104
+ }
105
+ if (!flags.yes) {
106
+ const preview = changes.length ? changes.map((x) => `${x.before === null ? "+ create" : "~ update"} ${x.relative}`).join("\n") : "No changes.";
107
+ return { payload: { dryRun: true, changed: changes.map((x) => x.relative), authenticated }, human: `${preview}\n${authenticated ? "Authentication: ready." : "Authentication: not logged in; run `nodii-dev login`."}\nRun \`nodii-dev setup --yes\` to apply.` };
108
+ }
109
+ for (const change of changes) {
110
+ await mkdir(dirname(change.path), { recursive: true });
111
+ await writeFile(change.path, change.content);
112
+ }
113
+ return { payload: { applied: true, changed: changes.map((x) => x.relative), authenticated }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Agent CLI for the Nodii development hub",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,8 @@
16
16
  },
17
17
  "scripts": {
18
18
  "build": "node build.mjs",
19
+ "lint": "node --check src/*.js && node --check test/*.test.js",
20
+ "format": "git diff --check",
19
21
  "test": "node --test test/*.test.js",
20
22
  "pack:smoke": "node scripts/pack-smoke.mjs",
21
23
  "openapi:snapshot": "curl -sf https://planning.dev.nucleus-cloud.in/openapi.json -o test/fixtures/devops-openapi.snapshot.json"