@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.
@@ -17,7 +17,10 @@ interface WorkerLike {
17
17
  }
18
18
  export interface FlagRegistryDependencies {
19
19
  endpoint?: string;
20
- createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
20
+ createWorker?: (filename: string | URL, options: WorkerOptions) => WorkerLike;
21
+ /** Test seam for exercising the no-source fail-open path. */
22
+ workerSource?: string;
23
+ captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
21
24
  }
22
25
  export interface ProcessFlagCompanyContext {
23
26
  companyUid?: string;
@@ -12,8 +12,10 @@ import { readMachineCompanyBinding } from "./work-context/company.js";
12
12
  import { readSessionState } from "./work-context/state.js";
13
13
  import { workContextRoot } from "./work-context/paths.js";
14
14
  import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
15
- import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
15
+ import { loadCachedTokens, resolveDefaultHqRoot } from "../utils/cognito-session.js";
16
16
  import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
17
+ import { FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, flagRegistryStateDir, flagRegistryWorkerModeCachePath, readCachedFlagRegistryWorkerMode, writeCachedFlagRegistryWorkerMode, } from "./flag-registry-worker-mode-cache.js";
18
+ import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
17
19
  export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
18
20
  const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
19
21
  const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -99,6 +101,75 @@ function processSnapshotReader(companyIdentifiers) {
99
101
  },
100
102
  };
101
103
  }
104
+ function personSubjectForWorkerMode(idToken) {
105
+ if (typeof idToken !== "string")
106
+ return null;
107
+ const payload = idToken.split(".")[1];
108
+ if (!payload)
109
+ return null;
110
+ try {
111
+ const subject = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).sub;
112
+ return typeof subject === "string" && subject.trim() ? subject : null;
113
+ }
114
+ catch {
115
+ Sentry.captureMessage("Could not scope cached flag-registry worker mode because the cached ID token payload is invalid", { level: "warning", tags: { component: "flag-registry-worker-mode-cache" } });
116
+ return null;
117
+ }
118
+ }
119
+ /** Scope a persisted rollout value to the same identity that fetched it. */
120
+ function workerModeCacheScopes(company) {
121
+ const tokens = loadCachedTokens();
122
+ const personSubject = tokens ? personSubjectForWorkerMode(tokens.idToken) : null;
123
+ if (tokens && !personSubject)
124
+ return null;
125
+ return [
126
+ ...(company.companyUid ? [`company:${company.companyUid}`] : []),
127
+ ...(company.companySlug ? [`company-slug:${company.companySlug}`] : []),
128
+ ...(personSubject ? [`person:${personSubject}`] : []),
129
+ ];
130
+ }
131
+ function selfContainedWorkerEnabled(company) {
132
+ const localOverride = readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env);
133
+ if (localOverride !== null)
134
+ return localOverride;
135
+ const scopes = workerModeCacheScopes(company);
136
+ if (!scopes)
137
+ return false;
138
+ const filePath = flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes);
139
+ try {
140
+ return readCachedFlagRegistryWorkerMode(filePath)?.value ?? false;
141
+ }
142
+ catch (error) {
143
+ Sentry.captureException(error, {
144
+ tags: { component: "flag-registry-worker-mode-cache", worker: "flag-registry" },
145
+ });
146
+ return false;
147
+ }
148
+ }
149
+ function cacheWorkerModeFromSnapshot(snapshot, company) {
150
+ // A developer's local hq-flags override is process-local and must not become
151
+ // the value selected by a future process.
152
+ if (readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env) !== null)
153
+ return;
154
+ const scopes = workerModeCacheScopes(company);
155
+ if (!scopes)
156
+ return;
157
+ const decision = resolveFlag({
158
+ flagKey: FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG,
159
+ env: {},
160
+ snapshot,
161
+ companyIdentifiers: [company.companyUid, company.companySlug].filter((value) => Boolean(value)),
162
+ lookup: { fallback: false },
163
+ });
164
+ try {
165
+ writeCachedFlagRegistryWorkerMode(flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes), { version: snapshot.version, value: decision.value });
166
+ }
167
+ catch (error) {
168
+ Sentry.captureException(error, {
169
+ tags: { component: "flag-registry-worker-mode-cache", worker: "flag-registry" },
170
+ });
171
+ }
172
+ }
102
173
  /**
103
174
  * Begin one registry refresh without awaiting it or retaining the CLI process.
104
175
  *
@@ -117,23 +188,43 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
117
188
  const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
118
189
  processReader = processSnapshotReader(companyIdentifiers);
119
190
  try {
191
+ let workerSource = new URL("./flag-registry-worker.js", import.meta.url);
192
+ let workerOptions = { workerData: {
193
+ endpoint,
194
+ ...(companyUid ? { companyUid } : {}),
195
+ ...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
196
+ } };
197
+ if (selfContainedWorkerEnabled(company)) {
198
+ // The worker source is generated and embedded in this parent module at
199
+ // build time. It is already resident before company, identity, and cache
200
+ // reads can yield to an updater replacing this package tree.
201
+ const embeddedSource = dependencies.workerSource ?? FLAG_REGISTRY_WORKER_SOURCE;
202
+ if (embeddedSource.length === 0) {
203
+ (dependencies.captureWorkerFailure ?? ((error, tags) => {
204
+ Sentry.captureException(error, { tags });
205
+ }))(new Error("Self-contained flag-registry worker source is unavailable"), {
206
+ component: "flag-registry-worker-startup",
207
+ worker: "flag-registry",
208
+ flag_worker_failure: "embedded_source_unavailable",
209
+ });
210
+ return processReader;
211
+ }
212
+ workerSource = embeddedSource;
213
+ workerOptions = { ...workerOptions, eval: true };
214
+ }
120
215
  // The SDK's child-process integration captures every Worker error unless
121
216
  // its process-wide captureWorkerErrors option is disabled. Scope only this
122
217
  // worker so the reporting boundary can classify its startup errors without
123
218
  // changing capture for unrelated workers.
124
219
  const worker = Sentry.withScope((scope) => {
125
220
  scope.setTag("worker", "flag-registry");
126
- return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), {
127
- workerData: {
128
- endpoint,
129
- ...(companyUid ? { companyUid } : {}),
130
- ...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
131
- },
132
- });
221
+ return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(workerSource, workerOptions);
133
222
  });
134
223
  worker.on("message", (value) => {
135
- if (isFlagSnapshot(value))
224
+ if (isFlagSnapshot(value)) {
136
225
  heldSnapshot = value;
226
+ cacheWorkerModeFromSnapshot(value, company);
227
+ }
137
228
  });
138
229
  // A failed background refresh must stay invisible to an offline CLI.
139
230
  worker.on("error", () => { });
@@ -7,7 +7,7 @@
7
7
  * that resolve to the same home are refused (identity-keyed-cap rule).
8
8
  */
9
9
  import * as fs from "node:fs";
10
- import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig } from "./types.js";
10
+ import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig, type TrackerChecksConfig } from "./types.js";
11
11
  export declare const LANES_CONFIG_REL: string;
12
12
  export declare const DEFAULT_MAX_QUEUED = 3;
13
13
  export declare const DEFAULT_CPU_BUSY_PAUSE = 0.85;
@@ -39,6 +39,7 @@ export declare const DEFAULT_TURN_START_LOST_MS: number;
39
39
  export declare const DEFAULT_SILENT_LOST_MS: number;
40
40
  /** Unanswered session questions move to the next audience after 10 minutes. */
41
41
  export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
42
+ export declare const DEFAULT_TRACKER_CHECKS: TrackerChecksConfig;
42
43
  export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
43
44
  export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
44
45
  export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
@@ -43,6 +43,15 @@ export const DEFAULT_TURN_START_LOST_MS = 2 * 60 * 60 * 1000;
43
43
  export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
44
44
  /** Unanswered session questions move to the next audience after 10 minutes. */
45
45
  export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
46
+ export const DEFAULT_TRACKER_CHECKS = {
47
+ owner_question_surface_ms: 10 * 60 * 1000,
48
+ open_question_age_ms: 2 * 60 * 60 * 1000,
49
+ status_stale_ms: 90 * 60 * 1000,
50
+ task_list_stale_ms: 120 * 60 * 1000,
51
+ blocker_max_age_ms: 24 * 60 * 60 * 1000,
52
+ kpi_value_max_age_ms: 24 * 60 * 60 * 1000,
53
+ directive_ack_timeout_ms: 30 * 60 * 1000,
54
+ };
46
55
  export const DEFAULT_LIVENESS_WINDOWS = {
47
56
  live_within_ms: DEFAULT_LIVE_WITHIN_MS,
48
57
  turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
@@ -362,6 +371,25 @@ function parseLiveness(raw) {
362
371
  silent_lost_ms: positiveInt(obj.silent_lost_ms, "liveness.silent_lost_ms", DEFAULT_SILENT_LOST_MS),
363
372
  };
364
373
  }
374
+ function parseTrackerChecks(raw) {
375
+ const tracker = raw === undefined ? {} : raw;
376
+ if (!isPlainObject(tracker)) {
377
+ throw new LanesConfigError("invalid_config", "tracker must be an object");
378
+ }
379
+ const checks = tracker.checks === undefined ? {} : tracker.checks;
380
+ if (!isPlainObject(checks)) {
381
+ throw new LanesConfigError("invalid_config", "tracker.checks must be an object");
382
+ }
383
+ return {
384
+ owner_question_surface_ms: positiveInt(checks.owner_question_surface_ms, "tracker.checks.owner_question_surface_ms", DEFAULT_TRACKER_CHECKS.owner_question_surface_ms),
385
+ open_question_age_ms: positiveInt(checks.open_question_age_ms, "tracker.checks.open_question_age_ms", DEFAULT_TRACKER_CHECKS.open_question_age_ms),
386
+ status_stale_ms: positiveInt(checks.status_stale_ms, "tracker.checks.status_stale_ms", DEFAULT_TRACKER_CHECKS.status_stale_ms),
387
+ task_list_stale_ms: positiveInt(checks.task_list_stale_ms, "tracker.checks.task_list_stale_ms", DEFAULT_TRACKER_CHECKS.task_list_stale_ms),
388
+ blocker_max_age_ms: positiveInt(checks.blocker_max_age_ms, "tracker.checks.blocker_max_age_ms", DEFAULT_TRACKER_CHECKS.blocker_max_age_ms),
389
+ kpi_value_max_age_ms: positiveInt(checks.kpi_value_max_age_ms, "tracker.checks.kpi_value_max_age_ms", DEFAULT_TRACKER_CHECKS.kpi_value_max_age_ms),
390
+ directive_ack_timeout_ms: positiveInt(checks.directive_ack_timeout_ms, "tracker.checks.directive_ack_timeout_ms", DEFAULT_TRACKER_CHECKS.directive_ack_timeout_ms),
391
+ };
392
+ }
365
393
  export function documentedDefaultConfig(deps = defaultDeps()) {
366
394
  const providers = {};
367
395
  for (const provider of LANE_PROVIDERS) {
@@ -384,6 +412,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
384
412
  min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
385
413
  },
386
414
  liveness: { ...DEFAULT_LIVENESS_WINDOWS },
415
+ tracker: { checks: { ...DEFAULT_TRACKER_CHECKS } },
387
416
  session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
388
417
  };
389
418
  }
@@ -425,6 +454,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
425
454
  providers,
426
455
  throttle: parseThrottle(raw.throttle),
427
456
  liveness: parseLiveness(raw.liveness),
457
+ tracker: { checks: parseTrackerChecks(raw.tracker) },
428
458
  session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
429
459
  };
430
460
  }
@@ -3,7 +3,7 @@
3
3
  * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
4
  * drift on hop, origin, raised_via, body store, and routing_history.
5
5
  */
6
- import { type LaneEnvelope, type LaneRecord, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
6
+ import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
7
7
  export interface QuestionBody {
8
8
  question: string;
9
9
  context: string;
@@ -11,8 +11,20 @@ export interface QuestionBody {
11
11
  recommended?: string;
12
12
  notes?: string;
13
13
  }
14
+ export declare function assertQuestionBodySecretSafe(body: QuestionBody): void;
15
+ /** Errors that make an envelope ask unsafe or impossible to persist. */
16
+ export declare function questionRecordRefusalCode(err: unknown): "secret_text_refused" | "question_company_unavailable" | null;
14
17
  export declare function parseQuestionBodyStore(value: unknown): QuestionBodyStore;
15
- export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company">): QuestionBodyStore;
18
+ export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company" | "mesh_company">, resolvedCompany?: LaneCompanyRef): QuestionBodyStore;
19
+ export declare function resolveQuestionCompany(lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, activeCompany?: LaneCompanyRef | null, hqRoot?: string): LaneCompanyRef;
20
+ /**
21
+ * Resolve project ownership only when exactly one manifest company contains
22
+ * the project's directory. Multiple matches are unresolved so the caller can
23
+ * continue to an explicit active-company choice without picking an owner.
24
+ */
25
+ export declare function resolveProjectQuestionCompany(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "project_id">): LaneCompanyRef | null;
26
+ /** Resolve the lane/project company before lazily consulting active HQ scope. */
27
+ export declare function resolveQuestionCompanyWithFallback(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, resolveActiveCompany?: () => Promise<LaneCompanyRef | null>): Promise<LaneCompanyRef>;
16
28
  export declare function questionBodyRef(questionId: string): string;
17
29
  export declare function sha256Text(text: string): string;
18
30
  /** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
@@ -28,6 +40,7 @@ export interface RaiseLaneQuestionInput {
28
40
  body: QuestionBody;
29
41
  raisedVia: RaisedVia;
30
42
  at: string;
43
+ activeCompany?: LaneCompanyRef | null;
31
44
  questionId?: string;
32
45
  }
33
46
  /**
@@ -43,15 +56,15 @@ export declare function raiseLaneQuestion(input: RaiseLaneQuestionInput): Questi
43
56
  */
44
57
  export declare function classifyQuestionKind(options: readonly string[]): QuestionKind | undefined;
45
58
  /** The store a lane's question bodies live in, derived from the lane and never the reverse. */
46
- export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company">, questionId: string): string;
59
+ export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company" | "mesh_company">, questionId: string): string;
47
60
  /**
48
61
  * Free-text notes the operator attached with `questions answer --notes`.
49
62
  * Read from the lane's own store (spec §6.5): the company store for a company
50
63
  * lane, the personal store otherwise. Returns undefined when the answer has
51
- * no notes_ref or the body carries no notes; a body the host cannot read is
52
- * reported and skipped rather than failing the resume.
64
+ * no notes_ref or the body carries no notes. An unavailable or malformed body
65
+ * fails the resume so the lane cannot continue without its answer notes.
53
66
  */
54
- export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company">, question: Pick<QuestionRecord, "question_id" | "answer">): string | undefined;
67
+ export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company" | "mesh_company">, question: Pick<QuestionRecord, "question_id" | "company" | "body" | "answer">): string | undefined;
55
68
  export interface AnsweredQuestionEntry {
56
69
  question_id: string;
57
70
  choice: string | undefined;
@@ -66,10 +79,11 @@ export interface AnsweredQuestionEntry {
66
79
  * directory on the executing host.
67
80
  */
68
81
  export declare function answeredQuestionEntries(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company">, questions: QuestionRecord[]): AnsweredQuestionEntry[];
82
+ export declare function markAnsweredQuestionsDelivered(hqRoot: string, laneId: string, questionIds: readonly string[], deliveredAt: string): QuestionRecord[];
69
83
  export declare function envelopeQuestionId(laneId: string, envelopeRef: string, index: number): string;
70
84
  /**
71
85
  * Spec §6.1: each envelope questions[] entry becomes one queue record, via
72
86
  * raiseLaneQuestion (the same function the CLI ask path uses).
73
87
  */
74
- export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[]): QuestionRecord[];
88
+ export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[], activeCompany?: LaneCompanyRef | null): QuestionRecord[];
75
89
  //# sourceMappingURL=questions.d.ts.map
@@ -7,8 +7,29 @@ import { createHash } from "node:crypto";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { LanesError } from "./errors.js";
10
+ import { assertNoSecretText } from "./text-scan.js";
11
+ import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
10
12
  import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
11
13
  import { QUESTION_BODY_STORES, } from "./types.js";
14
+ export function assertQuestionBodySecretSafe(body) {
15
+ assertNoSecretText("question", body.question);
16
+ assertNoSecretText("context", body.context);
17
+ body.options.forEach((option, index) => assertNoSecretText(`option[${index}]`, option));
18
+ if (body.recommended !== undefined) {
19
+ assertNoSecretText("recommended", body.recommended);
20
+ }
21
+ if (body.notes !== undefined)
22
+ assertNoSecretText("answer notes", body.notes);
23
+ }
24
+ /** Errors that make an envelope ask unsafe or impossible to persist. */
25
+ export function questionRecordRefusalCode(err) {
26
+ if (!(err instanceof LanesError))
27
+ return null;
28
+ if (err.error === "secret_text_refused" || err.error === "question_company_unavailable") {
29
+ return err.error;
30
+ }
31
+ return null;
32
+ }
12
33
  export function parseQuestionBodyStore(value) {
13
34
  if (typeof value === "string" &&
14
35
  QUESTION_BODY_STORES.includes(value)) {
@@ -16,8 +37,93 @@ export function parseQuestionBodyStore(value) {
16
37
  }
17
38
  throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
18
39
  }
19
- export function bodyStoreForLane(lane) {
20
- return lane.company ? "company" : "personal";
40
+ export function bodyStoreForLane(lane, resolvedCompany) {
41
+ return lane.company || lane.mesh_company || resolvedCompany ? "company" : "personal";
42
+ }
43
+ export function resolveQuestionCompany(lane, activeCompany, hqRoot) {
44
+ const laneCompany = lane.company ?? lane.mesh_company;
45
+ if (laneCompany !== null && laneCompany !== undefined) {
46
+ return requireQuestionCompany(laneCompany, lane.lane_id, "lane");
47
+ }
48
+ if (hqRoot) {
49
+ const projectCompany = resolveProjectQuestionCompany(hqRoot, lane);
50
+ if (projectCompany)
51
+ return projectCompany;
52
+ }
53
+ const company = activeCompany;
54
+ if (!company || !company.slug.trim() || !company.uid.trim()) {
55
+ throw new LanesError("question_company_unavailable", `Cannot raise a question for lane ${lane.lane_id}: no lane, project, or active company could be resolved`, { lane_id: lane.lane_id, resolution: "active_company_required" });
56
+ }
57
+ return company;
58
+ }
59
+ function requireQuestionCompany(company, laneId, source) {
60
+ if (!company.slug.trim() || !company.uid.trim()) {
61
+ throw new LanesError("question_company_unavailable", `Cannot resolve the ${source} company for lane ${laneId}: its slug or uid is missing`, { lane_id: laneId, resolution: `${source}_company_invalid` });
62
+ }
63
+ return company;
64
+ }
65
+ /**
66
+ * Resolve project ownership only when exactly one manifest company contains
67
+ * the project's directory. Multiple matches are unresolved so the caller can
68
+ * continue to an explicit active-company choice without picking an owner.
69
+ */
70
+ export function resolveProjectQuestionCompany(hqRoot, lane) {
71
+ const projectId = lane.project_id?.trim();
72
+ if (!projectId)
73
+ return null;
74
+ if (projectId === "." || projectId === ".." || /[\\/\0]/.test(projectId)) {
75
+ throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: its project id is invalid`, { lane_id: lane.lane_id, resolution: "project_id_invalid" });
76
+ }
77
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
78
+ if (!fs.existsSync(manifestPath))
79
+ return null;
80
+ const companies = readCompaniesManifestMap(hqRoot);
81
+ if (!companies) {
82
+ throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: companies/manifest.yaml is unreadable or malformed`, { lane_id: lane.lane_id, resolution: "company_manifest_unavailable" });
83
+ }
84
+ const matches = [];
85
+ for (const [slug, entry] of Object.entries(companies)) {
86
+ if (!entry || typeof entry !== "object" || !slug || /[\\/\0]/.test(slug))
87
+ continue;
88
+ const projectDir = path.join(hqRoot, "companies", slug, "projects", projectId);
89
+ let isDirectory = false;
90
+ try {
91
+ isDirectory = fs.statSync(projectDir).isDirectory();
92
+ }
93
+ catch (err) {
94
+ if (err.code !== "ENOENT" && err.code !== "ENOTDIR") {
95
+ throw new LanesError("question_company_unavailable", `Cannot verify project ownership for lane ${lane.lane_id}`, { lane_id: lane.lane_id, resolution: "project_directory_unavailable" });
96
+ }
97
+ }
98
+ if (!isDirectory)
99
+ continue;
100
+ const uid = typeof entry.cloud_uid === "string" ? entry.cloud_uid.trim() : "";
101
+ matches.push({ slug, uid });
102
+ }
103
+ if (matches.length > 1)
104
+ return null;
105
+ const match = matches[0];
106
+ if (!match)
107
+ return null;
108
+ if (!match.uid) {
109
+ throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: its manifest company has no cloud uid`, { lane_id: lane.lane_id, resolution: "project_company_uid_missing" });
110
+ }
111
+ return requireQuestionCompany({ slug: match.slug, uid: match.uid }, lane.lane_id, "project");
112
+ }
113
+ /** Resolve the lane/project company before lazily consulting active HQ scope. */
114
+ export async function resolveQuestionCompanyWithFallback(hqRoot, lane, resolveActiveCompany) {
115
+ try {
116
+ return resolveQuestionCompany(lane, null, hqRoot);
117
+ }
118
+ catch (err) {
119
+ if (!(err instanceof LanesError) ||
120
+ err.error !== "question_company_unavailable" ||
121
+ err.details.resolution !== "active_company_required" ||
122
+ !resolveActiveCompany) {
123
+ throw err;
124
+ }
125
+ return resolveQuestionCompany(lane, await resolveActiveCompany(), hqRoot);
126
+ }
21
127
  }
22
128
  export function questionBodyRef(questionId) {
23
129
  return path.posix.join("lanes", "questions", `${questionId}.json`);
@@ -37,6 +143,7 @@ export function questionBodyAbsPath(hqRoot, question) {
37
143
  return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
38
144
  }
39
145
  export function writeQuestionBody(hqRoot, question, body) {
146
+ assertQuestionBodySecretSafe(body);
40
147
  const store = parseQuestionBodyStore(question.body.store);
41
148
  const filePath = questionBodyAbsPath(hqRoot, question);
42
149
  const serialized = `${JSON.stringify(body, null, 2)}\n`;
@@ -53,12 +160,14 @@ export function writeQuestionBody(hqRoot, question, body) {
53
160
  * tenant body, then CAS routing_history with reason `asked`.
54
161
  */
55
162
  export function raiseLaneQuestion(input) {
56
- const store = bodyStoreForLane(input.lane);
163
+ assertQuestionBodySecretSafe(input.body);
164
+ const company = resolveQuestionCompany(input.lane, input.activeCompany, input.hqRoot);
165
+ const store = bodyStoreForLane(input.lane, company);
57
166
  let questionId = input.questionId;
58
167
  if (!questionId) {
59
168
  const created = createQuestion(input.hqRoot, {
60
169
  lane_id: input.lane.lane_id,
61
- company: input.lane.company,
170
+ company,
62
171
  project_id: input.lane.project_id,
63
172
  story_id: input.lane.story_id,
64
173
  thread_id: input.lane.thread_id,
@@ -77,7 +186,7 @@ export function raiseLaneQuestion(input) {
77
186
  createQuestion(input.hqRoot, {
78
187
  question_id: questionId,
79
188
  lane_id: input.lane.lane_id,
80
- company: input.lane.company,
189
+ company,
81
190
  project_id: input.lane.project_id,
82
191
  story_id: input.lane.story_id,
83
192
  thread_id: input.lane.thread_id,
@@ -97,7 +206,7 @@ export function raiseLaneQuestion(input) {
97
206
  }
98
207
  }
99
208
  const stored = writeQuestionBody(input.hqRoot, {
100
- company: input.lane.company,
209
+ company,
101
210
  question_id: questionId,
102
211
  body: { store, ref: "", digest: "" },
103
212
  }, input.body);
@@ -149,34 +258,40 @@ export function questionBodyPathForLane(hqRoot, lane, questionId) {
149
258
  * Free-text notes the operator attached with `questions answer --notes`.
150
259
  * Read from the lane's own store (spec §6.5): the company store for a company
151
260
  * lane, the personal store otherwise. Returns undefined when the answer has
152
- * no notes_ref or the body carries no notes; a body the host cannot read is
153
- * reported and skipped rather than failing the resume.
261
+ * no notes_ref or the body carries no notes. An unavailable or malformed body
262
+ * fails the resume so the lane cannot continue without its answer notes.
154
263
  */
155
264
  export function readAnswerNotes(hqRoot, lane, question) {
156
265
  if (!question.answer?.notes_ref)
157
266
  return undefined;
158
- const filePath = questionBodyPathForLane(hqRoot, lane, question.question_id);
267
+ const bodyStore = parseQuestionBodyStore(question.body.store);
268
+ const company = question.company ?? (bodyStore === "company" ? lane.company ?? lane.mesh_company ?? null : null);
269
+ if (bodyStore === "personal" && company !== question.company) {
270
+ throw new LanesError("question_answer_notes_store_mismatch", `Answer notes for question ${question.question_id} cannot use lane company attribution for a personal-store body`, { question_id: question.question_id });
271
+ }
272
+ const filePath = questionBodyAbsPath(hqRoot, { ...question, company });
159
273
  let text;
160
274
  try {
161
275
  text = fs.readFileSync(filePath, "utf8");
162
276
  }
163
277
  catch (err) {
164
- const code = err.code;
165
- process.stderr.write(`hq lanes: answer notes for ${question.question_id} unreadable at ${filePath} (${code ?? (err instanceof Error ? err.message : String(err))})\n`);
166
- return undefined;
278
+ throw new LanesError("question_answer_notes_unavailable", `Answer notes for question ${question.question_id} are unavailable on the owning host`, { question_id: question.question_id, code: err.code });
167
279
  }
168
280
  let parsed;
169
281
  try {
170
282
  parsed = JSON.parse(text);
171
283
  }
172
284
  catch (err) {
173
- process.stderr.write(`hq lanes: answer notes for ${question.question_id} at ${filePath} is not JSON: ${err instanceof Error ? err.message : String(err)}\n`);
174
- return undefined;
285
+ throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are not valid JSON`, { question_id: question.question_id });
286
+ }
287
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
288
+ throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} have an invalid body`, { question_id: question.question_id });
175
289
  }
176
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
177
- return undefined;
178
290
  const notes = parsed.notes;
179
- return typeof notes === "string" && notes.length > 0 ? notes : undefined;
291
+ if (typeof notes !== "string") {
292
+ throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are missing from the body`, { question_id: question.question_id });
293
+ }
294
+ return notes.length > 0 ? notes : undefined;
180
295
  }
181
296
  /**
182
297
  * The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
@@ -200,6 +315,22 @@ export function answeredQuestionEntries(hqRoot, lane, questions) {
200
315
  return entry;
201
316
  });
202
317
  }
318
+ export function markAnsweredQuestionsDelivered(hqRoot, laneId, questionIds, deliveredAt) {
319
+ if (!Number.isFinite(Date.parse(deliveredAt))) {
320
+ throw new LanesError("question_delivery_timestamp_invalid", "Cannot mark an answer delivered with an invalid timestamp", { lane_id: laneId });
321
+ }
322
+ return [...new Set(questionIds)].map((questionId) => retryUpdateQuestion(hqRoot, questionId, (current) => {
323
+ if (current.lane_id !== laneId || current.origin.id !== laneId) {
324
+ throw new LanesError("question_lane_mismatch", `Question ${questionId} does not belong to the resumed lane ${laneId}`, { lane_id: laneId, question_id: questionId });
325
+ }
326
+ if (current.status !== "answered" || !current.answer) {
327
+ throw new LanesError("question_not_answered", `Question ${questionId} is not answered and cannot be marked delivered`, { lane_id: laneId, question_id: questionId });
328
+ }
329
+ if (current.delivered_at)
330
+ return current;
331
+ return { ...current, delivered_at: deliveredAt };
332
+ }));
333
+ }
203
334
  export function envelopeQuestionId(laneId, envelopeRef, index) {
204
335
  const stem = path.basename(envelopeRef, ".json").replace(/[^A-Za-z0-9_-]/g, "");
205
336
  const digest = createHash("sha256")
@@ -222,8 +353,15 @@ function envelopeQuestionBody(item) {
222
353
  * Spec §6.1: each envelope questions[] entry becomes one queue record, via
223
354
  * raiseLaneQuestion (the same function the CLI ask path uses).
224
355
  */
225
- export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes) {
356
+ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes, activeCompany) {
226
357
  const items = envelope.questions ?? [];
358
+ const presentItems = items.filter((item) => item !== undefined);
359
+ if (presentItems.length === 0)
360
+ return [];
361
+ for (const item of presentItems) {
362
+ assertQuestionBodySecretSafe(envelopeQuestionBody(item));
363
+ }
364
+ resolveQuestionCompany(lane, activeCompany, hqRoot);
227
365
  const raised = [];
228
366
  for (let i = 0; i < items.length; i += 1) {
229
367
  const item = items[i];
@@ -236,6 +374,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at,
236
374
  raisedVia: "envelope",
237
375
  at,
238
376
  questionId: envelopeQuestionId(lane.lane_id, envelopeRef, questionIndexes?.[i] ?? i),
377
+ activeCompany,
239
378
  }));
240
379
  }
241
380
  return raised;
@@ -10,7 +10,7 @@ import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
10
10
  import { type ThrottleDeps } from "./throttle.js";
11
11
  import { readLanesRegistryFlag } from "./registry-flag.js";
12
12
  import { type KeepAliveBlockedReason } from "./keep-alive.js";
13
- import type { LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
13
+ import type { LaneCompanyRef, LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
14
14
  import { type LaneMesh } from "./mesh-events.js";
15
15
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
16
16
  export declare const QUESTION_AGE_MS: number;
@@ -79,6 +79,8 @@ export interface ReconcileDeps {
79
79
  env?: NodeJS.ProcessEnv;
80
80
  /** Flag reader seam used to assert rollout reads happen before admission locks. */
81
81
  readRegistryFlag?: typeof readLanesRegistryFlag;
82
+ /** Active-company fallback for questions raised from an unscoped lane. */
83
+ resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
82
84
  /** Detached mesh seam for dead-watcher recovery. */
83
85
  mesh?: LaneMesh | null;
84
86
  /** Bound on one terminal mesh emit during dead-host recovery. Default 10 s. */
@@ -24,7 +24,7 @@ import { readLanesRegistryFlag } from "./registry-flag.js";
24
24
  import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
25
25
  import { providerSupportsSessionResume } from "./delivery.js";
26
26
  import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
27
- import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
27
+ import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
28
28
  import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
29
29
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
30
30
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
@@ -1010,9 +1010,10 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1010
1010
  if (!repairCandidate.missing)
1011
1011
  invalidEnvelope = { errors: repair.errors };
1012
1012
  }
1013
- const repairedClassification = invalidEnvelope
1013
+ let repairedClassification = invalidEnvelope
1014
1014
  ? classifyLaneExit({ envelope: null, hasPartialOutput: false, invalidEnvelope })
1015
1015
  : classification;
1016
+ let questionRefusalCode = null;
1016
1017
  const accountRetryReason = quota
1017
1018
  ? "usage_limit"
1018
1019
  : providerRejection && classification.reason === "account_not_logged_in"
@@ -1052,7 +1053,21 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1052
1053
  classification.state === "awaiting_input" &&
1053
1054
  latest &&
1054
1055
  (latest.envelope.questions?.length ?? 0) > 0) {
1055
- raiseEnvelopeQuestions(hqRoot, readLane(hqRoot, lane.lane_id), latest.envelope, latest.ref, nowIso);
1056
+ const questionLane = readLane(hqRoot, lane.lane_id);
1057
+ try {
1058
+ const activeCompany = await resolveQuestionCompanyWithFallback(hqRoot, questionLane, deps.resolveQuestionActiveCompany);
1059
+ raiseEnvelopeQuestions(hqRoot, questionLane, latest.envelope, latest.ref, nowIso, undefined, activeCompany);
1060
+ }
1061
+ catch (err) {
1062
+ questionRefusalCode = questionRecordRefusalCode(err);
1063
+ if (questionRefusalCode === null)
1064
+ throw err;
1065
+ repairedClassification = {
1066
+ state: "failed",
1067
+ reason: `question_record_refused:${questionRefusalCode}`,
1068
+ };
1069
+ process.stderr.write(`hq lanes reconcile: terminal ask refused for ${lane.lane_id}: ${questionRefusalCode}\n`);
1070
+ }
1056
1071
  }
1057
1072
  const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
1058
1073
  const next = {
@@ -1100,7 +1115,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1100
1115
  to: repairedClassification.state,
1101
1116
  reason: repairedClassification.reason ?? "host_lost",
1102
1117
  });
1103
- if (latest?.envelope) {
1118
+ if (latest?.envelope && questionRefusalCode === null) {
1104
1119
  const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
1105
1120
  if (mesh) {
1106
1121
  const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
@@ -2030,10 +2045,12 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2030
2045
  }
2031
2046
  catch (err) {
2032
2047
  terminalizeModelUnsupported(hqRoot, lane, err);
2048
+ const errorName = err instanceof LanesError ? `${err.error}: ` : "";
2049
+ const errorMessage = err instanceof Error ? err.message : String(err);
2033
2050
  markDirty(summary, {
2034
2051
  kind: "lane",
2035
2052
  id: lane.lane_id,
2036
- why: `resume failed: ${err instanceof Error ? err.message : String(err)}`,
2053
+ why: `resume failed: ${errorName}${errorMessage}`,
2037
2054
  });
2038
2055
  }
2039
2056
  }