@indigoai-us/hq-cli 5.197.0 → 5.198.1

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.
@@ -15,12 +15,14 @@ export type QmdReindexOptions = UtilityIo & {
15
15
  cwd: string;
16
16
  }) => unknown;
17
17
  sizeLimit?: (hqRoot: string) => unknown;
18
- /** Test seams for the lock shared by every hq-cli embed entry point. */
18
+ /** Test seams for the lock shared by every hq-cli QMD writer. */
19
19
  embedLockDependencies?: EmbedLockDependencies;
20
20
  /** Test seams for the host load gate consulted while the embed lock is held. */
21
21
  loadGateDependencies?: LoadGateDependencies;
22
22
  /** Test seam for the hq-flags-gated warning on qmd native startup failures. */
23
23
  startupFailureWarningsEnabled?: () => boolean;
24
+ /** Test seam for the default-off shared lock around the full reindex pass. */
25
+ singleFlightEnabled?: () => boolean;
24
26
  };
25
27
  export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
26
28
  //# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Contract preserved verbatim: a missing qmd or a non-HQ path is a silent
9
9
  * success (a sync must never fail because indexing could not run), the lexical
10
- * update always runs, and embeddings rebuild only on `--embed`.
10
+ * update normally runs, and embeddings rebuild only on `--embed`. An enabled
11
+ * single-flight canary may skip a pass while another QMD writer owns the lock.
11
12
  */
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
@@ -16,6 +17,7 @@ import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
16
17
  import { defaultEmbedLockDependencies, runWithEmbedLock, } from "../search-index/embed-lock.js";
17
18
  import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from "../search-index/load-gate.js";
18
19
  import { resolveProcessFlagGate } from "../flag-registry.js";
20
+ import { qmdReindexSingleFlightEnabled } from "../search-index/reindex-single-flight-flag.js";
19
21
  import { isQmdNativeBindingError, QMD_NATIVE_BINDING_REMEDY, } from "../../utils/qmd-native-binding-error.js";
20
22
  import { ioFor, line } from "./common.js";
21
23
  export const QMD_REINDEX_STARTUP_WARNING_FLAG = "qmd.reindex-startup-warning";
@@ -58,52 +60,59 @@ export function qmdReindexAfterSync(args = [], options = {}) {
58
60
  startupFailureWarningShown = true;
59
61
  line(stderr, `hq: search index was not updated after sync; ${QMD_NATIVE_BINDING_REMEDY}`);
60
62
  };
61
- // Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
62
- try {
63
- reconcile(hqRoot, { bin });
64
- }
65
- catch {
66
- /* registration is advisory; an update still helps */
67
- }
68
- // The cap has to land before the update, not after: qmd decides what to read
69
- // at glob time, so an ignore entry written afterwards saves nothing on this
70
- // pass. This is the post-sync entry point, which reaches `qmd update` without
71
- // going through `hq index sync` at all.
72
- try {
73
- sizeLimit(hqRoot);
74
- }
75
- catch {
76
- /* an uncapped index is worse than none, but still better than a failed sync */
77
- }
78
- try {
79
- run(["update"], { bin, cwd: hqRoot });
80
- }
81
- catch (error) {
82
- warnOnNativeStartupFailure(error);
83
- /* never fail a sync on index freshness */
84
- }
85
- if (embed) {
63
+ const updateIndex = () => {
64
+ // Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
65
+ try {
66
+ reconcile(hqRoot, { bin });
67
+ }
68
+ catch {
69
+ /* registration is advisory; an update still helps */
70
+ }
71
+ // The cap has to land before the update: qmd decides what to read
72
+ // at glob time, so an ignore entry written afterwards saves nothing on this
73
+ // pass. This is the post-sync entry point, which reaches `qmd update` without
74
+ // going through `hq index sync` at all.
75
+ try {
76
+ sizeLimit(hqRoot);
77
+ }
78
+ catch {
79
+ /* an uncapped index is worse than none, but still better than a failed sync */
80
+ }
81
+ try {
82
+ run(["update"], { bin, cwd: hqRoot });
83
+ }
84
+ catch (error) {
85
+ warnOnNativeStartupFailure(error);
86
+ /* never fail a sync on index freshness */
87
+ }
88
+ };
89
+ const runEmbed = (home, lockDependencies) => {
90
+ const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
91
+ const decision = checkEmbedLoad(home, loadGateDependencies);
92
+ if (decision.action === "skip") {
93
+ line(stderr, decision.notice.trimEnd());
94
+ return "skipped";
95
+ }
96
+ if (decision.forced)
97
+ line(stderr, decision.notice.trimEnd());
98
+ run(["embed"], { bin, cwd: hqRoot });
99
+ recordSuccessfulEmbed(home, loadGateDependencies);
100
+ return undefined;
101
+ };
102
+ if ((options.singleFlightEnabled ?? qmdReindexSingleFlightEnabled)()) {
86
103
  const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
87
104
  const home = resolveQmdHome(lockDependencies.env);
88
- const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
89
105
  try {
90
106
  const outcome = runWithEmbedLock(home, lockDependencies, () => {
91
- const decision = checkEmbedLoad(home, loadGateDependencies);
92
- if (decision.action === "skip") {
93
- line(stderr, decision.notice.trimEnd());
94
- return "skipped";
95
- }
96
- if (decision.forced)
97
- line(stderr, decision.notice.trimEnd());
98
- run(["embed"], { bin, cwd: hqRoot });
99
- recordSuccessfulEmbed(home, loadGateDependencies);
100
- return undefined;
107
+ updateIndex();
108
+ return embed ? runEmbed(home, lockDependencies) : undefined;
101
109
  });
102
110
  if (outcome === "busy") {
103
- line(stderr, "hq: qmd embed is already running; skipping this pass.");
111
+ line(stderr, "hq: qmd reindex is already running; skipping this post-sync pass.");
112
+ }
113
+ if (outcome === "unavailable") {
114
+ line(stderr, "hq: cannot access the qmd reindex lock; skipping this post-sync pass.");
104
115
  }
105
- if (outcome === "unavailable")
106
- line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
107
116
  }
108
117
  catch (error) {
109
118
  if (error instanceof LoadGateConfigurationError)
@@ -111,6 +120,26 @@ export function qmdReindexAfterSync(args = [], options = {}) {
111
120
  warnOnNativeStartupFailure(error);
112
121
  /* embeddings are deferred-cost and optional */
113
122
  }
123
+ return 0;
124
+ }
125
+ updateIndex();
126
+ if (!embed)
127
+ return 0;
128
+ const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
129
+ const home = resolveQmdHome(lockDependencies.env);
130
+ try {
131
+ const outcome = runWithEmbedLock(home, lockDependencies, () => runEmbed(home, lockDependencies));
132
+ if (outcome === "busy") {
133
+ line(stderr, "hq: qmd embed is already running; skipping this pass.");
134
+ }
135
+ if (outcome === "unavailable")
136
+ line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
137
+ }
138
+ catch (error) {
139
+ if (error instanceof LoadGateConfigurationError)
140
+ line(stderr, `hq: ${error.message}`);
141
+ warnOnNativeStartupFailure(error);
142
+ /* embeddings are deferred-cost and optional */
114
143
  }
115
144
  return 0;
116
145
  }
@@ -37,6 +37,8 @@ export declare const DEFAULT_LIVE_WITHIN_MS: number;
37
37
  export declare const DEFAULT_TURN_START_LOST_MS: number;
38
38
  /** Spec §9: no event for 12 hours (and no session_end) → lost. */
39
39
  export declare const DEFAULT_SILENT_LOST_MS: number;
40
+ /** Unanswered session questions move to the next audience after 10 minutes. */
41
+ export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
40
42
  export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
41
43
  export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
42
44
  export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
@@ -41,6 +41,8 @@ export const DEFAULT_LIVE_WITHIN_MS = 10 * 60 * 1000;
41
41
  export const DEFAULT_TURN_START_LOST_MS = 2 * 60 * 60 * 1000;
42
42
  /** Spec §9: no event for 12 hours (and no session_end) → lost. */
43
43
  export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
44
+ /** Unanswered session questions move to the next audience after 10 minutes. */
45
+ export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
44
46
  export const DEFAULT_LIVENESS_WINDOWS = {
45
47
  live_within_ms: DEFAULT_LIVE_WITHIN_MS,
46
48
  turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
@@ -340,6 +342,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
340
342
  min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
341
343
  },
342
344
  liveness: { ...DEFAULT_LIVENESS_WINDOWS },
345
+ session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
343
346
  };
344
347
  }
345
348
  export function parseLanesConfig(raw, deps = defaultDeps()) {
@@ -380,6 +383,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
380
383
  providers,
381
384
  throttle: parseThrottle(raw.throttle),
382
385
  liveness: parseLiveness(raw.liveness),
386
+ session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
383
387
  };
384
388
  }
385
389
  export function lanesConfigPath(hqRoot) {
@@ -40,6 +40,10 @@ export interface LivenessDeps {
40
40
  existsSync: (file: string) => boolean;
41
41
  mkdirSync: typeof fs.mkdirSync;
42
42
  statSync: (file: string) => fs.Stats;
43
+ lstatSync: (file: string) => fs.Stats;
44
+ readdirSync: (dir: string, options: {
45
+ withFileTypes: true;
46
+ }) => fs.Dirent[];
43
47
  }
44
48
  export declare function defaultLivenessDeps(): LivenessDeps;
45
49
  export declare function defaultSpoolPath(deps?: LivenessDeps): string;
@@ -53,6 +57,14 @@ export declare function livenessIndexPath(hqRoot: string): string;
53
57
  * `session_end` as the last event is `ended` even when recent (fast path).
54
58
  */
55
59
  export declare function classifySessionLiveness(entry: SessionIndexEntry, nowMs: number, presenceConnected?: boolean, windows?: LivenessWindows): LivenessState;
60
+ /**
61
+ * Use recent Claude transcript file activity when a session has no mesh event
62
+ * entry. This reads file metadata only; transcript contents are never opened.
63
+ * If a session transcript exists in more than one configured home, the newest
64
+ * mtime is the activity signal. A stale or absent transcript remains unknown
65
+ * and is not evidence of an end.
66
+ */
67
+ export declare function unindexedClaudeSessionLiveness(sessionId: string, nowMs: number, windows: LivenessWindows, deps?: LivenessDeps, configuredHomes?: readonly string[]): LivenessState | null;
56
68
  export declare function ingestSpoolLine(line: string, sessions: Record<string, SessionIndexEntry>, lineNo: number): void;
57
69
  export declare function buildLivenessIndexFromSpool(spoolContents: string, meta: {
58
70
  spool_path: string;
@@ -37,6 +37,8 @@ export function defaultLivenessDeps() {
37
37
  existsSync: (file) => fs.existsSync(file),
38
38
  mkdirSync: fs.mkdirSync.bind(fs),
39
39
  statSync: (file) => fs.statSync(file),
40
+ lstatSync: (file) => fs.lstatSync(file),
41
+ readdirSync: fs.readdirSync.bind(fs),
40
42
  };
41
43
  }
42
44
  export function defaultSpoolPath(deps = defaultLivenessDeps()) {
@@ -81,6 +83,64 @@ export function classifySessionLiveness(entry, nowMs, presenceConnected = false,
81
83
  return "idle";
82
84
  return "idle";
83
85
  }
86
+ /**
87
+ * Use recent Claude transcript file activity when a session has no mesh event
88
+ * entry. This reads file metadata only; transcript contents are never opened.
89
+ * If a session transcript exists in more than one configured home, the newest
90
+ * mtime is the activity signal. A stale or absent transcript remains unknown
91
+ * and is not evidence of an end.
92
+ */
93
+ export function unindexedClaudeSessionLiveness(sessionId, nowMs, windows, deps = defaultLivenessDeps(), configuredHomes = []) {
94
+ if (sessionId.length === 0 ||
95
+ sessionId.trim() !== sessionId ||
96
+ sessionId === "." ||
97
+ sessionId === ".." ||
98
+ /[\\/\0]/.test(sessionId)) {
99
+ throw new LanesError("session_activity_invalid_id", "Cannot inspect Claude activity for an invalid session id", { session_id: sessionId });
100
+ }
101
+ const homes = [
102
+ deps.env.CLAUDE_CONFIG_DIR?.trim(),
103
+ path.join(deps.homedir(), ".claude"),
104
+ ...configuredHomes,
105
+ ].filter((home) => typeof home === "string" && home.trim() !== "");
106
+ const projectsDirs = [...new Set(homes.map((home) => path.resolve(home, "projects")))];
107
+ let activityAt = null;
108
+ for (const projectsDir of projectsDirs) {
109
+ let projects;
110
+ try {
111
+ projects = deps.readdirSync(projectsDir, { withFileTypes: true });
112
+ }
113
+ catch (err) {
114
+ if (err.code === "ENOENT")
115
+ continue;
116
+ throw new LanesError("session_activity_unreadable", `Cannot inspect Claude project activity: ${err instanceof Error ? err.message : String(err)}`, { path: projectsDir });
117
+ }
118
+ for (const project of projects) {
119
+ if (!project.isDirectory() || project.isSymbolicLink())
120
+ continue;
121
+ const transcriptPath = path.join(projectsDir, project.name, `${sessionId}.jsonl`);
122
+ let transcript;
123
+ try {
124
+ transcript = deps.lstatSync(transcriptPath);
125
+ }
126
+ catch (err) {
127
+ if (err.code === "ENOENT")
128
+ continue;
129
+ throw new LanesError("session_activity_unreadable", `Cannot inspect Claude session activity: ${err instanceof Error ? err.message : String(err)}`, { path: transcriptPath });
130
+ }
131
+ if (!transcript.isFile() || transcript.isSymbolicLink())
132
+ continue;
133
+ if (!Number.isFinite(transcript.mtimeMs)) {
134
+ throw new LanesError("session_activity_unreadable", `Cannot inspect Claude session activity: invalid modification time`, { path: transcriptPath });
135
+ }
136
+ activityAt = Math.max(activityAt ?? Number.NEGATIVE_INFINITY, transcript.mtimeMs);
137
+ }
138
+ }
139
+ if (activityAt === null)
140
+ return null;
141
+ const ageMs = nowMs - activityAt;
142
+ return ageMs >= 0 && ageMs <= windows.live_within_ms ? "live" : null;
143
+ }
84
144
  export function ingestSpoolLine(line, sessions, lineNo) {
85
145
  const trimmed = line.trim();
86
146
  if (!trimmed)
@@ -310,9 +370,11 @@ export function sessionLiveness(hqRoot, sessionId, opts = {}) {
310
370
  const deps = opts.deps ?? defaultLivenessDeps();
311
371
  const index = loadLivenessIndex(hqRoot, opts.spoolPath, deps);
312
372
  const entry = index.sessions[sessionId];
313
- if (!entry)
314
- return null;
315
- const windows = opts.windows ?? loadLanesConfig(hqRoot).liveness;
373
+ const config = loadLanesConfig(hqRoot);
374
+ const windows = opts.windows ?? config.liveness;
375
+ if (!entry) {
376
+ return unindexedClaudeSessionLiveness(sessionId, opts.nowMs ?? deps.now(), windows, deps, Object.values(config.providers.claude.accounts).map((account) => account.home));
377
+ }
316
378
  const presenceConnected = opts.presenceConnected ?? readDaemonPresenceConnected(deps);
317
379
  return classifySessionLiveness(entry, opts.nowMs ?? deps.now(), presenceConnected, windows);
318
380
  }
@@ -86,7 +86,7 @@ export declare function timeoutPath(hqRoot: string, laneId: string): string;
86
86
  export declare function laneRunsHere(lane: LaneRecord, host: ReconcileHost): boolean;
87
87
  export declare function agedAdvanceCount(question: QuestionRecord): number;
88
88
  export declare function currentHopStartedAtMs(question: QuestionRecord): number;
89
- export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number): boolean;
89
+ export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number): boolean;
90
90
  export declare function shouldAdvanceAudienceEnded(audience: SeniorRef, liveness: LivenessState | null): boolean;
91
91
  export declare function readTimeoutDeadline(hqRoot: string, laneId: string): {
92
92
  deadline_at: string;
@@ -14,7 +14,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
14
14
  import { MAX_LANE_DEPTH } from "./constants.js";
15
15
  import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
16
16
  import { loadWorkerProfile } from "./profile.js";
17
- import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
17
+ import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
18
18
  import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
19
19
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
20
20
  import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
@@ -106,7 +106,7 @@ export function currentHopStartedAtMs(question) {
106
106
  const ms = Date.parse(raw);
107
107
  return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
108
108
  }
109
- export function shouldAgeQuestion(question, audience, nowMs) {
109
+ export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS) {
110
110
  if (question.status !== "pending")
111
111
  return false;
112
112
  if (audience.kind === "user")
@@ -116,14 +116,14 @@ export function shouldAgeQuestion(question, audience, nowMs) {
116
116
  const started = currentHopStartedAtMs(question);
117
117
  if (started === Number.NEGATIVE_INFINITY)
118
118
  return false;
119
- return nowMs - started >= QUESTION_AGE_MS;
119
+ return nowMs - started >= ageWindowMs;
120
120
  }
121
121
  export function shouldAdvanceAudienceEnded(audience, liveness) {
122
122
  if (audience.kind === "user")
123
123
  return false;
124
124
  if (audience.kind !== "session")
125
125
  return false;
126
- return liveness === "ended" || liveness === "lost" || liveness === null;
126
+ return liveness === "ended";
127
127
  }
128
128
  export function readTimeoutDeadline(hqRoot, laneId) {
129
129
  const filePath = timeoutPath(hqRoot, laneId);
@@ -655,12 +655,25 @@ export async function runPassBody(hqRoot, deps) {
655
655
  };
656
656
  const readSessionSenior = deps.readSessionSenior ??
657
657
  ((sessionId) => defaultReadSessionSenior(hqRoot, sessionId));
658
+ const configuredClaudeHomes = Object.values(config.providers.claude.accounts).map((account) => account.home);
659
+ const sessionStateCache = new Map();
658
660
  const sessionState = (sessionId) => {
661
+ if (sessionStateCache.has(sessionId)) {
662
+ return sessionStateCache.get(sessionId) ?? null;
663
+ }
659
664
  const entry = livenessIndex.sessions[sessionId];
660
- if (!entry)
661
- return null;
662
- return classifySessionLiveness(entry, nowMs, presenceConnected, config.liveness);
665
+ if (!entry) {
666
+ const state = unindexedClaudeSessionLiveness(sessionId, nowMs, config.liveness, livenessDeps, configuredClaudeHomes);
667
+ sessionStateCache.set(sessionId, state);
668
+ return state;
669
+ }
670
+ const state = classifySessionLiveness(entry, nowMs, presenceConnected, config.liveness);
671
+ sessionStateCache.set(sessionId, state);
672
+ return state;
663
673
  };
674
+ const questionAgeWindowMs = (audience) => audience.kind === "session"
675
+ ? config.session_question_timeout_secs * 1000
676
+ : QUESTION_AGE_MS;
664
677
  if (deps.afterLoad)
665
678
  await deps.afterLoad();
666
679
  markOverdueLanes(hqRoot, lanes, summary, deps, now);
@@ -680,12 +693,22 @@ export async function runPassBody(hqRoot, deps) {
680
693
  });
681
694
  continue;
682
695
  }
683
- const liveness = audience.kind === "session" && audience.id
684
- ? sessionState(audience.id)
685
- : audience.kind === "user"
686
- ? "live"
687
- : null;
688
696
  try {
697
+ let liveness;
698
+ try {
699
+ liveness =
700
+ audience.kind === "session" && audience.id
701
+ ? sessionState(audience.id)
702
+ : audience.kind === "user"
703
+ ? "live"
704
+ : null;
705
+ }
706
+ catch (err) {
707
+ // A failed metadata probe cannot prove that the session ended, but it
708
+ // must not suppress the independent unanswered-timeout rule below.
709
+ markError(summary, err);
710
+ liveness = null;
711
+ }
689
712
  if (shouldAdvanceAudienceEnded(audience, liveness)) {
690
713
  const nextHop = question.hop + 1;
691
714
  const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
@@ -712,12 +735,17 @@ export async function runPassBody(hqRoot, deps) {
712
735
  }
713
736
  continue;
714
737
  }
715
- if (shouldAgeQuestion(question, audience, nowMs)) {
738
+ if (shouldAgeQuestion(question, audience, nowMs, questionAgeWindowMs(audience))) {
716
739
  const nextHop = question.hop + 1;
717
740
  const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
718
- const result = casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending" &&
719
- agedAdvanceCount(current) < MAX_AGED_ADVANCES &&
720
- shouldAgeQuestion(current, resolveQuestionAudience(current, readLaneSenior, readSessionSenior), nowMs), (current) => {
741
+ const result = casQuestionIf(hqRoot, question.question_id, (current) => {
742
+ if (current.status !== "pending" ||
743
+ agedAdvanceCount(current) >= MAX_AGED_ADVANCES) {
744
+ return false;
745
+ }
746
+ const currentAudience = resolveQuestionAudience(current, readLaneSenior, readSessionSenior);
747
+ return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience));
748
+ }, (current) => {
721
749
  const entry = {
722
750
  hop: current.hop + 1,
723
751
  audience: nextAudience,
@@ -748,7 +776,7 @@ export async function runPassBody(hqRoot, deps) {
748
776
  markDirty(summary, {
749
777
  kind: "question",
750
778
  id: question.question_id,
751
- why: `advance failed: ${err instanceof Error ? err.message : String(err)}`,
779
+ why: `advance failed: ${err instanceof LanesError ? `${err.error}: ` : ""}${err instanceof Error ? err.message : String(err)}`,
752
780
  });
753
781
  }
754
782
  }
@@ -0,0 +1,27 @@
1
+ export type TrackerKind = "blocker" | "kpi" | "okr" | "directive";
2
+ export interface TrackerRecord {
3
+ schema_version: "hq-lanes-tracker-record.v1";
4
+ kind: TrackerKind;
5
+ id: string;
6
+ company: string;
7
+ project_id: string;
8
+ version: number;
9
+ created_at: string;
10
+ updated_at: string;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface TrackerRecordInput {
14
+ kind: TrackerKind;
15
+ id?: string;
16
+ company: string;
17
+ project_id: string;
18
+ [key: string]: unknown;
19
+ }
20
+ export interface TrackerStore {
21
+ read(kind: TrackerKind, id: string): TrackerRecord | null;
22
+ list(kind: TrackerKind): TrackerRecord[];
23
+ create(record: TrackerRecordInput): TrackerRecord;
24
+ update(kind: TrackerKind, id: string, expectedVersion: number, change: (current: TrackerRecord) => TrackerRecord): TrackerRecord;
25
+ }
26
+ export declare function createLocalTrackerStore(projectDirectory: string, now?: () => Date): TrackerStore;
27
+ //# sourceMappingURL=tracker-store.d.ts.map