@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 +64 -1
- package/dist/api.js +5 -1
- package/dist/core.js +1 -1
- package/dist/hooks.js +23 -0
- package/dist/lanes.js +160 -0
- package/dist/nodii-dev.js +41 -1
- package/dist/setup.js +114 -0
- package/package.json +3 -1
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,
|
|
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.
|
|
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
|
+
"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"
|