@indigoai-us/hq-cli 5.160.0 → 5.162.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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.162.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - An invalid envelope that remains after its repair turn no longer leaves a
10
+ lane inaccessible. Operators can resume or message a failed lane with a
11
+ recorded provider session, and the next envelope slot gets one new repair
12
+ attempt with the validation errors in the prompt.
13
+
14
+ ## [5.161.0] — 2026-09-24
15
+
16
+ ### Fixed
17
+
18
+ - No-reader flag gates now honor only `true` and `false` in `HQ_FLAG_<KEY>`
19
+ overrides when the registry endpoint is unset or its reader is unavailable.
20
+
5
21
  ## [5.160.0] — 2026-09-24
6
22
 
7
23
  ### Added
@@ -3830,7 +3830,9 @@ function laneWithRecoveredProviderSession(hqRoot, lane) {
3830
3830
  }
3831
3831
  /**
3832
3832
  * `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
3833
- * continues a lane in `interrupted`, `done` or `blocked` on its recorded
3833
+ * continues a lane in `interrupted`, `done` or `blocked`, or a failed lane
3834
+ * whose only terminal reason is an invalid envelope after its repair turn,
3835
+ * on its recorded
3834
3836
  * provider session. `hq lanes message` keeps refusing `interrupted`; this
3835
3837
  * command is the path its refusal names. The launch is the same
3836
3838
  * `spawnLaneWorker` a message resume uses, so the resume-time compaction
@@ -6,11 +6,17 @@
6
6
  * keep a short-lived, offline CLI alive until the client's network deadline.
7
7
  */
8
8
  import { Worker } from "node:worker_threads";
9
- import { isFlagSnapshot, resolveFlag, } from "@indigoai-us/hq-flags-client";
9
+ import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
10
10
  /** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
11
11
  export const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
12
12
  let heldSnapshot = null;
13
13
  let processReader = null;
14
+ function processLocalOverride(flagKey) {
15
+ return readLocalOverride(flagKey, process.env);
16
+ }
17
+ function localOverrideOrFallback(flagKey, legacyFallback) {
18
+ return processLocalOverride(flagKey) ?? legacyFallback();
19
+ }
14
20
  function processSnapshotReader() {
15
21
  return {
16
22
  isEnabled(flagKey, lookup = {}) {
@@ -65,12 +71,12 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
65
71
  */
66
72
  export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
67
73
  if (!reader)
68
- return legacyFallback();
74
+ return localOverrideOrFallback(flagKey, legacyFallback);
69
75
  try {
70
76
  return reader.isEnabled(flagKey, lookup);
71
77
  }
72
78
  catch {
73
- return legacyFallback();
79
+ return localOverrideOrFallback(flagKey, legacyFallback);
74
80
  }
75
81
  }
76
82
  /** Resolve a process gate against the shared snapshot reader, when configured. */
@@ -57,7 +57,9 @@ export type ResumeCommandState = (typeof RESUME_COMMAND_STATES)[number];
57
57
  export declare function isResumeCommandState(state: LaneState): state is ResumeCommandState;
58
58
  export declare const LANE_DELIVERY_MECHANISMS: readonly ["dropbox", "resume", "none"];
59
59
  export type LaneDeliveryMechanism = (typeof LANE_DELIVERY_MECHANISMS)[number];
60
- export type DeliveryLane = Pick<LaneRecord, "state" | "provider" | "provider_session_id">;
60
+ export type DeliveryLane = Pick<LaneRecord, "state" | "provider" | "provider_session_id" | "terminal_reason" | "envelope_repair">;
61
+ /** Only the terminal result of a failed automatic envelope repair is recoverable. */
62
+ export declare function isFailedEnvelopeRepairLane(lane: DeliveryLane): boolean;
61
63
  /**
62
64
  * `dropbox`: a process is (or is about to be) alive to drain the inbox.
63
65
  * `resume`: the process has exited, the provider can resume, the state allows
@@ -120,7 +122,7 @@ export type LaunchTrigger = {
120
122
  /** `hq lanes resume`: the operator's explicit opt-in to continue an exited lane. */
121
123
  kind: "resume";
122
124
  /** Lane state at the moment of the command; recorded as `last_resume.resumed_from`. */
123
- from: ResumeCommandState;
125
+ from: ResumeCommandState | "failed";
124
126
  text?: string;
125
127
  message_id?: string;
126
128
  };
@@ -147,5 +149,5 @@ export declare function resumeCommandRefusal(lane: DeliveryLane): {
147
149
  */
148
150
  export declare function planLaneLaunch(lane: DeliveryLane, trigger: LaunchTrigger): LaunchPlan;
149
151
  /** Lane states a launch may claim `running` from, per trigger. */
150
- export declare function claimableStatesFor(trigger: LaunchTrigger): readonly LaneState[];
152
+ export declare function claimableStatesFor(trigger: LaunchTrigger, lane?: DeliveryLane): readonly LaneState[];
151
153
  //# sourceMappingURL=delivery.d.ts.map
@@ -20,7 +20,7 @@
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  import { LanesError } from "./errors.js";
23
- import { parseLaneProvider, } from "./types.js";
23
+ import { parseLaneProvider, ENVELOPE_INVALID_REASON, } from "./types.js";
24
24
  /** Which providers can continue an exited worker from a recorded session id. */
25
25
  export const PROVIDER_SESSION_RESUME = {
26
26
  claude: true,
@@ -67,6 +67,13 @@ export function isResumeCommandState(state) {
67
67
  return RESUME_COMMAND_STATES.includes(state);
68
68
  }
69
69
  export const LANE_DELIVERY_MECHANISMS = ["dropbox", "resume", "none"];
70
+ /** Only the terminal result of a failed automatic envelope repair is recoverable. */
71
+ export function isFailedEnvelopeRepairLane(lane) {
72
+ return (lane.state === "failed" &&
73
+ lane.terminal_reason === ENVELOPE_INVALID_REASON &&
74
+ lane.envelope_repair?.attempted === true &&
75
+ lane.envelope_repair.succeeded === false);
76
+ }
70
77
  /**
71
78
  * `dropbox`: a process is (or is about to be) alive to drain the inbox.
72
79
  * `resume`: the process has exited, the provider can resume, the state allows
@@ -77,7 +84,7 @@ export function laneDeliveryMechanism(lane) {
77
84
  if (lane.state === "queued" || lane.state === "running")
78
85
  return "dropbox";
79
86
  if (providerSupportsSessionResume(lane.provider) &&
80
- isResumableLaneState(lane.state) &&
87
+ (isResumableLaneState(lane.state) || isFailedEnvelopeRepairLane(lane)) &&
81
88
  hasRecordedSessionId(lane)) {
82
89
  return "resume";
83
90
  }
@@ -275,7 +282,7 @@ export const LAUNCH_TRIGGERS = ["admit", "answered_questions", "message", "resum
275
282
  * without a resume path, no recorded session. Null when the resume can go.
276
283
  */
277
284
  export function resumeCommandRefusal(lane) {
278
- if (!isResumeCommandState(lane.state)) {
285
+ if (!isResumeCommandState(lane.state) && !isFailedEnvelopeRepairLane(lane)) {
279
286
  return {
280
287
  error: "lane_not_resumable",
281
288
  message: `Lane is ${lane.state}. hq lanes resume continues a lane in ${RESUME_COMMAND_STATES.join(", ")}; ${lane.state === "awaiting_input"
@@ -337,16 +344,17 @@ export function planLaneLaunch(lane, trigger) {
337
344
  }
338
345
  }
339
346
  /** Lane states a launch may claim `running` from, per trigger. */
340
- export function claimableStatesFor(trigger) {
347
+ export function claimableStatesFor(trigger, lane) {
348
+ const envelopeRepairFailed = lane !== undefined && isFailedEnvelopeRepairLane(lane) && hasRecordedSessionId(lane);
341
349
  switch (trigger.kind) {
342
350
  case "admit":
343
351
  return ["queued"];
344
352
  case "answered_questions":
345
353
  return ["awaiting_input"];
346
354
  case "message":
347
- return RESUMABLE_LANE_STATES;
355
+ return envelopeRepairFailed ? [...RESUMABLE_LANE_STATES, "failed"] : RESUMABLE_LANE_STATES;
348
356
  case "resume":
349
- return RESUME_COMMAND_STATES;
357
+ return envelopeRepairFailed ? [...RESUME_COMMAND_STATES, "failed"] : RESUME_COMMAND_STATES;
350
358
  default: {
351
359
  const _exhaustive = trigger;
352
360
  return _exhaustive;
@@ -192,6 +192,11 @@ export declare function resumePrompt(input: {
192
192
  answers?: AnsweredQuestionLine[];
193
193
  /** `hq lanes resume`: the state the operator resumed the lane from. */
194
194
  resumedFrom?: string;
195
+ /** Exact validator errors recorded after the prior envelope repair failed. */
196
+ envelopeRepairErrors?: {
197
+ first_error: string;
198
+ second_error?: string;
199
+ };
195
200
  }): string;
196
201
  /** Spec §4: the one repair turn. Schema, parser error, raw text, JSON only. */
197
202
  export declare function envelopeRepairPrompt(input: {
@@ -26,7 +26,7 @@ import { copySessionFiles, planSessionCopy } from "./move-account.js";
26
26
  import { ensureSessionHome } from "./session-relink.js";
27
27
  import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
28
28
  import { resolveAdmissionHost } from "./admission-host.js";
29
- import { LANE_ENVELOPE_JSON_SCHEMA, parseLaneProvider, } from "./types.js";
29
+ import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
30
30
  export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
31
31
  /** Written by reconcile before a resume spawn; the worker prompt names this path. */
32
32
  export const ANSWERED_QUESTIONS_FILE = "answered-questions.json";
@@ -996,6 +996,16 @@ export function resumePrompt(input) {
996
996
  ...(lines.length > 0 ? ["Answers:", ...lines] : []),
997
997
  ].join("\n"));
998
998
  }
999
+ if (input.envelopeRepairErrors !== undefined) {
1000
+ const errors = [input.envelopeRepairErrors.first_error, input.envelopeRepairErrors.second_error]
1001
+ .filter((error) => typeof error === "string" && error.length > 0);
1002
+ parts.push([
1003
+ `The previous envelope repair failed validation. The next envelope slot is ${input.envelopePath}; write a new valid envelope there.`,
1004
+ "Exact validation errors recorded in envelope_repair:",
1005
+ ...errors.map((error, index) => `- envelope_repair.${index === 0 ? "first_error" : "second_error"}: ${JSON.stringify(error)}`),
1006
+ "For an ask question, include non-empty id, question, and context fields.",
1007
+ ].join("\n"));
1008
+ }
999
1009
  // Every resume turn carries the authorization, including a textless
1000
1010
  // `hq lanes resume`: a pre-upgrade session's brief never had the paragraph.
1001
1011
  if (input.operatorMessage !== undefined ||
@@ -1028,6 +1038,7 @@ export function envelopeRepairPrompt(input) {
1028
1038
  `The file you wrote at ${input.envelopePath} is not a valid hq-lane-envelope.v1. ` +
1029
1039
  `This is the single repair turn: rewrite that file so it validates, and do nothing else.`,
1030
1040
  `Parser error: ${input.error}`,
1041
+ "For an ask question, include non-empty id, question, and context fields.",
1031
1042
  "Schema:",
1032
1043
  JSON.stringify(LANE_ENVELOPE_JSON_SCHEMA, null, 2),
1033
1044
  "Your previous output was:",
@@ -1723,6 +1734,16 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1723
1734
  answers: (trigger.kind === "answered_questions" || pendingResume?.trigger === "answered_questions") && existsSync(answersPath)
1724
1735
  ? readAnsweredQuestionLines(answersPath)
1725
1736
  : undefined,
1737
+ envelopeRepairErrors: lane.terminal_reason === ENVELOPE_INVALID_REASON &&
1738
+ lane.envelope_repair?.attempted === true &&
1739
+ lane.envelope_repair.succeeded === false
1740
+ ? {
1741
+ first_error: lane.envelope_repair.first_error,
1742
+ ...(lane.envelope_repair.second_error !== undefined
1743
+ ? { second_error: lane.envelope_repair.second_error }
1744
+ : {}),
1745
+ }
1746
+ : undefined,
1726
1747
  }),
1727
1748
  }
1728
1749
  : undefined;
@@ -1805,7 +1826,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1805
1826
  }
1806
1827
  // Claim running before spawning. Two callers that both observe queued must
1807
1828
  // not both start processes; the CAS loser returns without spawn.
1808
- const claimable = claimableStatesFor(trigger);
1809
1829
  const nowIso = (deps.now ?? (() => new Date()))().toISOString();
1810
1830
  let claimed = false;
1811
1831
  const claimedRecord = retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
@@ -1816,7 +1836,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1816
1836
  }
1817
1837
  return current;
1818
1838
  }
1819
- if (!claimable.includes(current.state)) {
1839
+ if (!claimableStatesFor(trigger, current).includes(current.state)) {
1820
1840
  throw new LanesError("spawn_state_mismatch", `Cannot mark ${current.lane_id} running from ${current.state}`, { lane_id: current.lane_id, state: current.state, trigger: trigger.kind });
1821
1841
  }
1822
1842
  claimed = true;
@@ -1826,6 +1846,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1826
1846
  queued_reason: undefined,
1827
1847
  queued_at: undefined,
1828
1848
  admission_reserved_at: undefined,
1849
+ terminal_reason: undefined,
1850
+ terminal_details: undefined,
1829
1851
  // An account move's reservation ends with the claim.
1830
1852
  pending_resume: undefined,
1831
1853
  account: resolved.id,
@@ -7,6 +7,8 @@ export declare const LANE_SCHEMA_VERSION: "hq-lane.v1";
7
7
  export declare const QUESTION_SCHEMA_VERSION: "hq-lane-question.v1";
8
8
  export declare const ENVELOPE_SCHEMA_VERSION: "hq-lane-envelope.v1";
9
9
  export declare const CONFIG_SCHEMA_VERSION: "hq-lanes-config.v1";
10
+ /** Lane terminal reason for an envelope still invalid after its one repair turn. */
11
+ export declare const ENVELOPE_INVALID_REASON: "envelope_invalid";
10
12
  /**
11
13
  * The envelope contract as a JSON schema. `parseLaneEnvelope` is the runtime
12
14
  * check; this is the text handed to a worker on the repair turn (spec §4) so
@@ -36,6 +38,11 @@ export declare const LANE_ENVELOPE_JSON_SCHEMA: {
36
38
  readonly additionalProperties: false;
37
39
  readonly required: readonly ["question", "context", "options"];
38
40
  readonly properties: {
41
+ readonly id: {
42
+ readonly type: "string";
43
+ readonly minLength: 1;
44
+ readonly pattern: "\\S";
45
+ };
39
46
  readonly question: {
40
47
  readonly type: "string";
41
48
  };
@@ -444,7 +451,7 @@ export interface AccountHistoryEntry {
444
451
  export interface PendingResume {
445
452
  session_id: string;
446
453
  trigger: "answered_questions" | "message" | "resume";
447
- resumed_from?: "interrupted" | "done" | "blocked";
454
+ resumed_from?: "interrupted" | "done" | "blocked" | "failed";
448
455
  text?: string;
449
456
  message_id?: string;
450
457
  /** False when the command passed --no-compact; the deferred spawn honours it. */
@@ -471,7 +478,7 @@ export interface LaneResumeRecord {
471
478
  * `hq lanes resume` only: the state the lane was in when the operator
472
479
  * resumed it (`interrupted`, `done` or `blocked`) and the text they sent.
473
480
  */
474
- resumed_from?: "interrupted" | "done" | "blocked";
481
+ resumed_from?: "interrupted" | "done" | "blocked" | "failed";
475
482
  text?: string;
476
483
  /** False when the continued turn explicitly skipped compaction. */
477
484
  compact?: boolean;
@@ -555,6 +562,8 @@ export interface QuestionRecord {
555
562
  answer_history?: QuestionAnswer[];
556
563
  }
557
564
  export interface EnvelopeQuestion {
565
+ /** Optional for compatibility with existing v1 envelopes; repair prompts request it. */
566
+ id?: string;
558
567
  question: string;
559
568
  context: string;
560
569
  options: string[];
@@ -8,6 +8,8 @@ export const LANE_SCHEMA_VERSION = "hq-lane.v1";
8
8
  export const QUESTION_SCHEMA_VERSION = "hq-lane-question.v1";
9
9
  export const ENVELOPE_SCHEMA_VERSION = "hq-lane-envelope.v1";
10
10
  export const CONFIG_SCHEMA_VERSION = "hq-lanes-config.v1";
11
+ /** Lane terminal reason for an envelope still invalid after its one repair turn. */
12
+ export const ENVELOPE_INVALID_REASON = "envelope_invalid";
11
13
  /**
12
14
  * The envelope contract as a JSON schema. `parseLaneEnvelope` is the runtime
13
15
  * check; this is the text handed to a worker on the repair turn (spec §4) so
@@ -30,6 +32,7 @@ export const LANE_ENVELOPE_JSON_SCHEMA = {
30
32
  additionalProperties: false,
31
33
  required: ["question", "context", "options"],
32
34
  properties: {
35
+ id: { type: "string", minLength: 1, pattern: "\\S" },
33
36
  question: { type: "string" },
34
37
  context: { type: "string" },
35
38
  options: { type: "array", items: { type: "string" } },
@@ -357,6 +360,14 @@ export function parseLaneEnvelope(raw) {
357
360
  context: requireString(item.context, `questions[${index}].context`),
358
361
  options,
359
362
  };
363
+ if (item.id !== undefined) {
364
+ if (typeof item.id !== "string" || item.id.trim().length === 0) {
365
+ throw new LaneSchemaError(`Expected non-empty string for questions[${index}].id`, {
366
+ field: `questions[${index}].id`,
367
+ });
368
+ }
369
+ parsed.id = item.id;
370
+ }
360
371
  if (item.recommended !== undefined) {
361
372
  parsed.recommended = requireString(item.recommended, `questions[${index}].recommended`);
362
373
  }
@@ -31,7 +31,7 @@
31
31
  * Queue admission of the next lane when a slot frees is owned by the
32
32
  * periodic reconcile pass and is out of scope here.
33
33
  */
34
- import { type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
34
+ import { ENVELOPE_INVALID_REASON, type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
35
35
  import { type QuotaSignal } from "./quota.js";
36
36
  import { type ProviderRejection } from "./provider-rejection.js";
37
37
  import type { AdmissionHost } from "./admission.js";
@@ -50,7 +50,7 @@ export declare const WATCHER_HEARTBEAT_MS: number;
50
50
  export declare const WATCHER_OUT_MAX_BYTES: number;
51
51
  export declare const WATCHER_DECISION_EVENTS: readonly ["liveness_transition", "silence_deadline", "envelope_observed", "envelope_invalid", "envelope_repair", "classification", "terminal_write", "mesh_event", "mesh_event_failed"];
52
52
  /** Terminal reason when the envelope failed to parse after the repair turn (spec §4). */
53
- export declare const ENVELOPE_INVALID_REASON: "envelope_invalid";
53
+ export { ENVELOPE_INVALID_REASON };
54
54
  export type WatcherDecisionEvent = (typeof WATCHER_DECISION_EVENTS)[number];
55
55
  export declare const WATCHER_SIGNAL_KINDS: readonly ["progress", "terminal", "silence"];
56
56
  export type WatcherSignalKind = (typeof WATCHER_SIGNAL_KINDS)[number];
@@ -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, parseLaneEnvelope, } from "./types.js";
38
+ import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, } 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";
@@ -70,7 +70,7 @@ export const WATCHER_DECISION_EVENTS = [
70
70
  "mesh_event_failed",
71
71
  ];
72
72
  /** Terminal reason when the envelope failed to parse after the repair turn (spec §4). */
73
- export const ENVELOPE_INVALID_REASON = "envelope_invalid";
73
+ export { ENVELOPE_INVALID_REASON };
74
74
  export const WATCHER_SIGNAL_KINDS = ["progress", "terminal", "silence"];
75
75
  export function parseWatcherSignalKind(value) {
76
76
  if (typeof value === "string" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.160.0",
3
+ "version": "5.162.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {