@indigoai-us/hq-cli 5.218.0 → 5.220.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.
@@ -27,6 +27,7 @@ import { appendCappedHistory, listLaneIds, readLane, retryUpdateLane } from "./s
27
27
  import { copySessionFiles, planSessionCopy } from "./move-account.js";
28
28
  import { ensureSessionHome } from "./session-relink.js";
29
29
  import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
30
+ import { markAnsweredQuestionsDelivered } from "./questions.js";
30
31
  import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
31
32
  import { resolveAdmissionHost } from "./admission-host.js";
32
33
  import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
@@ -979,22 +980,30 @@ export function readAnsweredQuestionLines(answersPath) {
979
980
  parsed = JSON.parse(text);
980
981
  }
981
982
  catch (err) {
982
- process.stderr.write(`hq lanes: ${answersPath} is not JSON: ${err instanceof Error ? err.message : String(err)}\n`);
983
- return [];
983
+ throw new LanesError("answered_questions_invalid", `Answered questions at ${answersPath} are not valid JSON`, { path: answersPath });
984
+ }
985
+ if (!Array.isArray(parsed)) {
986
+ throw new LanesError("answered_questions_invalid", `Answered questions at ${answersPath} must be a JSON array`, { path: answersPath });
984
987
  }
985
- if (!Array.isArray(parsed))
986
- return [];
987
988
  const out = [];
988
- for (const item of parsed) {
989
- if (!item || typeof item !== "object")
990
- continue;
989
+ for (const [index, item] of parsed.entries()) {
990
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
991
+ throw new LanesError("answered_questions_invalid", `Answered question ${index} at ${answersPath} must be an object`, { path: answersPath, index });
992
+ }
991
993
  const rec = item;
992
- if (typeof rec.question_id !== "string")
993
- continue;
994
+ if (typeof rec.question_id !== "string" || !rec.question_id.trim()) {
995
+ throw new LanesError("answered_questions_invalid", `Answered question ${index} at ${answersPath} must have a question_id`, { path: answersPath, index });
996
+ }
997
+ if (typeof rec.choice !== "string") {
998
+ throw new LanesError("answered_questions_invalid", `Answered question ${rec.question_id} at ${answersPath} must have a choice`, { path: answersPath, question_id: rec.question_id });
999
+ }
994
1000
  const line = {
995
1001
  question_id: rec.question_id,
996
- choice: typeof rec.choice === "string" ? rec.choice : JSON.stringify(rec.choice ?? null),
1002
+ choice: rec.choice,
997
1003
  };
1004
+ if (rec.notes !== undefined && typeof rec.notes !== "string") {
1005
+ throw new LanesError("answered_questions_invalid", `Answered question ${rec.question_id} at ${answersPath} has invalid notes`, { path: answersPath, question_id: rec.question_id });
1006
+ }
998
1007
  if (typeof rec.notes === "string" && rec.notes.length > 0)
999
1008
  line.notes = rec.notes;
1000
1009
  out.push(line);
@@ -1744,6 +1753,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1744
1753
  const envelopePath = nextEnvelopePath(runDir);
1745
1754
  const lastMessagePath = path.join(logsDir, "last-message.txt");
1746
1755
  const answersPath = path.join(runDir, ANSWERED_QUESTIONS_FILE);
1756
+ const carriesAnsweredQuestions = trigger.kind === "answered_questions" || pendingResume?.trigger === "answered_questions";
1757
+ const answeredLines = carriesAnsweredQuestions && existsSync(answersPath)
1758
+ ? readAnsweredQuestionLines(answersPath)
1759
+ : undefined;
1747
1760
  const extraPrompt = existsSync(answersPath)
1748
1761
  ? `Previously answered questions are at ${answersPath}; treat those choices as operator decisions that outrank the brief.`
1749
1762
  : undefined;
@@ -1795,12 +1808,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1795
1808
  ? pendingResume.text
1796
1809
  : undefined,
1797
1810
  resumedFrom: trigger.kind === "resume" ? trigger.from : pendingResume?.resumed_from,
1798
- answersPath: (trigger.kind === "answered_questions" || pendingResume?.trigger === "answered_questions") && existsSync(answersPath)
1799
- ? answersPath
1800
- : undefined,
1801
- answers: (trigger.kind === "answered_questions" || pendingResume?.trigger === "answered_questions") && existsSync(answersPath)
1802
- ? readAnsweredQuestionLines(answersPath)
1803
- : undefined,
1811
+ answersPath: answeredLines !== undefined ? answersPath : undefined,
1812
+ answers: answeredLines,
1804
1813
  envelopeRepairErrors: lane.terminal_reason === ENVELOPE_INVALID_REASON &&
1805
1814
  lane.envelope_repair?.attempted === true &&
1806
1815
  lane.envelope_repair.succeeded === false
@@ -2053,6 +2062,9 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2053
2062
  starttime: readProcStarttime(workerPid) ?? undefined,
2054
2063
  });
2055
2064
  requireDetached(proveDetached, workerPid, "worker");
2065
+ if (answeredLines && answeredLines.length > 0) {
2066
+ markAnsweredQuestionsDelivered(hqRoot, lane.lane_id, answeredLines.map(({ question_id }) => question_id), (deps.now ?? (() => new Date()))().toISOString());
2067
+ }
2056
2068
  watcherPid = armLaneWatcher(hqRoot, lane.lane_id, deps);
2057
2069
  writeSpawnManifest(runDir, {
2058
2070
  command: command.command,
@@ -700,7 +700,15 @@ export function parseQuestionRecord(raw, filePath) {
700
700
  path: filePath,
701
701
  });
702
702
  }
703
- return raw;
703
+ const delivered_at = raw.delivered_at === undefined ? null : raw.delivered_at;
704
+ if (delivered_at !== null &&
705
+ (typeof delivered_at !== "string" || !Number.isFinite(Date.parse(delivered_at)))) {
706
+ throw new LaneSchemaError("delivered_at must be a valid timestamp or null", {
707
+ path: filePath,
708
+ value: delivered_at,
709
+ });
710
+ }
711
+ return { ...raw, delivered_at };
704
712
  }
705
713
  /**
706
714
  * HQ Sync sibling-twin grammar from `@indigoai-us/hq-cloud` `buildConflictPath`:
@@ -845,8 +853,13 @@ export function mergeQuestionRecords(live, sidecars) {
845
853
  return live;
846
854
  const all = [live, ...sidecars];
847
855
  const base = higherVersionRecord(live, sidecars);
856
+ const deliveredAt = all
857
+ .map((record) => record.delivered_at)
858
+ .filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)))
859
+ .sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null;
848
860
  return {
849
861
  ...base,
862
+ delivered_at: deliveredAt,
850
863
  answer_history: unionHistory(all.map(questionAnswerHistory), entryIdentity, questionAnswerHistory(base)),
851
864
  routing_history: unionHistory(all.map((record) => record.routing_history), entryIdentity, base.routing_history),
852
865
  };
@@ -1205,6 +1218,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
1205
1218
  ...init,
1206
1219
  schema_version: init.schema_version ?? QUESTION_SCHEMA_VERSION,
1207
1220
  question_id,
1221
+ delivered_at: init.delivered_at ?? null,
1208
1222
  version: 1,
1209
1223
  routing_history: init.routing_history ?? [],
1210
1224
  };
@@ -0,0 +1,77 @@
1
+ import type { TrackerChecksConfig } from "./types.js";
2
+ export interface TrackerCheckQuestion {
3
+ id: string;
4
+ status: string;
5
+ asked_at: string;
6
+ routing_history: Array<{
7
+ at: string;
8
+ audience_kind: string;
9
+ }>;
10
+ surfaced_at: string | null;
11
+ }
12
+ export interface TrackerCheckTeam {
13
+ team: string;
14
+ project_id: string;
15
+ lead_lane?: {
16
+ id: string;
17
+ state: string;
18
+ };
19
+ open_story_ids: string[];
20
+ open_pr_refs: string[];
21
+ running_worker_ids: string[];
22
+ status_path: string;
23
+ status_updated_at: string | null;
24
+ task_list_path: string;
25
+ task_list_updated_at: string | null;
26
+ }
27
+ export interface TrackerCheckBlocker {
28
+ id: string;
29
+ opened_at: string;
30
+ resolved_at: string | null;
31
+ next_step: string | null;
32
+ }
33
+ export interface TrackerCheckKpi {
34
+ id: string;
35
+ created_at: string;
36
+ defined_at?: string;
37
+ values: Array<{
38
+ at: string;
39
+ }>;
40
+ }
41
+ export interface TrackerCheckDirective {
42
+ id: string;
43
+ created_at: string;
44
+ teams: string[];
45
+ acknowledgements: Array<{
46
+ team: string;
47
+ lane_id: string;
48
+ at: string;
49
+ }>;
50
+ }
51
+ export interface TrackerCheckDirectiveTeam {
52
+ team: string;
53
+ lead_lane_id?: string;
54
+ }
55
+ export interface TrackerChecksInput {
56
+ now: Date;
57
+ thresholds: TrackerChecksConfig;
58
+ questions: TrackerCheckQuestion[];
59
+ teams: TrackerCheckTeam[];
60
+ blockers: TrackerCheckBlocker[];
61
+ kpis: TrackerCheckKpi[];
62
+ directives: TrackerCheckDirective[];
63
+ /** Company-wide mapping retained when the caller filters checks to one project. */
64
+ directive_teams?: TrackerCheckDirectiveTeam[];
65
+ }
66
+ export interface TrackerCheckFinding {
67
+ id: string;
68
+ reason: string;
69
+ [key: string]: unknown;
70
+ }
71
+ export interface TrackerCheckResult {
72
+ id: "questions" | "lead_lanes" | "freshness" | "blockers" | "kpis" | "directives";
73
+ result: "ok" | "failing";
74
+ items: TrackerCheckFinding[];
75
+ }
76
+ export declare function evaluateTrackerChecks(input: TrackerChecksInput): TrackerCheckResult[];
77
+ //# sourceMappingURL=tracker-checks.d.ts.map
@@ -0,0 +1,134 @@
1
+ function timestamp(value, field) {
2
+ const parsed = Date.parse(value);
3
+ if (!Number.isFinite(parsed))
4
+ throw new TypeError(`Invalid tracker check timestamp for ${field}`);
5
+ return parsed;
6
+ }
7
+ function check(id, items) {
8
+ return { id, result: items.length === 0 ? "ok" : "failing", items };
9
+ }
10
+ export function evaluateTrackerChecks(input) {
11
+ const now = input.now.getTime();
12
+ const questions = new Map();
13
+ for (const question of input.questions) {
14
+ if (question.status !== "pending")
15
+ continue;
16
+ const askedAt = timestamp(question.asked_at, `question ${question.id}.asked_at`);
17
+ if (now - askedAt > input.thresholds.open_question_age_ms) {
18
+ questions.set(question.id, {
19
+ id: question.id,
20
+ reason: "open_question_overdue",
21
+ age_ms: now - askedAt,
22
+ });
23
+ }
24
+ const latestRoute = question.routing_history
25
+ .map((route) => ({ ...route, at_ms: timestamp(route.at, `question ${question.id}.routing_history.at`) }))
26
+ .sort((a, b) => a.at_ms - b.at_ms)
27
+ .at(-1);
28
+ if (!latestRoute || latestRoute.audience_kind !== "user")
29
+ continue;
30
+ const surfacedAt = question.surfaced_at === null
31
+ ? null
32
+ : timestamp(question.surfaced_at, `question ${question.id}.surfaced_at`);
33
+ if (now - latestRoute.at_ms >= input.thresholds.owner_question_surface_ms &&
34
+ (surfacedAt === null || surfacedAt < latestRoute.at_ms)) {
35
+ const finding = questions.get(question.id);
36
+ questions.set(question.id, {
37
+ ...(finding ?? { id: question.id }),
38
+ reason: finding ? `${finding.reason},owner_not_surfaced` : "owner_not_surfaced",
39
+ routed_to_owner_at: new Date(latestRoute.at_ms).toISOString(),
40
+ });
41
+ }
42
+ }
43
+ const leadLanes = input.teams.flatMap((team) => {
44
+ const lead = team.lead_lane;
45
+ if (!lead || (lead.state !== "done" && lead.state !== "failed"))
46
+ return [];
47
+ const hasOpenWork = team.open_story_ids.length > 0 ||
48
+ team.open_pr_refs.length > 0 || team.running_worker_ids.length > 0;
49
+ if (!hasOpenWork)
50
+ return [];
51
+ return [{
52
+ id: team.team,
53
+ reason: "terminal_lead_with_open_work",
54
+ lead_lane_id: lead.id,
55
+ lead_state: lead.state,
56
+ open_story_ids: team.open_story_ids,
57
+ open_pr_refs: team.open_pr_refs,
58
+ running_worker_ids: team.running_worker_ids,
59
+ }];
60
+ });
61
+ const freshness = [];
62
+ for (const team of input.teams) {
63
+ if (team.status_updated_at === null) {
64
+ freshness.push({ id: team.team, reason: "status_missing", path: team.status_path });
65
+ }
66
+ else {
67
+ const age = now - timestamp(team.status_updated_at, `${team.team}.status.updated_at`);
68
+ if (age >= input.thresholds.status_stale_ms) {
69
+ freshness.push({ id: team.team, reason: "status_stale", path: team.status_path, age_ms: age });
70
+ }
71
+ }
72
+ if (team.task_list_updated_at === null) {
73
+ freshness.push({ id: team.team, reason: "task_list_missing", path: team.task_list_path });
74
+ }
75
+ else {
76
+ const age = now - timestamp(team.task_list_updated_at, `${team.team}.task_list.mtime`);
77
+ if (age >= input.thresholds.task_list_stale_ms) {
78
+ freshness.push({ id: team.team, reason: "task_list_stale", path: team.task_list_path, age_ms: age });
79
+ }
80
+ }
81
+ }
82
+ const blockers = input.blockers.flatMap((blocker) => {
83
+ if (blocker.resolved_at !== null)
84
+ return [];
85
+ const reasons = [];
86
+ const age = now - timestamp(blocker.opened_at, `blocker ${blocker.id}.opened_at`);
87
+ if (typeof blocker.next_step !== "string" || blocker.next_step.trim() === "")
88
+ reasons.push("missing_next_step");
89
+ if (age > input.thresholds.blocker_max_age_ms)
90
+ reasons.push("open_overdue");
91
+ return reasons.length === 0 ? [] : [{ id: blocker.id, reason: reasons.join(","), age_ms: age }];
92
+ });
93
+ const kpis = input.kpis.flatMap((kpi) => {
94
+ const latestValue = kpi.values
95
+ .map((value) => timestamp(value.at, `KPI ${kpi.id}.values.at`))
96
+ .reduce((latest, at) => latest === null || at > latest ? at : latest, null);
97
+ const baseline = latestValue ?? timestamp(kpi.created_at, `KPI ${kpi.id}.created_at`);
98
+ const age = now - baseline;
99
+ return age >= input.thresholds.kpi_value_max_age_ms
100
+ ? [{ id: kpi.id, reason: latestValue === null ? "no_value_overdue" : "value_stale", age_ms: age }]
101
+ : [];
102
+ });
103
+ const directiveTeams = input.directive_teams ?? input.teams.map((team) => ({
104
+ team: team.team,
105
+ ...(team.lead_lane ? { lead_lane_id: team.lead_lane.id } : {}),
106
+ }));
107
+ const teamByName = new Map(directiveTeams.map((team) => [team.team, team.lead_lane_id]));
108
+ const directives = input.directives.flatMap((directive) => {
109
+ const age = now - timestamp(directive.created_at, `directive ${directive.id}.created_at`);
110
+ if (age < input.thresholds.directive_ack_timeout_ms)
111
+ return [];
112
+ return directive.teams.flatMap((teamName) => {
113
+ const leadId = teamByName.get(teamName);
114
+ const acknowledged = leadId !== undefined && directive.acknowledgements.some((ack) => ack.team === teamName && ack.lane_id === leadId);
115
+ return acknowledged ? [] : [{
116
+ id: `${directive.id}:${teamName}`,
117
+ reason: leadId === undefined ? "target_team_lead_unavailable" : "lead_ack_missing",
118
+ directive_id: directive.id,
119
+ team: teamName,
120
+ lead_lane_id: leadId ?? null,
121
+ age_ms: age,
122
+ }];
123
+ });
124
+ });
125
+ return [
126
+ check("questions", [...questions.values()]),
127
+ check("lead_lanes", leadLanes),
128
+ check("freshness", freshness),
129
+ check("blockers", blockers),
130
+ check("kpis", kpis),
131
+ check("directives", directives),
132
+ ];
133
+ }
134
+ //# sourceMappingURL=tracker-checks.js.map
@@ -636,6 +636,8 @@ export interface QuestionRecord {
636
636
  hop: number;
637
637
  status: QuestionStatus;
638
638
  version: number;
639
+ /** Null until the owner starts a resume carrying this answer; absent on old records. */
640
+ delivered_at?: string | null;
639
641
  body: QuestionBodyRef;
640
642
  routing_history: RoutingHistoryEntry[];
641
643
  answer?: QuestionAnswer;
@@ -727,11 +729,24 @@ export interface LivenessWindows {
727
729
  /** No event at all for this window (and no `session_end`) → lost. Default 12 hours. */
728
730
  silent_lost_ms: number;
729
731
  }
732
+ /** Age limits used by `hq lanes checks`; each value is a positive millisecond count. */
733
+ export interface TrackerChecksConfig {
734
+ owner_question_surface_ms: number;
735
+ open_question_age_ms: number;
736
+ status_stale_ms: number;
737
+ task_list_stale_ms: number;
738
+ blocker_max_age_ms: number;
739
+ kpi_value_max_age_ms: number;
740
+ directive_ack_timeout_ms: number;
741
+ }
730
742
  export interface LanesConfig {
731
743
  schema_version: typeof CONFIG_SCHEMA_VERSION | string;
732
744
  providers: Record<LaneProvider, ProviderConfig>;
733
745
  throttle: ThrottleConfig;
734
746
  liveness: LivenessWindows;
747
+ tracker: {
748
+ checks: TrackerChecksConfig;
749
+ };
735
750
  /** Seconds an unanswered question waits at a session audience before advancing. */
736
751
  session_question_timeout_secs: number;
737
752
  }
@@ -6,6 +6,20 @@
6
6
  * Requires HQ_ROOT in the environment.
7
7
  */
8
8
  import { runWatcherLoop } from "./watcher.js";
9
+ import { LanesError } from "./errors.js";
10
+ import { openMeshTransport } from "../mesh/client.js";
11
+ import { CompanySelectionError } from "../../utils/company-selection-error.js";
12
+ async function resolveQuestionActiveCompany() {
13
+ try {
14
+ const { company } = await openMeshTransport({});
15
+ return { slug: company.companySlug, uid: company.companyUid };
16
+ }
17
+ catch (err) {
18
+ if (!(err instanceof CompanySelectionError))
19
+ throw err;
20
+ throw new LanesError("question_company_unavailable", "Cannot raise a lane question because the active HQ company could not be resolved", {});
21
+ }
22
+ }
9
23
  function main() {
10
24
  const laneId = process.argv[2]?.trim();
11
25
  const hqRoot = process.env.HQ_ROOT?.trim();
@@ -13,7 +27,9 @@ function main() {
13
27
  process.stderr.write("hq lanes watcher: requires lane id argv and HQ_ROOT in the environment\n");
14
28
  process.exit(1);
15
29
  }
16
- void runWatcherLoop(hqRoot, laneId).catch((err) => {
30
+ void runWatcherLoop(hqRoot, laneId, {
31
+ resolveQuestionActiveCompany,
32
+ }).catch((err) => {
17
33
  process.stderr.write(`hq lanes watcher: ${err instanceof Error ? err.message : String(err)}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
18
34
  process.exit(1);
19
35
  });
@@ -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 { ENVELOPE_INVALID_REASON, type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
34
+ import { ENVELOPE_INVALID_REASON, type LaneEnvelope, type LaneCompanyRef, 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";
@@ -145,6 +145,7 @@ export interface ApplyTerminalOpts {
145
145
  envelopeRef?: string;
146
146
  envelopeValidationErrors?: string[];
147
147
  questionIndexes?: readonly number[];
148
+ activeCompany?: LaneCompanyRef | null;
148
149
  at?: string;
149
150
  quota?: QuotaSignal | null;
150
151
  /** Written to `terminal_details` with the model and account the run used. */
@@ -219,6 +220,8 @@ export interface WatcherLoopDeps {
219
220
  resolveAdmissionHost?: () => AdmissionHost;
220
221
  /** Spawn seams for account retry; host scope is supplied by the resolver above. */
221
222
  retrySpawnDeps?: Omit<SpawnLaneDeps, "admissionHost">;
223
+ /** Active-company fallback when an unscoped lane asks from its envelope. */
224
+ resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
222
225
  }
223
226
  /**
224
227
  * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
@@ -34,7 +34,7 @@
34
34
  import * as fs from "node:fs";
35
35
  import * as path from "node:path";
36
36
  import { LanesError } from "./errors.js";
37
- import { raiseEnvelopeQuestions } from "./questions.js";
37
+ import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
38
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";
@@ -309,12 +309,27 @@ export function applyTerminalToLane(hqRoot, laneId, classification, opts = {}) {
309
309
  export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts = {}) {
310
310
  const at = opts.at ?? new Date().toISOString();
311
311
  const existing = readLane(hqRoot, laneId);
312
+ let questionRefusalCode = null;
312
313
  if (!isTerminalLaneState(existing.state) &&
313
314
  classification.state === "awaiting_input" &&
314
315
  opts.envelope &&
315
316
  (opts.envelope.questions?.length ?? 0) > 0) {
316
- raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at, opts.questionIndexes);
317
+ try {
318
+ raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at, opts.questionIndexes, opts.activeCompany);
319
+ }
320
+ catch (err) {
321
+ questionRefusalCode = questionRecordRefusalCode(err);
322
+ if (questionRefusalCode === null)
323
+ throw err;
324
+ process.stderr.write(`hq lanes watcher: terminal ask refused for ${laneId}: ${questionRefusalCode}\n`);
325
+ }
317
326
  }
327
+ const terminalClassification = questionRefusalCode === null
328
+ ? classification
329
+ : {
330
+ state: "failed",
331
+ reason: `question_record_refused:${questionRefusalCode}`,
332
+ };
318
333
  let changed = false;
319
334
  const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
320
335
  const terminal = current.state === "done" ||
@@ -331,7 +346,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
331
346
  const next = {
332
347
  ...current,
333
348
  ...runResultFields(hqRoot, current),
334
- state: classification.state,
349
+ state: terminalClassification.state,
335
350
  queued_reason: undefined,
336
351
  stalled: undefined,
337
352
  stalled_at: undefined,
@@ -340,9 +355,9 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
340
355
  envelope_validation_errors: opts.envelopeValidationErrors,
341
356
  };
342
357
  changed = true;
343
- if (classification.reason) {
358
+ if (terminalClassification.reason) {
344
359
  next.terminal_reason =
345
- classification.reason;
360
+ terminalClassification.reason;
346
361
  }
347
362
  if (opts.quota?.quota_reset_at) {
348
363
  next.quota_reset_at = opts.quota.quota_reset_at;
@@ -1054,11 +1069,25 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
1054
1069
  return;
1055
1070
  }
1056
1071
  }
1072
+ const terminalLane = readLane(hqRoot, laneId);
1073
+ let activeQuestionCompany;
1074
+ if (envelope?.decision === "ask" && (envelope.questions?.length ?? 0) > 0) {
1075
+ try {
1076
+ activeQuestionCompany = await resolveQuestionCompanyWithFallback(hqRoot, terminalLane, deps.resolveQuestionActiveCompany);
1077
+ }
1078
+ catch (err) {
1079
+ if (questionRecordRefusalCode(err) !== "question_company_unavailable")
1080
+ throw err;
1081
+ // applyTerminalToLaneWithResult repeats the synchronous lane and
1082
+ // project checks, then terminalizes this ask with the named refusal.
1083
+ }
1084
+ }
1057
1085
  const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
1058
1086
  envelope,
1059
1087
  envelopeRef: latest?.ref,
1060
1088
  envelopeValidationErrors,
1061
1089
  questionIndexes,
1090
+ activeCompany: activeQuestionCompany,
1062
1091
  at: tick.emit.at,
1063
1092
  quota,
1064
1093
  providerRejection,
@@ -1074,7 +1103,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
1074
1103
  // lane_blocked; ask → lane_ask with the queue ids. After the state
1075
1104
  // write, so a mesh failure can never leave the lane unclassified.
1076
1105
  if (mesh && terminalWrite.changed) {
1077
- if (envelope && latest) {
1106
+ if (envelope && latest && updated.state === classification.state) {
1078
1107
  const questionIds = envelope.decision === "ask"
1079
1108
  ? envelopeQuestionIds(laneId, latest.ref, envelope, questionIndexes)
1080
1109
  : undefined;
@@ -1086,7 +1115,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
1086
1115
  }
1087
1116
  else if (updated.state === "failed" || updated.state === "interrupted" ||
1088
1117
  updated.state === "cancelled" || updated.state === "killed") {
1089
- await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, classification.reason, signal), meshRequestTimeoutMs);
1118
+ await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, updated.terminal_reason ?? classification.reason, signal), meshRequestTimeoutMs);
1090
1119
  }
1091
1120
  }
1092
1121
  }
package/dist/sentry.js CHANGED
@@ -26,6 +26,9 @@ export function epipeAwareBeforeSend(event, hint) {
26
26
  return null;
27
27
  event.level = "warning";
28
28
  event.tags = { ...event.tags, worker: "flag-registry" };
29
+ const missingFile = flagRegistryWorkerMissingFileTags(hint?.originalException, event);
30
+ if (missingFile)
31
+ event.tags = { ...event.tags, ...missingFile };
29
32
  }
30
33
  if (!flagRegistryWorkerEvent && isEpipe(hint?.originalException))
31
34
  return null;
@@ -123,6 +126,44 @@ function isExpectedFlagRegistryWorkerStartupFailure(error) {
123
126
  return missingModule.replaceAll("\\", "/").endsWith("/flag-registry-worker.js") ||
124
127
  missingModule === "flag-registry-worker.js";
125
128
  }
129
+ function flagRegistryWorkerMissingFileTags(error, event) {
130
+ if (typeof error !== "object" || error === null)
131
+ return null;
132
+ const details = error;
133
+ if (details.code !== "ENOENT" || !["open", "lstat"].includes(String(details.syscall))) {
134
+ return null;
135
+ }
136
+ let missingPath = typeof details.path === "string" ? details.path : "";
137
+ if (!missingPath && typeof details.message === "string") {
138
+ missingPath = /\b(?:open|lstat)\s+['"]([^'"]+)['"]/.exec(details.message)?.[1] ?? "";
139
+ }
140
+ const basename = missingPath.replaceAll("\\", "/").split("/").at(-1) ?? "";
141
+ if (!basename)
142
+ return null;
143
+ const normalizedPath = missingPath.replaceAll("\\", "/");
144
+ const role = basename === "flag-registry-worker.js"
145
+ ? "worker_entry"
146
+ : normalizedPath.includes("/node_modules/")
147
+ ? "dependency"
148
+ : "other";
149
+ const eventText = [
150
+ event.message,
151
+ ...(event.exception?.values?.map((value) => value.value) ?? []),
152
+ ].filter((value) => typeof value === "string").join("\n");
153
+ const pathVisibility = eventText.includes(missingPath)
154
+ ? "full_path"
155
+ : eventText.includes(basename)
156
+ ? "basename_only"
157
+ : "omitted";
158
+ return {
159
+ flag_worker_load_failure: "1",
160
+ flag_worker_missing_basename: basename,
161
+ flag_worker_missing_file_role: role,
162
+ flag_worker_path_in_event: pathVisibility,
163
+ // The HQ beforeSend scrubber redacts credentials but does not strip paths.
164
+ flag_worker_path_scrubbed_by_hq_cli: "no",
165
+ };
166
+ }
126
167
  /**
127
168
  * Tests substitute only the transport so the real SDK lifecycle can be
128
169
  * exercised without an ingest request. Production callers pass nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.218.0",
3
+ "version": "5.220.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -22,12 +22,14 @@
22
22
  "scripts/mesh-daemon-if-stale.mjs"
23
23
  ],
24
24
  "scripts": {
25
- "build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
25
+ "build": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
26
26
  "prepublishOnly": "npm run build",
27
+ "pretypecheck": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
27
28
  "typecheck": "tsc --noEmit",
28
29
  "gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
29
30
  "gen:command-catalog": "tsc && node scripts/generate-command-catalog.mjs",
30
31
  "lint": "eslint .",
32
+ "pretest": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
31
33
  "test": "vitest run",
32
34
  "test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
33
35
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
@@ -66,6 +68,7 @@
66
68
  "@vitest/coverage-v8": "4.1.6",
67
69
  "aedes": "1.1.2",
68
70
  "aws-sdk-client-mock": "^4.1.0",
71
+ "esbuild": "0.27.0",
69
72
  "eslint": "^10.5.0",
70
73
  "husky": "^9.1.7",
71
74
  "lint-staged": "^15.5.2",