@nodii/dev 0.6.46 → 0.6.48

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 CHANGED
@@ -141,10 +141,10 @@ nodii-dev rules find "QUESTION" [--limit N]
141
141
  nodii-dev rules show CLAUSE_ID
142
142
  nodii-dev gather tune cases|runs
143
143
  nodii-dev gather tune replay CASE_ID
144
- nodii-dev gather tune start --cases all|ID,ID --corpus-ref REF [--trigger T] [--settings JSON]
144
+ nodii-dev gather tune start --cases all|ID,ID --corpus-ref REF [--pickers P,Q] [--trigger T] [--settings JSON]
145
145
  nodii-dev gather tune exec RUN_ID [--pickers shortlist-only,gemma-e4b,gemma-12b,gemma-26b]
146
146
  nodii-dev gather tune trial RUN_ID --case ID --picker P --iteration N [--documents A,B]
147
- nodii-dev gather tune finalize RUN_ID
147
+ nodii-dev gather tune finalize RUN_ID [--supersedes RUN_ID --reason TEXT]
148
148
  nodii-dev gather timings [--model M] [--last N]
149
149
 
150
150
  nodii-dev open-questions list|show|answer
@@ -954,7 +954,11 @@ its exit code (a lane that exits 0 after Gather included). It is reported as a
954
954
  `process-exit` with its code, fingerprint (`lane-unfinished-exit-0`,
955
955
  `lane-exit-N` or `lane-signal-S`) and redacted log tail. Dev orphans that run,
956
956
  so `lane resume` relaunches it with `recoveryOf`. A lane status Dev can't serve
957
- counts as unfinished. Every report carries `exit_code`, `worktree` and
957
+ counts as unfinished. A lane that recorded BLOCKED (a blocked step, or a
958
+ latest heartbeat note that starts with "blocked") and then exits ends its run
959
+ `blocked` with that reason: never an orphan, never a death that counts toward
960
+ the same-death stop (hub-58). A Dev without that status gets the process exit,
961
+ which it also ends blocked from the lane's record. Every report carries `exit_code`, `worktree` and
958
962
  `lane_status` evidence. `process.pid` still holds the runtime's own PID, and the
959
963
  supervisor appends its report line to `process.log`.
960
964
 
package/dist/cli.js CHANGED
@@ -167,13 +167,16 @@ Usage: nodii-dev <command> [options] [--json]
167
167
  rules cap TOKENS # operator: set the core token cap (default 3000)
168
168
  gather tune cases|runs # Gather tuning (AH-11): frozen cases; runs with per-picker scores
169
169
  gather tune replay <case-id> # blind replay + trigger shortlist, for the lane-agent picker
170
- gather tune start --cases all|ID,ID --corpus-ref REF [--trigger manual] [--settings JSON]
170
+ gather tune start --cases all|ID,ID --corpus-ref REF [--pickers P,Q] [--trigger manual] [--settings JSON]
171
+ # --pickers: the subset this run scores (default all five)
171
172
  gather tune exec <run-id> [--pickers shortlist-only,gemma-e4b,gemma-12b,gemma-26b]
172
- # runs each automatic picker 5x per case; a Gemma model this Mac
173
- # lacks is reported unavailable (exit 1), never filled in
173
+ # runs each automatic picker the run scores 5x per case; a Gemma
174
+ # model this Mac lacks is reported unavailable (exit 1), never filled in
174
175
  gather tune trial <run-id> --case ID --picker P --iteration N [--documents A,B]
175
176
  # one trial; lane-agent passes its own --documents pick
176
- gather tune finalize <run-id> # Dev scores the full 5-picker x 5-repeat matrix
177
+ gather tune finalize <run-id> [--supersedes <run-id> --reason TEXT]
178
+ # Dev scores the run's own picker x 5-repeat matrix; --supersedes
179
+ # retires an earlier run in favour of this one (its trials are kept)
177
180
  gather picker-latency [--picker gemma-12b|gemma-e4b|gemma-26b]
178
181
  # cold (model unloaded first) and warm pick latency, for the tuning record
179
182
  gather timings [--model M] [--last N] # this Mac's lane Gather picks: warm-up and first-pick p50/p95
@@ -214,6 +217,8 @@ Usage: nodii-dev <command> [options] [--json]
214
217
  # N min (default 10, then cancelled); --artifact is a path there
215
218
 
216
219
  lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
220
+ lane start <ISSUE> --key KEY --title TEXT --kind KIND --side SIDE
221
+ # --key names the lane; the positional is then its issue
217
222
  lane step <key> --side SIDE --start|--done|--block <step> [--reason TEXT]
218
223
  # --block --reason on a blocked step records its new reason
219
224
  lane step <key> --side SIDE --done <step> --review REVIEW.json
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.46";
7
+ export const VERSION = "0.6.48";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -93,10 +93,15 @@ export const doneCheckRecorded = (lane) =>
93
93
  || FINISHED_LANE.has(lane?.status);
94
94
 
95
95
  /** A lane is BLOCKED by a blocked step (`lane step --block`) or a latest heartbeat note that says so. */
96
- export function laneBlocked(lane) {
97
- if ((lane?.steps ?? []).some((step) => step.state === "blocked")) return true;
96
+ export const laneBlocked = (lane) => Boolean(laneBlockedReason(lane));
97
+
98
+ /** Why a lane is BLOCKED, or null (the same rule Dev applies to a run's exit). */
99
+ export function laneBlockedReason(lane) {
100
+ const step = (lane?.steps ?? []).find((candidate) => candidate.state === "blocked");
101
+ if (step) return `Lane step ${step.side}/${step.stepKey} is blocked.`;
98
102
  const heartbeat = (lane?.events ?? []).filter((event) => event.type === "heartbeat").at(-1);
99
- return /^\s*blocked\b/i.test(String(heartbeat?.metadata?.note ?? ""));
103
+ const note = String(heartbeat?.metadata?.note ?? "");
104
+ return /^\s*blocked\b/i.test(note) ? `Lane heartbeat: ${note.slice(0, 500)}` : null;
100
105
  }
101
106
 
102
107
  /** The step a lane that stopped early goes back to (hub-58, NODII-DEV-CLI-8/9). */
@@ -114,9 +119,9 @@ export function continueTaskMessage(laneKey, lane) {
114
119
  async function laneProgress(request, laneKey) {
115
120
  try {
116
121
  const lane = (await request("GET", `/api/v1/lanes/${encode(laneKey)}`)).data;
117
- return { status: lane?.status ?? null, finished: doneCheckRecorded(lane) };
122
+ return { status: lane?.status ?? null, finished: doneCheckRecorded(lane), blocked: laneBlockedReason(lane) };
118
123
  } catch {
119
- return { status: null, finished: false };
124
+ return { status: null, finished: false, blocked: null };
120
125
  }
121
126
  }
122
127
 
@@ -134,7 +139,7 @@ export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktre
134
139
  markTerminal = markWatchedTerminal,
135
140
  } = {}) {
136
141
  const worktree = await inspect(worktrees);
137
- const { status, finished } = await laneProgress(request, laneKey);
142
+ const { status, finished, blocked } = await laneProgress(request, laneKey);
138
143
  const clean = exitCode === 0 && !signal;
139
144
  const exitRef = signal ? `signal ${signal}` : String(exitCode ?? -1);
140
145
  const exited = signal ? `was ended by ${signal}` : `exited with code ${exitCode ?? -1}`;
@@ -154,6 +159,11 @@ export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktre
154
159
  // A finished lane has nothing to resume, so a failed exit is not a death.
155
160
  outcome = "failed";
156
161
  body = { status: "failed", reason: `The lane process ${exited} after lane ${laneKey} reached ${status}.`, evidence };
162
+ } else if (blocked) {
163
+ // hub-58 (30 Oct): a lane that recorded BLOCKED stopped on purpose. Its
164
+ // run ends blocked: never an orphan, never a death to resume.
165
+ outcome = "blocked";
166
+ body = { status: "blocked", reason: `${blocked} The lane process ${exited} while BLOCKED.`, evidence };
157
167
  } else {
158
168
  outcome = "process_exit";
159
169
  const logTail = redactRunText(await tail(logPath).catch(() => "")).trim();
@@ -168,8 +178,17 @@ export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktre
168
178
  await send(request, `/api/v1/runs/${encode(runId)}/${outcome === "process_exit" ? "process-exit" : "end"}`, body);
169
179
  } catch (error) {
170
180
  // The agent may have ended its own run already; that is not a failure.
171
- if (error?.code !== "RUN_ALREADY_TERMINAL") throw error;
172
- outcome = "already_terminal";
181
+ if (error?.code === "RUN_ALREADY_TERMINAL") outcome = "already_terminal";
182
+ // A Dev without the blocked status refuses it: report the exit, which a
183
+ // current Dev also ends blocked from the lane's own record.
184
+ else if (outcome === "blocked" && error instanceof CliError) {
185
+ outcome = "process_exit";
186
+ await send(request, `/api/v1/runs/${encode(runId)}/process-exit`, {
187
+ reason: body.reason,
188
+ deathFingerprint: signal ? `lane-signal-${signal}` : clean ? "lane-unfinished-exit-0" : `lane-exit-${exitCode ?? -1}`,
189
+ exitCode: Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? exitCode : -1,
190
+ });
191
+ } else throw error;
173
192
  }
174
193
  await markTerminal(runId).catch(() => {});
175
194
  return { outcome, exitCode: exitCode ?? null, signal: signal ?? null, worktree: worktree.state, laneStatus: status };
package/dist/lanes.js CHANGED
@@ -294,13 +294,19 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
294
294
  return { payload, human: (p) => p.data.map(laneLine).join("\n") || "No lanes." };
295
295
  }
296
296
  if (action === "start") {
297
- const key = need(args[0], "lane start needs <key>");
297
+ // `--key` names the lane; the positional is then the issue (NODII-DEV-CLI-13).
298
+ if (flags.key !== undefined && (typeof flags.key !== "string" || !flags.key.trim()))
299
+ throw new CliError("USAGE", "lane start --key needs one lane key", 2);
300
+ if (flags.key !== undefined && args[0] !== undefined && flags.issue !== undefined)
301
+ throw new CliError("USAGE", `lane start got both <${args[0]}> and --key ${flags.key} --issue ${flags.issue}; pass the lane key once (as <key> or --key) and the issue once.`, 2);
302
+ const key = need(flags.key?.trim() ?? args[0], "lane start needs <key>");
303
+ const issue = flags.key !== undefined ? flags.issue ?? args[0] : flags.issue;
298
304
  const c = await config();
299
305
  const context = await gitContext();
300
306
  const agent = (await request("GET", `/api/v1/agents/${encode(c.agentId)}`)).data;
301
307
  if (!agent.machineId) throw new CliError("MACHINE_REQUIRED", "The current agent is not assigned to a machine.", 1);
302
308
  let issueIds = [];
303
- if (flags.issue) issueIds = [String(flags.issue)].flatMap((x) => x.split(",")).map(String);
309
+ if (issue) issueIds = [String(issue)].flatMap((x) => x.split(",")).map(String);
304
310
  issueIds = await Promise.all(issueIds.map(async (ref) => (await request("GET", `/api/v1/issues/${encode(ref)}`)).data.id));
305
311
  await request("POST", "/api/v1/lanes", {
306
312
  key, title: requireFlag(flags, "title"), kind: requireFlag(flags, "kind"), side: requireFlag(flags, "side"),
package/dist/tune.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { CliError, devRequest, encode, query } from "./core.js";
2
2
  import { GATHER_TIMEOUT_MS, TUNING_MODELS, gatherText, pickDocuments, warmPicker } from "./gather.js";
3
3
 
4
- // Dev's tuning matrix (AH-11): every case, every picker, five repeats. Finalize
5
- // refuses anything short of the full matrix, so a picker that cannot run here is
6
- // reported unavailable and sends nothing — a missing trial is never faked.
4
+ // Dev's tuning matrix (AH-11): every case, each of the run's pickers, five
5
+ // repeats. A run names its pickers at start (all five by default; hub-58 lets an
6
+ // agent-picker run leave the Gemma columns out). Finalize refuses anything short
7
+ // of the run's own matrix, so a picker that cannot run here is reported
8
+ // unavailable and sends nothing — a missing trial is never faked.
7
9
  export const TUNING_PICKERS = ["shortlist-only", "lane-agent", "gemma-e4b", "gemma-12b", "gemma-26b"];
8
10
  export const TUNING_REPEATS = 5;
9
- const AUTOMATIC = TUNING_PICKERS.filter((picker) => picker !== "lane-agent");
10
11
  const TRIGGERS = ["setup", "trigger_change", "document_change", "missed_trigger", "manual"];
11
12
  const SECTION_LIMIT = 3;
12
13
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -31,6 +32,15 @@ function pickerFlag(value) {
31
32
  return value;
32
33
  }
33
34
 
35
+ function pickersFlag(value) {
36
+ const pickers = list(value).map((picker) => {
37
+ if (!TUNING_PICKERS.includes(picker)) throw new CliError("USAGE", `--pickers must name pickers from ${TUNING_PICKERS.join(", ")}`, 2);
38
+ return picker;
39
+ });
40
+ if (!pickers.length || new Set(pickers).size !== pickers.length) throw new CliError("USAGE", "--pickers needs a comma list of distinct pickers", 2);
41
+ return pickers;
42
+ }
43
+
34
44
  function iterationFlag(value) {
35
45
  const n = Number(value);
36
46
  if (!Number.isInteger(n) || n < 1 || n > TUNING_REPEATS) throw new CliError("USAGE", `--iteration needs a whole number from 1 to ${TUNING_REPEATS}`, 2);
@@ -223,7 +233,8 @@ async function runCases(request, id) {
223
233
  if (run.state !== "running") throw new CliError("TUNING_RUN_NOT_RUNNING", `tuning run ${id} is ${run.state}; only a running run accepts trials`, 1);
224
234
  const cases = Array.isArray(run.settings?.caseIds) ? run.settings.caseIds : [];
225
235
  if (!cases.length) throw new CliError("TUNING_RUN_INVALID", `tuning run ${id} has no frozen case cohort`, 1);
226
- return { run, cases };
236
+ const pickers = Array.isArray(run.pickers) && run.pickers.length ? run.pickers : TUNING_PICKERS;
237
+ return { run, cases, pickers };
227
238
  }
228
239
 
229
240
  const putTrial = (request, runId, trial, iteration) => request("PUT", `/api/v1/gather/tuning/runs/${encode(runId)}/trials`, {
@@ -249,9 +260,12 @@ function execText(payload) {
249
260
  }
250
261
 
251
262
  function summaryText(run) {
252
- const lines = [`Tuning run ${run.id}: ${run.state}${run.summary ? ` · ${run.summary.trials} trials` : ""}`];
263
+ const coverage = run.summary?.coverage?.note ?? run.settings?.coverage?.note;
264
+ const lines = [`Tuning run ${run.id}: ${run.state}${run.summary ? ` · ${run.summary.trials} trials` : ""}${coverage ? ` · ${coverage}` : ""}`];
265
+ if (run.state === "superseded") lines.push(` superseded by ${run.supersededByRunId}: ${run.supersededReason}`);
253
266
  for (const [picker, s] of Object.entries(run.summary?.perPicker ?? {}))
254
267
  lines.push(` ${picker.padEnd(15)}recall ${bps(s.recallBps)} ⚑ recall ${bps(s.flaggedRecallBps)} noise ${bps(s.noiseBps)} ~${s.averageMapTokens} tokens consistency ${bps(s.consistencyBps)}${s.flaggedMissedTrials ? ` ⚑ missed in ${s.flaggedMissedTrials} trials` : ""}`);
268
+ if (run.superseded) lines.push(`Superseded ${run.superseded.id}: ${run.superseded.supersededReason}`);
255
269
  return lines.join("\n");
256
270
  }
257
271
 
@@ -302,8 +316,16 @@ export async function tuneCommand(action, args, flags, { request = devRequest, p
302
316
  catch { throw new CliError("USAGE", "--settings must be a JSON object", 2); }
303
317
  if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new CliError("USAGE", "--settings must be a JSON object", 2);
304
318
  }
305
- const payload = await request("POST", "/api/v1/gather/tuning/runs", { trigger, caseIds, corpusRef: flags["corpus-ref"].trim(), settings });
306
- return { payload, human: (p) => `Started tuning run ${p.data.id}: ${p.data.caseCount} case${p.data.caseCount === 1 ? "" : "s"} × ${p.data.pickers.length} pickers × ${p.data.repeats} repeats.\nRun the automatic pickers: nodii-dev gather tune exec ${p.data.id}` };
319
+ const body = { trigger, caseIds, corpusRef: flags["corpus-ref"].trim(), settings };
320
+ if (flags.pickers !== undefined) body.pickers = pickersFlag(flags.pickers);
321
+ const payload = await request("POST", "/api/v1/gather/tuning/runs", body);
322
+ return { payload, human: (p) => {
323
+ const lines = [`Started tuning run ${p.data.id}: ${p.data.caseCount} case${p.data.caseCount === 1 ? "" : "s"} × ${p.data.pickers.length} picker${p.data.pickers.length === 1 ? "" : "s"} (${p.data.pickers.join(", ")}) × ${p.data.repeats} repeats.`];
324
+ if (p.data.settings?.coverage?.note) lines.push(`Coverage: ${p.data.settings.coverage.note}.`);
325
+ if (p.data.pickers.some((picker) => picker !== "lane-agent")) lines.push(`Run the automatic pickers: nodii-dev gather tune exec ${p.data.id}`);
326
+ if (p.data.pickers.includes("lane-agent")) lines.push(`Record each lane-agent pick: nodii-dev gather tune replay <case-id>, then gather tune trial ${p.data.id} --picker lane-agent`);
327
+ return lines.join("\n");
328
+ } };
307
329
  }
308
330
  if (action === "trial") {
309
331
  const runId = uuid(args[0], "<run-id>");
@@ -311,8 +333,9 @@ export async function tuneCommand(action, args, flags, { request = devRequest, p
311
333
  const picker = pickerFlag(flags.picker);
312
334
  const iteration = iterationFlag(flags.iteration);
313
335
  if (picker !== "lane-agent" && flags.documents !== undefined) throw new CliError("USAGE", "--documents is the lane-agent's own pick; other pickers choose for themselves", 2);
314
- const { cases } = await runCases(request, runId);
336
+ const { cases, pickers } = await runCases(request, runId);
315
337
  if (!cases.includes(caseId)) throw new CliError("TUNING_CASE_OUTSIDE_RUN", `case ${caseId} is not in run ${runId}`, 1);
338
+ if (!pickers.includes(picker)) throw new CliError("TUNING_TRIAL_OUTSIDE_MATRIX", `run ${runId} scores ${pickers.join(", ")}, not ${picker}`, 1);
316
339
  const replay = (await request("GET", `/api/v1/gather/tuning/cases/${encode(caseId)}/replay`)).data;
317
340
  if (TUNING_MODELS[picker]) await warm({ model: TUNING_MODELS[picker] });
318
341
  const trial = await runTrial(replay, picker, { request, pick, corpus: corpus ?? tuningCorpus(request), documents: list(flags.documents) });
@@ -321,9 +344,13 @@ export async function tuneCommand(action, args, flags, { request = devRequest, p
321
344
  }
322
345
  if (action === "exec") {
323
346
  const runId = uuid(args[0], "<run-id>");
324
- const pickers = flags.pickers === undefined ? AUTOMATIC : list(flags.pickers).map(pickerFlag);
325
- if (pickers.includes("lane-agent")) throw new CliError("USAGE", "the lane-agent picker is the agent itself: record each pick with `gather tune trial --picker lane-agent`", 2);
326
- const { cases } = await runCases(request, runId);
347
+ const requested = flags.pickers === undefined ? null : pickersFlag(flags.pickers);
348
+ if (requested?.includes("lane-agent")) throw new CliError("USAGE", "the lane-agent picker is the agent itself: record each pick with `gather tune trial --picker lane-agent`", 2);
349
+ const { cases, pickers: runPickers } = await runCases(request, runId);
350
+ const outside = (requested ?? []).filter((picker) => !runPickers.includes(picker));
351
+ if (outside.length) throw new CliError("TUNING_TRIAL_OUTSIDE_MATRIX", `run ${runId} scores ${runPickers.join(", ")}, not ${outside.join(", ")}`, 1);
352
+ // By default, every automatic picker the run scores.
353
+ const pickers = requested ?? runPickers.filter((picker) => picker !== "lane-agent");
327
354
  const shared = corpus ?? tuningCorpus(request);
328
355
  const replays = new Map();
329
356
  const replayOf = async (caseId) => {
@@ -354,8 +381,15 @@ export async function tuneCommand(action, args, flags, { request = devRequest, p
354
381
  }
355
382
  if (action === "finalize") {
356
383
  const runId = uuid(args[0], "<run-id>");
357
- const payload = await request("POST", `/api/v1/gather/tuning/runs/${encode(runId)}/finalize`, {});
384
+ const body = {};
385
+ if (flags.supersedes !== undefined || flags.reason !== undefined) {
386
+ const supersedes = uuid(flags.supersedes, "--supersedes <run-id>");
387
+ if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("USAGE", "--supersedes needs --reason TEXT: why the earlier run is retired", 2);
388
+ if (supersedes === runId) throw new CliError("USAGE", "a run cannot supersede itself", 2);
389
+ body.supersedes = { runId: supersedes, reason: flags.reason.trim() };
390
+ }
391
+ const payload = await request("POST", `/api/v1/gather/tuning/runs/${encode(runId)}/finalize`, body);
358
392
  return { payload, human: (p) => summaryText(p.data) };
359
393
  }
360
- throw new CliError("USAGE", "gather tune cases|replay <case-id>|start --cases all|ID,ID --corpus-ref REF|trial <run-id> --case ID --picker P --iteration N [--documents A,B]|exec <run-id> [--pickers P,Q]|finalize <run-id>|runs", 2);
394
+ throw new CliError("USAGE", "gather tune cases|replay <case-id>|start --cases all|ID,ID --corpus-ref REF [--pickers P,Q]|trial <run-id> --case ID --picker P --iteration N [--documents A,B]|exec <run-id> [--pickers P,Q]|finalize <run-id> [--supersedes <run-id> --reason TEXT]|runs", 2);
361
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.46",
3
+ "version": "0.6.48",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {