@indigoai-us/hq-cli 5.215.0 → 5.216.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.216.0] — 2026-09-26
6
+
7
+ ### Fixed
8
+
9
+ - Lane watchers keep valid questions from a malformed `ask` envelope when
10
+ repair is skipped, cannot start, or still produces invalid output. The lane
11
+ stores the validation errors, and recovered question context includes them.
12
+ Repair spawns ignore unknown profile metadata and log the field names.
13
+ `hq lanes create` and `hq lanes resume --worker` keep strict validation.
14
+
15
+ - `hq lanes` stops sending status updates for stories missing from the project
16
+ board. The watcher reports the refusal once per lane run and keeps appending
17
+ lane events.
18
+
5
19
  ## [5.215.0] — 2026-09-26
6
20
 
7
21
  ### Fixed
@@ -9,6 +23,7 @@
9
23
  - hq-cli 5.211.0 could resume a retired lead from its worker profile's
10
24
  `keep_alive` value. Reconcile now requires an explicit lane opt-in written
11
25
  after the latest envelope, and skips retired or superseded leads.
26
+
12
27
  ## [5.214.0] — 2026-09-26
13
28
 
14
29
  ### Added
@@ -36,7 +36,7 @@ import { CompanySelectionError } from "../utils/company-selection-error.js";
36
36
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
37
37
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
38
38
  import { effectiveAccountHome } from "../lib/lanes/account-home.js";
39
- import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
39
+ import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
40
40
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
41
41
  import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
42
42
  import { keepAliveExclusion, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
@@ -897,41 +897,6 @@ async function withResumeHireAdmission(ctx, lane, fn) {
897
897
  return fn();
898
898
  });
899
899
  }
900
- function hireGrantCapacityError(parent, children) {
901
- const grant = parent.hire_grant ?? 0;
902
- const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
903
- parent.senior.id
904
- ? { kind: parent.senior.kind, id: parent.senior.id }
905
- : null;
906
- const grantorReason = grantor
907
- ? undefined
908
- : parent.senior.kind === "user"
909
- ? "senior_is_user"
910
- : "parent_senior_missing_id";
911
- const capacity = children.length === grant
912
- ? `is at its hire grant (${children.length} of ${grant} alive children)`
913
- : `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
914
- const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
915
- const authorityGuidance = grantor
916
- ? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
917
- : parent.senior.kind === "user"
918
- ? `Its senior is the user and can raise it with: ${grantCommand}`
919
- : `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
920
- const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
921
- return new LanesError("hire_grant_exhausted", message, {
922
- senior_lane_id: parent.lane_id,
923
- parent_grant: grant,
924
- alive_children: children.map((child) => child.lane_id),
925
- alive_child_count: children.length,
926
- grantor,
927
- ...(grantorReason ? { grantor_reason: grantorReason } : {}),
928
- message,
929
- ask_guidance: {
930
- envelope: "decision=ask; include the desired total grant and the reason",
931
- senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
932
- },
933
- });
934
- }
935
900
  const CAPACITY_PROBE_TIMEOUT_MS = 15_000;
936
901
  const MAX_PLACEMENT_OUTPOSTS = 16;
937
902
  function outpostIdentityUnknownError(message, expectedId, details = {}) {
@@ -2072,7 +2037,7 @@ async function emitLaneTerminalStateSoft(ctx, lane) {
2072
2037
  process.stderr.write(`hq lanes: terminal mesh update failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
2073
2038
  }
2074
2039
  }
2075
- async function emitLaneStartedForStories(ctx, company, lane, threadId) {
2040
+ async function emitLaneStartedForStories(ctx, company, lane, threadId, knownAbsentStories = new Set()) {
2076
2041
  let ready = lane;
2077
2042
  if (ctx.mesh.ensureLaneThread) {
2078
2043
  try {
@@ -2108,6 +2073,8 @@ async function emitLaneStartedForStories(ctx, company, lane, threadId) {
2108
2073
  failures.push(err);
2109
2074
  continue;
2110
2075
  }
2076
+ if (knownAbsentStories.has(storyBoardStoryKey(story.project_id, story.story_id)))
2077
+ continue;
2111
2078
  try {
2112
2079
  await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
2113
2080
  }
@@ -2135,20 +2102,30 @@ function storyNotOnBoardWarning(err) {
2135
2102
  ? err.message
2136
2103
  : undefined;
2137
2104
  }
2105
+ function storyBoardStoryKey(projectId, storyId) {
2106
+ return JSON.stringify([projectId, storyId]);
2107
+ }
2138
2108
  function combinedStoryWarning(lane, additional = []) {
2139
2109
  const warnings = [storyWarning(lane), ...additional]
2140
2110
  .filter((warning) => warning !== undefined && warning.trim().length > 0);
2141
2111
  return [...new Set(warnings)].join(" ") || null;
2142
2112
  }
2143
- async function storyBoardWarning(ctx, company, projectId, storyIds) {
2113
+ async function storyBoardCheck(ctx, company, projectId, storyIds) {
2144
2114
  try {
2145
2115
  await assertStoriesOnBoard(ctx, company, projectId, storyIds);
2146
- return undefined;
2116
+ return { absentStoryIds: new Set() };
2147
2117
  }
2148
2118
  catch (err) {
2149
2119
  const warning = storyNotOnBoardWarning(err);
2150
- if (warning !== undefined)
2151
- return warning;
2120
+ if (warning !== undefined) {
2121
+ const storyId = isLanesError(err) && typeof err.details.story_id === "string"
2122
+ ? err.details.story_id
2123
+ : undefined;
2124
+ return {
2125
+ warning,
2126
+ absentStoryIds: new Set(storyId ? [storyId] : []),
2127
+ };
2128
+ }
2152
2129
  throw err;
2153
2130
  }
2154
2131
  }
@@ -2451,10 +2428,14 @@ async function runCreate(ctx, opts) {
2451
2428
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
2452
2429
  assertSameTenant(company, senior, seniorCompany);
2453
2430
  const createStoryWarnings = [];
2431
+ const knownAbsentStories = new Set();
2454
2432
  if (meshCompany && storyIds.length > 0) {
2455
- const warning = await storyBoardWarning(ctx, meshCompany, project, storyIds);
2456
- if (warning !== undefined)
2457
- createStoryWarnings.push(warning);
2433
+ const boardCheck = await storyBoardCheck(ctx, meshCompany, project, storyIds);
2434
+ if (boardCheck.warning !== undefined)
2435
+ createStoryWarnings.push(boardCheck.warning);
2436
+ for (const storyId of boardCheck.absentStoryIds) {
2437
+ knownAbsentStories.add(storyBoardStoryKey(project, storyId));
2438
+ }
2458
2439
  }
2459
2440
  let resolvedWorker;
2460
2441
  if (opts.worker) {
@@ -3067,7 +3048,7 @@ async function runCreate(ctx, opts) {
3067
3048
  locateTranscript: ctx.locateTranscript,
3068
3049
  });
3069
3050
  if (meshCompany) {
3070
- createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id));
3051
+ createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id, knownAbsentStories));
3071
3052
  }
3072
3053
  }
3073
3054
  else if (outpostId) {
@@ -4510,6 +4491,7 @@ async function runShow(ctx, laneId) {
4510
4491
  provider_session_id: lane.provider_session_id ?? null,
4511
4492
  provider_session_id_absent: lane.provider_session_id_absent ?? null,
4512
4493
  envelope_repair_mode: lane.envelope_repair?.mode ?? null,
4494
+ envelope_validation_errors: lane.envelope_validation_errors ?? [],
4513
4495
  last_resume: lane.last_resume ?? null,
4514
4496
  account: lane.account ?? null,
4515
4497
  account_history: lane.account_history ?? [],
@@ -6327,7 +6309,7 @@ async function ensureLaneThreadRecord(ctx, lane, projectId = lane.project_id, st
6327
6309
  }
6328
6310
  return updated;
6329
6311
  }
6330
- async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}) {
6312
+ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}, skipStoryStatusPatch = false) {
6331
6313
  const company = lane.mesh_company ?? lane.company;
6332
6314
  if (!company)
6333
6315
  return;
@@ -6341,7 +6323,9 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
6341
6323
  ...payload,
6342
6324
  lane_status: lane.state,
6343
6325
  });
6344
- await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
6326
+ if (!skipStoryStatusPatch) {
6327
+ await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
6328
+ }
6345
6329
  }
6346
6330
  function sameCommittedStory(current, expected, projectId, storyId) {
6347
6331
  const currentStory = laneStory(current, projectId, storyId);
@@ -6350,9 +6334,9 @@ function sameCommittedStory(current, expected, projectId, storyId) {
6350
6334
  current.story_id === expected.story_id &&
6351
6335
  JSON.stringify(currentStory) === JSON.stringify(expectedStory));
6352
6336
  }
6353
- async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}) {
6337
+ async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}, skipStoryStatusPatch = false) {
6354
6338
  try {
6355
- await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload);
6339
+ await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload, skipStoryStatusPatch);
6356
6340
  return undefined;
6357
6341
  }
6358
6342
  catch (err) {
@@ -6408,10 +6392,12 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
6408
6392
  }
6409
6393
  const company = lane.mesh_company ?? lane.company;
6410
6394
  const warnings = [];
6395
+ let storyKnownAbsent = false;
6411
6396
  if (company) {
6412
- const warning = await storyBoardWarning(ctx, company, projectId, [normalizedStoryId]);
6413
- if (warning !== undefined)
6414
- warnings.push(warning);
6397
+ const boardCheck = await storyBoardCheck(ctx, company, projectId, [normalizedStoryId]);
6398
+ if (boardCheck.warning !== undefined)
6399
+ warnings.push(boardCheck.warning);
6400
+ storyKnownAbsent = boardCheck.absentStoryIds.has(normalizedStoryId);
6415
6401
  }
6416
6402
  const at = ctx.now().toISOString();
6417
6403
  const thread = await ensureStoryThreadForTransition(ctx, lane, projectId, normalizedStoryId);
@@ -6427,7 +6413,7 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
6427
6413
  no_story_reason: undefined,
6428
6414
  };
6429
6415
  });
6430
- const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
6416
+ const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress", {}, storyKnownAbsent);
6431
6417
  if (transitionWarning !== undefined)
6432
6418
  warnings.push(transitionWarning);
6433
6419
  const finalLane = readLane(hqRoot, laneId);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
6
+ export { assignedChildHireGrants, aliveDirectChildren, hireGrantCapacityError, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
7
7
  export type { HireGrantBackfillRow } from "./hire-grants.js";
8
8
  export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
9
9
  export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
6
+ export { assignedChildHireGrants, aliveDirectChildren, hireGrantCapacityError, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
7
7
  export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
8
8
  export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
9
9
  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";
@@ -92,6 +92,7 @@ export interface CreateLaneMeshOptions {
92
92
  }) => Promise<void>;
93
93
  }
94
94
  export declare function storyNotOnBoardError(company: LaneCompanyRef, projectId: string, storyId: string): LanesError;
95
+ export declare function laneStoryStatusSuppressionKey(company: LaneCompanyRef, projectId: string, storyId: string): string;
95
96
  export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
96
97
  /** Resolve the explicit mesh attribution before falling back to the lane tenant. */
97
98
  export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
@@ -106,6 +107,8 @@ export declare function laneMeshEventIdentity(lane: LaneRecord): Record<string,
106
107
  export declare function emitLaneDecisionEvents(mesh: LaneMesh, lane: LaneRecord, envelope: Pick<LaneEnvelope, "decision" | "summary" | "blocked_reason">, opts?: {
107
108
  questionIds?: string[];
108
109
  signal?: AbortSignal;
110
+ /** Per-watcher-run cache for story_not_on_board refusals. */
111
+ storyStatusSuppressions?: Set<string>;
109
112
  }): Promise<void>;
110
113
  /** Emit an abnormal terminal state even when the worker produced no envelope. */
111
114
  export declare function emitLaneTerminalStateEvent(mesh: LaneMesh, lane: LaneRecord, state: Exclude<TerminalLaneState, "done">, summary?: string, signal?: AbortSignal): Promise<void>;
@@ -229,6 +229,17 @@ function isUnknownProjectStory(error) {
229
229
  typeof candidate.message === "string" &&
230
230
  candidate.message.includes("Unknown story"));
231
231
  }
232
+ function isUncodedStoryPatchNotFound(error) {
233
+ if (!error || typeof error !== "object")
234
+ return false;
235
+ const candidate = error;
236
+ // The story-absent route response is exactly `404 Not found` without a code.
237
+ // Keep coded 404s, especially PROJECT_VIEW_NOT_FOUND, on their own paths.
238
+ return (candidate.status === 404 &&
239
+ candidate.code === undefined &&
240
+ typeof candidate.message === "string" &&
241
+ /^404 not found$/i.test(candidate.message));
242
+ }
232
243
  function shellQuote(value) {
233
244
  return "'" + value.replace(/'/g, "'\\''") + "'";
234
245
  }
@@ -245,6 +256,9 @@ export function storyNotOnBoardError(company, projectId, storyId) {
245
256
  command,
246
257
  });
247
258
  }
259
+ export function laneStoryStatusSuppressionKey(company, projectId, storyId) {
260
+ return JSON.stringify([company.uid, projectId, storyId]);
261
+ }
248
262
  function isAlreadyRegistered(error) {
249
263
  if (!error || typeof error !== "object")
250
264
  return false;
@@ -365,7 +379,7 @@ export function createLaneMesh(opts = {}) {
365
379
  await patchStoryStatus(tok, company.uid, projectId, storyId, status);
366
380
  }
367
381
  catch (error) {
368
- if (isUnknownProjectStory(error)) {
382
+ if (isUnknownProjectStory(error) || isUncodedStoryPatchNotFound(error)) {
369
383
  throw storyNotOnBoardError(company, projectId, storyId);
370
384
  }
371
385
  throw error;
@@ -437,6 +451,21 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
437
451
  const threadId = await ensureLaneEventThread(mesh, company, lane, lane.state, opts.signal);
438
452
  if (!company || !threadId)
439
453
  return;
454
+ const patchStoryStatus = async (status) => {
455
+ const suppressions = opts.storyStatusSuppressions;
456
+ const key = laneStoryStatusSuppressionKey(company, lane.project_id, lane.story_id);
457
+ if (suppressions?.has(key))
458
+ return;
459
+ try {
460
+ await mesh.patchStoryStatus(company, lane.project_id, lane.story_id, status);
461
+ }
462
+ catch (err) {
463
+ if (suppressions && err instanceof LanesError && err.error === "story_not_on_board") {
464
+ suppressions.add(key);
465
+ }
466
+ throw err;
467
+ }
468
+ };
440
469
  switch (envelope.decision) {
441
470
  case "done": {
442
471
  const outcomes = await Promise.allSettled([
@@ -444,7 +473,7 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
444
473
  ...laneMeshEventIdentity(lane),
445
474
  summary: envelope.summary,
446
475
  }, opts.signal),
447
- mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "review"),
476
+ patchStoryStatus("review"),
448
477
  ]);
449
478
  const failure = outcomes.find((outcome) => outcome.status === "rejected");
450
479
  if (failure?.status === "rejected")
@@ -458,7 +487,7 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
458
487
  ...laneMeshEventIdentity(lane),
459
488
  blocked_reason: envelope.blocked_reason ?? "",
460
489
  }),
461
- mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "in_progress"),
490
+ patchStoryStatus("in_progress"),
462
491
  ]);
463
492
  const failure = outcomes.find((outcome) => outcome.status === "rejected");
464
493
  if (failure?.status === "rejected")
@@ -33,10 +33,14 @@ export interface WorkerProfileRuntime {
33
33
  settingsPath?: string;
34
34
  hooksDir?: string;
35
35
  }
36
+ export interface LoadWorkerProfileOptions {
37
+ /** Unknown keys may be ignored only by callers with an explicit recovery path. */
38
+ onUnknownMetaFields?: (fields: readonly string[]) => void;
39
+ }
36
40
  export declare function personalWorkerProfileDir(hqRoot: string, id: string): string;
37
41
  /** A meta.yaml file is the explicit marker for a personal worker profile. */
38
42
  export declare function workerProfileMarker(hqRoot: string, id: string): "absent" | "present";
39
- export declare function loadWorkerProfile(hqRoot: string, id: string): WorkerProfile;
43
+ export declare function loadWorkerProfile(hqRoot: string, id: string, options?: LoadWorkerProfileOptions): WorkerProfile;
40
44
  export interface EffectiveLaneKeepAlive {
41
45
  enabled: boolean;
42
46
  source: "lane" | "none";
@@ -117,15 +117,18 @@ function collectFiles(id, dir, relativeDir) {
117
117
  visit(absoluteDir, relativeDir);
118
118
  return files.sort((a, b) => a.localeCompare(b));
119
119
  }
120
- function parseMeta(id, dir, raw) {
120
+ function parseMeta(id, dir, raw, options = {}) {
121
121
  if (!isPlainObject(raw)) {
122
122
  throw profileError(id, dir, "meta.yaml must contain an object");
123
123
  }
124
124
  const unknown = Object.keys(raw).filter((key) => !PROFILE_META_KEYS.has(key));
125
125
  if (unknown.length > 0) {
126
- throw profileError(id, dir, `meta.yaml has unknown field(s): ${unknown.join(", ")}`, {
127
- fields: unknown,
128
- });
126
+ if (options.onUnknownMetaFields === undefined) {
127
+ throw profileError(id, dir, `meta.yaml has unknown field(s): ${unknown.join(", ")}`, {
128
+ fields: unknown,
129
+ });
130
+ }
131
+ options.onUnknownMetaFields(unknown);
129
132
  }
130
133
  const provider = raw.provider;
131
134
  if (typeof provider !== "string" || !isLaneProvider(provider.trim())) {
@@ -305,7 +308,7 @@ export function workerProfileMarker(hqRoot, id) {
305
308
  return "present";
306
309
  return "present";
307
310
  }
308
- export function loadWorkerProfile(hqRoot, id) {
311
+ export function loadWorkerProfile(hqRoot, id, options = {}) {
309
312
  const dir = personalWorkerProfileDir(hqRoot, id);
310
313
  if (!validProfileId(id)) {
311
314
  throw profileError(id, dir, "profile id must be a simple directory name", {
@@ -338,7 +341,7 @@ export function loadWorkerProfile(hqRoot, id) {
338
341
  field: PROFILE_META,
339
342
  });
340
343
  }
341
- const meta = parseMeta(id, dir, metaRaw);
344
+ const meta = parseMeta(id, dir, metaRaw, options);
342
345
  const systemPromptPath = path.join(dir, PROFILE_SYSTEM_PROMPT);
343
346
  const systemPrompt = readRequiredText(id, dir, systemPromptPath, PROFILE_SYSTEM_PROMPT);
344
347
  if (systemPrompt.trim().length === 0) {
@@ -71,5 +71,5 @@ export declare function envelopeQuestionId(laneId: string, envelopeRef: string,
71
71
  * Spec §6.1: each envelope questions[] entry becomes one queue record, via
72
72
  * raiseLaneQuestion (the same function the CLI ask path uses).
73
73
  */
74
- export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string): QuestionRecord[];
74
+ export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[]): QuestionRecord[];
75
75
  //# sourceMappingURL=questions.d.ts.map
@@ -222,7 +222,7 @@ function envelopeQuestionBody(item) {
222
222
  * Spec §6.1: each envelope questions[] entry becomes one queue record, via
223
223
  * raiseLaneQuestion (the same function the CLI ask path uses).
224
224
  */
225
- export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at) {
225
+ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes) {
226
226
  const items = envelope.questions ?? [];
227
227
  const raised = [];
228
228
  for (let i = 0; i < items.length; i += 1) {
@@ -235,7 +235,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at)
235
235
  body: envelopeQuestionBody(item),
236
236
  raisedVia: "envelope",
237
237
  at,
238
- questionId: envelopeQuestionId(lane.lane_id, envelopeRef, i),
238
+ questionId: envelopeQuestionId(lane.lane_id, envelopeRef, questionIndexes?.[i] ?? i),
239
239
  }));
240
240
  }
241
241
  return raised;
@@ -305,6 +305,8 @@ export interface EnvelopeRepairSpawnInput {
305
305
  rawText: string;
306
306
  /** True when this run never wrote its expected envelope file. */
307
307
  missing?: boolean;
308
+ /** Repair-only warning hook for profile metadata ignored during recovery. */
309
+ onProfileWarning?: (fields: readonly string[]) => void;
308
310
  }
309
311
  /** How the spec §4 repair turn reaches the provider (recorded as `envelope_repair.mode`). */
310
312
  export type EnvelopeRepairMode = "resume" | "fresh_turn";
@@ -2152,7 +2152,12 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2152
2152
  ensureDir(logsDir);
2153
2153
  const config = loadLanesConfig(hqRoot);
2154
2154
  const profile = lane.worker_profile
2155
- ? loadWorkerProfile(hqRoot, lane.worker_profile)
2155
+ ? loadWorkerProfile(hqRoot, lane.worker_profile, {
2156
+ onUnknownMetaFields: (fields) => {
2157
+ process.stderr.write(`hq lanes watcher: envelope repair ignored unknown worker profile meta field(s) for ${lane.worker_profile}: ${fields.join(", ")}\n`);
2158
+ input.onProfileWarning?.(fields);
2159
+ },
2160
+ })
2156
2161
  : undefined;
2157
2162
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
2158
2163
  const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
@@ -470,6 +470,11 @@ export function parseLaneRecord(raw, filePath) {
470
470
  new Set(raw.pending_question_ids).size !== raw.pending_question_ids.length)) {
471
471
  throw new LaneSchemaError("pending_question_ids must be an array of unique non-empty ids", { path: filePath, field: "pending_question_ids" });
472
472
  }
473
+ if (raw.envelope_validation_errors !== undefined && (!Array.isArray(raw.envelope_validation_errors) ||
474
+ raw.envelope_validation_errors.length === 0 ||
475
+ raw.envelope_validation_errors.some((error) => typeof error !== "string" || error.trim() === ""))) {
476
+ throw new LaneSchemaError("envelope_validation_errors must be a non-empty array of non-empty strings", { path: filePath, field: "envelope_validation_errors" });
477
+ }
473
478
  requireTimestamp(raw.created_at, "created_at", filePath);
474
479
  requireTimestamp(raw.updated_at, "updated_at", filePath);
475
480
  if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
@@ -432,6 +432,8 @@ export interface LaneRecord {
432
432
  story_complete_reason?: string;
433
433
  /** Copied from the last ingested envelope when present. */
434
434
  story_status?: StoryStatus;
435
+ /** Envelope parser failures retained when a usable ask is recovered. */
436
+ envelope_validation_errors?: string[];
435
437
  /** Set by the reconcile pass when `--timeout` has passed (spec §5.7). */
436
438
  overdue?: boolean;
437
439
  overdue_at?: string;
@@ -232,7 +232,7 @@ export const LANE_FIELD_WRITERS = {
232
232
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
233
233
  admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
234
234
  quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
235
- provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
235
+ provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
236
236
  };
237
237
  function isPlainObject(value) {
238
238
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -143,6 +143,8 @@ export declare function notifySlotFreed(_input: {
143
143
  export interface ApplyTerminalOpts {
144
144
  envelope?: LaneEnvelope | null;
145
145
  envelopeRef?: string;
146
+ envelopeValidationErrors?: string[];
147
+ questionIndexes?: readonly number[];
146
148
  at?: string;
147
149
  quota?: QuotaSignal | null;
148
150
  /** Written to `terminal_details` with the model and account the run used. */
@@ -225,7 +227,7 @@ export interface WatcherLoopDeps {
225
227
  */
226
228
  export declare function emitMeshSoft(event: string, logDecision: (record: Record<string, unknown>) => void, at: string, fn: (signal: AbortSignal) => Promise<void>, timeoutMs?: number): Promise<boolean>;
227
229
  /** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
228
- export declare function envelopeQuestionIds(laneId: string, envelopeRef: string, envelope: LaneEnvelope): string[];
230
+ export declare function envelopeQuestionIds(laneId: string, envelopeRef: string, envelope: LaneEnvelope, questionIndexes?: readonly number[]): string[];
229
231
  export declare function runWatcherLoop(hqRoot: string, laneId: string, deps?: WatcherLoopDeps): Promise<void>;
230
232
  export type EnvelopeRepairDecision = {
231
233
  action: "spawned";
@@ -35,7 +35,7 @@ import * as fs from "node:fs";
35
35
  import * as path from "node:path";
36
36
  import { LanesError } from "./errors.js";
37
37
  import { raiseEnvelopeQuestions } from "./questions.js";
38
- import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, } from "./types.js";
38
+ import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, } from "./types.js";
39
39
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
40
40
  import { readLane, retryUpdateLane } from "./store.js";
41
41
  import { readWorkerRunResult } from "./delivery.js";
@@ -44,7 +44,7 @@ import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, provide
44
44
  import { readToolActivity, toolActivityPath } from "./dropbox.js";
45
45
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
46
46
  import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
47
- import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, progressEventDue, } from "./mesh-events.js";
47
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
48
48
  import { envelopeQuestionId } from "./questions.js";
49
49
  import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, retryLaneOnAlternateAccount, } from "./spawn.js";
50
50
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
@@ -313,7 +313,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
313
313
  classification.state === "awaiting_input" &&
314
314
  opts.envelope &&
315
315
  (opts.envelope.questions?.length ?? 0) > 0) {
316
- raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at);
316
+ raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at, opts.questionIndexes);
317
317
  }
318
318
  let changed = false;
319
319
  const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
@@ -336,6 +336,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
336
336
  stalled: undefined,
337
337
  stalled_at: undefined,
338
338
  stalled_reason: undefined,
339
+ envelope_validation_errors: opts.envelopeValidationErrors,
339
340
  };
340
341
  changed = true;
341
342
  if (classification.reason) {
@@ -432,6 +433,81 @@ function fileNonEmpty(filePath) {
432
433
  throw err;
433
434
  }
434
435
  }
436
+ function isPlainObject(value) {
437
+ return value !== null && typeof value === "object" && !Array.isArray(value);
438
+ }
439
+ /** Keep only questions that the normal envelope parser accepts. */
440
+ function recoverAskEnvelope(text, validationErrors) {
441
+ const raw = JSON.parse(text);
442
+ if (!isPlainObject(raw) || raw.decision !== "ask" || !Array.isArray(raw.questions)) {
443
+ return null;
444
+ }
445
+ const validQuestions = [];
446
+ const questionIndexes = [];
447
+ const questionErrors = [];
448
+ for (let index = 0; index < raw.questions.length; index += 1) {
449
+ const question = raw.questions[index];
450
+ try {
451
+ const parsed = parseLaneEnvelope({
452
+ schema_version: "hq-lane-envelope.v1",
453
+ decision: "ask",
454
+ summary: "Recovered operator question",
455
+ story_status: "in_progress",
456
+ artifacts: [],
457
+ questions: [question],
458
+ });
459
+ const item = parsed.questions?.[0];
460
+ if (item === undefined) {
461
+ throw new Error("question parser returned no question");
462
+ }
463
+ validQuestions.push(item);
464
+ questionIndexes.push(index);
465
+ }
466
+ catch (err) {
467
+ questionErrors.push(`questions[${index}] is invalid: ${err instanceof Error ? err.message : String(err)}`);
468
+ }
469
+ }
470
+ if (validQuestions.length === 0) {
471
+ return {
472
+ envelope: {
473
+ schema_version: "hq-lane-envelope.v1",
474
+ decision: "ask",
475
+ summary: "The worker needs operator input after envelope validation failed.",
476
+ story_status: STORY_STATUSES.includes(raw.story_status)
477
+ ? raw.story_status
478
+ : "in_progress",
479
+ artifacts: [],
480
+ questions: [],
481
+ },
482
+ questionIndexes,
483
+ questionErrors,
484
+ sourceQuestionCount: raw.questions.length,
485
+ };
486
+ }
487
+ const contextErrors = [...new Set([...validationErrors, ...questionErrors])];
488
+ const storyStatus = STORY_STATUSES.includes(raw.story_status) && raw.story_status !== "done"
489
+ ? raw.story_status
490
+ : "in_progress";
491
+ const questions = validQuestions.map((question) => ({
492
+ ...question,
493
+ context: `${question.context}\n\nEnvelope validation errors:\n${contextErrors.map((error) => `- ${error}`).join("\n")}`,
494
+ }));
495
+ return {
496
+ envelope: {
497
+ schema_version: "hq-lane-envelope.v1",
498
+ decision: "ask",
499
+ summary: typeof raw.summary === "string" && raw.summary.length > 0
500
+ ? raw.summary
501
+ : "The worker needs operator input after envelope validation failed.",
502
+ story_status: storyStatus,
503
+ artifacts: [],
504
+ questions,
505
+ },
506
+ questionIndexes,
507
+ questionErrors,
508
+ sourceQuestionCount: raw.questions.length,
509
+ };
510
+ }
435
511
  export function readLatestEnvelope(hqRoot, laneId, minName) {
436
512
  return readLatestEnvelopeRecord(hqRoot, laneId, minName)?.envelope ?? null;
437
513
  }
@@ -570,8 +646,8 @@ function readLaneSoft(hqRoot, laneId) {
570
646
  }
571
647
  }
572
648
  /** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
573
- export function envelopeQuestionIds(laneId, envelopeRef, envelope) {
574
- return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, i));
649
+ export function envelopeQuestionIds(laneId, envelopeRef, envelope, questionIndexes) {
650
+ return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, questionIndexes?.[i] ?? i));
575
651
  }
576
652
  function defaultSleep(ms) {
577
653
  return new Promise((resolve) => {
@@ -604,6 +680,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
604
680
  const progressFiles = [...logFiles, ...activityFiles];
605
681
  // Mesh seam (spec §8). Non-interactive: nobody is at this process's tty.
606
682
  const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
683
+ const storyStatusSuppressions = new Set();
607
684
  const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
608
685
  const initialLane = readLaneSoft(hqRoot, laneId);
609
686
  if (mesh && initialLane) {
@@ -631,6 +708,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
631
708
  }
632
709
  catch (err) {
633
710
  const message = err instanceof Error ? err.message : String(err);
711
+ if (company && initialLane.story_id &&
712
+ err instanceof LanesError && err.error === "story_not_on_board") {
713
+ storyStatusSuppressions.add(laneStoryStatusSuppressionKey(company, initialLane.project_id, initialLane.story_id));
714
+ }
634
715
  process.stderr.write(`hq lanes watcher: mesh thread ensure failed for ${laneId}: ${message}\n`);
635
716
  writeWatcherDecision(outPath, {
636
717
  event: "mesh_event_failed",
@@ -701,10 +782,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
701
782
  const missingExpected = !processAlive && latestFile === null
702
783
  ? missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)
703
784
  : null;
704
- const latest = latestFile && latestFile.envelope
785
+ let latest = latestFile && latestFile.envelope
705
786
  ? { envelope: latestFile.envelope, ref: latestFile.ref }
706
787
  : null;
707
- const envelope = latest?.envelope ?? null;
788
+ let envelope = latest?.envelope ?? null;
708
789
  if (latest && latest.ref !== lastEnvelopeRef) {
709
790
  logDecision({
710
791
  event: "envelope_observed",
@@ -751,6 +832,9 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
751
832
  // fails the same way, and would end the lane envelope_invalid without
752
833
  // ever recording the hold. The quota result applies immediately instead.
753
834
  let invalidEnvelope = null;
835
+ let envelopeValidationErrors;
836
+ let questionIndexes;
837
+ let recoveredAsk = false;
754
838
  const repairCandidate = latestFile ?? missingExpected;
755
839
  if (!processAlive &&
756
840
  !envelope &&
@@ -771,11 +855,70 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
771
855
  await sleep(pollMs);
772
856
  continue;
773
857
  }
774
- if (!repairCandidate.missing)
775
- invalidEnvelope = { errors: repair.errors };
858
+ if (!repairCandidate.missing) {
859
+ const validationErrors = repair.errors.filter((error) => !error.startsWith("repair_spawn_failed:"));
860
+ let recovery = null;
861
+ try {
862
+ recovery = recoverAskEnvelope(repairCandidate.text, validationErrors);
863
+ }
864
+ catch (err) {
865
+ const message = err instanceof Error ? err.message : String(err);
866
+ process.stderr.write(`hq lanes watcher: invalid ask fallback could not parse ${repairCandidate.path} for ${laneId}: ${message}\n`);
867
+ logDecision({
868
+ event: "envelope_repair",
869
+ at: new Date(now()).toISOString(),
870
+ outcome: "ask_fallback_skipped",
871
+ reason: "invalid_json",
872
+ error: message,
873
+ validation_errors: validationErrors,
874
+ });
875
+ }
876
+ if (recovery !== null && (recovery.envelope.questions?.length ?? 0) > 0) {
877
+ const errors = [...new Set([...validationErrors, ...recovery.questionErrors])];
878
+ envelopeValidationErrors = errors;
879
+ questionIndexes = recovery.questionIndexes;
880
+ latest = { envelope: recovery.envelope, ref: repairCandidate.ref };
881
+ envelope = recovery.envelope;
882
+ recoveredAsk = true;
883
+ const message = `hq lanes watcher: routing invalid ask envelope for ${laneId} to awaiting_input ` +
884
+ `with ${recovery.envelope.questions?.length ?? 0} validated question(s); errors: ${errors.join("; ")}`;
885
+ process.stderr.write(`${message}\n`);
886
+ logDecision({
887
+ event: "envelope_repair",
888
+ at: new Date(now()).toISOString(),
889
+ outcome: "ask_fallback_routed",
890
+ state: "awaiting_input",
891
+ questions: recovery.envelope.questions?.length ?? 0,
892
+ validation_errors: errors,
893
+ });
894
+ }
895
+ else {
896
+ const errors = recovery === null
897
+ ? repair.errors
898
+ : [...new Set([...repair.errors, ...recovery.questionErrors])];
899
+ invalidEnvelope = { errors };
900
+ if (recovery !== null) {
901
+ const message = `hq lanes watcher: invalid ask envelope for ${laneId} remains failed; ` +
902
+ `0 of ${recovery.sourceQuestionCount} questions passed validation`;
903
+ process.stderr.write(`${message}\n`);
904
+ logDecision({
905
+ event: "envelope_repair",
906
+ at: new Date(now()).toISOString(),
907
+ outcome: "ask_fallback_skipped",
908
+ reason: "no_valid_questions",
909
+ questions: 0,
910
+ validation_errors: errors,
911
+ });
912
+ }
913
+ }
914
+ }
776
915
  }
777
916
  }
778
- if (processAlive === false && envelope && latestFile) {
917
+ if (processAlive === false &&
918
+ envelope &&
919
+ latestFile !== null &&
920
+ latestFile.envelope !== null &&
921
+ !recoveredAsk) {
779
922
  markRepairSucceeded(hqRoot, laneId, latestFile.ref);
780
923
  }
781
924
  const tick = tickWatcher({
@@ -913,6 +1056,8 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
913
1056
  const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
914
1057
  envelope,
915
1058
  envelopeRef: latest?.ref,
1059
+ envelopeValidationErrors,
1060
+ questionIndexes,
916
1061
  at: tick.emit.at,
917
1062
  quota,
918
1063
  providerRejection,
@@ -930,9 +1075,13 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
930
1075
  if (mesh && terminalWrite.changed) {
931
1076
  if (envelope && latest) {
932
1077
  const questionIds = envelope.decision === "ask"
933
- ? envelopeQuestionIds(laneId, latest.ref, envelope)
1078
+ ? envelopeQuestionIds(laneId, latest.ref, envelope, questionIndexes)
934
1079
  : undefined;
935
- await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, { questionIds, signal }), meshRequestTimeoutMs);
1080
+ await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, {
1081
+ questionIds,
1082
+ signal,
1083
+ storyStatusSuppressions,
1084
+ }), meshRequestTimeoutMs);
936
1085
  }
937
1086
  else if (updated.state === "failed" || updated.state === "interrupted" ||
938
1087
  updated.state === "cancelled" || updated.state === "killed") {
@@ -973,9 +1122,11 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
973
1122
  const runDir = laneRunDir(hqRoot, laneId);
974
1123
  const manifest = readSpawnManifest(runDir);
975
1124
  const prior = lane.envelope_repair;
976
- const repairRef = missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)?.ref ??
977
- prior?.envelope_ref ??
978
- latest.ref;
1125
+ // A resumed worker can write its next envelope before spawn.json points to
1126
+ // that slot. Use the newest of the manifest's expected slot and the actual
1127
+ // latest file so an older manifest or repair record cannot spend its budget.
1128
+ const expectedRef = missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)?.ref;
1129
+ const repairRef = expectedRef !== undefined && expectedRef > latest.ref ? expectedRef : latest.ref;
979
1130
  const priorAppliesToSlot = prior?.envelope_ref === undefined || prior.envelope_ref === repairRef;
980
1131
  if (prior && prior.attempted && priorAppliesToSlot) {
981
1132
  // The repair turn ran and its output does not parse either.
@@ -1025,6 +1176,14 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
1025
1176
  error,
1026
1177
  rawText: latest.text,
1027
1178
  missing: latest.missing,
1179
+ onProfileWarning: (fields) => {
1180
+ logDecision({
1181
+ event: "envelope_repair",
1182
+ at,
1183
+ outcome: "profile_unknown_meta_fields_ignored",
1184
+ fields: [...fields],
1185
+ });
1186
+ },
1028
1187
  });
1029
1188
  logDecision({ event: "envelope_repair", at, outcome: "spawned", mode, pid, session_id: sessionId, error });
1030
1189
  return { action: "spawned" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.215.0",
3
+ "version": "5.216.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {