@nodii/dev 0.6.35 → 0.6.37
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 +91 -10
- package/dist/api.js +96 -4
- package/dist/cli.js +87 -15
- package/dist/core.js +1 -1
- package/dist/devops-openapi.json +1 -1
- package/dist/gather.js +105 -9
- package/dist/lanes.js +63 -11
- package/dist/nodii-dev.js +13 -8
- package/dist/remote.js +281 -0
- package/dist/runs.js +41 -7
- package/dist/safety.js +112 -1
- package/dist/starting.js +24 -11
- package/dist/stdout.js +26 -0
- package/dist/upload.js +5 -1
- package/package.json +1 -1
package/dist/runs.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
2
4
|
import { basename } from "node:path";
|
|
3
5
|
import { CliError, devRequest, encode } from "./core.js";
|
|
4
6
|
|
|
@@ -36,7 +38,7 @@ function writeSafe(write, value) {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
function currentRunId(runId = process.env[RUN_ID_ENV]) {
|
|
41
|
+
export function currentRunId(runId = process.env[RUN_ID_ENV]) {
|
|
40
42
|
if (runId === undefined || runId === "") return null;
|
|
41
43
|
if (!UUID.test(runId))
|
|
42
44
|
throw new CliError(
|
|
@@ -113,7 +115,7 @@ function commandLabel(command) {
|
|
|
113
115
|
return basename(command[0] ?? "command").slice(0, 240) || "command";
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function runKind(value) {
|
|
118
|
+
export function runKind(value) {
|
|
117
119
|
const kind = value ?? "build";
|
|
118
120
|
if (!RUN_KINDS.has(kind))
|
|
119
121
|
throw new CliError(
|
|
@@ -138,7 +140,7 @@ function runEvidence(command, exitCode, signal, tail) {
|
|
|
138
140
|
return evidence;
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
async function endRun(request, runId, body, attempts = 3) {
|
|
143
|
+
export async function endRun(request, runId, body, attempts = 3) {
|
|
142
144
|
let error;
|
|
143
145
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
144
146
|
try {
|
|
@@ -152,6 +154,25 @@ async function endRun(request, runId, body, attempts = 3) {
|
|
|
152
154
|
throw error;
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// The Result artifact contract (AGENT-HARNESS-41): a run that succeeds can
|
|
158
|
+
// attach one text artifact (e.g. a benchmark table as JSON). It rides in the
|
|
159
|
+
// Result as an `artifact` evidence item, delivered whole to the waiter: the
|
|
160
|
+
// note is the content, the ref names it with its size and sha256. Dev holds at
|
|
161
|
+
// most 10,000 characters in a note, so a larger artifact fails the run rather
|
|
162
|
+
// than arriving cut.
|
|
163
|
+
export const ARTIFACT_MAX_CHARS = 10_000;
|
|
164
|
+
|
|
165
|
+
export function artifactEvidence(name, content) {
|
|
166
|
+
const text = redactRunText(content);
|
|
167
|
+
if (!text.trim()) throw new CliError("ARTIFACT_EMPTY", `Artifact ${basename(name)} is empty.`, 1);
|
|
168
|
+
if (text.length > ARTIFACT_MAX_CHARS)
|
|
169
|
+
throw new CliError("ARTIFACT_TOO_LARGE", `Artifact ${basename(name)} is ${text.length} characters; a Result carries at most ${ARTIFACT_MAX_CHARS}.`, 1);
|
|
170
|
+
const sha256 = createHash("sha256").update(text).digest("hex");
|
|
171
|
+
return { kind: "artifact", ref: `${basename(name)} · ${Buffer.byteLength(text)} bytes · sha256:${sha256}`, note: text };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const artifactFailure = (error) => `Artifact not attached: ${redactRunText(error?.code === "ENOENT" ? "the file does not exist" : error?.message ?? error)}`;
|
|
175
|
+
|
|
155
176
|
export async function superviseRun({
|
|
156
177
|
command,
|
|
157
178
|
waiterRunId = process.env[RUN_ID_ENV],
|
|
@@ -160,6 +181,8 @@ export async function superviseRun({
|
|
|
160
181
|
expectedHeartbeatSeconds = 60,
|
|
161
182
|
heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
|
|
162
183
|
tailBytes = DEFAULT_TAIL_BYTES,
|
|
184
|
+
artifact,
|
|
185
|
+
readArtifact = (path) => readFile(path, "utf8"),
|
|
163
186
|
request = devRequest,
|
|
164
187
|
spawn = nodeSpawn,
|
|
165
188
|
stdoutWrite = (value) => process.stdout.write(value),
|
|
@@ -268,12 +291,21 @@ export async function superviseRun({
|
|
|
268
291
|
outcome.signal,
|
|
269
292
|
tail,
|
|
270
293
|
);
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
let succeeded = outcome.exitCode === 0 && !outcome.signal && !spawnError;
|
|
295
|
+
let artifactError = null;
|
|
296
|
+
if (succeeded && artifact) {
|
|
297
|
+
try {
|
|
298
|
+
evidence.push(artifactEvidence(artifact, await readArtifact(artifact)));
|
|
299
|
+
} catch (error) {
|
|
300
|
+
succeeded = false;
|
|
301
|
+
artifactError = artifactFailure(error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const reason = artifactError ?? (spawnError
|
|
273
305
|
? `Command could not start: ${redactRunText(spawnError.code ?? spawnError.message)}`
|
|
274
306
|
: outcome.signal
|
|
275
307
|
? `Command ended by signal ${outcome.signal}`
|
|
276
|
-
: `Command exited with code ${outcome.exitCode ?? -1}
|
|
308
|
+
: `Command exited with code ${outcome.exitCode ?? -1}`);
|
|
277
309
|
await endRun(
|
|
278
310
|
request,
|
|
279
311
|
runId,
|
|
@@ -283,7 +315,7 @@ export async function superviseRun({
|
|
|
283
315
|
);
|
|
284
316
|
return {
|
|
285
317
|
runId,
|
|
286
|
-
exitCode: succeeded ? 0 : (outcome.exitCode
|
|
318
|
+
exitCode: succeeded ? 0 : (outcome.exitCode || 1),
|
|
287
319
|
signal: outcome.signal,
|
|
288
320
|
tail: redactRunText(tail.toString("utf8")),
|
|
289
321
|
};
|
|
@@ -302,6 +334,7 @@ export async function launchRunSupervisor(
|
|
|
302
334
|
env = process.env,
|
|
303
335
|
kind,
|
|
304
336
|
name,
|
|
337
|
+
artifact,
|
|
305
338
|
} = {},
|
|
306
339
|
) {
|
|
307
340
|
if (!entrypoint)
|
|
@@ -309,6 +342,7 @@ export async function launchRunSupervisor(
|
|
|
309
342
|
const args = [entrypoint, "__run-supervisor"];
|
|
310
343
|
if (kind) args.push("--kind", kind);
|
|
311
344
|
if (name) args.push("--name", name);
|
|
345
|
+
if (artifact) args.push("--artifact", artifact);
|
|
312
346
|
args.push("--", ...command);
|
|
313
347
|
const supervisor = spawn(process.execPath, args, {
|
|
314
348
|
detached: true,
|
package/dist/safety.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
@@ -109,7 +110,103 @@ function createsBranch(after) {
|
|
|
109
110
|
return after.some((item) => ["-b", "-B", "-c", "-C", "--orphan", "--create", "--force-create"].includes(item));
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
// A hook bypass by another name is still --no-verify: lefthook's and husky's
|
|
114
|
+
// off switches, and pointing Git's hooks somewhere else.
|
|
115
|
+
const HOOK_BYPASS = /(?:^|[\s;&|(])(?:export\s+|env\s+)?(?:LEFTHOOK\s*=\s*["']?(?:0|false|off)\b|HUSKY\s*=\s*["']?0\b)|\bcore\.hooks[Pp]ath\b/;
|
|
116
|
+
const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify")) || HOOK_BYPASS.test(command);
|
|
117
|
+
|
|
118
|
+
// Operator, 29 Oct: a pattern kill on macOS once killed every agent on the
|
|
119
|
+
// machine. Only a PID the caller started (and saved) may be killed.
|
|
120
|
+
const AT_COMMAND = String.raw`(?:^|[;&|(\x60{\n]|\$\()\s*(?:(?:sudo|exec|nohup|time|command)(?:\s+-\S+)*\s+)*`;
|
|
121
|
+
const PATTERN_KILLS = [
|
|
122
|
+
new RegExp(`${AT_COMMAND}(?:\\S*/)?(?:pkill|killall)\\b`),
|
|
123
|
+
/(?:^|[;&|(\s])kill\b[^;&|\n]*(?:\$\(|`)\s*(?:\S*\/)?(?:pgrep|pidof|ps|lsof|jobs)\b/,
|
|
124
|
+
/\b(?:pgrep|pidof|ps|lsof)\b[^;&\n]*\|(?:[^;&\n]*\|)?\s*xargs\b[^;&|\n]*\bkill\b/,
|
|
125
|
+
/(?:^|[;&|(\s])kill\s+(?:-(?:\d+|[A-Z]+|s\s+\S+)\s+)*(?:--\s+)?-1\b/,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Blocks that hold for every caller, in a lane or not, and before any lane discovery. */
|
|
129
|
+
export function machineViolations(command) {
|
|
130
|
+
if (PATTERN_KILLS.some((pattern) => pattern.test(command)))
|
|
131
|
+
return [{ code: "SAFETY_PATTERN_KILL", message: "Pattern kills (pkill, killall, kill $(pgrep ...), ps | xargs kill, kill -1) can kill every agent on this machine. Kill only a PID you started and saved to a .pid file." }];
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function forcePush(invocation) {
|
|
136
|
+
if (invocation.verb !== "push") return false;
|
|
137
|
+
return invocation.args.some((arg) => ["-f", "--force", "--force-if-includes", "--mirror"].includes(arg) || arg.startsWith("--force-with-lease") || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg) || /^\+[^\s]/.test(arg));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Writes a lane may make outside its worktree: temporary and scratch space.
|
|
141
|
+
const WRITABLE_ELSEWHERE = () => [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders", "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/tty"];
|
|
142
|
+
const WRITE_COMMANDS = new Map([["cp", "last"], ["mv", "last"], ["ln", "last"], ["install", "last"], ["rsync", "last"], ["rm", "all"], ["rmdir", "all"], ["touch", "all"], ["mkdir", "all"], ["truncate", "all"], ["chmod", "rest"], ["chown", "rest"], ["tee", "all"]]);
|
|
143
|
+
const READ_ONLY_GIT = new Set(["status", "diff", "log", "show", "rev-parse", "rev-list", "branch", "ls-files", "ls-tree", "cat-file", "describe", "blame", "grep", "merge-base", "config", "remote", "fetch", "worktree", "for-each-ref", "symbolic-ref", "shortlog", "reflog", "name-rev", "check-ignore", "var", "help", "version"]);
|
|
144
|
+
|
|
145
|
+
/** Shell words with their quoting kept, split on control operators, for write detection. */
|
|
146
|
+
function segments(command) {
|
|
147
|
+
const out = [[]];
|
|
148
|
+
const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(&&|\|\||[;|\n])|([^\s"';|&]+(?:&(?![&])[^\s"';|&]*)*)|(&)/g;
|
|
149
|
+
for (const match of command.matchAll(re)) {
|
|
150
|
+
if (match[3] || match[5]) out.push([]);
|
|
151
|
+
else out.at(-1).push({ text: match[1] ?? match[2] ?? match[4], quoted: match[4] === undefined });
|
|
152
|
+
}
|
|
153
|
+
return out.filter((x) => x.length);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeTargets(command) {
|
|
157
|
+
const targets = [];
|
|
158
|
+
for (const segment of segments(command)) {
|
|
159
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
160
|
+
const { text, quoted } = segment[i];
|
|
161
|
+
if (quoted) continue;
|
|
162
|
+
const redirect = /^(?:\d*|&)>{1,2}\|?(.*)$/.exec(text);
|
|
163
|
+
if (redirect) {
|
|
164
|
+
const target = redirect[1] || segment[i + 1]?.text;
|
|
165
|
+
if (target && !target.startsWith("&")) targets.push(target);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const words = segment.filter((x, i) => !(i === 0 && /^[A-Z_][A-Z0-9_]*=/.test(x.text)));
|
|
169
|
+
const at = words.findIndex((x) => !["sudo", "command", "exec", "nohup"].includes(x.text));
|
|
170
|
+
const name = basename(words[at]?.text ?? "");
|
|
171
|
+
const mode = WRITE_COMMANDS.get(name);
|
|
172
|
+
if (!mode) continue;
|
|
173
|
+
const args = words.slice(at + 1).map((x) => x.text).filter((x) => !x.startsWith("-") && !/^\d*>/.test(x));
|
|
174
|
+
if (mode === "last") targets.push(...args.slice(-1));
|
|
175
|
+
else if (mode === "rest") targets.push(...args.slice(1));
|
|
176
|
+
else targets.push(...args);
|
|
177
|
+
}
|
|
178
|
+
return targets;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function outsideWorktree(target, context, relativeKnown) {
|
|
182
|
+
let path = target;
|
|
183
|
+
if (path === "~" || path.startsWith("~/")) path = join(homedir(), path.slice(1));
|
|
184
|
+
else if (/^\$\{?HOME\}?(?:\/|$)/.test(path)) path = join(homedir(), path.replace(/^\$\{?HOME\}?/, ""));
|
|
185
|
+
if (path.includes("$") || path.includes("`")) return false;
|
|
186
|
+
if (!isAbsolute(path)) {
|
|
187
|
+
if (!relativeKnown) return false;
|
|
188
|
+
path = resolve(context.cwd, path);
|
|
189
|
+
}
|
|
190
|
+
const inside = (root) => { const rel = relative(root, path); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };
|
|
191
|
+
return !inside(context.root) && !WRITABLE_ELSEWHERE().some(inside);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function gitOutsideWorktree(tokens, context) {
|
|
195
|
+
for (let at = 0; at < tokens.length; at += 1) {
|
|
196
|
+
if (tokens[at] !== "git" && !tokens[at].endsWith("/git")) continue;
|
|
197
|
+
let cursor = at + 1;
|
|
198
|
+
let target = null;
|
|
199
|
+
while (cursor < tokens.length && tokens[cursor].startsWith("-")) {
|
|
200
|
+
const option = tokens[cursor];
|
|
201
|
+
if (["-C", "--git-dir", "--work-tree"].includes(option)) target = tokens[cursor + 1] ?? null;
|
|
202
|
+
else if (/^--(?:git-dir|work-tree)=/.test(option)) target = option.slice(option.indexOf("=") + 1);
|
|
203
|
+
cursor += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(option) ? 2 : 1;
|
|
204
|
+
}
|
|
205
|
+
const verb = tokens[cursor];
|
|
206
|
+
if (target && verb && !READ_ONLY_GIT.has(verb) && outsideWorktree(target, context, true)) return { target, verb };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
113
210
|
|
|
114
211
|
function deployCommand(tokens, command) {
|
|
115
212
|
if (commandHas(tokens, "tofu", "apply")) return true;
|
|
@@ -189,6 +286,16 @@ export function commandViolations(command, context) {
|
|
|
189
286
|
}
|
|
190
287
|
if (gitCommands.some((x) => x.verb === "worktree" && x.args[0] === "remove" && x.args.some((arg) => arg === "-f" || arg === "--force")))
|
|
191
288
|
block("SAFETY_WORKTREE_FORCE", "git worktree remove --force is forbidden; the coordinator removes a proven-safe worktree without --force.");
|
|
289
|
+
if (gitCommands.some(forcePush))
|
|
290
|
+
block("SAFETY_FORCE_PUSH", "A force push rewrites history others may hold; a lane pushes fast-forward only. Ask the coordinator.");
|
|
291
|
+
if (context.root) {
|
|
292
|
+
const other = gitOutsideWorktree(tokens, context);
|
|
293
|
+
if (other) block("SAFETY_OTHER_WORKTREE", `git ${other.verb} in ${other.target} touches a checkout outside this lane's worktree ${context.root}.`);
|
|
294
|
+
const relativeKnown = !/(?:^|[;&|(\s])(?:cd|pushd)\s/.test(command);
|
|
295
|
+
const outside = writeTargets(command).filter((target) => outsideWorktree(target, context, relativeKnown));
|
|
296
|
+
if (outside.length)
|
|
297
|
+
block("SAFETY_WRITE_OUTSIDE_WORKTREE", `Write blocked outside the lane worktree ${context.root}: ${[...new Set(outside)].join(", ")}. Use the worktree or a temp/scratch directory.`);
|
|
298
|
+
}
|
|
192
299
|
|
|
193
300
|
if (usesNoVerify(command)) {
|
|
194
301
|
if (lane) block("SAFETY_NO_VERIFY", "--no-verify is forbidden in a lane or subagent.");
|
|
@@ -387,6 +494,10 @@ function blocked(violation) {
|
|
|
387
494
|
export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
|
|
388
495
|
const tool = payload.tool_name ?? "";
|
|
389
496
|
if (!SHELL_TOOLS.has(tool) && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
|
|
497
|
+
if (SHELL_TOOLS.has(tool)) {
|
|
498
|
+
const machine = machineViolations(String(payload.tool_input?.command ?? ""))[0];
|
|
499
|
+
if (machine) blocked(machine);
|
|
500
|
+
}
|
|
390
501
|
const cwd = payload.cwd ?? process.cwd();
|
|
391
502
|
const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
|
|
392
503
|
context.cwd = await realpath(cwd).catch(() => resolve(cwd));
|
package/dist/starting.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { CliError, configPath, devRequest, encode, query, readConfig, requireFlag } from "./core.js";
|
|
7
7
|
import { assertKnownSets, checkComponentSetKey, checkFileKey, loadDsCatalog, mergeScopes, scopesFromRoutes } from "./figma-scope.js";
|
|
8
|
-
import { GATHER_TIMEOUT_MS } from "./gather.js";
|
|
8
|
+
import { GATHER_TIMEOUT_MS, gatherDeadline } from "./gather.js";
|
|
9
9
|
import { laneCommand, nextCommand } from "./lanes.js";
|
|
10
10
|
|
|
11
11
|
const execFile = promisify(execFileCallback);
|
|
@@ -346,6 +346,19 @@ export async function launchLaneCommand(key, flags, {
|
|
|
346
346
|
} catch (error) {
|
|
347
347
|
throw new CliError(error?.code ?? "LANE_REGISTER_FAILED", `${error?.message ?? error}. Worktrees were created and were not removed; reconcile them before retrying.`, 1);
|
|
348
348
|
}
|
|
349
|
+
// An agent is dispatched only onto a lane whose start Dev recorded: building,
|
|
350
|
+
// with its first steps current (LANE-RECORD-GAP-APPLY-VERDICTS-1).
|
|
351
|
+
const status = payload.data?.lane?.status;
|
|
352
|
+
if (status !== "building") {
|
|
353
|
+
const reason = `Dev registered lane ${key} as ${status ?? "unknown"}, not started (no lane start recorded)`;
|
|
354
|
+
let reported = "Dev recorded it for lane resume";
|
|
355
|
+
try {
|
|
356
|
+
await request("POST", `/api/v1/lanes/${encode(payload.data.lane.id)}/resume/${encode(payload.data.run.id)}/launch-failed`, { reason, ...via });
|
|
357
|
+
} catch (reportError) {
|
|
358
|
+
reported = `Dev could not record the launch failure (${reportError.message})`;
|
|
359
|
+
}
|
|
360
|
+
throw new CliError("LANE_NOT_STARTED", `${reason}, so its agent was not dispatched; ${reported}. Worktrees were created and were not removed.`, 1);
|
|
361
|
+
}
|
|
349
362
|
await markers(repositories, { laneKey: key, laneId: payload.data.lane.id, runId: payload.data.run.id });
|
|
350
363
|
try {
|
|
351
364
|
await launcher({
|
|
@@ -415,31 +428,31 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
|
|
|
415
428
|
// Gather's human output is a renderer of its payload; Delta gather prints the
|
|
416
429
|
// Clause map too, never only "Gather complete.".
|
|
417
430
|
|
|
418
|
-
const runGather = (key, flags, { request,
|
|
431
|
+
const runGather = (key, flags, { request, deadline }) => laneCommand("gather", [key], flags, { request, deadline });
|
|
419
432
|
|
|
420
433
|
// Gather's human text is its Clause map (a function of the payload).
|
|
421
434
|
const gatheredText = (gathered) => typeof gathered.human === "function" ? gathered.human(gathered.payload) : gathered.human ?? "Gather complete.";
|
|
422
435
|
|
|
423
436
|
export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
424
|
-
|
|
425
|
-
const deadline =
|
|
426
|
-
const signal =
|
|
437
|
+
// The lane's local pick holds this deadline while it runs on its own budget.
|
|
438
|
+
const deadline = gatherDeadline(gatherTimeoutMs);
|
|
439
|
+
const signal = deadline.signal;
|
|
427
440
|
const timedRequest = (method, path, body, options = {}) => withSignal(request(method, path, body, { ...options, signal }), signal);
|
|
428
441
|
try {
|
|
429
442
|
const lane = await withSignal(marker(), signal);
|
|
430
443
|
key ??= lane?.laneKey;
|
|
431
444
|
if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
|
|
432
|
-
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest,
|
|
445
|
+
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
433
446
|
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);
|
|
434
|
-
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest,
|
|
447
|
+
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
435
448
|
const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
|
|
436
449
|
if (!pending.data.length) {
|
|
437
|
-
const gathered = await withSignal(gather(key, flags, { request: timedRequest,
|
|
450
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
438
451
|
if (gathered.payload?.data?.recorded === false) return gathered;
|
|
439
452
|
return { ...gathered, human: `${gatheredText(gathered)}\nNo pending amendments.` };
|
|
440
453
|
}
|
|
441
454
|
const version = Math.max(...pending.data.map((item) => item.version));
|
|
442
|
-
const gathered = await withSignal(gather(key, flags, { request: timedRequest,
|
|
455
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
443
456
|
// An agent picker's shortlist step records nothing, so it acknowledges nothing.
|
|
444
457
|
if (gathered.payload?.data?.recorded === false) return gathered;
|
|
445
458
|
const acknowledgement = await withSignal(acknowledge(key, {
|
|
@@ -454,9 +467,9 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
|
|
|
454
467
|
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${gatheredText(gathered)}\nAcknowledged amendment version ${version}.`,
|
|
455
468
|
};
|
|
456
469
|
} catch (error) {
|
|
457
|
-
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded
|
|
470
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(gatherTimeoutMs / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
|
|
458
471
|
throw error;
|
|
459
472
|
} finally {
|
|
460
|
-
|
|
473
|
+
deadline.clear();
|
|
461
474
|
}
|
|
462
475
|
}
|
package/dist/stdout.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { format } from "node:util";
|
|
2
|
+
|
|
3
|
+
// Dev issue 37: on Bun, once any module imports from `node:process`,
|
|
4
|
+
// console.log to a pipe stops at 65,536 bytes and the rest is lost at exit, so
|
|
5
|
+
// a large `--json` reached `jq` cut off. process.stdout.write delivers it all.
|
|
6
|
+
// The executable routes console.log through it and waits for the stream to
|
|
7
|
+
// drain before it returns. A reader that closes early (`| head -c`) is not an
|
|
8
|
+
// error.
|
|
9
|
+
let installed = false;
|
|
10
|
+
|
|
11
|
+
export function routeStdout(stream = process.stdout) {
|
|
12
|
+
if (installed) return;
|
|
13
|
+
installed = true;
|
|
14
|
+
stream.on("error", (error) => {
|
|
15
|
+
if (error?.code !== "EPIPE") throw error;
|
|
16
|
+
stream.destroy();
|
|
17
|
+
});
|
|
18
|
+
console.log = (...values) => {
|
|
19
|
+
if (!stream.destroyed) stream.write(`${format(...values)}\n`);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function drainStdout(stream = process.stdout) {
|
|
24
|
+
if (stream.destroyed || !stream.writable) return Promise.resolve();
|
|
25
|
+
return new Promise((resolve) => stream.write("", () => resolve()));
|
|
26
|
+
}
|
package/dist/upload.js
CHANGED
|
@@ -169,9 +169,12 @@ const uploadBody = (docs, cls, flags, decision) => ({
|
|
|
169
169
|
...(flags["meaning-unchanged"] && cls === "editorial" ? { meaningUnchangedReason: String(flags["meaning-unchanged"]) } : {}),
|
|
170
170
|
})),
|
|
171
171
|
...(decision ? { decision } : {}),
|
|
172
|
-
...(flags.pending ? { pending: true } : {}),
|
|
173
172
|
});
|
|
174
173
|
|
|
174
|
+
// Dev locks a ruling's decision as its change lands (DEV-DEC-AS-COMMIT-1,
|
|
175
|
+
// hub #406); there is no pending decision to draft any more.
|
|
176
|
+
export const PENDING_NO_OP = "--pending does nothing now: decisions lock on upload.";
|
|
177
|
+
|
|
175
178
|
export async function docsCommand(action, args, flags, { request = devRequest, log = console.error, cwd = process.cwd(), waitInterval } = {}) {
|
|
176
179
|
const json = Boolean(flags.json);
|
|
177
180
|
if (action === "check") {
|
|
@@ -193,6 +196,7 @@ export async function docsCommand(action, args, flags, { request = devRequest, l
|
|
|
193
196
|
throw new CliError("USAGE", "--pending is valid only with docs upload.", 2);
|
|
194
197
|
if (flags.pending && cls !== "ruling")
|
|
195
198
|
throw new CliError("USAGE", "--pending requires --class ruling and --decision FILE.", 2);
|
|
199
|
+
if (flags.pending) log(PENDING_NO_OP);
|
|
196
200
|
const decision = await readJsonFile(flags.decision, "decision");
|
|
197
201
|
if (cls === "ruling" && !decision) throw new CliError("RULING_NEEDS_DECISION", "A ruling write needs its decision: --decision DECISION.json (title, context, options, ruling, consequences, provenance, amends).", 2);
|
|
198
202
|
if (cls !== "ruling" && decision) throw new CliError("DECISION_NEEDS_RULING", "--decision goes with --class ruling only.", 2);
|