@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/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
- const succeeded = outcome.exitCode === 0 && !outcome.signal && !spawnError;
272
- const reason = spawnError
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 ?? 1),
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
- const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify"));
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, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
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
- const controller = new AbortController();
425
- const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
426
- const signal = controller.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, signal }), signal);
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, signal }), signal);
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, signal }), signal);
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, signal }), signal);
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 its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
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
- clearTimeout(deadline);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {