@indigoai-us/hq-cli 5.218.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;
@@ -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, } 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,23 @@ 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
+ 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
+ }
1056
1073
  }
1057
1074
  const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
1058
1075
  const next = {
@@ -1100,7 +1117,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1100
1117
  to: repairedClassification.state,
1101
1118
  reason: repairedClassification.reason ?? "host_lost",
1102
1119
  });
1103
- if (latest?.envelope) {
1120
+ if (latest?.envelope && questionRefusalCode === null) {
1104
1121
  const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
1105
1122
  if (mesh) {
1106
1123
  const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
@@ -2030,10 +2047,12 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2030
2047
  }
2031
2048
  catch (err) {
2032
2049
  terminalizeModelUnsupported(hqRoot, lane, err);
2050
+ const errorName = err instanceof LanesError ? `${err.error}: ` : "";
2051
+ const errorMessage = err instanceof Error ? err.message : String(err);
2033
2052
  markDirty(summary, {
2034
2053
  kind: "lane",
2035
2054
  id: lane.lane_id,
2036
- why: `resume failed: ${err instanceof Error ? err.message : String(err)}`,
2055
+ why: `resume failed: ${errorName}${errorMessage}`,
2037
2056
  });
2038
2057
  }
2039
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
@@ -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
  }