@indigoai-us/hq-cli 5.217.0 → 5.219.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.
@@ -7,8 +7,28 @@ 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";
10
11
  import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
11
12
  import { QUESTION_BODY_STORES, } from "./types.js";
13
+ export function assertQuestionBodySecretSafe(body) {
14
+ assertNoSecretText("question", body.question);
15
+ assertNoSecretText("context", body.context);
16
+ body.options.forEach((option, index) => assertNoSecretText(`option[${index}]`, option));
17
+ if (body.recommended !== undefined) {
18
+ assertNoSecretText("recommended", body.recommended);
19
+ }
20
+ if (body.notes !== undefined)
21
+ assertNoSecretText("answer notes", body.notes);
22
+ }
23
+ /** Errors that make an envelope ask unsafe or impossible to persist. */
24
+ export function questionRecordRefusalCode(err) {
25
+ if (!(err instanceof LanesError))
26
+ return null;
27
+ if (err.error === "secret_text_refused" || err.error === "question_company_unavailable") {
28
+ return err.error;
29
+ }
30
+ return null;
31
+ }
12
32
  export function parseQuestionBodyStore(value) {
13
33
  if (typeof value === "string" &&
14
34
  QUESTION_BODY_STORES.includes(value)) {
@@ -16,8 +36,15 @@ export function parseQuestionBodyStore(value) {
16
36
  }
17
37
  throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
18
38
  }
19
- export function bodyStoreForLane(lane) {
20
- return lane.company ? "company" : "personal";
39
+ export function bodyStoreForLane(lane, resolvedCompany) {
40
+ return lane.company || lane.mesh_company || resolvedCompany ? "company" : "personal";
41
+ }
42
+ export function resolveQuestionCompany(lane, activeCompany) {
43
+ const company = lane.company ?? lane.mesh_company ?? activeCompany;
44
+ if (!company || !company.slug.trim() || !company.uid.trim()) {
45
+ 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 });
46
+ }
47
+ return company;
21
48
  }
22
49
  export function questionBodyRef(questionId) {
23
50
  return path.posix.join("lanes", "questions", `${questionId}.json`);
@@ -37,6 +64,7 @@ export function questionBodyAbsPath(hqRoot, question) {
37
64
  return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
38
65
  }
39
66
  export function writeQuestionBody(hqRoot, question, body) {
67
+ assertQuestionBodySecretSafe(body);
40
68
  const store = parseQuestionBodyStore(question.body.store);
41
69
  const filePath = questionBodyAbsPath(hqRoot, question);
42
70
  const serialized = `${JSON.stringify(body, null, 2)}\n`;
@@ -53,12 +81,14 @@ export function writeQuestionBody(hqRoot, question, body) {
53
81
  * tenant body, then CAS routing_history with reason `asked`.
54
82
  */
55
83
  export function raiseLaneQuestion(input) {
56
- const store = bodyStoreForLane(input.lane);
84
+ assertQuestionBodySecretSafe(input.body);
85
+ const company = resolveQuestionCompany(input.lane, input.activeCompany);
86
+ const store = bodyStoreForLane(input.lane, company);
57
87
  let questionId = input.questionId;
58
88
  if (!questionId) {
59
89
  const created = createQuestion(input.hqRoot, {
60
90
  lane_id: input.lane.lane_id,
61
- company: input.lane.company,
91
+ company,
62
92
  project_id: input.lane.project_id,
63
93
  story_id: input.lane.story_id,
64
94
  thread_id: input.lane.thread_id,
@@ -77,7 +107,7 @@ export function raiseLaneQuestion(input) {
77
107
  createQuestion(input.hqRoot, {
78
108
  question_id: questionId,
79
109
  lane_id: input.lane.lane_id,
80
- company: input.lane.company,
110
+ company,
81
111
  project_id: input.lane.project_id,
82
112
  story_id: input.lane.story_id,
83
113
  thread_id: input.lane.thread_id,
@@ -97,7 +127,7 @@ export function raiseLaneQuestion(input) {
97
127
  }
98
128
  }
99
129
  const stored = writeQuestionBody(input.hqRoot, {
100
- company: input.lane.company,
130
+ company,
101
131
  question_id: questionId,
102
132
  body: { store, ref: "", digest: "" },
103
133
  }, input.body);
@@ -149,34 +179,40 @@ export function questionBodyPathForLane(hqRoot, lane, questionId) {
149
179
  * Free-text notes the operator attached with `questions answer --notes`.
150
180
  * Read from the lane's own store (spec §6.5): the company store for a company
151
181
  * 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.
182
+ * no notes_ref or the body carries no notes. An unavailable or malformed body
183
+ * fails the resume so the lane cannot continue without its answer notes.
154
184
  */
155
185
  export function readAnswerNotes(hqRoot, lane, question) {
156
186
  if (!question.answer?.notes_ref)
157
187
  return undefined;
158
- const filePath = questionBodyPathForLane(hqRoot, lane, question.question_id);
188
+ const bodyStore = parseQuestionBodyStore(question.body.store);
189
+ const company = question.company ?? (bodyStore === "company" ? lane.company ?? lane.mesh_company ?? null : null);
190
+ if (bodyStore === "personal" && company !== question.company) {
191
+ 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 });
192
+ }
193
+ const filePath = questionBodyAbsPath(hqRoot, { ...question, company });
159
194
  let text;
160
195
  try {
161
196
  text = fs.readFileSync(filePath, "utf8");
162
197
  }
163
198
  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;
199
+ 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
200
  }
168
201
  let parsed;
169
202
  try {
170
203
  parsed = JSON.parse(text);
171
204
  }
172
205
  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;
206
+ throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are not valid JSON`, { question_id: question.question_id });
207
+ }
208
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
209
+ throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} have an invalid body`, { question_id: question.question_id });
175
210
  }
176
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
177
- return undefined;
178
211
  const notes = parsed.notes;
179
- return typeof notes === "string" && notes.length > 0 ? notes : undefined;
212
+ if (typeof notes !== "string") {
213
+ throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are missing from the body`, { question_id: question.question_id });
214
+ }
215
+ return notes.length > 0 ? notes : undefined;
180
216
  }
181
217
  /**
182
218
  * The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
@@ -200,6 +236,22 @@ export function answeredQuestionEntries(hqRoot, lane, questions) {
200
236
  return entry;
201
237
  });
202
238
  }
239
+ export function markAnsweredQuestionsDelivered(hqRoot, laneId, questionIds, deliveredAt) {
240
+ if (!Number.isFinite(Date.parse(deliveredAt))) {
241
+ throw new LanesError("question_delivery_timestamp_invalid", "Cannot mark an answer delivered with an invalid timestamp", { lane_id: laneId });
242
+ }
243
+ return [...new Set(questionIds)].map((questionId) => retryUpdateQuestion(hqRoot, questionId, (current) => {
244
+ if (current.lane_id !== laneId || current.origin.id !== laneId) {
245
+ throw new LanesError("question_lane_mismatch", `Question ${questionId} does not belong to the resumed lane ${laneId}`, { lane_id: laneId, question_id: questionId });
246
+ }
247
+ if (current.status !== "answered" || !current.answer) {
248
+ throw new LanesError("question_not_answered", `Question ${questionId} is not answered and cannot be marked delivered`, { lane_id: laneId, question_id: questionId });
249
+ }
250
+ if (current.delivered_at)
251
+ return current;
252
+ return { ...current, delivered_at: deliveredAt };
253
+ }));
254
+ }
203
255
  export function envelopeQuestionId(laneId, envelopeRef, index) {
204
256
  const stem = path.basename(envelopeRef, ".json").replace(/[^A-Za-z0-9_-]/g, "");
205
257
  const digest = createHash("sha256")
@@ -222,8 +274,15 @@ function envelopeQuestionBody(item) {
222
274
  * Spec §6.1: each envelope questions[] entry becomes one queue record, via
223
275
  * raiseLaneQuestion (the same function the CLI ask path uses).
224
276
  */
225
- export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes) {
277
+ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes, activeCompany) {
226
278
  const items = envelope.questions ?? [];
279
+ const presentItems = items.filter((item) => item !== undefined);
280
+ if (presentItems.length === 0)
281
+ return [];
282
+ for (const item of presentItems) {
283
+ assertQuestionBodySecretSafe(envelopeQuestionBody(item));
284
+ }
285
+ resolveQuestionCompany(lane, activeCompany);
227
286
  const raised = [];
228
287
  for (let i = 0; i < items.length; i += 1) {
229
288
  const item = items[i];
@@ -236,6 +295,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at,
236
295
  raisedVia: "envelope",
237
296
  at,
238
297
  questionId: envelopeQuestionId(lane.lane_id, envelopeRef, questionIndexes?.[i] ?? i),
298
+ activeCompany,
239
299
  }));
240
300
  }
241
301
  return raised;
@@ -9,7 +9,8 @@ import { type LivenessDeps } from "./liveness.js";
9
9
  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
- import type { LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
12
+ import { type KeepAliveBlockedReason } from "./keep-alive.js";
13
+ import type { LaneCompanyRef, LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
13
14
  import { type LaneMesh } from "./mesh-events.js";
14
15
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
15
16
  export declare const QUESTION_AGE_MS: number;
@@ -49,6 +50,10 @@ export interface ReconcileSummary {
49
50
  dead_host: ReconcileChange[];
50
51
  heartbeats: string[];
51
52
  skipped: ReconcileSkip[];
53
+ keep_alive_blocked: Array<{
54
+ id: string;
55
+ reason: KeepAliveBlockedReason;
56
+ }>;
52
57
  errors: Array<{
53
58
  message: string;
54
59
  code?: string;
@@ -74,6 +79,8 @@ export interface ReconcileDeps {
74
79
  env?: NodeJS.ProcessEnv;
75
80
  /** Flag reader seam used to assert rollout reads happen before admission locks. */
76
81
  readRegistryFlag?: typeof readLanesRegistryFlag;
82
+ /** Active-company fallback for questions raised from an unscoped lane. */
83
+ resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
77
84
  /** Detached mesh seam for dead-watcher recovery. */
78
85
  mesh?: LaneMesh | null;
79
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, } 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";
@@ -66,6 +66,7 @@ function emptySummary(dryRun) {
66
66
  dead_host: [],
67
67
  heartbeats: [],
68
68
  skipped: [],
69
+ keep_alive_blocked: [],
69
70
  errors: [],
70
71
  heartbeat_at: null,
71
72
  };
@@ -1009,9 +1010,10 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1009
1010
  if (!repairCandidate.missing)
1010
1011
  invalidEnvelope = { errors: repair.errors };
1011
1012
  }
1012
- const repairedClassification = invalidEnvelope
1013
+ let repairedClassification = invalidEnvelope
1013
1014
  ? classifyLaneExit({ envelope: null, hasPartialOutput: false, invalidEnvelope })
1014
1015
  : classification;
1016
+ let questionRefusalCode = null;
1015
1017
  const accountRetryReason = quota
1016
1018
  ? "usage_limit"
1017
1019
  : providerRejection && classification.reason === "account_not_logged_in"
@@ -1051,7 +1053,23 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1051
1053
  classification.state === "awaiting_input" &&
1052
1054
  latest &&
1053
1055
  (latest.envelope.questions?.length ?? 0) > 0) {
1054
- raiseEnvelopeQuestions(hqRoot, readLane(hqRoot, lane.lane_id), latest.envelope, latest.ref, nowIso);
1056
+ const questionLane = readLane(hqRoot, lane.lane_id);
1057
+ const activeCompany = questionLane.company || questionLane.mesh_company
1058
+ ? undefined
1059
+ : await deps.resolveQuestionActiveCompany?.();
1060
+ try {
1061
+ raiseEnvelopeQuestions(hqRoot, questionLane, latest.envelope, latest.ref, nowIso, undefined, activeCompany);
1062
+ }
1063
+ catch (err) {
1064
+ questionRefusalCode = questionRecordRefusalCode(err);
1065
+ if (questionRefusalCode === null)
1066
+ throw err;
1067
+ repairedClassification = {
1068
+ state: "failed",
1069
+ reason: `question_record_refused:${questionRefusalCode}`,
1070
+ };
1071
+ process.stderr.write(`hq lanes reconcile: terminal ask refused for ${lane.lane_id}: ${questionRefusalCode}\n`);
1072
+ }
1055
1073
  }
1056
1074
  const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
1057
1075
  const next = {
@@ -1099,7 +1117,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1099
1117
  to: repairedClassification.state,
1100
1118
  reason: repairedClassification.reason ?? "host_lost",
1101
1119
  });
1102
- if (latest?.envelope) {
1120
+ if (latest?.envelope && questionRefusalCode === null) {
1103
1121
  const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
1104
1122
  if (mesh) {
1105
1123
  const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
@@ -1417,12 +1435,38 @@ function recordKeepAliveEvent(hqRoot, laneId, summary, event) {
1417
1435
  });
1418
1436
  }
1419
1437
  }
1438
+ function keepAliveSkipGuidance(laneId, reason) {
1439
+ switch (reason) {
1440
+ case "missing_opt_in_time":
1441
+ return `if appropriate, run hq lanes keep-alive ${laneId} --on`;
1442
+ case "not_opted_in":
1443
+ return "explicit opt-in is required before keep-alive can resume this lane";
1444
+ case "envelope_before_opt_in":
1445
+ return `the latest envelope predates opt-in; review the lane with hq lanes show ${laneId}`;
1446
+ case "interruption_before_opt_in":
1447
+ return `the interruption predates opt-in; review the lane with hq lanes show ${laneId}`;
1448
+ case "missing_interruption_time":
1449
+ return "the interruption time is unavailable; inspect the watcher classification before resuming";
1450
+ case "retired":
1451
+ return "the lane is retired; resolve its retired status before resuming";
1452
+ case "superseded":
1453
+ return "a newer lead exists for this project; continue on that lane";
1454
+ case "retired_list_invalid":
1455
+ return "the retired lane list is invalid; repair workspace/lane-briefs/juniors.json before retrying";
1456
+ case "terminal_state":
1457
+ return `the lane is in a terminal state; review it with hq lanes show ${laneId} before starting replacement work`;
1458
+ }
1459
+ }
1420
1460
  function recordKeepAliveSkip(hqRoot, lane, reason, summary, deps, detail) {
1421
1461
  summary.skipped.push({
1422
1462
  kind: "lane",
1423
1463
  id: lane.lane_id,
1424
1464
  why: `keep_alive_${reason}${detail ? `: ${detail}` : ""}`,
1425
1465
  });
1466
+ if (lane.keep_alive === true) {
1467
+ summary.keep_alive_blocked.push({ id: lane.lane_id, reason });
1468
+ process.stderr.write(`hq lanes reconcile: keep-alive blocked for lane ${lane.lane_id} (${reason}); ${keepAliveSkipGuidance(lane.lane_id, reason)}\n`);
1469
+ }
1426
1470
  if (deps.dryRun || lane.keep_alive_blocked_reason === reason)
1427
1471
  return;
1428
1472
  try {
@@ -2003,10 +2047,12 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2003
2047
  }
2004
2048
  catch (err) {
2005
2049
  terminalizeModelUnsupported(hqRoot, lane, err);
2050
+ const errorName = err instanceof LanesError ? `${err.error}: ` : "";
2051
+ const errorMessage = err instanceof Error ? err.message : String(err);
2006
2052
  markDirty(summary, {
2007
2053
  kind: "lane",
2008
2054
  id: lane.lane_id,
2009
- why: `resume failed: ${err instanceof Error ? err.message : String(err)}`,
2055
+ why: `resume failed: ${errorName}${errorMessage}`,
2010
2056
  });
2011
2057
  }
2012
2058
  }
@@ -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