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