@indigoai-us/hq-cli 5.292.0 → 5.293.0

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.
@@ -11,6 +11,9 @@ export const LEAD_AUTOPILOT_FILL_DELAY_MS = 15 * 60_000;
11
11
  export const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
12
12
  const LIVE_STATES = new Set(["running", "queued", "awaiting_input", "blocked"]);
13
13
  const OPEN_STORY_STATES = new Set(["todo", "queued", "in_progress", "review", "blocked"]);
14
+ function isOpenProjectStory(story) {
15
+ return story.passes !== true && OPEN_STORY_STATES.has(story.status);
16
+ }
14
17
  function isRecord(value) {
15
18
  return value !== null && typeof value === "object" && !Array.isArray(value);
16
19
  }
@@ -95,18 +98,25 @@ function projectStories(hqRoot, relativePath, companySlug, projectId) {
95
98
  throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: userStories or stories must be an array`, { path: file });
96
99
  }
97
100
  return rows.map((story, index) => {
98
- const status = isRecord(story) ? story.status ?? story.state : undefined;
101
+ const rawStatus = isRecord(story) ? story.status ?? story.state : undefined;
102
+ const status = isRecord(story) && story.passes === true ? "done" : rawStatus;
99
103
  if (!isRecord(story) || typeof story.id !== "string" || typeof status !== "string") {
100
104
  throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] needs string id and status/state`, { path: file, index });
101
105
  }
102
106
  const sourceRef = isRecord(story.source) ? story.source.ref : undefined;
103
107
  const rawBrief = story.brief_path ?? story.brief_file ?? sourceRef;
104
- if (story.lanes !== undefined && (!Array.isArray(story.lanes) || story.lanes.some((laneId) => typeof laneId !== "string" || !laneId.trim()))) {
105
- throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] lanes must be an array of non-empty lane IDs`, { path: file, index });
108
+ if (story.lanes !== undefined && (!Array.isArray(story.lanes) || story.lanes.some((lane) => typeof lane === "string" ? !lane.trim() : !isRecord(lane) || typeof lane.lane_id !== "string" || !lane.lane_id.trim()))) {
109
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] lanes must contain lane IDs or lane_id objects`, { path: file, index });
106
110
  }
107
111
  if (story.updated_at !== undefined && (typeof story.updated_at !== "string" || !Number.isFinite(Date.parse(story.updated_at)))) {
108
112
  throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] updated_at must be a valid timestamp`, { path: file, index });
109
113
  }
114
+ if (story.lane !== undefined && story.lane !== null && typeof story.lane !== "string") {
115
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] lane must be a string`, { path: file, index });
116
+ }
117
+ if (story.external_blocker !== undefined && (typeof story.external_blocker !== "string" || !story.external_blocker.trim())) {
118
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] external_blocker must be a non-empty string`, { path: file, index });
119
+ }
110
120
  if (rawBrief !== undefined && (typeof rawBrief !== "string" || !rawBrief.trim())) {
111
121
  throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] brief path must be a non-empty string`, { path: file, index });
112
122
  }
@@ -131,30 +141,96 @@ function projectStories(hqRoot, relativePath, companySlug, projectId) {
131
141
  return {
132
142
  id: story.id,
133
143
  status,
144
+ ...(story.passes === true ? { passes: true } : {}),
134
145
  ...(briefPath ? { briefPath } : {}),
135
- ...(Array.isArray(story.lanes) ? { laneIds: story.lanes } : {}),
146
+ ...(Array.isArray(story.lanes) ? { laneIds: story.lanes.map((lane) => typeof lane === "string" ? lane : lane.lane_id) } : typeof story.lane === "string" && story.lane.trim() ? { laneIds: [story.lane] } : story.lane === "" ? { laneIds: [] } : {}),
136
147
  ...(typeof story.updated_at === "string" ? { updatedAt: story.updated_at } : {}),
148
+ ...(typeof story.external_blocker === "string" ? { externalBlocker: story.external_blocker } : {}),
137
149
  };
138
150
  });
139
151
  }
140
- function readStatus(hqRoot, relativePath) {
141
- const file = registeredFilePath(hqRoot, relativePath, "lead_autopilot_status_path_invalid");
152
+ function readStatus(hqRoot, relativePath, companySlug) {
153
+ if (path.isAbsolute(relativePath)) {
154
+ throw new LanesError("lead_autopilot_status_path_invalid", `Registered status path must be relative to the HQ root: ${relativePath}`, { path: relativePath });
155
+ }
156
+ const root = path.resolve(hqRoot);
157
+ const file = registeredFilePath(root, relativePath, "lead_autopilot_status_path_invalid");
158
+ const assertCompanyBoundary = (candidate) => {
159
+ const relative = path.relative(root, candidate).split(path.sep);
160
+ if (relative[0] === "companies" && relative[1] !== companySlug) {
161
+ throw new LanesError("lead_autopilot_tenant_mismatch", `Manager status path does not match lead company ${companySlug}`, { path: relativePath, company: companySlug });
162
+ }
163
+ };
164
+ assertCompanyBoundary(path.resolve(root, relativePath));
165
+ assertCompanyBoundary(fs.realpathSync(file));
142
166
  const status = readJsonFile(file, "lead_autopilot_status_invalid");
143
167
  if (!isRecord(status))
144
168
  throw new LanesError("lead_autopilot_status_invalid", `Invalid manager status ${file}: expected an object`, { path: file });
145
169
  return status;
146
170
  }
171
+ function parseOkrReports(status) {
172
+ const raw = status.okr_reports;
173
+ if (raw === undefined)
174
+ return [];
175
+ if (!Array.isArray(raw)) {
176
+ throw new LanesError("lead_autopilot_okr_reports_invalid", "Manager status okr_reports must be an array");
177
+ }
178
+ return raw.map((value, index) => {
179
+ if (!isRecord(value) || typeof value.at !== "string" || !Number.isFinite(Date.parse(value.at)) || !Array.isArray(value.kpis) || !Array.isArray(value.okrs)) {
180
+ throw new LanesError("lead_autopilot_okr_reports_invalid", `Manager status okr_reports[${index}] must include at, kpis, and okrs`);
181
+ }
182
+ const parseLines = (rows, kind) => rows.map((row, lineIndex) => {
183
+ if (!isRecord(row) || typeof row.id !== "string" || !row.id.trim()) {
184
+ throw new LanesError("lead_autopilot_okr_reports_invalid", `Manager status okr_reports[${index}].${kind.toLowerCase()}s[${lineIndex}] needs an id`);
185
+ }
186
+ const text = typeof row.line === "string" ? row.line : typeof row.name === "string" ? row.name : typeof row.objective === "string" ? row.objective : "";
187
+ if (!text.trim()) {
188
+ throw new LanesError("lead_autopilot_okr_reports_invalid", `Manager status okr_reports[${index}].${kind.toLowerCase()}s[${lineIndex}] needs a line, name, or objective`);
189
+ }
190
+ if ((row.value !== undefined && (typeof row.value !== "number" || !Number.isFinite(row.value))) ||
191
+ (row.moved !== undefined && (typeof row.moved !== "number" || !Number.isFinite(row.moved)))) {
192
+ throw new LanesError("lead_autopilot_okr_reports_invalid", `Manager status okr_reports[${index}].${kind.toLowerCase()}s[${lineIndex}] has invalid numeric progress`);
193
+ }
194
+ return { id: row.id, line: text, ...(typeof row.value === "number" ? { value: row.value } : {}), ...(typeof row.moved === "number" ? { moved: row.moved } : {}) };
195
+ });
196
+ return { at: value.at, kpis: parseLines(value.kpis, "KPI"), okrs: parseLines(value.okrs, "OKR") };
197
+ }).sort((a, b) => a.at.localeCompare(b.at));
198
+ }
199
+ function statusBlockers(status) {
200
+ const raw = status.blockers;
201
+ if (raw === undefined)
202
+ return [];
203
+ if (!Array.isArray(raw))
204
+ throw new LanesError("lead_autopilot_status_invalid", "Manager status blockers must be an array");
205
+ return raw.map((value, index) => {
206
+ if (!isRecord(value) || typeof value.id !== "string" || !value.id.trim()) {
207
+ throw new LanesError("lead_autopilot_status_invalid", `Manager status blockers[${index}] needs a non-empty id`);
208
+ }
209
+ return value;
210
+ });
211
+ }
212
+ function leastMoved(lines, priorLines = []) {
213
+ const priorById = new Map(priorLines.map((line) => [line.id, line]));
214
+ return [...lines].sort((a, b) => {
215
+ const moved = (line) => line.moved !== undefined ? Math.abs(line.moved) :
216
+ line.value !== undefined && priorById.get(line.id)?.value !== undefined ? Math.abs(line.value - priorById.get(line.id).value) : 0;
217
+ return moved(a) - moved(b) || a.id.localeCompare(b.id);
218
+ })[0];
219
+ }
147
220
  export function planLeadAutopilotWakes(input) {
148
221
  const { lead, children, stories, now } = input;
149
222
  if (input.keepAliveResumed)
150
223
  return [];
224
+ const status = input.managerStatus ?? {};
225
+ const reports = parseOkrReports(status);
226
+ const blockers = statusBlockers(status);
151
227
  const byStory = new Map(stories.map((story) => [story.id, story]));
152
228
  const wakes = [];
153
229
  const finishedReviews = children.flatMap((child) => {
154
230
  if (!["done", "blocked", "failed", "interrupted"].includes(child.state))
155
231
  return [];
156
232
  const story = byStory.get(child.story_id);
157
- if (!story || !OPEN_STORY_STATES.has(story.status))
233
+ if (!story || !isOpenProjectStory(story))
158
234
  return [];
159
235
  const finishedAt = child.ended_at ?? child.updated_at;
160
236
  const finishedMs = Date.parse(finishedAt);
@@ -164,7 +240,7 @@ export function planLeadAutopilotWakes(input) {
164
240
  });
165
241
  const knownChildIds = new Set(children.map((child) => child.lane_id));
166
242
  const missingReviews = stories.flatMap((story) => {
167
- if (!OPEN_STORY_STATES.has(story.status) || !story.updatedAt || !story.laneIds)
243
+ if (!isOpenProjectStory(story) || !story.updatedAt || !story.laneIds)
168
244
  return [];
169
245
  const finishedMs = Date.parse(story.updatedAt);
170
246
  if (!Number.isFinite(finishedMs) || now.getTime() - finishedMs < LEAD_AUTOPILOT_REVIEW_DELAY_MS)
@@ -187,7 +263,7 @@ export function planLeadAutopilotWakes(input) {
187
263
  const liveChildren = children.filter((child) => LIVE_STATES.has(child.state));
188
264
  const liveCount = liveChildren.length;
189
265
  const assigned = new Set(liveChildren.flatMap((child) => [child.story_id, ...(child.stories ?? []).map((story) => story.story_id)]));
190
- const readyStory = stories.find((story) => story.status === "todo" && !assigned.has(story.id));
266
+ const readyStory = stories.find((story) => story.status === "todo" && !story.passes && !assigned.has(story.id));
191
267
  if (readyStory && liveCount < (lead.hire_grant ?? 0)) {
192
268
  const fingerprint = `fill:${readyStory.id}:${liveCount}:${lead.hire_grant ?? 0}`;
193
269
  wakes.push({
@@ -198,6 +274,84 @@ export function planLeadAutopilotWakes(input) {
198
274
  prompt: `Start a worker for ready story ${readyStory.id}. You have ${liveCount} live workers against a grant of ${lead.hire_grant ?? 0}. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
199
275
  });
200
276
  }
277
+ const hasFreeSlots = liveCount < (lead.hire_grant ?? 0);
278
+ if (hasFreeSlots && !readyStory) {
279
+ const latest = reports.at(-1);
280
+ const previous = reports.at(-2);
281
+ const kpiLine = latest ? leastMoved(latest.kpis, previous?.kpis) : undefined;
282
+ const okrLine = latest ? leastMoved(latest.okrs, previous?.okrs) : undefined;
283
+ const kpiText = kpiLine?.line ?? "no KPI line in the latest OKR report";
284
+ const okrText = okrLine?.line ?? "no OKR line in the latest OKR report";
285
+ const fingerprint = `plan:${latest?.at ?? "no-report"}:${kpiLine?.id ?? "no-kpi"}:${okrLine?.id ?? "no-okr"}:${stories.map((story) => `${story.id}:${story.status}`).sort().join(",")}`;
286
+ wakes.push({ condition: "plan_free_slots", lead_lane: lead.lane_id, story_id: "planning", fingerprint,
287
+ prompt: `You have ${liveCount} live workers against a grant of ${lead.hire_grant ?? 0} and no ready stories. Plan work against the least-moved KPI line: ${kpiText}; and OKR line: ${okrText}. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.` });
288
+ }
289
+ if (reports.length >= 2) {
290
+ const latest = reports.at(-1);
291
+ const prior = new Map(reports.at(-2).kpis.filter((line) => line.value !== undefined).map((line) => [line.id, line]));
292
+ const flat = latest.kpis.filter((line) => line.value !== undefined && prior.get(line.id)?.value === line.value);
293
+ if (flat.length > 0) {
294
+ const line = flat.map((item) => `${item.line} (${item.value})`).join("; ");
295
+ const fingerprint = `flat-kpi:${latest.at}:${flat.map((item) => `${item.id}:${item.value}`).join(",")}`;
296
+ wakes.push({ condition: "flat_kpi", lead_lane: lead.lane_id, story_id: flat.map((item) => item.id).join(","), fingerprint,
297
+ prompt: `KPI line(s) remained flat across the two latest OKR reports: ${line}. Re-plan the affected line(s). Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.` });
298
+ }
299
+ }
300
+ const staleBlockers = blockers.filter((blocker) => {
301
+ const blockerStatus = blocker.status ?? blocker.state;
302
+ const resolved = blockerStatus === "resolved" || blocker.resolved_at !== undefined && blocker.resolved_at !== null;
303
+ const owner = typeof blocker.unblock_owner === "string" ? blocker.unblock_owner : blocker.owner;
304
+ const due = blocker.unblock_by ?? blocker.unblock_at ?? blocker.due_at;
305
+ const ownerMissing = typeof owner !== "string" || !owner.trim();
306
+ const dueMissing = typeof due !== "string" || !Number.isFinite(Date.parse(due));
307
+ return resolved || ownerMissing || dueMissing;
308
+ });
309
+ if (staleBlockers.length > 0) {
310
+ const labels = staleBlockers.map((blocker) => `${String(blocker.id)}${typeof blocker.what === "string" ? ` (${blocker.what})` : ""}`).join("; ");
311
+ const fingerprint = `blockers:${staleBlockers.map((blocker) => `${String(blocker.id)}:${String(blocker.status ?? blocker.state ?? "")}:${String(blocker.resolved_at ?? "")}:${String(blocker.unblock_owner ?? blocker.owner ?? "")}:${String(blocker.unblock_by ?? blocker.unblock_at ?? blocker.due_at ?? "")}`).join(",")}`;
312
+ wakes.push({ condition: "blocker_record_needs_update", lead_lane: lead.lane_id, story_id: staleBlockers.map((blocker) => String(blocker.id)).join(","), fingerprint,
313
+ prompt: `Fix blocker records still listed as resolved or missing an unblock owner/time: ${labels}. Move resolved blockers out of blockers, and give every open blocker an owner and time. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.` });
314
+ }
315
+ if (stories.length > 0 && stories.every((story) => story.passes === true || story.status === "done" || story.status === "dropped" ||
316
+ ((story.status === "parked" || story.status === "blocked") && Boolean(story.externalBlocker)))) {
317
+ const fingerprint = `project-close:${stories.map((story) => `${story.id}:${story.status}:${story.externalBlocker ?? ""}`).sort().join(",")}`;
318
+ wakes.push({ condition: "project_ready_to_close", lead_lane: lead.lane_id, story_id: stories.map((story) => story.id).join(","), fingerprint,
319
+ prompt: `Every story is done, dropped, or parked on a named external blocker. Write the final report and mark the project complete. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.` });
320
+ }
321
+ const staleLedger = stories.flatMap((story) => {
322
+ if (story.status !== "in_progress" || !story.updatedAt || !story.laneIds)
323
+ return [];
324
+ const updated = Date.parse(story.updatedAt);
325
+ if (!Number.isFinite(updated) || now.getTime() - updated <= 30 * 60_000)
326
+ return [];
327
+ return story.laneIds.flatMap((laneId) => {
328
+ const worker = children.find((child) => child.lane_id === laneId && child.state === "running" &&
329
+ (child.story_id === story.id || (child.stories ?? []).some((carried) => carried.story_id === story.id)));
330
+ return worker ? [{ story, worker }] : [];
331
+ });
332
+ }).sort((a, b) => a.story.id.localeCompare(b.story.id) || a.worker.lane_id.localeCompare(b.worker.lane_id));
333
+ if (staleLedger.length > 0) {
334
+ const labels = staleLedger.map(({ story, worker }) => `${story.id} (lane ${worker.lane_id}, ledger updated ${story.updatedAt})`).join("; ");
335
+ const fingerprint = `stale-ledger:${staleLedger.map(({ story, worker }) => `${story.id}:${worker.lane_id}:${story.updatedAt}`).join(",")}`;
336
+ wakes.push({ condition: "stale_worker_ledger", lead_lane: lead.lane_id, story_id: staleLedger.map(({ story }) => story.id).join(","), worker_lane: staleLedger.length === 1 ? staleLedger[0].worker.lane_id : undefined, fingerprint,
337
+ prompt: `A running worker has made no ledger update in over 30 minutes: ${labels}. Check the worker and update the story ledger. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.` });
338
+ }
339
+ const withoutLiveLane = stories.flatMap((story) => {
340
+ if (story.status !== "in_progress")
341
+ return [];
342
+ const laneIds = story.laneIds ?? [];
343
+ const live = laneIds.filter((laneId) => children.some((child) => child.lane_id === laneId && LIVE_STATES.has(child.state)));
344
+ if (live.length > 0)
345
+ return [];
346
+ const detail = laneIds.length === 0 ? "empty lane assignment" : `no live lane among ${laneIds.join(", ")}`;
347
+ return [{ story, laneIds, detail }];
348
+ }).sort((a, b) => a.story.id.localeCompare(b.story.id));
349
+ if (withoutLiveLane.length > 0) {
350
+ const labels = withoutLiveLane.map(({ story, detail }) => `${story.id} (${detail})`).join("; ");
351
+ const fingerprint = `missing-worker:${withoutLiveLane.map(({ story, detail }) => `${story.id}:${story.updatedAt ?? ""}:${detail}`).join(",")}`;
352
+ wakes.push({ condition: "story_without_live_lane", lead_lane: lead.lane_id, story_id: withoutLiveLane.map(({ story }) => story.id).join(","), fingerprint,
353
+ prompt: `In-progress story has no live worker lane: ${labels}. Relaunch a worker or set the story to waiting/blocked with its wake. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.` });
354
+ }
201
355
  return wakes;
202
356
  }
203
357
  function readState(file) {
@@ -212,7 +366,10 @@ function readState(file) {
212
366
  (row.status_updated_at !== undefined && typeof row.status_updated_at !== "string") ||
213
367
  (row.acted !== undefined && typeof row.acted !== "boolean") ||
214
368
  (row.auto_started !== undefined && typeof row.auto_started !== "boolean") ||
215
- (row.worker_lane !== undefined && typeof row.worker_lane !== "string")) {
369
+ (row.worker_lane !== undefined && typeof row.worker_lane !== "string") ||
370
+ (row.wake_count !== undefined && (!Number.isSafeInteger(row.wake_count) || row.wake_count < 1)) ||
371
+ (row.last_wake_at !== undefined && typeof row.last_wake_at !== "string") ||
372
+ (row.escalated_at !== undefined && typeof row.escalated_at !== "string")) {
216
373
  throw new LanesError("lead_autopilot_state_invalid", `Invalid autopilot state entry ${key}`, { path: file, key });
217
374
  }
218
375
  }
@@ -224,9 +381,31 @@ function writeState(file, state) {
224
381
  fs.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
225
382
  fs.renameSync(temp, file);
226
383
  }
384
+ function recordOperatorEscalation(hqRoot, record) {
385
+ const file = path.join(hqRoot, "workspace", "lane-briefs", "ops-board-state.json");
386
+ let state = {};
387
+ if (fs.existsSync(file)) {
388
+ const value = readJsonFile(file, "lead_autopilot_escalation_record_invalid");
389
+ if (!isRecord(value))
390
+ throw new LanesError("lead_autopilot_escalation_record_invalid", `Invalid operator board state ${file}`);
391
+ state = value;
392
+ }
393
+ const current = state.autopilot_escalations;
394
+ if (current !== undefined && (!Array.isArray(current) || current.some((item) => !isRecord(item)))) {
395
+ throw new LanesError("lead_autopilot_escalation_record_invalid", `Invalid autopilot escalations in ${file}`);
396
+ }
397
+ const rows = (current ?? []);
398
+ if (rows.some((item) => item.lead_lane === record.lead_lane && item.fingerprint === record.fingerprint))
399
+ return;
400
+ const next = { ...state, autopilot_escalations: [...rows, record] };
401
+ fs.mkdirSync(path.dirname(file), { recursive: true });
402
+ const temp = `${file}.${process.pid}.tmp`;
403
+ fs.writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
404
+ fs.renameSync(temp, file);
405
+ }
227
406
  async function runLeadAutopilotPassUnlocked(input) {
228
407
  const { hqRoot, host, now } = input;
229
- const result = { ok: true, host, evaluated: 0, sent: [], started: [], skipped: [] };
408
+ const result = { ok: true, host, evaluated: 0, sent: [], started: [], escalated: [], skipped: [] };
230
409
  const leads = parseRegistry(hqRoot).filter((lead) => lead.host === host || (host === "local" && lead.host === input.hostname));
231
410
  const lanes = readAllLanes(hqRoot);
232
411
  const byId = new Map(lanes.map((lane) => [lane.lane_id, lane]));
@@ -253,7 +432,7 @@ async function runLeadAutopilotPassUnlocked(input) {
253
432
  result.skipped.push({ lead_lane: lead.lane_id, reason: "flag_off" });
254
433
  continue;
255
434
  }
256
- const status = readStatus(hqRoot, registered.status);
435
+ const status = readStatus(hqRoot, registered.status, lead.company.slug);
257
436
  const statusUpdatedAt = typeof status.updated_at === "string" && Number.isFinite(Date.parse(status.updated_at))
258
437
  ? new Date(Date.parse(status.updated_at)).toISOString()
259
438
  : undefined;
@@ -265,6 +444,7 @@ async function runLeadAutopilotPassUnlocked(input) {
265
444
  children,
266
445
  stories,
267
446
  now,
447
+ managerStatus: status,
268
448
  keepAliveResumed: input.keepAliveResumedLeads?.has(lead.lane_id) === true,
269
449
  });
270
450
  let stateChanged = false;
@@ -279,36 +459,69 @@ async function runLeadAutopilotPassUnlocked(input) {
279
459
  const key = `${lead.lane_id}:${wake.condition}`;
280
460
  const prior = state.sent[key];
281
461
  if (prior?.fingerprint === wake.fingerprint) {
282
- if (wake.condition === "fill_grant" && input.startWorker && !prior.acted && !prior.auto_started) {
283
- const wakeAt = Date.parse(prior.at);
284
- if (!Number.isFinite(wakeAt)) {
462
+ if (input.dryRun)
463
+ continue;
464
+ let current = prior;
465
+ const lastWakeAt = Date.parse(prior.last_wake_at ?? prior.at);
466
+ if (!Number.isFinite(lastWakeAt)) {
467
+ throw new LanesError("lead_autopilot_state_invalid", `Invalid wake time for ${lead.lane_id}`, { lead_lane: lead.lane_id, story_id: wake.story_id });
468
+ }
469
+ if (statusUpdatedAt === undefined || prior.status_updated_at === undefined) {
470
+ throw new LanesError("lead_autopilot_status_activity_unverifiable", `Cannot verify whether lead ${lead.lane_id} acted after its autopilot wake`, { lead_lane: lead.lane_id, story_id: wake.story_id });
471
+ }
472
+ if (!prior.acted && Date.parse(statusUpdatedAt) > Date.parse(prior.status_updated_at)) {
473
+ current = { ...prior, acted: true };
474
+ state.sent[key] = current;
475
+ stateChanged = true;
476
+ }
477
+ if (wake.condition === "fill_grant" && input.startWorker && !current.acted && !current.auto_started) {
478
+ const firstWakeAt = Date.parse(current.at);
479
+ if (!Number.isFinite(firstWakeAt)) {
285
480
  throw new LanesError("lead_autopilot_state_invalid", `Invalid fill-grant wake time for ${lead.lane_id}`, { lead_lane: lead.lane_id, story_id: wake.story_id });
286
481
  }
287
- if (statusUpdatedAt === undefined || prior.status_updated_at === undefined) {
288
- throw new LanesError("lead_autopilot_status_activity_unverifiable", `Cannot verify whether lead ${lead.lane_id} acted after its fill-grant wake`, { lead_lane: lead.lane_id, story_id: wake.story_id });
289
- }
290
- if (Date.parse(statusUpdatedAt) > Date.parse(prior.status_updated_at)) {
291
- state.sent[key] = { ...prior, acted: true };
292
- stateChanged = true;
293
- }
294
- else if (now.getTime() - wakeAt >= LEAD_AUTOPILOT_FILL_DELAY_MS) {
482
+ if (now.getTime() - firstWakeAt >= LEAD_AUTOPILOT_FILL_DELAY_MS) {
295
483
  const story = stories.find((candidate) => candidate.id === wake.story_id && candidate.status === "todo");
296
484
  if (!story?.briefPath) {
297
485
  throw new LanesError("lead_autopilot_story_brief_missing", `Cannot auto-start story ${wake.story_id}: its project record has no valid brief path`, { lead_lane: lead.lane_id, story_id: wake.story_id, project_id: lead.project_id });
298
486
  }
299
487
  if (lead.state !== "running" && lead.state !== "queued") {
300
- state.sent[key] = { ...prior, acted: true };
488
+ current = { ...current, acted: true };
489
+ state.sent[key] = current;
301
490
  stateChanged = true;
302
491
  result.skipped.push({ lead_lane: lead.lane_id, reason: "auto_start_lead_not_active" });
303
492
  }
304
493
  else {
305
494
  const started = await input.startWorker({ lead, story });
306
- state.sent[key] = { ...prior, auto_started: true, worker_lane: started.lane_id };
495
+ current = { ...current, auto_started: true, worker_lane: started.lane_id };
496
+ state.sent[key] = current;
307
497
  stateChanged = true;
308
498
  result.started.push({ lead_lane: lead.lane_id, story_id: story.id, worker_lane: started.lane_id });
309
499
  }
310
500
  }
311
501
  }
502
+ if (!current.acted && !current.auto_started && current.escalated_at === undefined && now.getTime() - lastWakeAt >= LEAD_AUTOPILOT_FILL_DELAY_MS) {
503
+ const wakeCount = current.wake_count ?? 1;
504
+ if (wakeCount < 2) {
505
+ appendDropboxMessage(hqRoot, lead.lane_id, {
506
+ at: now.toISOString(),
507
+ message_id: `lead-autopilot:${wake.fingerprint}:wake-2`,
508
+ kind: "system_notice",
509
+ text: wake.prompt,
510
+ });
511
+ current = { ...current, wake_count: 2, last_wake_at: now.toISOString() };
512
+ state.sent[key] = current;
513
+ stateChanged = true;
514
+ result.sent.push(wake);
515
+ }
516
+ else {
517
+ const escalation = { team: registered.team, lead_lane: lead.lane_id, condition: wake.condition, story_id: wake.story_id, at: now.toISOString(), fingerprint: wake.fingerprint };
518
+ recordOperatorEscalation(hqRoot, escalation);
519
+ current = { ...current, escalated_at: now.toISOString() };
520
+ state.sent[key] = current;
521
+ stateChanged = true;
522
+ result.escalated.push(escalation);
523
+ }
524
+ }
312
525
  continue;
313
526
  }
314
527
  if (!input.dryRun) {
@@ -322,6 +535,8 @@ async function runLeadAutopilotPassUnlocked(input) {
322
535
  fingerprint: wake.fingerprint,
323
536
  at: now.toISOString(),
324
537
  ...(statusUpdatedAt ? { status_updated_at: statusUpdatedAt } : {}),
538
+ wake_count: 1,
539
+ last_wake_at: now.toISOString(),
325
540
  };
326
541
  stateChanged = true;
327
542
  }
@@ -332,6 +547,17 @@ async function runLeadAutopilotPassUnlocked(input) {
332
547
  }
333
548
  return result;
334
549
  }
550
+ const LEAD_AUTOPILOT_STALE_LOCK_MAX_AGE_MS = 60 * 60_000;
551
+ function processStartIdentity(pid) {
552
+ try {
553
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
554
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
555
+ return fields[19] ?? null;
556
+ }
557
+ catch {
558
+ return null;
559
+ }
560
+ }
335
561
  function reclaimStaleLock(lockPath) {
336
562
  let before;
337
563
  let text;
@@ -348,17 +574,34 @@ function reclaimStaleLock(lockPath) {
348
574
  }
349
575
  if (!before.isFile() || before.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino)
350
576
  return false;
351
- const pid = Number(text.split("\n", 1)[0]);
577
+ const [pidText, createdAt, recordedStartIdentity] = text.split("\n");
578
+ const pid = Number(pidText);
352
579
  if (!Number.isSafeInteger(pid) || pid < 1)
353
580
  return false;
581
+ let dead = false;
354
582
  try {
355
583
  process.kill(pid, 0);
356
- return false;
584
+ const currentStartIdentity = processStartIdentity(pid);
585
+ if (recordedStartIdentity && currentStartIdentity) {
586
+ if (recordedStartIdentity !== currentStartIdentity)
587
+ dead = true;
588
+ else
589
+ return false;
590
+ }
591
+ else {
592
+ const createdMs = Date.parse(createdAt ?? "");
593
+ if (!Number.isFinite(createdMs) || Date.now() - createdMs < LEAD_AUTOPILOT_STALE_LOCK_MAX_AGE_MS)
594
+ return false;
595
+ dead = true;
596
+ }
357
597
  }
358
598
  catch (error) {
359
599
  if (error.code !== "ESRCH")
360
600
  return false;
601
+ dead = true;
361
602
  }
603
+ if (!dead)
604
+ return false;
362
605
  try {
363
606
  const current = fs.lstatSync(lockPath);
364
607
  if (current.dev !== before.dev || current.ino !== before.ino)
@@ -381,7 +624,7 @@ export async function runLeadAutopilotPass(input) {
381
624
  for (let attempt = 0; attempt < 2; attempt += 1) {
382
625
  try {
383
626
  lockFd = fs.openSync(lockPath, "wx", 0o600);
384
- fs.writeFileSync(lockFd, `${process.pid}\n${input.now.toISOString()}\n`, "utf8");
627
+ fs.writeFileSync(lockFd, `${process.pid}\n${input.now.toISOString()}\n${processStartIdentity(process.pid) ?? ""}\n`, "utf8");
385
628
  break;
386
629
  }
387
630
  catch (error) {
@@ -13,7 +13,7 @@ export interface HireGrantAdmissionSnapshot {
13
13
  export declare function terminalChildResumeHireGrantError(current: LaneRecord, snapshot: HireGrantAdmissionSnapshot): LanesError | null;
14
14
  export declare function hireGrantCapacityError(parent: LaneHireGrantRow, children: readonly LaneHireGrantRow[]): LanesError;
15
15
  /** Keep the occupancy snapshot and the resulting lane write in one parent-lock scope. */
16
- export declare function withHireGrantAdmission<T>(hqRoot: string, parentLaneId: string, fn: (snapshot: HireGrantAdmissionSnapshot) => T, suppliedHierarchy?: readonly LaneHireGrantRow[]): T;
16
+ export declare function withHireGrantAdmission<T>(hqRoot: string, parentLaneId: string, fn: (snapshot: HireGrantAdmissionSnapshot) => T, suppliedHierarchy?: readonly LaneHireGrantRow[], preferSuppliedLaneIds?: ReadonlySet<string>): T;
17
17
  export interface HireGrantBackfillRow {
18
18
  lane_id: string;
19
19
  existing_grant: number | null;
@@ -72,7 +72,7 @@ export function hireGrantCapacityError(parent, children) {
72
72
  });
73
73
  }
74
74
  /** Keep the occupancy snapshot and the resulting lane write in one parent-lock scope. */
75
- export function withHireGrantAdmission(hqRoot, parentLaneId, fn, suppliedHierarchy) {
75
+ export function withHireGrantAdmission(hqRoot, parentLaneId, fn, suppliedHierarchy, preferSuppliedLaneIds = new Set()) {
76
76
  return withLaneHireLocks(hqRoot, [parentLaneId], () => {
77
77
  const ownerLanes = listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
78
78
  let allLanes;
@@ -83,10 +83,12 @@ export function withHireGrantAdmission(hqRoot, parentLaneId, fn, suppliedHierarc
83
83
  }
84
84
  else {
85
85
  const byId = new Map(suppliedHierarchy.map((lane) => [lane.lane_id, lane]));
86
- // The owner copy is authoritative for every lane it stores; the supplied
87
- // primary snapshot fills only hierarchy rows that live on another host.
88
- for (const lane of ownerLanes)
89
- byId.set(lane.lane_id, lane);
86
+ // The owner copy is authoritative for every lane it stores unless the
87
+ // caller supplied a freshly verified owner snapshot for that lane.
88
+ for (const lane of ownerLanes) {
89
+ if (!preferSuppliedLaneIds.has(lane.lane_id))
90
+ byId.set(lane.lane_id, lane);
91
+ }
90
92
  allLanes = [...byId.values()];
91
93
  const suppliedParent = byId.get(parentLaneId);
92
94
  if (!suppliedParent) {
@@ -10,7 +10,7 @@ export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
10
10
  export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
11
11
  export type { HostCapacityAccount, HostCapacityProvider, HostCapacitySnapshot, HostPlacementChoice, } from "./host-capacity.js";
12
12
  export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
13
- export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, LaneSettingsHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
13
+ export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionPriority, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, LaneSettingsHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
14
14
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
15
15
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
16
16
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
@@ -1,6 +1,9 @@
1
1
  import type { LaneRecord, ManagerStatusGateHistoryEntry, QuestionRecord } from "./types.js";
2
2
  export declare const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
3
3
  export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
4
+ export declare const LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG = "lanes.keep-alive-resume-blocked";
5
+ export declare const LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_DEFAULT_ENABLED = false;
6
+ export declare const LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS: number;
4
7
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
5
8
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
6
9
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
@@ -20,10 +23,12 @@ export interface LaneTerminalResumeResult {
20
23
  export declare function queueLaneTerminalResume(current: LaneRecord, at: string, resumeText: string, options?: {
21
24
  allowResume?: boolean;
22
25
  mergeExisting?: boolean;
26
+ allowBlockedResume?: boolean;
23
27
  }): LaneTerminalResumeResult;
24
28
  /** Persist a manager status miss and queue its one bounded follow-up turn. */
25
29
  export declare function queueManagerStatusGateResume(hqRoot: string, laneId: string, entry: Omit<ManagerStatusGateHistoryEntry, "event" | "resume_queued" | "resume_skipped_reason">, resumeText: string): LaneRecord;
26
30
  export declare const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
31
+ export declare const LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT = "You ended blocked with no pending question. Record the event you wait on in waiting_on, re-check each blocker, and continue the next unblocked story.";
27
32
  export declare const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
28
33
  export type KeepAliveBlockedReason = NonNullable<LaneRecord["keep_alive_blocked_reason"]>;
29
34
  /** The latest terminal envelope fields used by both watch and reconcile. */
@@ -5,6 +5,9 @@ import { dropboxInboxHasMessages } from "./dropbox.js";
5
5
  import { appendCappedHistory, listQuestionIds, readQuestion, retryUpdateLane } from "./store.js";
6
6
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
7
7
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
8
+ export const LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG = "lanes.keep-alive-resume-blocked";
9
+ export const LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_DEFAULT_ENABLED = false;
10
+ export const LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS = 15 * 60 * 1000;
8
11
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
9
12
  export const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
10
13
  export const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
@@ -28,16 +31,17 @@ export function queueLaneTerminalResume(current, at, resumeText, options = {}) {
28
31
  resumeQueued: true,
29
32
  };
30
33
  }
31
- if (current.state === "blocked")
34
+ if (current.state === "blocked" && options.allowBlockedResume !== true)
32
35
  return { lane: current, resumeQueued: false, resumeSkippedReason: "blocked" };
33
36
  if (current.state === "awaiting_input")
34
37
  return { lane: current, resumeQueued: false, resumeSkippedReason: "open_question" };
35
38
  if (options.allowResume === false)
36
39
  return { lane: current, resumeQueued: false, resumeSkippedReason: "miss_limit" };
37
- if (current.state !== "done" && current.state !== "interrupted" && current.state !== "failed") {
40
+ if (current.state !== "done" && current.state !== "interrupted" && current.state !== "failed" && !(current.state === "blocked" && options.allowBlockedResume === true)) {
38
41
  return { lane: current, resumeQueued: false };
39
42
  }
40
- if (!current.provider_session_id) {
43
+ const sessionId = current.provider_session_id ?? current.last_resume?.session_id;
44
+ if (!sessionId) {
41
45
  return { lane: current, resumeQueued: false, resumeSkippedReason: "no_provider_session" };
42
46
  }
43
47
  return {
@@ -51,7 +55,7 @@ export function queueLaneTerminalResume(current, at, resumeText, options = {}) {
51
55
  terminal_reason: undefined,
52
56
  terminal_details: undefined,
53
57
  pending_resume: {
54
- session_id: current.provider_session_id,
58
+ session_id: sessionId,
55
59
  trigger: "resume",
56
60
  resumed_from: current.state,
57
61
  text: resumeText,
@@ -92,6 +96,7 @@ export function queueManagerStatusGateResume(hqRoot, laneId, entry, resumeText)
92
96
  });
93
97
  }
94
98
  export const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
99
+ export const LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT = "You ended blocked with no pending question. Record the event you wait on in waiting_on, re-check each blocker, and continue the next unblocked story.";
95
100
  export const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
96
101
  /** Shared done-state policy used by watch, reconcile, and the show command. */
97
102
  export function doneKeepAliveVerdict(lane, envelope, hasQueuedWork) {
@@ -3,7 +3,7 @@
3
3
  * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
4
  * drift on hop, origin, raised_via, body store, and routing_history.
5
5
  */
6
- import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
6
+ import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionPriority, type QuestionRecord, type RaisedVia } from "./types.js";
7
7
  export interface QuestionBody {
8
8
  question: string;
9
9
  context: string;
@@ -42,6 +42,7 @@ export interface RaiseLaneQuestionInput {
42
42
  at: string;
43
43
  activeCompany?: LaneCompanyRef | null;
44
44
  questionId?: string;
45
+ priority?: QuestionPriority;
45
46
  }
46
47
  /**
47
48
  * Persist one question the way `hq lanes ask` does: createQuestion, write the
@@ -172,6 +172,7 @@ export function raiseLaneQuestion(input) {
172
172
  story_id: input.lane.story_id,
173
173
  thread_id: input.lane.thread_id,
174
174
  raised_via: input.raisedVia,
175
+ ...(input.priority ? { priority: input.priority } : {}),
175
176
  asked_at: input.at,
176
177
  origin: { kind: "lane", id: input.lane.lane_id },
177
178
  hop: 1,
@@ -191,6 +192,7 @@ export function raiseLaneQuestion(input) {
191
192
  story_id: input.lane.story_id,
192
193
  thread_id: input.lane.thread_id,
193
194
  raised_via: input.raisedVia,
195
+ ...(input.priority ? { priority: input.priority } : {}),
194
196
  asked_at: input.at,
195
197
  origin: { kind: "lane", id: input.lane.lane_id },
196
198
  hop: 1,