@nodii/dev 0.6.28 → 0.6.30
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 +74 -1
- package/dist/api.js +4 -1
- package/dist/cli.js +49 -5
- package/dist/core.js +38 -15
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +19 -2
- package/dist/freeze.js +328 -0
- package/dist/gather.js +241 -0
- package/dist/hooks.js +25 -25
- package/dist/lanes.js +224 -17
- package/dist/nodii-dev.js +12 -2
- package/dist/runtime-launcher.js +270 -0
- package/dist/safety.js +19 -0
- package/dist/setup.js +25 -8
- package/dist/starting.js +435 -0
- package/dist/upload.js +11 -1
- package/dist/watchdog.js +308 -0
- package/package.json +3 -2
package/dist/setup.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { CliError, checkLogin } from "./core.js";
|
|
4
4
|
import { codexHooksFromClaude } from "./harness.js";
|
|
5
5
|
import { regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
|
|
6
|
+
import { PICKER_MODEL, setupOllama } from "./gather.js";
|
|
6
7
|
|
|
7
8
|
export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
|
|
8
9
|
const START = "<!-- nodii-dev build workflow:start -->";
|
|
@@ -17,8 +18,8 @@ description: Drive every Nodii build lane through nodii-dev from start to verifi
|
|
|
17
18
|
|
|
18
19
|
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.
|
|
19
20
|
|
|
20
|
-
1.
|
|
21
|
-
2.
|
|
21
|
+
1. The machine coordinator runs \`nodii-dev coordinator start --machine NAME\`, then \`nodii-dev lane launch KEY\` with the issue (whose title is the objective), done check, build kind/side, repeated repository/branch pairs, runtime profile/model and subagent quota. A launched agent begins with \`nodii-dev lane gather\` from its worktree.
|
|
22
|
+
2. Re-run \`nodii-dev lane gather\` for the current steps and rules; start, complete, or block the reported API step with \`nodii-dev lane step\`. After an authoritative \`lane amend\`, every run is blocked until its own \`lane gather --delta\` acknowledgement.
|
|
22
23
|
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).
|
|
23
24
|
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\`.
|
|
24
25
|
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.
|
|
@@ -118,21 +119,37 @@ export async function setupPlan(root = process.cwd()) {
|
|
|
118
119
|
return changes;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin } = {}) {
|
|
122
|
+
export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin, pickerSetup = setupOllama } = {}) {
|
|
123
|
+
if (flags.yes && flags.check) throw new CliError("USAGE", "setup accepts either --yes or --check, not both.", 2);
|
|
122
124
|
const changes = await setupPlan(root);
|
|
123
125
|
let authenticated = true;
|
|
124
126
|
try { await authCheck(); } catch { authenticated = false; }
|
|
127
|
+
let pickerReady = true;
|
|
128
|
+
let pickerStatus;
|
|
129
|
+
try { pickerStatus = await pickerSetup({ check: !flags.yes }); }
|
|
130
|
+
catch (error) {
|
|
131
|
+
pickerReady = false;
|
|
132
|
+
pickerStatus = { code: error?.code ?? "PICKER_UNAVAILABLE", message: error?.message ?? String(error) };
|
|
133
|
+
}
|
|
125
134
|
if (flags.check) {
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
const drift = [
|
|
136
|
+
...(changes.length ? [`Workflow harness drift: ${changes.map((x) => x.relative).join(", ")}`] : []),
|
|
137
|
+
...(!pickerReady ? [`Gather picker: ${pickerStatus.message}`] : []),
|
|
138
|
+
];
|
|
139
|
+
if (drift.length) throw new CliError("SETUP_DRIFT", drift.join("; "), 1);
|
|
140
|
+
return { payload: { ok: true, changed: [], authenticated, picker: pickerStatus }, human: authenticated ? "Workflow harness and Gather picker are current." : "Workflow harness and Gather picker are current. Not logged in; run `nodii-dev login`." };
|
|
128
141
|
}
|
|
129
142
|
if (!flags.yes) {
|
|
130
|
-
const preview =
|
|
131
|
-
|
|
143
|
+
const preview = [
|
|
144
|
+
...(changes.length ? changes.map((x) => `${x.before === null ? "+ create" : "~ update"} ${x.relative}`) : ["No workflow changes."]),
|
|
145
|
+
pickerReady ? `Gather picker: ${PICKER_MODEL} is ready.` : `+ install Ollama and pull ${PICKER_MODEL}`,
|
|
146
|
+
].join("\n");
|
|
147
|
+
return { payload: { dryRun: true, changed: [...changes.map((x) => x.relative), ...(!pickerReady ? [`ollama:${PICKER_MODEL}`] : [])], authenticated, picker: pickerStatus }, human: `${preview}\n${authenticated ? "Authentication: ready." : "Authentication: not logged in; run `nodii-dev login`."}\nRun \`nodii-dev setup --yes\` to apply.` };
|
|
132
148
|
}
|
|
149
|
+
if (!pickerReady) throw new CliError(pickerStatus.code, pickerStatus.message, 1);
|
|
133
150
|
for (const change of changes) {
|
|
134
151
|
await mkdir(dirname(change.path), { recursive: true });
|
|
135
152
|
await writeFile(change.path, change.content);
|
|
136
153
|
}
|
|
137
|
-
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`."}` };
|
|
154
|
+
return { payload: { applied: true, changed: [...changes.map((x) => x.relative), ...(pickerStatus.changed ?? []).map((x) => `ollama:${x}`)], authenticated, picker: pickerStatus }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\nGather picker: ${PICKER_MODEL} ready on Ollama ${pickerStatus.version}.\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
|
|
138
155
|
}
|
package/dist/starting.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, rmdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { CliError, configPath, devRequest, encode, query, readConfig, requireFlag } from "./core.js";
|
|
7
|
+
import { GATHER_TIMEOUT_MS } from "./gather.js";
|
|
8
|
+
import { laneCommand, nextCommand } from "./lanes.js";
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCallback);
|
|
11
|
+
const via = { via: "nodii-dev" };
|
|
12
|
+
const values = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
13
|
+
|
|
14
|
+
const withSignal = (promise, signal) => {
|
|
15
|
+
if (!signal) return promise;
|
|
16
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
17
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
18
|
+
const abort = () => rejectPromise(signal.reason ?? new Error("aborted"));
|
|
19
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
20
|
+
Promise.resolve(promise).then(
|
|
21
|
+
(value) => { signal.removeEventListener("abort", abort); resolvePromise(value); },
|
|
22
|
+
(error) => { signal.removeEventListener("abort", abort); rejectPromise(error); },
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function pairs(value, flag) {
|
|
28
|
+
const out = new Map();
|
|
29
|
+
for (const raw of values(value)) {
|
|
30
|
+
const at = String(raw).indexOf("=");
|
|
31
|
+
if (at < 1 || at === String(raw).length - 1)
|
|
32
|
+
throw new CliError("USAGE", `--${flag} must be NAME=VALUE and may be repeated.`, 2);
|
|
33
|
+
const key = String(raw).slice(0, at).trim();
|
|
34
|
+
const item = String(raw).slice(at + 1).trim();
|
|
35
|
+
if (out.has(key)) throw new CliError("USAGE", `--${flag} repeats ${key}.`, 2);
|
|
36
|
+
out.set(key, item);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function groupedPairs(value, flag) {
|
|
42
|
+
const out = new Map();
|
|
43
|
+
for (const raw of values(value)) {
|
|
44
|
+
const at = String(raw).indexOf("=");
|
|
45
|
+
if (at < 1 || at === String(raw).length - 1)
|
|
46
|
+
throw new CliError("USAGE", `--${flag} must be NAME=VALUE and may be repeated.`, 2);
|
|
47
|
+
const key = String(raw).slice(0, at).trim();
|
|
48
|
+
const item = String(raw).slice(at + 1).trim();
|
|
49
|
+
out.set(key, [...(out.get(key) ?? []), item]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function launchRepositories(key, flags) {
|
|
55
|
+
const sources = pairs(flags.repo, "repo");
|
|
56
|
+
const branches = pairs(flags.branch, "branch");
|
|
57
|
+
const bases = pairs(flags.base, "base");
|
|
58
|
+
const worktrees = pairs(flags.worktree, "worktree");
|
|
59
|
+
const owned = groupedPairs(flags["owned-path"], "owned-path");
|
|
60
|
+
if (!sources.size) throw new CliError("USAGE", "lane launch needs at least one --repo NAME=/absolute/source.", 2);
|
|
61
|
+
for (const collection of [branches, bases, worktrees, owned])
|
|
62
|
+
for (const name of collection.keys())
|
|
63
|
+
if (!sources.has(name)) throw new CliError("USAGE", `Repository options name unknown repo ${name}.`, 2);
|
|
64
|
+
return [...sources].map(([name, source]) => {
|
|
65
|
+
if (!isAbsolute(source)) throw new CliError("USAGE", `--repo ${name} needs an absolute source path.`, 2);
|
|
66
|
+
const branch = branches.get(name);
|
|
67
|
+
if (!branch) throw new CliError("USAGE", `Missing --branch ${name}=BRANCH.`, 2);
|
|
68
|
+
const worktree = worktrees.get(name) ?? join(source, ".claude", "worktrees", key);
|
|
69
|
+
if (!isAbsolute(worktree)) throw new CliError("USAGE", `--worktree ${name} needs an absolute path.`, 2);
|
|
70
|
+
return {
|
|
71
|
+
repo: name,
|
|
72
|
+
source: resolve(source),
|
|
73
|
+
branch,
|
|
74
|
+
worktree: resolve(worktree),
|
|
75
|
+
baseRef: bases.get(name) ?? "origin/main",
|
|
76
|
+
ownedPaths: owned.get(name) ?? [],
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function figmaScopes(value) {
|
|
82
|
+
return values(value).map((raw) => {
|
|
83
|
+
const scope = String(raw).trim();
|
|
84
|
+
const at = scope.indexOf("=");
|
|
85
|
+
const fileKey = (at < 0 ? scope : scope.slice(0, at)).trim();
|
|
86
|
+
const componentSets = at < 0 ? [] : scope.slice(at + 1).split(",").map((item) => item.trim()).filter(Boolean);
|
|
87
|
+
if (!fileKey || (at >= 0 && componentSets.length === 0))
|
|
88
|
+
throw new CliError("USAGE", "--figma must be FILE_KEY or FILE_KEY=COMPONENT_SET[,COMPONENT_SET] and may be repeated.", 2);
|
|
89
|
+
return {
|
|
90
|
+
fileKey,
|
|
91
|
+
componentSets,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stateFile = (machine) => join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.json`);
|
|
97
|
+
export function laneConfigPath(key, current = configPath()) {
|
|
98
|
+
const configDir = dirname(current);
|
|
99
|
+
const agentsDir = basename(configDir) === "agents" ? configDir : join(configDir, "agents");
|
|
100
|
+
const lane = String(key).toLowerCase().replaceAll(/[^a-z0-9_-]/g, "-");
|
|
101
|
+
return join(agentsDir, `lane-${lane}.json`);
|
|
102
|
+
}
|
|
103
|
+
async function saveJson(file, value) {
|
|
104
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
105
|
+
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
106
|
+
}
|
|
107
|
+
async function loadJson(file) {
|
|
108
|
+
try { return JSON.parse(await readFile(file, "utf8")); }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error?.code === "ENOENT") return null;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function coordinatorRuntimePid(run = execFile) {
|
|
116
|
+
let pid = process.ppid;
|
|
117
|
+
for (let depth = 0; depth < 8 && Number.isInteger(pid) && pid > 1; depth += 1) {
|
|
118
|
+
const { stdout } = await run("ps", ["-p", String(pid), "-o", "ppid=", "-o", "comm="], { encoding: "utf8" });
|
|
119
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(stdout);
|
|
120
|
+
if (!match) break;
|
|
121
|
+
if (/(?:^|\/)(?:codex|claude)(?:$|[-_.])/i.test(match[2])) return pid;
|
|
122
|
+
pid = Number(match[1]);
|
|
123
|
+
}
|
|
124
|
+
throw new CliError("COORDINATOR_PROCESS_NOT_FOUND", "Could not identify the parent Claude or Codex coordinator process.", 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function watchCoordinator(runId) {
|
|
128
|
+
const { recordWatchedRun } = await import("./watchdog.js");
|
|
129
|
+
return recordWatchedRun({
|
|
130
|
+
runId,
|
|
131
|
+
pid: await coordinatorRuntimePid(),
|
|
132
|
+
configPath: configPath(),
|
|
133
|
+
worktree: process.cwd(),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function coordinatorCommand(action, flags, { request = devRequest, config = readConfig, save = saveJson, watch = watchCoordinator } = {}) {
|
|
138
|
+
if (action !== "start") throw new CliError("USAGE", `Unknown coordinator action: ${action}`, 2);
|
|
139
|
+
const machine = requireFlag(flags, "machine");
|
|
140
|
+
const payload = await request("POST", "/api/v1/coordinator-runs/start", {
|
|
141
|
+
machine,
|
|
142
|
+
expectedHeartbeatSeconds: flags["heartbeat-seconds"] ? Number(flags["heartbeat-seconds"]) : 60,
|
|
143
|
+
...via,
|
|
144
|
+
});
|
|
145
|
+
const c = await config();
|
|
146
|
+
try {
|
|
147
|
+
await watch(payload.data.run.id);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
try {
|
|
150
|
+
await request("POST", `/api/v1/runs/${encode(payload.data.run.id)}/process-exit`, {
|
|
151
|
+
reason: "coordinator watcher registration failed",
|
|
152
|
+
deathFingerprint: "coordinator-watch-registration",
|
|
153
|
+
logTail: String(error.message).slice(0, 50_000),
|
|
154
|
+
});
|
|
155
|
+
} catch {}
|
|
156
|
+
throw new CliError("COORDINATOR_WATCH_FAILED", `Coordinator run ${payload.data.run.id} was registered, but its session could not be watched: ${error.message}`, 1);
|
|
157
|
+
}
|
|
158
|
+
await save(stateFile(machine), {
|
|
159
|
+
machine,
|
|
160
|
+
machineId: payload.data.machine.id,
|
|
161
|
+
runId: payload.data.run.id,
|
|
162
|
+
agentId: c.agentId,
|
|
163
|
+
startedAt: payload.data.run.startedAt,
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
payload,
|
|
167
|
+
human: (p) => [
|
|
168
|
+
`Coordinator ${p.data.run.id} is watching ${p.data.machine.name}.`,
|
|
169
|
+
`running lanes: ${p.data.runningLanes.length}`,
|
|
170
|
+
`orphans: ${p.data.orphanedRuns.length}`,
|
|
171
|
+
`unclaimed results: ${p.data.unclaimedResults.length}`,
|
|
172
|
+
...(p.data.takeoverOfRunId ? [`takeover recorded from ${p.data.takeoverOfRunId}`] : []),
|
|
173
|
+
].join("\n"),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function createWorktrees(repositories, run = execFile) {
|
|
178
|
+
for (const repository of repositories) {
|
|
179
|
+
try {
|
|
180
|
+
const { stdout } = await run("git", ["-C", repository.worktree, "branch", "--show-current"], { encoding: "utf8" });
|
|
181
|
+
if (stdout.trim() !== repository.branch)
|
|
182
|
+
throw new CliError("WORKTREE_CONFLICT", `${repository.worktree} exists on ${stdout.trim() || "a detached HEAD"}, not ${repository.branch}.`, 1);
|
|
183
|
+
continue;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error instanceof CliError) throw error;
|
|
186
|
+
}
|
|
187
|
+
await run("git", ["-C", repository.source, "fetch", "origin"]);
|
|
188
|
+
let branchExists = false;
|
|
189
|
+
try {
|
|
190
|
+
await run("git", ["-C", repository.source, "show-ref", "--verify", `refs/heads/${repository.branch}`]);
|
|
191
|
+
branchExists = true;
|
|
192
|
+
} catch {}
|
|
193
|
+
await run("git", ["-C", repository.source, "worktree", "add", repository.worktree, ...(branchExists ? [repository.branch] : ["-b", repository.branch, repository.baseRef])]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function writeLaneMarkers(repositories, marker, run = execFile, save = saveJson) {
|
|
198
|
+
for (const repository of repositories) {
|
|
199
|
+
const { stdout } = await run("git", ["-C", repository.worktree, "rev-parse", "--git-dir"], { encoding: "utf8" });
|
|
200
|
+
const gitDir = isAbsolute(stdout.trim()) ? stdout.trim() : resolve(repository.worktree, stdout.trim());
|
|
201
|
+
await save(join(gitDir, "nodii-lane.json"), marker);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function primaryHead(repositories, run = execFile) {
|
|
206
|
+
const { stdout } = await run("git", ["-C", repositories[0].worktree, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
207
|
+
return stdout.trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function defaultLaunchRuntime(options) {
|
|
211
|
+
const module = await import("./runtime-launcher.js");
|
|
212
|
+
return module.launchRuntime(options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function verifyFigmaSeat({ env = process.env, fetchImpl = fetch, rateLimit = figmaRateLimit } = {}) {
|
|
216
|
+
const token = env.FIGMA_PAT || env.FIGMA_TOKEN;
|
|
217
|
+
if (!token) throw new CliError("FIGMA_TOKEN_REQUIRED", "--figma preflight needs FIGMA_PAT for the assigned Figma account.", 2);
|
|
218
|
+
let response;
|
|
219
|
+
try {
|
|
220
|
+
response = await rateLimit(() => fetchImpl("https://api.figma.com/v1/me", {
|
|
221
|
+
headers: { "X-Figma-Token": token },
|
|
222
|
+
signal: AbortSignal.timeout(10_000),
|
|
223
|
+
}));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new CliError("FIGMA_SEAT_UNAVAILABLE", `Figma seat preflight could not complete: ${error.message}`, 1);
|
|
226
|
+
}
|
|
227
|
+
if (!response.ok) throw new CliError("FIGMA_SEAT_UNAVAILABLE", `Figma seat preflight returned HTTP ${response.status}.`, 1);
|
|
228
|
+
const account = await response.json();
|
|
229
|
+
if (!account?.id) throw new CliError("FIGMA_SEAT_UNAVAILABLE", "Figma did not identify the assigned account.", 1);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Serialize the machine's shared Figma token and keep call starts 10s apart. */
|
|
234
|
+
export async function figmaRateLimit(task, {
|
|
235
|
+
root = basename(dirname(configPath())) === "agents" ? dirname(dirname(configPath())) : dirname(configPath()),
|
|
236
|
+
sleep = delay,
|
|
237
|
+
now = Date.now,
|
|
238
|
+
fs = { mkdir, readFile, rmdir, writeFile },
|
|
239
|
+
} = {}) {
|
|
240
|
+
const lock = join(root, "figma-rest.lock");
|
|
241
|
+
const stamp = join(root, "figma-rest-last-call");
|
|
242
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
243
|
+
const deadline = now() + 30_000;
|
|
244
|
+
for (;;) {
|
|
245
|
+
try {
|
|
246
|
+
await fs.mkdir(lock, { mode: 0o700 });
|
|
247
|
+
break;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error?.code !== "EEXIST") throw error;
|
|
250
|
+
if (now() >= deadline) throw new CliError("FIGMA_RATE_LOCKED", "Another Figma REST call has held the machine lock for 30 seconds.", 1);
|
|
251
|
+
await sleep(100);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
let previous = 0;
|
|
256
|
+
try { previous = Number(await fs.readFile(stamp, "utf8")) || 0; }
|
|
257
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
258
|
+
const wait = Math.max(0, 10_000 - (now() - previous));
|
|
259
|
+
if (wait) await sleep(wait);
|
|
260
|
+
await fs.writeFile(stamp, `${now()}\n`, { mode: 0o600 });
|
|
261
|
+
return await task();
|
|
262
|
+
} finally {
|
|
263
|
+
await fs.rmdir(lock);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function launchLaneCommand(key, flags, {
|
|
268
|
+
request = devRequest,
|
|
269
|
+
config = readConfig,
|
|
270
|
+
state = loadJson,
|
|
271
|
+
worktrees = createWorktrees,
|
|
272
|
+
markers = writeLaneMarkers,
|
|
273
|
+
head = primaryHead,
|
|
274
|
+
launcher = defaultLaunchRuntime,
|
|
275
|
+
figmaSeat = verifyFigmaSeat,
|
|
276
|
+
laneConfig = loadJson,
|
|
277
|
+
identityPath = laneConfigPath,
|
|
278
|
+
} = {}) {
|
|
279
|
+
if (!key) throw new CliError("USAGE", "lane launch needs <key>.", 2);
|
|
280
|
+
const machine = requireFlag(flags, "machine");
|
|
281
|
+
const coordinator = flags["coordinator-run"]
|
|
282
|
+
? { runId: flags["coordinator-run"], machineId: requireFlag(flags, "machine-id") }
|
|
283
|
+
: await state(stateFile(machine));
|
|
284
|
+
if (!coordinator) throw new CliError("COORDINATOR_NOT_STARTED", `Run nodii-dev coordinator start --machine ${machine} first.`, 1);
|
|
285
|
+
const repositories = launchRepositories(key, flags);
|
|
286
|
+
const runtime = requireFlag(flags, "runtime");
|
|
287
|
+
if (!["claude", "codex"].includes(runtime)) throw new CliError("USAGE", "--runtime must be claude or codex.", 2);
|
|
288
|
+
const issueRefs = values(requireFlag(flags, "issue")).flatMap((item) => String(item).split(",")).filter(Boolean);
|
|
289
|
+
const issueIds = await Promise.all(issueRefs.map(async (ref) => (await request("GET", `/api/v1/issues/${encode(ref)}`)).data.id));
|
|
290
|
+
const c = await config();
|
|
291
|
+
if (coordinator.agentId && coordinator.agentId !== c.agentId)
|
|
292
|
+
throw new CliError("COORDINATOR_IDENTITY_MISMATCH", `The saved ${machine} coordinator run belongs to another Dev agent.`, 1);
|
|
293
|
+
const devConfigPath = identityPath(key);
|
|
294
|
+
const identity = await laneConfig(devConfigPath);
|
|
295
|
+
if (!identity?.agentId)
|
|
296
|
+
throw new CliError("LANE_IDENTITY_REQUIRED", `Lane ${key} needs its provisioned Dev identity at ${devConfigPath}.`, 1);
|
|
297
|
+
const scopes = figmaScopes(flags.figma);
|
|
298
|
+
const figmaSeatVerified = scopes.length ? await figmaSeat() : false;
|
|
299
|
+
const profile = requireFlag(flags, "profile");
|
|
300
|
+
if (!isAbsolute(profile)) throw new CliError("USAGE", "--profile must be an absolute runtime profile path.", 2);
|
|
301
|
+
const body = {
|
|
302
|
+
key,
|
|
303
|
+
doneCheck: requireFlag(flags, "done-check"),
|
|
304
|
+
kind: requireFlag(flags, "kind"),
|
|
305
|
+
side: requireFlag(flags, "side"),
|
|
306
|
+
issueIds,
|
|
307
|
+
agentId: identity.agentId,
|
|
308
|
+
machineId: coordinator.machineId,
|
|
309
|
+
coordinatorRunId: coordinator.runId,
|
|
310
|
+
repositories: repositories.map(({ source: _source, ...repository }) => repository),
|
|
311
|
+
runtime,
|
|
312
|
+
profile,
|
|
313
|
+
devConfigPath,
|
|
314
|
+
model: requireFlag(flags, "model"),
|
|
315
|
+
subagentQuota: Number(requireFlag(flags, "subagents")),
|
|
316
|
+
...(flags.notes ? { notes: flags.notes } : {}),
|
|
317
|
+
...(flags["narrowing-note"] ? { narrowingNote: flags["narrowing-note"] } : {}),
|
|
318
|
+
...(flags["narrowing-remainder"] ? { narrowingRemainder: flags["narrowing-remainder"] } : {}),
|
|
319
|
+
figmaScopes: scopes,
|
|
320
|
+
preflight: { ...(figmaSeatVerified ? { figmaSeatVerified: true } : {}) },
|
|
321
|
+
expectedHeartbeatSeconds: flags["heartbeat-seconds"] ? Number(flags["heartbeat-seconds"]) : 60,
|
|
322
|
+
...via,
|
|
323
|
+
};
|
|
324
|
+
if (!Number.isInteger(body.subagentQuota) || body.subagentQuota < 0 || body.subagentQuota > 100)
|
|
325
|
+
throw new CliError("USAGE", "--subagents must be an integer from 0 to 100.", 2);
|
|
326
|
+
await request("POST", "/api/v1/lanes/launch", { phase: "preflight", ...body });
|
|
327
|
+
await worktrees(repositories);
|
|
328
|
+
const lastPushedCommit = await head(repositories);
|
|
329
|
+
let payload;
|
|
330
|
+
try {
|
|
331
|
+
payload = await request("POST", "/api/v1/lanes/launch", { phase: "register", ...body, lastPushedCommit });
|
|
332
|
+
} catch (error) {
|
|
333
|
+
throw new CliError(error?.code ?? "LANE_REGISTER_FAILED", `${error?.message ?? error}. Worktrees were created and were not removed; reconcile them before retrying.`, 1);
|
|
334
|
+
}
|
|
335
|
+
await markers(repositories, { laneKey: key, laneId: payload.data.lane.id, runId: payload.data.run.id });
|
|
336
|
+
try {
|
|
337
|
+
await launcher({
|
|
338
|
+
laneKey: key,
|
|
339
|
+
devConfigPath,
|
|
340
|
+
runtime,
|
|
341
|
+
model: body.model,
|
|
342
|
+
profileHome: body.profile,
|
|
343
|
+
cwd: repositories[0].worktree,
|
|
344
|
+
addDirs: repositories.slice(1).map(({ worktree }) => worktree),
|
|
345
|
+
prompt: payload.data.brief,
|
|
346
|
+
runId: payload.data.run.id,
|
|
347
|
+
});
|
|
348
|
+
} catch (error) {
|
|
349
|
+
try {
|
|
350
|
+
await request("POST", `/api/v1/lanes/${encode(payload.data.lane.id)}/resume/${encode(payload.data.run.id)}/launch-failed`, {
|
|
351
|
+
reason: error.message,
|
|
352
|
+
...via,
|
|
353
|
+
});
|
|
354
|
+
} catch (reportError) {
|
|
355
|
+
throw new CliError("LANE_LAUNCH_FAILED", `Lane ${key} is registered but its runtime did not start (${error.message}), and Dev could not record the launch failure (${reportError.message}).`, 1);
|
|
356
|
+
}
|
|
357
|
+
throw new CliError("LANE_LAUNCH_FAILED", `Lane ${key} is registered but its runtime did not start: ${error.message}. Dev recorded it for lane resume.`, 1);
|
|
358
|
+
}
|
|
359
|
+
return { payload, human: `Launched ${key}: ${payload.data.brief}` };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function amendLaneCommand(key, flags, { request = devRequest } = {}) {
|
|
363
|
+
if (!key) throw new CliError("USAGE", "lane amend needs <key>.", 2);
|
|
364
|
+
const change = Object.fromEntries([["objective", flags.objective], ["scope", flags.scope], ["rules", flags.rules]].filter(([, value]) => value));
|
|
365
|
+
if (!Object.keys(change).length) throw new CliError("USAGE", "lane amend needs --objective, --scope, or --rules.", 2);
|
|
366
|
+
const payload = await request("POST", `/api/v1/lanes/${encode(key)}/amendments`, { change, reason: requireFlag(flags, "reason"), ...via });
|
|
367
|
+
return { payload, human: `Recorded amendment ${payload.data.version} for ${key}.` };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function currentMarker(run = execFile, load = loadJson, env = process.env) {
|
|
371
|
+
let stdout;
|
|
372
|
+
try {
|
|
373
|
+
({ stdout } = await run("git", ["rev-parse", "--git-dir"], { encoding: "utf8" }));
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const gitDir = isAbsolute(stdout.trim()) ? stdout.trim() : resolve(process.cwd(), stdout.trim());
|
|
378
|
+
const marker = await load(join(gitDir, "nodii-lane.json"));
|
|
379
|
+
if (!marker) return null;
|
|
380
|
+
const runId = env.NODII_DEV_RUN_ID ?? marker.runId;
|
|
381
|
+
if (typeof marker.laneKey !== "string" || !marker.laneKey.trim() || typeof runId !== "string" || !runId.trim())
|
|
382
|
+
throw new CliError("LANE_MARKER_INVALID", "This worktree's Nodii lane marker is invalid; repair the launch record before running CLI commands.", 1);
|
|
383
|
+
return { ...marker, runId };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function amendmentGuard(argv, { request = devRequest, marker = currentMarker } = {}) {
|
|
387
|
+
const lane = await marker();
|
|
388
|
+
if (!lane) return null;
|
|
389
|
+
const [command, action] = argv.filter((token) => !token.startsWith("--"));
|
|
390
|
+
if ((command === "lane" && action === "gather" && argv.includes("--delta")) || (command === "lane" && action === "amend")) return lane;
|
|
391
|
+
const pending = await request("GET", `/api/v1/lanes/${encode(lane.laneKey)}/amendments${query({ runId: lane.runId })}`);
|
|
392
|
+
if (!pending.data.length) return lane;
|
|
393
|
+
const detail = pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n");
|
|
394
|
+
throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run nodii-dev lane gather ${lane.laneKey} --delta.`, 1, { amendments: pending.data });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const runGather = (key, flags, { request, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
|
|
398
|
+
|
|
399
|
+
export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
|
|
402
|
+
const signal = controller.signal;
|
|
403
|
+
const timedRequest = (method, path, body, options = {}) => withSignal(request(method, path, body, { ...options, signal }), signal);
|
|
404
|
+
try {
|
|
405
|
+
const lane = await withSignal(marker(), signal);
|
|
406
|
+
key ??= lane?.laneKey;
|
|
407
|
+
if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
|
|
408
|
+
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
409
|
+
if (lane && lane.laneKey !== key) throw new CliError("LANE_RUN_REQUIRED", "Delta gather for another lane cannot run inside this lane's registered worktree.", 1);
|
|
410
|
+
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
411
|
+
const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
|
|
412
|
+
if (!pending.data.length) {
|
|
413
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
414
|
+
return { ...gathered, human: `${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nNo pending amendments.` };
|
|
415
|
+
}
|
|
416
|
+
const version = Math.max(...pending.data.map((item) => item.version));
|
|
417
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
418
|
+
const acknowledgement = await withSignal(acknowledge(key, {
|
|
419
|
+
...flags,
|
|
420
|
+
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
|
421
|
+
}, { request: timedRequest }), signal);
|
|
422
|
+
const acknowledgedVersion = acknowledgement.payload?.rules?.amendmentAcknowledgement?.version;
|
|
423
|
+
if (acknowledgedVersion !== version)
|
|
424
|
+
throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
|
|
425
|
+
return {
|
|
426
|
+
payload: { ...gathered.payload, rules: acknowledgement.payload?.rules, amendments: pending.data, acknowledgedVersion },
|
|
427
|
+
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
|
|
428
|
+
};
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
|
|
431
|
+
throw error;
|
|
432
|
+
} finally {
|
|
433
|
+
clearTimeout(deadline);
|
|
434
|
+
}
|
|
435
|
+
}
|
package/dist/upload.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, relative, resolve } from "node:path";
|
|
|
8
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
9
|
import { CliError, devRequest, devTextRequest, encode, query } from "./core.js";
|
|
10
10
|
import { CHANGE_CLASSES, validateDocument } from "./docs.js";
|
|
11
|
+
import { rulesFindText } from "./gather.js";
|
|
11
12
|
|
|
12
13
|
/** Exit code when Dev is waiting on the operator to acknowledge parked items. */
|
|
13
14
|
export const EXIT_PARKED = 4;
|
|
@@ -307,6 +308,15 @@ const pad = (v, n) => String(v).padStart(n);
|
|
|
307
308
|
* core, the delivery report, and the operator's core token cap.
|
|
308
309
|
*/
|
|
309
310
|
export async function rulesCommand(action, args, flags, { request = devRequest } = {}) {
|
|
311
|
+
if (action === "find") {
|
|
312
|
+
const question = args.join(" ").trim();
|
|
313
|
+
if (!question) throw new CliError("USAGE", 'rules find needs a question, for example: nodii-dev rules find "may this route use offset paging?"', 2);
|
|
314
|
+
if (question.length > 20_000) throw new CliError("USAGE", "rules find questions are at most 20,000 characters", 2);
|
|
315
|
+
const limit = flags.limit === undefined ? 10 : Number(flags.limit);
|
|
316
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new CliError("USAGE", "rules find --limit needs a whole number from 1 to 100", 2);
|
|
317
|
+
const payload = await request("GET", `/api/v1/rules/find${query({ question, limit })}`);
|
|
318
|
+
return { payload, human: rulesFindText };
|
|
319
|
+
}
|
|
310
320
|
if (action === "core") {
|
|
311
321
|
// Dev refuses a core over the cap with CORE_OVER_CAP; that error is the output.
|
|
312
322
|
const payload = await request("GET", "/api/v1/rules/core");
|
|
@@ -337,5 +347,5 @@ export async function rulesCommand(action, args, flags, { request = devRequest }
|
|
|
337
347
|
const payload = await request("PUT", "/api/v1/rules/core/cap", { cap });
|
|
338
348
|
return { payload, human: (p) => `Core cap: ${p.data.previous} -> ${p.data.cap} tokens.` };
|
|
339
349
|
}
|
|
340
|
-
throw new CliError("USAGE", "rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS
|
|
350
|
+
throw new CliError("USAGE", 'rules find "QUESTION" [--limit N] | rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS', 2);
|
|
341
351
|
}
|