@indigoai-us/hq-cli 5.345.73 → 5.345.74

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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.74] — 2026-10-10
6
+
7
+ - Lane envelope repair prompts now identify parser locations and schema fields,
8
+ include a minimal valid envelope, and keep `next_check_at` optional. Section 8
9
+ loop workers receive explicit handoff enums and one same-session repair turn;
10
+ failed handoff summaries retain the validation error. Both behaviors have
11
+ default-on hq-flags kill switches.
12
+
5
13
  ## [5.345.73] — 2026-10-10
6
14
  - Fixed company resolution so a minimal entity liveness response is not treated as a full company record.
7
15
 
@@ -9,6 +9,8 @@ import { raiseLaneQuestion } from "../lib/lanes/questions.js";
9
9
  import { isTerminalLaneState } from "../lib/lanes/types.js";
10
10
  import { clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
11
11
  import { laneStories } from "../lib/lanes/stories.js";
12
+ import { readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
13
+ import { LANE_LOOP_HANDOFF_REPAIR_FLAG } from "../lib/lanes/flag-keys.js";
12
14
  const LOOP_RUNS_REL = path.join("workspace", "lanes-runs");
13
15
  function loopRunDir(hqRoot, laneId) {
14
16
  return path.join(hqRoot, LOOP_RUNS_REL, laneId);
@@ -34,6 +36,11 @@ export async function runLaneLoopController(ctx, laneId, deps) {
34
36
  if (lane.loop_mode !== true)
35
37
  throw new LanesError("lane_not_loop_mode", `Lane ${laneId} is not a loop lane`, { lane_id: laneId });
36
38
  const runDir = loopRunDir(hqRoot, laneId);
39
+ const handoffRepairEnabled = await readLanesRegistryFlagFailOpen(LANE_LOOP_HANDOFF_REPAIR_FLAG, {
40
+ company: lane.company ?? lane.mesh_company,
41
+ defaultValue: true,
42
+ env: ctx.env,
43
+ });
37
44
  let initialAdmission = ctx.env.HQ_LANE_LOOP_INITIAL === "1";
38
45
  const stop = new AbortController();
39
46
  const onSignal = () => stop.abort();
@@ -43,9 +50,10 @@ export async function runLaneLoopController(ctx, laneId, deps) {
43
50
  const result = await runLoopLane({
44
51
  lane,
45
52
  runDir,
53
+ repairInvalidHandoff: handoffRepairEnabled,
46
54
  signal: stop.signal,
47
55
  idlePollMs: 250,
48
- execute: async ({ envelope, signal, fresh, sessionId }) => {
56
+ execute: async ({ envelope, signal, fresh, sessionId, repairError }) => {
49
57
  let current = readLane(hqRoot, laneId);
50
58
  const data = envelope;
51
59
  const storyId = typeof data.story_id === "string" && data.story_id.trim() ? data.story_id.trim() : current.story_id;
@@ -88,9 +96,21 @@ export async function runLaneLoopController(ctx, laneId, deps) {
88
96
  const prompt = typeof data.prompt === "string" ? data.prompt :
89
97
  `${phaseContext}\n\nPhase envelope:\n${JSON.stringify(envelope, null, 2)}`;
90
98
  const briefPath = path.join(runDir, "brief.md");
99
+ const repairInstructions = repairError && data.schema === "hq-phase-envelope/v1"
100
+ ? [
101
+ "HANDOFF REPAIR TURN: the previous handoff failed validation.",
102
+ `Exact validation error: ${repairError}`,
103
+ "Return a corrected hq-phase-handoff/v1 object. Preserve every valid supplied value and extension field.",
104
+ "Allowed status values: passed, failed, blocked. Allowed back_pressure values for tests, lint, typecheck, and build: pass, fail, skip.",
105
+ `Minimal valid shape: ${JSON.stringify({ schema: "hq-phase-handoff/v1", story_id: String(data.story_id), phase: String(data.phase), worker_id: String(data.worker_id), status: "passed", summary: "...", files_changed: [], commits: [], back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" }, context_for_next: "..." }, null, 2)}`,
106
+ ].join("\n")
107
+ : "";
91
108
  fs.writeFileSync(briefPath, [
92
109
  "Follow the lane worker instructions and complete this single bounded phase.",
93
- "Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts; it must identify the requested story_id and phase and include worker_id, status, summary, files_changed, commits, back_pressure (tests/lint/typecheck/build), and context_for_next.",
110
+ handoffRepairEnabled
111
+ ? "Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts. The lane envelope decision uses done/ask/blocked; handoff status uses passed/failed/blocked. Allowed handoff status values are passed, failed, blocked. Allowed back_pressure values for tests, lint, typecheck, and build are pass, fail, skip. Include story_id, phase, worker_id, summary, files_changed, commits, back_pressure, and context_for_next."
112
+ : "Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts; it must identify the requested story_id and phase and include worker_id, status, summary, files_changed, commits, back_pressure (tests/lint/typecheck/build), and context_for_next.",
113
+ repairInstructions,
94
114
  "",
95
115
  prompt,
96
116
  ].join("\n"));
@@ -137,6 +157,7 @@ export async function runLaneLoopController(ctx, laneId, deps) {
137
157
  story: storyId,
138
158
  freshThread: fresh,
139
159
  ...(sessionId ? { loopSessionId: sessionId } : {}),
160
+ ...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
140
161
  workingDirectory: worktree,
141
162
  });
142
163
  }
@@ -1,4 +1,5 @@
1
- import { type Ctx, type LaneBalanceMoveTargetOptions, type LaneResumeHandlerDeps, type LaneResumeOptions } from "./lanes-shared.js";
1
+ import { type Ctx, type LaneBalanceMoveTargetOptions, type LaneRecord, type LaneResumeHandlerDeps, type LaneResumeOptions } from "./lanes-shared.js";
2
+ export declare function isLoopHandoffRepairResume(lane: Pick<LaneRecord, "loop_mode" | "state" | "provider_session_id">, opts: Pick<LaneResumeOptions, "loopHandoffRepair" | "loopSessionId">): boolean;
2
3
  export declare function createLaneResumeHandlers(deps: LaneResumeHandlerDeps): {
3
4
  runClaudeBalanceMoveTarget: (ctx: Ctx, laneId: string, opts: LaneBalanceMoveTargetOptions) => Promise<void>;
4
5
  runResume: (ctx: Ctx, laneId: string, opts: LaneResumeOptions) => Promise<void>;
@@ -10,6 +10,13 @@ import { markSpawnFailure, spawnLaneWorker } from "../lib/lanes/spawn.js";
10
10
  import { appendProjectFocusOverride, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
11
11
  import { appendLaneFocusOverride, makeFocusOverride } from "../lib/lanes/focus-guard.js";
12
12
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
13
+ export function isLoopHandoffRepairResume(lane, opts) {
14
+ return opts.loopHandoffRepair === true &&
15
+ lane.loop_mode === true &&
16
+ lane.state === "awaiting_input" &&
17
+ opts.loopSessionId !== undefined &&
18
+ opts.loopSessionId === lane.provider_session_id;
19
+ }
13
20
  export function createLaneResumeHandlers(deps) {
14
21
  const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, automaticBalanceMoveHistoryReason, authorizeFailedResume, authorizeResumeProfileRepin, casUpdateLane, dispatchOutpostLaneCommand, laneIsHostedHere, laneWithRecoveredProviderSession, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
15
22
  async function runClaudeBalanceMoveTarget(ctx, laneId, opts) {
@@ -156,6 +163,10 @@ export function createLaneResumeHandlers(deps) {
156
163
  throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
157
164
  }
158
165
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
166
+ const loopHandoffRepair = isLoopHandoffRepairResume(lane, opts);
167
+ if (opts.loopHandoffRepair === true && !loopHandoffRepair) {
168
+ throw new LanesError("loop_handoff_repair_session_mismatch", "Loop handoff repair requires the awaiting-input loop lane's current provider session", { lane_id: laneId, state: lane.state });
169
+ }
159
170
  if (opts.loopSessionId !== undefined && opts.loopSessionId !== lane.provider_session_id) {
160
171
  lane = casUpdateLane(hqRoot, laneId, (current) => ({ ...current, provider_session_id: opts.loopSessionId }));
161
172
  }
@@ -192,7 +203,7 @@ export function createLaneResumeHandlers(deps) {
192
203
  const pendingQuestions = lane.state === "awaiting_input"
193
204
  ? await deps.pendingQuestionsForLane(ctx, lane)
194
205
  : [];
195
- if (pendingQuestions.length > 0) {
206
+ if (pendingQuestions.length > 0 && !loopHandoffRepair) {
196
207
  const companySlug = lane.company?.slug ?? lane.mesh_company ?? "<company-slug>";
197
208
  const instructions = pendingQuestions.map(({ question_id, audience }) => `question ${question_id} awaits ${audience.kind}:${audience.id}; answer it with hq lanes questions answer ${question_id} --company ${companySlug} --freeform <answer>`);
198
209
  const message = `Lane ${laneId} is awaiting input. ${instructions.join("; ")}`;
@@ -632,7 +643,7 @@ export function createLaneResumeHandlers(deps) {
632
643
  kind: "resume",
633
644
  from: resumedFrom,
634
645
  ...(opts.freshThread === true ? { fresh: true } : {}),
635
- ...(resumedFrom === "awaiting_input" && pendingQuestions.length === 0 ? { allowAwaitingInput: true } : {}),
646
+ ...(resumedFrom === "awaiting_input" && (pendingQuestions.length === 0 || loopHandoffRepair) ? { allowAwaitingInput: true } : {}),
636
647
  ...(opts.fromFailed === true ? { fromFailed: true } : {}),
637
648
  ...(failedResumeExpectedSenior !== undefined
638
649
  ? { expectedSenior: failedResumeExpectedSenior }
@@ -81,6 +81,8 @@ export interface LaneResumeOptions {
81
81
  workingDirectory?: string;
82
82
  /** Internal loop runner session; selects this story's saved provider session. */
83
83
  loopSessionId?: string;
84
+ /** Allows only a section 8 handoff repair to continue a loop lane with a pending question. */
85
+ loopHandoffRepair?: boolean;
84
86
  meshCompany?: string;
85
87
  profileRepinContext?: string;
86
88
  grantContext?: string;
@@ -42,7 +42,7 @@ import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFla
42
42
  import { readLaneStatusRowFields } from "../lib/lanes/status-fields.js";
43
43
  import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
44
44
  import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
45
- import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, } from "../lib/lanes/flag-keys.js";
45
+ import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, } from "../lib/lanes/flag-keys.js";
46
46
  import { findWorkerContinuityLane, sameLaneHost, workerContinuityPrompt } from "../lib/lanes/worker-continuity.js";
47
47
  import { enqueueLoopEnvelope, interruptLoopQueue, interruptLoopQueueForStory, loopControllerIsAlive, loopStatus, validateLoopEnvelope } from "../lib/lanes/loop.js";
48
48
  import { runLaneLoopController, startLaneLoopController } from "./lanes-loop.js";
@@ -10021,8 +10021,24 @@ export function registerLanesCommand(parent, deps = {}) {
10021
10021
  catch (err) {
10022
10022
  throw new LanesError("repair_request_invalid", `Envelope repair request is invalid JSON: ${err instanceof Error ? err.message : String(err)}`, {});
10023
10023
  }
10024
+ const laneId = payload && typeof payload === "object" && !Array.isArray(payload) && typeof payload.lane_id === "string"
10025
+ ? payload.lane_id
10026
+ : undefined;
10027
+ const company = laneId ? (() => { try {
10028
+ const lane = readLane(ctx.hqRoot(), laneId);
10029
+ return lane.company ?? lane.mesh_company;
10030
+ }
10031
+ catch {
10032
+ return undefined;
10033
+ } })() : undefined;
10034
+ const envelopeRepairGuidanceEnabled = await readLanesRegistryFlagFailOpen(LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, {
10035
+ company,
10036
+ defaultValue: true,
10037
+ env: ctx.env,
10038
+ });
10024
10039
  printJson(spawnEnvelopeRepairRequest(ctx.hqRoot(), payload, {
10025
10040
  env: ctx.env,
10041
+ envelopeRepairGuidanceEnabled,
10026
10042
  hostname: ctx.hostname(),
10027
10043
  existsSync: ctx.existsSync,
10028
10044
  spawnProcess: ctx.spawnProcess,
@@ -44,4 +44,6 @@ export declare const LANE_REAP_STOP_FLAG = "lanes.reap-stop";
44
44
  export declare const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
45
45
  /** Kill switch for conduct loop lanes; absence or read failure keeps it enabled. */
46
46
  export declare const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
47
+ export declare const LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG = "lanes.envelope-repair-guidance";
48
+ export declare const LANE_LOOP_HANDOFF_REPAIR_FLAG = "lanes.loop-handoff-repair";
47
49
  //# sourceMappingURL=flag-keys.d.ts.map
@@ -44,4 +44,6 @@ export const LANE_REAP_STOP_FLAG = "lanes.reap-stop";
44
44
  export const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
45
45
  /** Kill switch for conduct loop lanes; absence or read failure keeps it enabled. */
46
46
  export const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
47
+ export const LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG = "lanes.envelope-repair-guidance";
48
+ export const LANE_LOOP_HANDOFF_REPAIR_FLAG = "lanes.loop-handoff-repair";
47
49
  //# sourceMappingURL=flag-keys.js.map
@@ -95,6 +95,7 @@ export interface LoopExecutionContext {
95
95
  signal: AbortSignal;
96
96
  sessionId?: string;
97
97
  fresh: boolean;
98
+ repairError?: string;
98
99
  }
99
100
  export interface LoopExecutionResult {
100
101
  output: string;
@@ -116,6 +117,7 @@ export interface LoopRunnerOptions {
116
117
  now?: () => Date;
117
118
  idlePollMs?: number;
118
119
  signal?: AbortSignal;
120
+ repairInvalidHandoff?: boolean;
119
121
  }
120
122
  export interface LoopRunSummary {
121
123
  state: "stopped" | "parked" | "aborted";
@@ -750,7 +750,7 @@ export async function runLoopLane(options) {
750
750
  if (envelope.kind === "stop") {
751
751
  const stopped = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stopped", started_at: record.queued_at, finished_at: now().toISOString() };
752
752
  try {
753
- writeResultAt(options.runDir, file, record, stopped);
753
+ writeResultAt(options.runDir, file, record, stopped, options.repairInvalidHandoff !== false);
754
754
  persistStatus(options.runDir, options.lane.lane_id, { state: "stopped", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
755
755
  }
756
756
  catch (error) {
@@ -811,7 +811,7 @@ export async function runLoopLane(options) {
811
811
  if (timer.signal.aborted)
812
812
  throw timer.signal.reason;
813
813
  const finishedAt = now().toISOString();
814
- const success = {
814
+ let success = {
815
815
  schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "done", started_at: startedAt, finished_at: finishedAt,
816
816
  output: result.output, ...(result.sessionId ? { session_id: result.sessionId } : {}),
817
817
  ...(previousSession && !fresh && !fallback ? { resumed: true } : { resumed: false }),
@@ -820,17 +820,55 @@ export async function runLoopLane(options) {
820
820
  if (envelope.schema === PHASE_ENVELOPE_SCHEMA) {
821
821
  let handoff;
822
822
  try {
823
- handoff = JSON.parse(result.output);
824
- }
825
- catch {
826
- throw new Error("section 8 phase result is not valid JSON");
823
+ try {
824
+ handoff = JSON.parse(result.output);
825
+ }
826
+ catch {
827
+ throw new Error("section 8 phase result is not valid JSON");
828
+ }
829
+ const validated = validateSection8Handoff(handoff, envelope);
830
+ if (validated.worker_id !== envelope.worker_id) {
831
+ throw new Error("handoff worker_id must match the queued phase envelope");
832
+ }
827
833
  }
828
- const validated = validateSection8Handoff(handoff, envelope);
829
- if (validated.worker_id !== envelope.worker_id) {
830
- throw new Error("handoff worker_id must match the queued phase envelope");
834
+ catch (validationError) {
835
+ if (options.repairInvalidHandoff !== false && !timer.signal.aborted) {
836
+ const repairError = validationError instanceof Error ? validationError.message : String(validationError);
837
+ const repaired = await options.execute({
838
+ lane: options.lane,
839
+ envelope,
840
+ envelopeId: record.envelope_id,
841
+ signal: timer.signal,
842
+ fresh: false,
843
+ ...(result.sessionId ? { sessionId: result.sessionId } : {}),
844
+ repairError,
845
+ });
846
+ if (timer.signal.aborted)
847
+ throw timer.signal.reason;
848
+ let repairedHandoff;
849
+ try {
850
+ repairedHandoff = JSON.parse(repaired.output);
851
+ }
852
+ catch {
853
+ throw new Error(`handoff repair returned invalid JSON: ${repairError}`);
854
+ }
855
+ const repairedValidated = validateSection8Handoff(repairedHandoff, envelope);
856
+ if (repairedValidated.worker_id !== envelope.worker_id) {
857
+ throw new Error(`handoff worker_id must match the queued phase envelope: ${repairError}`);
858
+ }
859
+ result = repaired;
860
+ success = {
861
+ ...success,
862
+ output: repaired.output,
863
+ ...(repaired.sessionId ? { session_id: repaired.sessionId } : {}),
864
+ };
865
+ }
866
+ else {
867
+ throw validationError;
868
+ }
831
869
  }
832
870
  }
833
- writeResultAt(options.runDir, file, record, success);
871
+ writeResultAt(options.runDir, file, record, success, options.repairInvalidHandoff !== false);
834
872
  if (result.sessionId) {
835
873
  sessions.set(sessionKey, result.sessionId);
836
874
  atomicJson(sessionsPath(options.runDir), Object.fromEntries(sessions));
@@ -853,7 +891,7 @@ export async function runLoopLane(options) {
853
891
  }
854
892
  if (!timer.signal.aborted) {
855
893
  const failure = error instanceof Error ? error.message : String(error);
856
- writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure });
894
+ writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure }, options.repairInvalidHandoff !== false);
857
895
  completed += 1;
858
896
  persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
859
897
  continue;
@@ -880,7 +918,7 @@ export async function runLoopLane(options) {
880
918
  const message = error instanceof Error ? error.message : String(error);
881
919
  const questionId = options.raiseQuestion ? randomUUID() : undefined;
882
920
  const result = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", started_at: startedAt, finished_at: now().toISOString(), error: message, ...(questionId ? { question_id: questionId } : {}) };
883
- writeResultAt(options.runDir, file, record, result);
921
+ writeResultAt(options.runDir, file, record, result, options.repairInvalidHandoff !== false);
884
922
  persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
885
923
  completed += 1;
886
924
  try {
@@ -921,7 +959,7 @@ function appendJournal(runDir, value) {
921
959
  fs.mkdirSync(path.dirname(file), { recursive: true });
922
960
  fs.appendFileSync(file, `${JSON.stringify(value)}\n`, { mode: 0o600 });
923
961
  }
924
- function writeResultAt(runDir, activeFile, record, result) {
962
+ function writeResultAt(runDir, activeFile, record, result, includeHandoffValidationError = true) {
925
963
  const localPath = path.join(resultsDir(runDir), resultName(record.envelope_id));
926
964
  atomicJson(localPath, result);
927
965
  const envelope = record.envelope;
@@ -939,7 +977,7 @@ function writeResultAt(runDir, activeFile, record, result) {
939
977
  schema: PHASE_HANDOFF_SCHEMA,
940
978
  story_id: String(envelope.story_id), phase: String(envelope.phase),
941
979
  worker_id: String(envelope.worker_id), status: stalled ? "blocked" : "failed",
942
- summary: stalled ? "Phase stalled twice and needs operator direction." : "Phase execution failed.",
980
+ summary: stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
943
981
  files_changed: [], commits: [],
944
982
  back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
945
983
  context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
@@ -24,7 +24,7 @@ import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecor
24
24
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, retryUpdateLane, readQuestion, listQuestionIds, updateLane, updateQuestion, } from "./store.js";
25
25
  import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyObservation, recordCpuBusyObservation, throttleStatePath, writeThrottleState, } from "./throttle.js";
26
26
  import { readLanesRegistryFlag, readLanesRegistryFlagFailOff, readLanesRegistryFlagFailOpen } from "./registry-flag.js";
27
- import { LANE_BLOCKED_ESCALATION_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_KEEP_ALIVE_DUE_WAKE_PRECISE_FLAG, LANE_LEAD_ADMISSION_PRIORITY_FLAG, LANE_QUOTA_ACCOUNT_FAILOVER_FLAG, } from "./flag-keys.js";
27
+ import { LANE_BLOCKED_ESCALATION_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_KEEP_ALIVE_DUE_WAKE_PRECISE_FLAG, LANE_LEAD_ADMISSION_PRIORITY_FLAG, LANE_QUOTA_ACCOUNT_FAILOVER_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, } from "./flag-keys.js";
28
28
  import { checkLaneDiskSpace, configuredCodexRolloutHomes, laneDiskAccount, retainCodexRollouts } from "./disk-guard.js";
29
29
  import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
30
30
  import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "./dropbox.js";
@@ -1700,7 +1700,13 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1700
1700
  }
1701
1701
  const spawnRepair = deps.spawnRepair ??
1702
1702
  ((root, repairLane, input) => spawnEnvelopeRepair(root, repairLane, input, spawnDepsForHost(deps)));
1703
- const repair = decideEnvelopeRepair(hqRoot, lane.lane_id, repairCandidate, nowMs, spawnRepair, () => undefined);
1703
+ const repairGuidanceFlagReader = deps.readRegistryFlag ?? readLanesRegistryFlagFailOpen;
1704
+ const repairGuidanceEnabled = await repairGuidanceFlagReader(LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, {
1705
+ company: resolveLaneCompany(hqRoot, classified).company,
1706
+ defaultValue: true,
1707
+ env: deps.spawnLaneDeps?.env ?? deps.env ?? process.env,
1708
+ });
1709
+ const repair = decideEnvelopeRepair(hqRoot, lane.lane_id, repairCandidate, nowMs, spawnRepair, () => undefined, repairGuidanceEnabled);
1704
1710
  if (repair.action === "spawned") {
1705
1711
  armLaneWatcher(hqRoot, lane.lane_id, spawnDepsForHost(deps));
1706
1712
  summary.heartbeats.push(`${lane.lane_id}:envelope-repair`);
@@ -94,6 +94,7 @@ export interface SpawnLaneDeps {
94
94
  locateTranscript?: (home: string, sessionId: string) => string | null;
95
95
  /** Internal loop-lane override for the single provider turn's working directory. */
96
96
  workingDirectory?: string;
97
+ envelopeRepairGuidanceEnabled?: boolean;
97
98
  }
98
99
  export interface ProviderLaunch {
99
100
  command: string;
@@ -210,12 +211,12 @@ export declare function resumePrompt(input: {
210
211
  second_error?: string;
211
212
  };
212
213
  }): string;
213
- /** Spec §4: the one repair turn. Schema, parser error, raw text, JSON only. */
214
214
  export declare function envelopeRepairPrompt(input: {
215
215
  envelopePath: string;
216
216
  error: string;
217
217
  rawText: string;
218
218
  missing?: boolean;
219
+ detailedGuidance?: boolean;
219
220
  }): string;
220
221
  export interface ProviderResumeInput {
221
222
  session_id: string;
@@ -319,6 +320,7 @@ export interface EnvelopeRepairSpawnInput {
319
320
  rawText: string;
320
321
  /** True when this run never wrote its expected envelope file. */
321
322
  missing?: boolean;
323
+ detailedGuidance?: boolean;
322
324
  /** Repair-only warning hook for profile metadata ignored during recovery. */
323
325
  onProfileWarning?: (fields: readonly string[]) => void;
324
326
  }
@@ -1375,6 +1375,42 @@ export function resumePrompt(input) {
1375
1375
  return parts.join("\n\n");
1376
1376
  }
1377
1377
  /** Spec §4: the one repair turn. Schema, parser error, raw text, JSON only. */
1378
+ function recoverRawJsonArrayField(rawText, field) {
1379
+ const match = new RegExp(`"${field}"\\s*:\\s*\\[`).exec(rawText);
1380
+ if (!match)
1381
+ return undefined;
1382
+ const start = match.index + match[0].lastIndexOf("[");
1383
+ let depth = 0;
1384
+ let inString = false;
1385
+ let escaped = false;
1386
+ for (let index = start; index < rawText.length; index += 1) {
1387
+ const char = rawText[index];
1388
+ if (inString) {
1389
+ if (escaped)
1390
+ escaped = false;
1391
+ else if (char === "\\")
1392
+ escaped = true;
1393
+ else if (char === '"')
1394
+ inString = false;
1395
+ continue;
1396
+ }
1397
+ if (char === '"')
1398
+ inString = true;
1399
+ else if (char === "[")
1400
+ depth += 1;
1401
+ else if (char === "]" && --depth === 0) {
1402
+ try {
1403
+ const parsed = JSON.parse(rawText.slice(start, index + 1));
1404
+ return Array.isArray(parsed) ? parsed : undefined;
1405
+ }
1406
+ catch {
1407
+ process.stderr.write("hq lanes: envelope repair could not recover the supplied JSON array\n");
1408
+ return undefined;
1409
+ }
1410
+ }
1411
+ }
1412
+ return undefined;
1413
+ }
1378
1414
  export function envelopeRepairPrompt(input) {
1379
1415
  const maxRawTextChars = 24_000;
1380
1416
  const rawText = input.rawText.length <= maxRawTextChars
@@ -1393,11 +1429,94 @@ export function envelopeRepairPrompt(input) {
1393
1429
  `Output only that JSON. Do not add keys the schema does not name, and do not resume the task.`,
1394
1430
  ].join("\n\n");
1395
1431
  }
1432
+ if (input.detailedGuidance === false) {
1433
+ return [
1434
+ `The file you wrote at ${input.envelopePath} is not a valid hq-lane-envelope.v1. This is the single repair turn: rewrite that file so it validates, and do nothing else.`,
1435
+ `Parser error: ${input.error}`,
1436
+ "For an ask question, include non-empty id, question, and context fields.",
1437
+ "Schema:", JSON.stringify(LANE_ENVELOPE_JSON_SCHEMA, null, 2),
1438
+ "Your previous output was:", rawText,
1439
+ `Write the corrected JSON object to ${input.envelopePath}, overwriting it. Output only that JSON. Do not add keys the schema does not name, and do not resume the task.`,
1440
+ ].join("\n\n");
1441
+ }
1442
+ const location = input.error.match(/position (\d+) \(line (\d+) column (\d+)\)/i);
1443
+ const lineColumn = input.error.match(/line (\d+) column (\d+)/i);
1444
+ const offendingToken = location
1445
+ ? input.rawText[Number(location[1])] ?? "<end of file>"
1446
+ : undefined;
1447
+ const preciseError = location || lineColumn
1448
+ ? `${input.error}\nFault location: line ${location?.[2] ?? lineColumn?.[1]}, column ${location?.[3] ?? lineColumn?.[2]}.${offendingToken === undefined ? "" : ` Offending token: ${JSON.stringify(offendingToken)}.`}`
1449
+ : input.error;
1450
+ const syntaxFault = /unexpected (?:token|end)|property name.*in JSON at position|JSON at position \d+|position \d+/i.test(input.error);
1451
+ const originalDecision = input.rawText.match(/"decision"\s*:\s*"(done|ask|blocked)"/)?.[1] ?? "done";
1452
+ const originalCompleteMatch = input.rawText.match(/"story_complete"\s*:\s*(true|false)/)?.[1];
1453
+ const originalComplete = originalCompleteMatch === undefined ? undefined : originalCompleteMatch === "true";
1454
+ const originalStatus = input.rawText.match(/"story_status"\s*:\s*"(queued|in_progress|review|done)"/)?.[1];
1455
+ let originalObject = {};
1456
+ try {
1457
+ const parsed = JSON.parse(input.rawText);
1458
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
1459
+ originalObject = parsed;
1460
+ }
1461
+ catch {
1462
+ process.stderr.write("hq lanes: envelope repair is recovering values from invalid JSON\n");
1463
+ }
1464
+ const rawStringField = (field) => {
1465
+ const token = input.rawText.match(new RegExp(`"${field}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`))?.[1];
1466
+ if (!token)
1467
+ return undefined;
1468
+ try {
1469
+ return JSON.parse(token);
1470
+ }
1471
+ catch {
1472
+ process.stderr.write(`hq lanes: envelope repair could not recover the supplied ${field} string\n`);
1473
+ return undefined;
1474
+ }
1475
+ };
1476
+ const minimalEnvelope = {
1477
+ schema_version: "hq-lane-envelope.v1",
1478
+ decision: originalDecision,
1479
+ summary: rawStringField("summary") || "Describe the completed work or remaining issue.",
1480
+ story_status: originalComplete === true ? "done" : originalComplete === false && originalStatus === "done" ? "review" : originalStatus ?? "review",
1481
+ artifacts: [],
1482
+ };
1483
+ const originalCompleteReason = rawStringField("story_complete_reason");
1484
+ if (originalComplete !== undefined)
1485
+ minimalEnvelope.story_complete = originalComplete;
1486
+ if (originalComplete !== undefined || originalCompleteReason !== undefined) {
1487
+ minimalEnvelope.story_complete_reason = originalCompleteReason || (originalComplete === true ? "State what was delivered and how it was verified." : "State what remains missing.");
1488
+ }
1489
+ const originalArtifacts = Array.isArray(originalObject.artifacts)
1490
+ ? originalObject.artifacts
1491
+ : recoverRawJsonArrayField(input.rawText, "artifacts");
1492
+ if (originalArtifacts)
1493
+ minimalEnvelope.artifacts = originalArtifacts;
1494
+ if (originalDecision === "ask") {
1495
+ const originalQuestion = Array.isArray(originalObject.questions) && originalObject.questions[0] && typeof originalObject.questions[0] === "object"
1496
+ ? originalObject.questions[0]
1497
+ : {};
1498
+ const options = Array.isArray(originalQuestion.options) && originalQuestion.options.every((item) => typeof item === "string")
1499
+ ? originalQuestion.options
1500
+ : ["Option A", "Option B"];
1501
+ minimalEnvelope.questions = [{
1502
+ id: typeof originalQuestion.id === "string" && originalQuestion.id.trim() ? originalQuestion.id : "repair-question",
1503
+ question: typeof originalQuestion.question === "string" && originalQuestion.question.trim() ? originalQuestion.question : rawStringField("question") || "What decision is needed?",
1504
+ context: typeof originalQuestion.context === "string" && originalQuestion.context.trim() ? originalQuestion.context : rawStringField("context") || "State the unresolved choice.",
1505
+ options,
1506
+ }];
1507
+ }
1508
+ if (originalDecision === "blocked")
1509
+ minimalEnvelope.blocked_reason = rawStringField("blocked_reason") || "State the external blocker.";
1396
1510
  return [
1397
1511
  `The file you wrote at ${input.envelopePath} is not a valid hq-lane-envelope.v1. ` +
1398
1512
  `This is the single repair turn: rewrite that file so it validates, and do nothing else.`,
1399
- `Parser error: ${input.error}`,
1400
- "For an ask question, include non-empty id, question, and context fields.",
1513
+ `Exact validation error: ${preciseError}`,
1514
+ syntaxFault
1515
+ ? "This is a JSON syntax error. Fix the exact reported line and column and remove the offending token or delimiter. Do not add trailing commas."
1516
+ : "This is a schema validation error. Correct the named field path and use only the allowed value(s) stated in the error.",
1517
+ "Keep every supplied value that is already valid. Do not invent next_check_at; it is optional. For decision=ask include non-empty id, question, and context fields; for decision=blocked include blocked_reason.",
1518
+ "Minimal valid envelope shape (retain your original decision and values when valid):",
1519
+ JSON.stringify(minimalEnvelope, null, 2),
1401
1520
  "Schema:",
1402
1521
  JSON.stringify(LANE_ENVELOPE_JSON_SCHEMA, null, 2),
1403
1522
  "Your previous output was:",
@@ -2894,6 +3013,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2894
3013
  error: input.error,
2895
3014
  rawText: input.rawText,
2896
3015
  missing: input.missing,
3016
+ detailedGuidance: input.detailedGuidance ?? deps.envelopeRepairGuidanceEnabled,
2897
3017
  });
2898
3018
  const command = buildProviderCommand(lane.provider, {
2899
3019
  hqRoot,
@@ -800,6 +800,10 @@ export interface LaneEnvelopeRepair {
800
800
  skipped_reason?: string;
801
801
  /** Envelope slot the repair was asked to rewrite. */
802
802
  envelope_ref?: string;
803
+ /** Absolute raw envelope retained when the repair output also fails JSON parsing. */
804
+ raw_envelope_path?: string;
805
+ /** Recoverable artifacts from malformed raw JSON, surfaced by lanes show --json. */
806
+ artifacts?: unknown[];
803
807
  at: string;
804
808
  }
805
809
  export interface AccountHistoryEntry {
@@ -247,18 +247,7 @@ export type EnvelopeRepairDecision = {
247
247
  action: "give_up";
248
248
  errors: string[];
249
249
  };
250
- /**
251
- * Spec §4 repair decision for an exited worker whose expected envelope is
252
- * invalid or missing. First failure on a resumable provider with a recorded
253
- * session id: record `envelope_repair` on the lane and spawn the repair turn.
254
- * A provider with no session resume gets the same single attempt as a fresh
255
- * turn for invalid files. Anything else (already attempted, no session id on a
256
- * resumable provider, non-running lane, spawn failure): give up with every
257
- * error collected. The caller preserves the normal interrupted classification
258
- * for a missing file and uses `envelope_invalid` only for an existing invalid
259
- * file.
260
- */
261
- export declare function decideEnvelopeRepair(hqRoot: string, laneId: string, latest: LatestEnvelopeFile, nowMs: number, spawnRepair: NonNullable<WatcherLoopDeps["spawnRepair"]>, logDecision: (record: Record<string, unknown>) => void): EnvelopeRepairDecision;
250
+ export declare function decideEnvelopeRepair(hqRoot: string, laneId: string, latest: LatestEnvelopeFile, nowMs: number, spawnRepair: NonNullable<WatcherLoopDeps["spawnRepair"]>, logDecision: (record: Record<string, unknown>) => void, repairGuidanceEnabled?: boolean): EnvelopeRepairDecision;
262
251
  /** Exported so tests can prove the watcher reads the live record, not spawn env. */
263
252
  export declare function laneIdentityFromRecord(hqRoot: string, laneId: string): {
264
253
  lane_id: string;
@@ -49,13 +49,13 @@ import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefau
49
49
  import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
50
50
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
51
51
  import { envelopeQuestionId } from "./questions.js";
52
- import { readLanesRegistryFlag } from "./registry-flag.js";
52
+ import { readLanesRegistryFlag, readLanesRegistryFlagFailOpen } from "./registry-flag.js";
53
53
  import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
54
54
  import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "./blocker-freshness.js";
55
55
  import { krFreshnessFingerprint, resolveKrFreshnessForLane, } from "./kr-freshness.js";
56
56
  import { managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, } from "./manager-status-gate.js";
57
57
  import { LEAD_LANE_KEEP_ALIVE_FIRST_RESUME_SETTLE_MS, LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_FLAG, LEAD_LANE_KEEP_ALIVE_TERMINAL_TRIGGER_FLAG, queueLaneTerminalResume, queueManagerStatusGateResume, } from "./keep-alive.js";
58
- import { LANE_ENVELOPE_REPAIR_CONTINUE_FLAG } from "./flag-keys.js";
58
+ import { LANE_ENVELOPE_REPAIR_CONTINUE_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG } from "./flag-keys.js";
59
59
  import { monitorSessionDir } from "../monitor.js";
60
60
  import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, writePidRecord, retryLaneOnAlternateAccount, } from "./spawn.js";
61
61
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
@@ -1574,7 +1574,13 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
1574
1574
  const missingRepairAllowed = !repairCandidate.missing ||
1575
1575
  (lane !== null && envelopeRepairMode(lane.provider) === "resume");
1576
1576
  if (missingRepairAllowed) {
1577
- const repair = decideEnvelopeRepair(hqRoot, laneId, repairCandidate, now(), spawnRepair, logDecision);
1577
+ const repairLane = readLaneSoft(hqRoot, laneId);
1578
+ const repairGuidanceEnabled = await readLanesRegistryFlagFailOpen(LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, {
1579
+ company: repairLane?.company ?? repairLane?.mesh_company,
1580
+ defaultValue: true,
1581
+ env: process.env,
1582
+ });
1583
+ const repair = decideEnvelopeRepair(hqRoot, laneId, repairCandidate, now(), spawnRepair, logDecision, repairGuidanceEnabled);
1578
1584
  if (repair.action === "spawned") {
1579
1585
  lastSignalMs = now();
1580
1586
  lastAlive = null;
@@ -1941,7 +1947,43 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
1941
1947
  * for a missing file and uses `envelope_invalid` only for an existing invalid
1942
1948
  * file.
1943
1949
  */
1944
- export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair, logDecision) {
1950
+ function recoverRawEnvelopeArtifacts(rawText) {
1951
+ const match = /"artifacts"\s*:\s*\[/.exec(rawText);
1952
+ if (!match)
1953
+ return undefined;
1954
+ const start = match.index + match[0].lastIndexOf("[");
1955
+ let depth = 0;
1956
+ let inString = false;
1957
+ let escaped = false;
1958
+ for (let index = start; index < rawText.length; index += 1) {
1959
+ const char = rawText[index];
1960
+ if (inString) {
1961
+ if (escaped)
1962
+ escaped = false;
1963
+ else if (char === "\\")
1964
+ escaped = true;
1965
+ else if (char === '"')
1966
+ inString = false;
1967
+ continue;
1968
+ }
1969
+ if (char === '"')
1970
+ inString = true;
1971
+ else if (char === "[")
1972
+ depth += 1;
1973
+ else if (char === "]" && --depth === 0) {
1974
+ try {
1975
+ const parsed = JSON.parse(rawText.slice(start, index + 1));
1976
+ return Array.isArray(parsed) ? parsed : undefined;
1977
+ }
1978
+ catch {
1979
+ process.stderr.write("hq lanes watcher: could not recover artifacts from the invalid envelope\n");
1980
+ return undefined;
1981
+ }
1982
+ }
1983
+ }
1984
+ return undefined;
1985
+ }
1986
+ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair, logDecision, repairGuidanceEnabled = true) {
1945
1987
  const at = new Date(nowMs).toISOString();
1946
1988
  const error = latest.error ?? "unparseable envelope";
1947
1989
  const lane = readLaneSoft(hqRoot, laneId);
@@ -1964,10 +2006,14 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
1964
2006
  // The repair turn ran and its output does not parse either.
1965
2007
  if (prior.attempted) {
1966
2008
  const errors = [prior.first_error, error];
2009
+ let artifacts;
2010
+ if (repairGuidanceEnabled) {
2011
+ artifacts = recoverRawEnvelopeArtifacts(latest.text);
2012
+ }
1967
2013
  try {
1968
2014
  retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({
1969
2015
  ...current,
1970
- envelope_repair: { ...prior, succeeded: false, second_error: error },
2016
+ envelope_repair: { ...prior, succeeded: false, second_error: error, ...(repairGuidanceEnabled ? { raw_envelope_path: latest.path } : {}), ...(artifacts ? { artifacts } : {}) },
1971
2017
  }));
1972
2018
  }
1973
2019
  catch (err) {
@@ -1993,6 +2039,7 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
1993
2039
  const base = {
1994
2040
  first_error: error,
1995
2041
  envelope_ref: repairRef,
2042
+ ...(repairGuidanceEnabled ? { raw_envelope_path: latest.path } : {}),
1996
2043
  at,
1997
2044
  };
1998
2045
  if (skipped !== undefined) {
@@ -2008,6 +2055,7 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
2008
2055
  error,
2009
2056
  rawText: latest.text,
2010
2057
  missing: latest.missing,
2058
+ detailedGuidance: repairGuidanceEnabled,
2011
2059
  onProfileWarning: (fields) => {
2012
2060
  logDecision({
2013
2061
  event: "envelope_repair",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.73",
3
+ "version": "5.345.74",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {