@nodii/dev 0.6.34 → 0.6.36

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/freeze.js CHANGED
@@ -29,9 +29,43 @@ export function shouldReground(argv) {
29
29
 
30
30
  /**
31
31
  * The only Gather → Freeze shape adapter. Gather may add display metadata,
32
- * but Freeze retains the stable clause identity/tags and full text only for ⚑.
32
+ * but Freeze pins only the ⚑ clauses, in full (hub-58): every other clause
33
+ * stays in the Clause map, which the Freeze references.
33
34
  */
34
35
  export function pinnedClausesFromGather(clauses) {
36
+ return checkedGatherClauses(clauses)
37
+ .filter((clause) => clause.counterInstinct)
38
+ .map((clause) => ({
39
+ documentCode: clause.documentCode,
40
+ documentVersion: clause.documentVersion,
41
+ clauseId: clause.clauseId,
42
+ ...(clause.breadcrumb || clause.sectionId
43
+ ? { section: clause.breadcrumb ?? clause.sectionId }
44
+ : {}),
45
+ gist: clause.gist,
46
+ text: clause.text,
47
+ counterInstinct: true,
48
+ repos: clause.repos,
49
+ steps: clause.steps,
50
+ }));
51
+ }
52
+
53
+ /**
54
+ * The Freeze's reference to the Clause map: its Gather id and the non-flagged
55
+ * clauses' one-line gists in rank order (Dev cuts them at the token cap).
56
+ */
57
+ export function clauseMapFromGather(gather) {
58
+ if (typeof gather?.gatherId !== "string" || !gather.gatherId)
59
+ throw new CliError("GATHER_CLAUSE_MAP_INVALID", "Gather returned no gather id", 1);
60
+ const clauses = checkedGatherClauses(gather.clauses)
61
+ .map((clause, index) => ({ clause, index }))
62
+ .filter(({ clause }) => !clause.counterInstinct)
63
+ .sort((a, b) => (a.clause.rank ?? Infinity) - (b.clause.rank ?? Infinity) || a.index - b.index)
64
+ .map(({ clause }) => ({ clauseId: clause.clauseId, gist: clause.gist }));
65
+ return { gatherId: gather.gatherId, clauses };
66
+ }
67
+
68
+ function checkedGatherClauses(clauses) {
35
69
  if (!Array.isArray(clauses))
36
70
  throw new CliError("GATHER_CLAUSE_MAP_INVALID", "Gather returned no clause map", 1);
37
71
  return clauses.map((clause) => {
@@ -66,25 +100,13 @@ export function pinnedClausesFromGather(clauses) {
66
100
  ].join(", ")}`,
67
101
  1,
68
102
  );
69
- return {
70
- documentCode: clause.documentCode,
71
- documentVersion: clause.documentVersion,
72
- clauseId: clause.clauseId,
73
- ...(clause.breadcrumb || clause.sectionId
74
- ? { section: clause.breadcrumb ?? clause.sectionId }
75
- : {}),
76
- gist: clause.gist,
77
- ...(clause.counterInstinct ? { text: clause.text } : {}),
78
- counterInstinct: clause.counterInstinct,
79
- repos: clause.repos,
80
- steps: clause.steps,
81
- };
103
+ return clause;
82
104
  });
83
105
  }
84
106
 
85
107
  export const GATHER_LOOKUP_IDS = ["figma-guidelines"];
86
108
 
87
- /** Build the six-section stored contract; Delta Gather keeps lane-authored sections. */
109
+ /** Build the stored contract; Delta Gather keeps lane-authored sections. */
88
110
  export function freezeFromGather({
89
111
  gather,
90
112
  boundaries,
@@ -119,6 +141,7 @@ export function freezeFromGather({
119
141
  },
120
142
  boundaries,
121
143
  pinnedClauses: pinnedClausesFromGather(gather.clauses),
144
+ clauseMap: clauseMapFromGather(gather),
122
145
  // Gather owns the lookups it derives (the Figma scope's guidelines): each
123
146
  // Gather replaces them, or drops them when the scope no longer yields any.
124
147
  // Lookups the lane added itself are kept.
package/dist/gather.js CHANGED
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { promisify } from "node:util";
4
4
  import { randomUUID } from "node:crypto";
5
- import { link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
5
+ import { appendFile, link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
7
  import { CliError } from "./core.js";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
@@ -21,6 +21,10 @@ export const PICKER_CONTEXT = 8192;
21
21
  // A hard per-pick deadline: a pick that has not answered by then is stopped
22
22
  // (PICKER_TIMEOUT) so the next one never queues behind it.
23
23
  export const PICKER_TIMEOUT_MS = 30_000;
24
+ // A lane Gather's first pick, timed only once the model is warm: real lanes
25
+ // hit the 60 s Gather cap on a cold 12B (GATHER_TIMEOUT, 30 Oct).
26
+ export const PICKER_FIRST_PICK_TIMEOUT_MS = 120_000;
27
+ // Dev's requests in a Gather; the local warm-up and pick run on their own budgets.
24
28
  export const GATHER_TIMEOUT_MS = 60_000;
25
29
  export const PICKER_WARM_TIMEOUT_MS = 180_000;
26
30
  export const OLLAMA_URL = "http://127.0.0.1:11434";
@@ -79,9 +83,8 @@ async function jsonRequest(path, { baseUrl = ollamaUrl(), fetchImpl = fetch, tim
79
83
  }), requestSignal);
80
84
  } catch (error) {
81
85
  const timedOut = requestSignal.aborted || error?.name === "TimeoutError" || error?.name === "AbortError";
82
- throw new CliError("PICKER_UNAVAILABLE", timedOut
83
- ? `Local Gather picker timed out after ${timeoutMs} ms.`
84
- : `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1);
86
+ if (timedOut) throw new CliError("PICKER_UNAVAILABLE", `Local Gather picker timed out after ${timeoutMs} ms.`, 1);
87
+ throw new CliError("PICKER_UNAVAILABLE", `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1, { unreachable: true, url: baseUrl });
85
88
  }
86
89
  let raw;
87
90
  try {
@@ -139,8 +142,41 @@ export async function requirePicker(options = {}) {
139
142
  */
140
143
  export async function warmPicker(options = {}) {
141
144
  const status = await requirePicker(options);
142
- await jsonRequest("/api/generate", { ...options, timeoutMs: options.timeoutMs ?? PICKER_WARM_TIMEOUT_MS, body: { model: (options.model ?? PINNED).name, prompt: "", stream: false } });
143
- return status;
145
+ const started = Date.now();
146
+ const payload = await jsonRequest("/api/generate", { ...options, timeoutMs: options.timeoutMs ?? PICKER_WARM_TIMEOUT_MS, body: { model: (options.model ?? PINNED).name, prompt: "", stream: false } });
147
+ return { ...status, latency: pickLatency(payload, Date.now() - started) };
148
+ }
149
+
150
+ /**
151
+ * Gather's deadline for Dev's requests. It stops while `hold` runs the local
152
+ * pick, which has its own budgets (warm-up, then the first pick), so a cold
153
+ * model never spends the requests' time.
154
+ */
155
+ export function gatherDeadline(ms) {
156
+ const controller = new AbortController();
157
+ let remaining = ms;
158
+ let since = Date.now();
159
+ let timer = setTimeout(() => controller.abort(), remaining);
160
+ let holds = 0;
161
+ return {
162
+ ms,
163
+ signal: controller.signal,
164
+ async hold(task) {
165
+ if (holds++ === 0) {
166
+ clearTimeout(timer);
167
+ remaining -= Date.now() - since;
168
+ }
169
+ try {
170
+ return await task();
171
+ } finally {
172
+ if (--holds === 0 && !controller.signal.aborted) {
173
+ since = Date.now();
174
+ timer = setTimeout(() => controller.abort(), Math.max(0, remaining));
175
+ }
176
+ }
177
+ },
178
+ clear: () => clearTimeout(timer),
179
+ };
144
180
  }
145
181
 
146
182
  function pickerPrompt({ objective, files = [], triggers }) {
@@ -156,6 +192,16 @@ function pickerPrompt({ objective, files = [], triggers }) {
156
192
 
157
193
  const forced = (trigger) => Array.isArray(trigger?.forcedBy) ? trigger.forcedBy.length > 0 : Boolean(trigger?.forcedBy);
158
194
 
195
+ /**
196
+ * Why a lane Gather hands the pick to the agent instead (hub-58, 30 Oct): this
197
+ * Mac has no picker model, or no Ollama at all. Null for every other failure.
198
+ */
199
+ export function agentFallbackReason(error) {
200
+ if (error?.code === "PICKER_MODEL_MISSING") return `${error.details?.model ?? PICKER_MODEL} not installed; run nodii-dev setup`;
201
+ if (error?.code === "PICKER_UNAVAILABLE" && error.details?.unreachable) return `no local Ollama at ${error.details.url}; run nodii-dev setup`;
202
+ return null;
203
+ }
204
+
159
205
  export async function pickDocuments(input, options = {}) {
160
206
  if (!input || typeof input.objective !== "string" || !input.objective.trim())
161
207
  throw new CliError("PICKER_INPUT_INVALID", "Gather needs a non-empty objective.", 2);
@@ -287,7 +333,7 @@ async function removeIfStale(lock, token, { alive, now, staleMs }) {
287
333
  * long). Takeovers are serialized, so the lock path is never emptied under a
288
334
  * live pick.
289
335
  */
290
- export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 * PICKER_TIMEOUT_MS, attempts = 40, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
336
+ export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 * PICKER_FIRST_PICK_TIMEOUT_MS, attempts = 40, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
291
337
  await privateDir(dir);
292
338
  const name = encodeURIComponent(lane);
293
339
  const lock = join(dir, `${name}.lock`);
@@ -306,7 +352,7 @@ export async function withPickLock(lane, task, { dir = pickLockDir(), pid = proc
306
352
  }
307
353
  const verdict = await removeIfStale(lock, token, { alive, now, staleMs });
308
354
  if (!verdict.retry)
309
- throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(PICKER_TIMEOUT_MS / 1000)} s. Rerun after it finishes (lock: ${lock}).`, 1, { pid: verdict.holder, lock });
355
+ throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(PICKER_FIRST_PICK_TIMEOUT_MS / 1000)} s. Rerun after it finishes (lock: ${lock}).`, 1, { pid: verdict.holder, lock });
310
356
  await sleep(5 + Math.random() * 20);
311
357
  }
312
358
  } finally {
@@ -325,6 +371,56 @@ const processAlive = (pid) => {
325
371
  catch (error) { return error?.code === "EPERM"; }
326
372
  };
327
373
 
374
+ /** This Mac's Gather pick timings, one JSON line per lane pick (the p50/p95 evidence). */
375
+ export const pickTimingsPath = (env = process.env) => env.NODII_DEV_GATHER_TIMINGS || join(homedir(), ".cache", "nodii-dev", "gather-timings.jsonl");
376
+
377
+ export async function recordPickTiming(entry, { path = pickTimingsPath() } = {}) {
378
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
379
+ await appendFile(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
380
+ }
381
+
382
+ /** The newest `last` timings, optionally for one model; unreadable lines are skipped. */
383
+ export async function readPickTimings({ path = pickTimingsPath(), model, last = 50 } = {}) {
384
+ const raw = await readFile(path, "utf8").catch((error) => { if (error?.code === "ENOENT") return ""; throw error; });
385
+ const entries = raw.split("\n").flatMap((line) => { try { return line ? [JSON.parse(line)] : []; } catch { return []; } });
386
+ return entries.filter((x) => !model || x.model === model).slice(-last);
387
+ }
388
+
389
+ /** Nearest-rank percentile; null for no values. */
390
+ export function percentile(values, p) {
391
+ const sorted = values.filter((x) => typeof x === "number").sort((a, b) => a - b);
392
+ if (!sorted.length) return null;
393
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
394
+ }
395
+
396
+ export function pickTimingStats(entries) {
397
+ const ok = entries.filter((x) => x.outcome === "ok");
398
+ const spread = (key) => ({ p50: percentile(ok.map((x) => x[key]), 50), p95: percentile(ok.map((x) => x[key]), 95) });
399
+ const failed = {};
400
+ for (const x of entries) if (x.outcome !== "ok") failed[x.outcome] = (failed[x.outcome] ?? 0) + 1;
401
+ return { picks: entries.length, ok: ok.length, warmMs: spread("warmMs"), pickMs: spread("pickMs"), gatherPickMs: spread("totalMs"), failed };
402
+ }
403
+
404
+ const seconds = (value) => value === null || value === undefined ? "?" : `${(value / 1000).toFixed(1)} s`;
405
+
406
+ export function pickTimingText(stats, { model, path } = {}) {
407
+ if (!stats.picks) return `${model ?? "Gather picker"}: no lane picks recorded on this Mac yet${path ? ` (${path})` : ""}.`;
408
+ const failed = Object.entries(stats.failed).map(([code, n]) => `${n} ${code}`).join(", ");
409
+ return [
410
+ `${model ?? "Gather picker"} · ${stats.picks} lane pick${stats.picks === 1 ? "" : "s"} on this Mac${path ? ` (${path})` : ""}`,
411
+ ` warm-up p50 ${seconds(stats.warmMs.p50)} p95 ${seconds(stats.warmMs.p95)}`,
412
+ ` first pick p50 ${seconds(stats.pickMs.p50)} p95 ${seconds(stats.pickMs.p95)} (budget ${seconds(PICKER_FIRST_PICK_TIMEOUT_MS)} once warm)`,
413
+ ` warm-up + pick p50 ${seconds(stats.gatherPickMs.p50)} p95 ${seconds(stats.gatherPickMs.p95)}`,
414
+ ...(failed ? [` failed ${failed}`] : []),
415
+ ].join("\n");
416
+ }
417
+
418
+ /** One line for a lane Gather's output: this pick, then this Mac's spread. */
419
+ export function pickTimingLine(timing, stats) {
420
+ const spread = stats?.ok ? `; this Mac p50 ${seconds(stats.pickMs.p50)}, p95 ${seconds(stats.pickMs.p95)} over ${stats.ok} pick${stats.ok === 1 ? "" : "s"} (nodii-dev gather timings)` : "";
421
+ return `Picker ${timing.model}: warm-up ${seconds(timing.warmMs)}, pick ${seconds(timing.pickMs)} of its ${seconds(timing.budgetMs)} budget${spread}.`;
422
+ }
423
+
328
424
  const LATENCY_PROBE = {
329
425
  objective: "Add a contacts list endpoint with cursor paging.",
330
426
  triggers: [
package/dist/hooks.js CHANGED
@@ -254,9 +254,13 @@ export async function sessionStartContext(payload, { request = devRequest, confi
254
254
  },
255
255
  { request, config },
256
256
  );
257
+ // A Freeze written before it referenced the Clause map has no pointer to it.
258
+ const pointer = result.shown && !context.includes("lane gather show")
259
+ ? `\nFull Clause map: \`nodii-dev lane gather show ${result.lane.key}\`; one clause in full: \`nodii-dev rules show <ID>\`.`
260
+ : "";
257
261
  return result.shown
258
- ? context.trimEnd()
259
- : "Freeze re-grounding: no active Dev lane with initialized Freeze was available for this worktree; run `nodii-dev lane freeze show <key>` before continuing.";
262
+ ? `${context.trimEnd()}${pointer}`
263
+ : "Freeze re-grounding: no active Dev lane with initialized Freeze was available for this worktree; run `nodii-dev lane freeze show <key>` (and `nodii-dev lane gather show <key>` for its Clause map) before continuing.";
260
264
  }
261
265
 
262
266
  export function workflowSuggestion(payload) {
package/dist/lanes.js CHANGED
@@ -4,7 +4,7 @@ import { promisify } from "node:util";
4
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
5
5
  import { launchRecovery } from "./runtime-launcher.js";
6
6
  import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
7
- import { GATHER_TIMEOUT_MS, PICKER_MODEL, gatherText, pickDocuments, withPickLock } from "./gather.js";
7
+ import { GATHER_TIMEOUT_MS, PICKER_FIRST_PICK_TIMEOUT_MS, PICKER_MODEL, agentFallbackReason, gatherDeadline, gatherText, pickDocuments, pickTimingLine, pickTimingStats, readPickTimings, recordPickTiming, warmPicker, withPickLock } from "./gather.js";
8
8
  import { storeGatherFreeze } from "./freeze.js";
9
9
  import { SWAP_BOUNDARIES, componentsForScope, guidelineLookup, laneFigmaScope, loadDsCatalog } from "./figma-scope.js";
10
10
 
@@ -207,7 +207,41 @@ async function currentGatherLane(request, config, gitContext) {
207
207
  return matches[0].key;
208
208
  }
209
209
 
210
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, gatherSignal, storeFreeze = storeGatherFreeze, launch = launchRecovery, catalog = loadDsCatalog, pickLock = withPickLock } = {}) {
210
+ const timedWarm = async (warm, signal) => {
211
+ const started = Date.now();
212
+ const status = await warm({ signal });
213
+ return { wallMs: Date.now() - started, loadMs: status?.latency?.loadMs ?? null };
214
+ };
215
+
216
+ /**
217
+ * The lane's local pick: wait for the warm-up begun at Gather start, then the
218
+ * first pick on its own budget. Every pick that reached the model is logged
219
+ * for this Mac's p50/p95, failures included.
220
+ */
221
+ async function localPick(lane, warming, pick, { budgetMs, timings }) {
222
+ const started = Date.now();
223
+ const warmed = await warming;
224
+ const pickStarted = Date.now();
225
+ const entry = (outcome, picked) => ({
226
+ at: new Date().toISOString(), lane, model: picked?.model ?? PICKER_MODEL, outcome,
227
+ warmMs: pickStarted - started, warmLoadMs: warmed?.loadMs ?? null,
228
+ pickMs: picked?.latency?.wallMs ?? Date.now() - pickStarted, loadMs: picked?.latency?.loadMs ?? null,
229
+ totalMs: Date.now() - started, budgetMs,
230
+ });
231
+ let picked;
232
+ try {
233
+ picked = await pick();
234
+ } catch (error) {
235
+ if (error instanceof CliError && ["PICKER_TIMEOUT", "PICKER_RESPONSE_INVALID"].includes(error.code)) await timings.record(entry(error.code)).catch(() => {});
236
+ throw error;
237
+ }
238
+ const timing = entry("ok", picked);
239
+ await timings.record(timing).catch(() => {});
240
+ const stats = pickTimingStats(await timings.read({ model: timing.model }).catch(() => []));
241
+ return { ...picked, timing, stats };
242
+ }
243
+
244
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, warm = warmPicker, firstPickTimeoutMs = PICKER_FIRST_PICK_TIMEOUT_MS, timings = { record: recordPickTiming, read: readPickTimings }, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, deadline: outerDeadline, storeFreeze = storeGatherFreeze, launch = launchRecovery, catalog = loadDsCatalog, pickLock = withPickLock } = {}) {
211
245
  if (action === "list") {
212
246
  const c = flags.mine ? await config() : null;
213
247
  const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
@@ -231,6 +265,16 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
231
265
  const payload = await request("POST", `/api/v1/lanes/${encode(key)}/start`, via);
232
266
  return { payload, human: (p) => `Started ${laneLine(p.data)}` };
233
267
  }
268
+ // The Freeze references the Clause map rather than holding it (hub-58).
269
+ if (action === "gather" && args[0] === "show") {
270
+ // A Gather flag here means a Gather was meant: never show silently instead.
271
+ const gatherFlags = ["picker", "shortlist", "documents", "delta", "objective", "done-check", "paths", "components", "limit", "section-limit-per-document"].filter((flag) => flags[flag] !== undefined);
272
+ if (gatherFlags.length)
273
+ throw new CliError("USAGE", `lane gather show takes only [<key>] [--gather ID]; --${gatherFlags.join(", --")} belong to lane gather.`, 2);
274
+ const key = need(args[1] ?? await currentGatherLane(request, config, gitContext), "lane gather show needs <key>");
275
+ const payload = await request("GET", `/api/v1/lanes/${encode(key)}/gather${query({ gatherId: flags.gather })}`);
276
+ return { payload, human: (p) => `Gather ${p.data.gatherId}\n${gatherText(p, p.data.objective ?? "")}` };
277
+ }
234
278
  const ref = need(args[0] ?? (action === "gather" ? await currentGatherLane(request, config, gitContext) : undefined), `lane ${action} needs <key>`);
235
279
  const id = ref;
236
280
  if (action === "show") {
@@ -238,11 +282,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
238
282
  return { payload, human: (p) => detailText(p.data) };
239
283
  }
240
284
  if (action === "gather") {
241
- const controller = gatherSignal ? null : new AbortController();
242
- const deadline = controller ? setTimeout(() => controller.abort(), gatherTimeoutMs) : null;
243
- const signal = gatherSignal ?? controller.signal;
285
+ const deadline = outerDeadline ?? gatherDeadline(gatherTimeoutMs);
286
+ const signal = deadline.signal;
287
+ // Stops a warm-up still loading when Gather fails before its pick.
288
+ const warmStop = new AbortController();
244
289
  try {
245
290
  const byAgent = agentPickerFlags(flags);
291
+ // Warm the local picker at Gather start, alongside Dev's requests (GATHER_TIMEOUT, 30 Oct).
292
+ const warming = byAgent ? null : timedWarm(warm, warmStop.signal);
293
+ warming?.catch(() => {});
246
294
  const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
247
295
  if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
248
296
  throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
@@ -343,7 +391,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
343
391
  return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers } }, human: (p) => agentShortlistText(ref, p.data, flags) };
344
392
  // Only the local model pick takes the per-lane pick lock; an agent pick
345
393
  // records a choice already made (AGENT-HARNESS-19 + GATHER-NA).
346
- const picked = byAgent ? { documents: byAgent.documents } : await pickLock(lane.id ?? id, () => picker({
394
+ const pickInput = {
347
395
  objective,
348
396
  files: paths,
349
397
  triggers: triggers.map((trigger) => ({
@@ -351,7 +399,19 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
351
399
  trigger: trigger.sentence,
352
400
  ...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}),
353
401
  })),
354
- }, { signal }));
402
+ };
403
+ let picked = byAgent && { documents: byAgent.documents };
404
+ if (!picked) {
405
+ try {
406
+ // Dev's deadline stops while the pick runs on its own budgets.
407
+ picked = await deadline.hold(() => localPick(ref, warming, () => pickLock(lane.id ?? id, () => picker(pickInput, { signal, timeoutMs: firstPickTimeoutMs })), { budgetMs: firstPickTimeoutMs, timings }));
408
+ } catch (error) {
409
+ // No picker on this Mac: the agent picks from the same shortlist (hub-58).
410
+ const fallback = agentFallbackReason(error);
411
+ if (!fallback) throw error;
412
+ return { payload: { data: { recorded: false, picker: "agent", fallback, shortlistId, objective, triggers } }, human: (p) => `picker: agent (${p.data.fallback})\n${agentShortlistText(ref, p.data, flags)}` };
413
+ }
414
+ }
355
415
  const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
356
416
  objective,
357
417
  documentCodes: picked.documents,
@@ -391,13 +451,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
391
451
  ...(invalidScope ? [`Notice: the launch Figma scope is not published keys (${invalidScope.replace(/\.$/, "")}), so the Freeze was stored with no Figma scope and every Figma lock and write is refused. Set the published keys with ${SWAP_BOUNDARIES(id)}.`] : []),
392
452
  ].map((line) => `\n${line}`).join("");
393
453
  // The pick's model and latency ride along in --json (morning check records them).
394
- const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null } };
395
- return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}` };
454
+ const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null, ...(picked.timing ? { timing: picked.timing, stats: picked.stats } : {}) } };
455
+ const timingLine = picked.timing ? `\n${pickTimingLine(picked.timing, picked.stats)}` : "";
456
+ return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}${timingLine}` };
396
457
  } catch (error) {
397
- if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
458
+ if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(deadline.ms / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
398
459
  throw error;
399
460
  } finally {
400
- if (deadline) clearTimeout(deadline);
461
+ warmStop.abort();
462
+ if (!outerDeadline) deadline.clear();
401
463
  }
402
464
  }
403
465
  if (action === "step") {