@indigoai-us/hq-cli 5.315.0 → 5.316.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/assets/scaffold/core/scripts/detect-stale-review-base.sh +199 -0
  3. package/assets/scaffold/core/scripts/repo-run-registry.sh +408 -0
  4. package/assets/scaffold/core/scripts/share-suggestion-state.sh +541 -0
  5. package/dist/command-catalog.generated.d.ts +45 -0
  6. package/dist/command-catalog.generated.js +56 -0
  7. package/dist/commands/bot-jobs.d.ts +5 -1
  8. package/dist/commands/bot-jobs.js +24 -4
  9. package/dist/commands/core-detect-stale-review-base-native.d.ts +23 -0
  10. package/dist/commands/core-detect-stale-review-base-native.js +178 -0
  11. package/dist/commands/core-hook-state-helpers-native.d.ts +11 -0
  12. package/dist/commands/core-hook-state-helpers-native.js +74 -0
  13. package/dist/commands/core-repo-run-registry-native.d.ts +19 -0
  14. package/dist/commands/core-repo-run-registry-native.js +357 -0
  15. package/dist/commands/core-share-suggestion-state-native.d.ts +18 -0
  16. package/dist/commands/core-share-suggestion-state-native.js +445 -0
  17. package/dist/commands/core.js +5 -0
  18. package/dist/commands/lanes.js +35 -5
  19. package/dist/fast-core.d.ts +1 -0
  20. package/dist/fast-core.js +10 -6
  21. package/dist/fast-scaffold.js +17 -0
  22. package/dist/lib/bot/job-quiet-flag.d.ts +4 -0
  23. package/dist/lib/bot/job-quiet-flag.js +14 -0
  24. package/dist/lib/bot/job-run.d.ts +5 -0
  25. package/dist/lib/bot/job-run.js +23 -3
  26. package/dist/lib/bot/run.d.ts +2 -0
  27. package/dist/lib/bot/run.js +15 -3
  28. package/dist/lib/core-utils/common.d.ts +0 -1
  29. package/dist/lib/core-utils/common.js +0 -9
  30. package/dist/lib/lanes/autopilot.d.ts +5 -1
  31. package/dist/lib/lanes/autopilot.js +72 -3
  32. package/dist/lib/lanes/disk-guard.d.ts +3 -4
  33. package/dist/lib/lanes/disk-guard.js +2 -2
  34. package/dist/lib/lanes/flag-keys.d.ts +3 -0
  35. package/dist/lib/lanes/flag-keys.js +3 -0
  36. package/dist/lib/lanes/hooks-install.js +78 -2
  37. package/dist/lib/lanes/keep-alive-audit.d.ts +6 -5
  38. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -0
  39. package/dist/lib/lanes/outpost-dispatch.js +22 -0
  40. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ /** Default-off rollout gate for per-job quiet success delivery. */
2
+ export const BOT_JOB_QUIET_OPTION_FLAG = "cli.bot-job-quiet-option";
3
+ export async function botJobQuietOptionEnabled() {
4
+ try {
5
+ const { resolveProcessFlagGate } = await import("../flag-registry.js");
6
+ return resolveProcessFlagGate(BOT_JOB_QUIET_OPTION_FLAG, { fallback: false }, () => false);
7
+ }
8
+ catch (error) {
9
+ const errorClass = error instanceof Error ? error.name : "unknown";
10
+ console.warn(`hq: Could not resolve ${BOT_JOB_QUIET_OPTION_FLAG} (${errorClass}); quiet jobs stay disabled.`);
11
+ return false;
12
+ }
13
+ }
14
+ //# sourceMappingURL=job-quiet-flag.js.map
@@ -20,6 +20,9 @@
20
20
  * Everything in this file is pure so the rules are unit-testable.
21
21
  */
22
22
  import type { InboxItem } from "./api.js";
23
+ /** Private metadata persisted in the job's existing opaque context object. */
24
+ export declare const JOB_RUN_OPTIONS_CONTEXT_KEY = "__hq_cli_job_options_v1";
25
+ export declare function isQuietJobContext(context: unknown): boolean;
23
26
  /** The job fields an inbox item carries when `channel === "job"`. */
24
27
  export interface JobRunFields {
25
28
  jobId: string;
@@ -32,6 +35,8 @@ export interface JobRunFields {
32
35
  /** Delivery deadline (ISO-8601); past it, this is a catch-up. */
33
36
  staleAfter?: string;
34
37
  requestId?: string;
38
+ /** Whether successful runs should be silent when the local rollout flag is on. */
39
+ quiet?: boolean;
35
40
  }
36
41
  export declare function isJobItem(item: Pick<InboxItem, "channel">): boolean;
37
42
  /**
@@ -19,6 +19,25 @@
19
19
  *
20
20
  * Everything in this file is pure so the rules are unit-testable.
21
21
  */
22
+ /** Private metadata persisted in the job's existing opaque context object. */
23
+ export const JOB_RUN_OPTIONS_CONTEXT_KEY = "__hq_cli_job_options_v1";
24
+ function objectRecord(value) {
25
+ return value && typeof value === "object" && !Array.isArray(value)
26
+ ? value
27
+ : null;
28
+ }
29
+ export function isQuietJobContext(context) {
30
+ const options = objectRecord(objectRecord(context)?.[JOB_RUN_OPTIONS_CONTEXT_KEY]);
31
+ return options?.quiet === true;
32
+ }
33
+ function visibleJobContext(context) {
34
+ if (!(JOB_RUN_OPTIONS_CONTEXT_KEY in context)) {
35
+ return Object.keys(context).length ? context : null;
36
+ }
37
+ const visible = { ...context };
38
+ delete visible[JOB_RUN_OPTIONS_CONTEXT_KEY];
39
+ return Object.keys(visible).length ? visible : null;
40
+ }
22
41
  export function isJobItem(item) {
23
42
  return item.channel === "job";
24
43
  }
@@ -37,6 +56,7 @@ export function readJobRun(item) {
37
56
  return {
38
57
  jobId,
39
58
  prompt,
59
+ ...(isQuietJobContext(item.context) ? { quiet: true } : {}),
40
60
  ...(item.context !== undefined && item.context !== null ? { context: item.context } : {}),
41
61
  ...(typeof item.script === "string" && item.script.trim() ? { script: item.script } : {}),
42
62
  ...(typeof item.firedAt === "string" && item.firedAt.trim() ? { firedAt: item.firedAt.trim() } : {}),
@@ -51,10 +71,10 @@ export function jobContextText(context) {
51
71
  if (typeof context === "string")
52
72
  return context.trim() || null;
53
73
  try {
54
- const keys = Object.keys(context);
55
- if (keys.length === 0)
74
+ const visible = visibleJobContext(context);
75
+ if (!visible)
56
76
  return null;
57
- return JSON.stringify(context, null, 2);
77
+ return JSON.stringify(visible, null, 2);
58
78
  }
59
79
  catch {
60
80
  return null;
@@ -120,6 +120,8 @@ export interface BotRunDeps {
120
120
  inboxIntervalMs?: number;
121
121
  /** Cached, default-off reader for the local inbox polling gate. */
122
122
  inboxPollBackoffEnabled?: () => Promise<boolean>;
123
+ /** Default-off gate for the per-job quiet-success option. */
124
+ jobQuietOptionEnabled?: () => boolean | Promise<boolean>;
123
125
  heartbeatIntervalMs?: number;
124
126
  /** Override the rendered system prompt (tests). */
125
127
  systemPromptOverride?: string;
@@ -69,6 +69,7 @@ import { attachmentTurnBlock, downloadTurnAttachments } from "./attachments.js";
69
69
  import { inboxEntryAttachments } from "../agent-kit/inbox-state.js";
70
70
  import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./room-policy.js";
71
71
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
72
+ import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
72
73
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
73
74
  import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
74
75
  import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
@@ -277,6 +278,7 @@ export async function runBot(deps) {
277
278
  const runTurn = deps.runTurn ?? ((rt, input) => runRuntimeTurn(rt, input));
278
279
  const inboxIntervalMs = deps.inboxIntervalMs ?? INBOX_POLL_INTERVAL_MS;
279
280
  const inboxPollBackoffEnabled = deps.inboxPollBackoffEnabled ?? createInboxPollBackoffFlagReader(config);
281
+ const jobQuietOptionEnabled = deps.jobQuietOptionEnabled ?? botJobQuietOptionEnabled;
280
282
  const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
281
283
  const exit = deps.exit ?? ((code) => process.exit(code));
282
284
  const fallback = () => defaultBotStatus(pid, config.runtime, now);
@@ -763,7 +765,7 @@ export async function runBot(deps) {
763
765
  ...(isSetupBot ? { dropPreToolMessages: true } : {}),
764
766
  ...deps.progress,
765
767
  ...(args.workingNotice === false || silenceable || isSetupBot ? { workingNoticeMs: 0 } : {}),
766
- ...(silenceable ? { buffer: true } : {}),
768
+ ...(silenceable || args.suppressOutput ? { buffer: true } : {}),
767
769
  send: makeSendFn(args.send),
768
770
  ...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
769
771
  });
@@ -788,6 +790,11 @@ export async function runBot(deps) {
788
790
  // meta chatter that let two bots loop on 2026-09-25.
789
791
  const stayQuiet = silenceable && isMetaNonReply(turn.text);
790
792
  const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim() || stayQuiet;
793
+ if (args.suppressOutput) {
794
+ await poster.abandon();
795
+ const finalText = sentinelOrEmpty ? "" : turn.text;
796
+ return { outcome: { ok: true, reply: finalText.trim(), ...(sentinelOrEmpty ? { silent: true } : {}) }, poster };
797
+ }
791
798
  // Unmentioned room turn: a no_reply sentinel (bare, fenced, or with
792
799
  // preamble), an empty/whitespace reply, or a stay-quiet phrase means stay
793
800
  // quiet. Abandon the poster (it posts nothing) and return a silent outcome;
@@ -1182,6 +1189,7 @@ export async function runBot(deps) {
1182
1189
  return;
1183
1190
  }
1184
1191
  const catchUp = isCatchUpRun(job, now());
1192
+ const quietJob = target.job.quiet === true && await jobQuietOptionEnabled();
1185
1193
  const attempt = (attempts.get(id) ?? 0) + 1;
1186
1194
  attempts.set(id, attempt);
1187
1195
  const tReceived = now().getTime();
@@ -1268,9 +1276,10 @@ export async function runBot(deps) {
1268
1276
  sendFinal,
1269
1277
  // A scheduled run has nobody waiting in a chat: no "working on it" filler.
1270
1278
  workingNotice: false,
1279
+ ...(quietJob ? { suppressOutput: true } : {}),
1271
1280
  });
1272
1281
  if (outcome.ok) {
1273
- if (!reportDelivered(outcome.reply)) {
1282
+ if (!quietJob && !reportDelivered(outcome.reply)) {
1274
1283
  // The work finished but the REPORT did not fully reach the DM — even if
1275
1284
  // a progress post did. Keep it (and the run un-reported and un-acked)
1276
1285
  // so the next poll delivers it, resuming at the parts already read,
@@ -1285,7 +1294,10 @@ export async function runBot(deps) {
1285
1294
  await api.ackInbox(config.agentUid, id);
1286
1295
  clearInflight(dir, id);
1287
1296
  attempts.delete(id);
1288
- setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
1297
+ setStatus({
1298
+ ...(!quietJob ? { repliesSent: (readStatus()?.repliesSent ?? 0) + 1 } : {}),
1299
+ lastActivityAt: now().toISOString(),
1300
+ });
1289
1301
  log("info", `job ${jobId} run ${id} done (${outcome.reply.length} chars${catchUp ? ", catch-up" : ""})`);
1290
1302
  return;
1291
1303
  }
@@ -10,5 +10,4 @@ export declare function command(command: string, args: string[], cwd?: string):
10
10
  stdout: string;
11
11
  stderr: string;
12
12
  };
13
- export declare function exists(pathname: string): boolean;
14
13
  //# sourceMappingURL=common.d.ts.map
@@ -1,5 +1,4 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import * as fs from "node:fs";
3
2
  export function ioFor(io = {}) {
4
3
  return {
5
4
  stdout: io.stdout ?? ((text) => process.stdout.write(text)),
@@ -39,12 +38,4 @@ export function command(command, args, cwd) {
39
38
  }
40
39
  return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
41
40
  }
42
- export function exists(pathname) {
43
- try {
44
- return Boolean(fs.lstatSync(pathname));
45
- }
46
- catch {
47
- return false;
48
- }
49
- }
50
41
  //# sourceMappingURL=common.js.map
@@ -13,6 +13,7 @@ export declare const LEAD_AUTOPILOT_REVIEW_DELAY_MS: number;
13
13
  export declare const LEAD_AUTOPILOT_FILL_DELAY_MS: number;
14
14
  export declare const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
15
15
  export declare const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
16
+ export declare const LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES = 55;
16
17
  export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
17
18
  export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
18
19
  export interface RegisteredLead {
@@ -22,7 +23,7 @@ export interface RegisteredLead {
22
23
  status: string;
23
24
  prd: string;
24
25
  }
25
- export type AutopilotCondition = "review_finished_worker" | "fill_grant" | "plan_free_slots" | "flat_kpi" | "stale_kpi" | "blocker_record_needs_update" | "project_ready_to_close" | "stale_worker_ledger" | "story_without_live_lane" | "missed_deadline";
26
+ export type AutopilotCondition = "review_finished_worker" | "fill_grant" | "plan_free_slots" | "flat_kpi" | "stale_kpi" | "stale_headline" | "blocker_record_needs_update" | "project_ready_to_close" | "stale_worker_ledger" | "story_without_live_lane" | "missed_deadline";
26
27
  export interface AutopilotWake {
27
28
  condition: AutopilotCondition;
28
29
  lead_lane: string;
@@ -31,6 +32,8 @@ export interface AutopilotWake {
31
32
  prompt: string;
32
33
  fingerprint: string;
33
34
  kpi_id?: string;
35
+ /** Headline episode represented inside the KPI batch notice, if any. */
36
+ headline_fingerprint?: string;
34
37
  }
35
38
  export interface AutopilotKpiStatus {
36
39
  lead_lane: string;
@@ -109,6 +112,7 @@ export declare function planLeadAutopilotWakes(input: {
109
112
  keepAliveResumed?: boolean;
110
113
  kpiStalenessEnabled?: boolean;
111
114
  kpiStalenessBatchEnabled?: boolean;
115
+ headlineStalenessEnabled?: boolean;
112
116
  }): AutopilotWake[];
113
117
  export declare function runLeadAutopilotPass(input: {
114
118
  hqRoot: string;
@@ -6,7 +6,7 @@ import { isControllerAliasOutpostId } from "./admission-host.js";
6
6
  import { LEAD_REGISTRY_REL, validateOutpostLeadRegistry } from "./autopilot-registry.js";
7
7
  import { appendDropboxMessage } from "./dropbox.js";
8
8
  import { LanesError } from "./errors.js";
9
- import { LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_FLAG, LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG as PER_LEAD_ERRORS_FLAG, } from "./flag-keys.js";
9
+ import { LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_FLAG, LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG as PER_LEAD_ERRORS_FLAG, } from "./flag-keys.js";
10
10
  import { resolveLaneCompany } from "./lane-company.js";
11
11
  import { readLanesRegistryFlag } from "./registry-flag.js";
12
12
  import { readAllLanes } from "./store.js";
@@ -24,6 +24,7 @@ export const LEAD_AUTOPILOT_REVIEW_DELAY_MS = 15 * 60_000;
24
24
  export const LEAD_AUTOPILOT_FILL_DELAY_MS = 15 * 60_000;
25
25
  export const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
26
26
  export const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
27
+ export const LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES = 55;
27
28
  export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = PER_LEAD_ERRORS_FLAG;
28
29
  export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
29
30
  const LIVE_STATES = new Set(["running", "queued", "awaiting_input", "blocked"]);
@@ -414,6 +415,33 @@ function managerKpiFreshness(status, now) {
414
415
  };
415
416
  });
416
417
  }
418
+ function managerHeadlineFreshness(status, now) {
419
+ let field;
420
+ let rawTimestamp;
421
+ if (typeof status.headline_at === "string" && status.headline_at.trim()) {
422
+ field = "headline_at";
423
+ rawTimestamp = status.headline_at;
424
+ }
425
+ else if (status.headline_at === undefined || status.headline_at === null) {
426
+ if (typeof status.now !== "string")
427
+ return undefined;
428
+ const prefix = status.now.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))/)?.[1];
429
+ if (!prefix)
430
+ return undefined;
431
+ field = "now";
432
+ rawTimestamp = prefix;
433
+ }
434
+ else {
435
+ return undefined;
436
+ }
437
+ const timestamp = Date.parse(rawTimestamp);
438
+ if (!Number.isFinite(timestamp))
439
+ return undefined;
440
+ const ageMinutes = Math.floor(Math.max(0, now.getTime() - timestamp) / 60_000);
441
+ if (now.getTime() - timestamp <= LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES * 60_000)
442
+ return undefined;
443
+ return { field, measuredAt: new Date(timestamp).toISOString(), ageMinutes };
444
+ }
417
445
  function leastMoved(lines, priorLines = []) {
418
446
  const priorById = new Map(priorLines.map((line) => [line.id, line]));
419
447
  return [...lines].sort((a, b) => {
@@ -431,6 +459,19 @@ export function planLeadAutopilotWakes(input) {
431
459
  const blockers = statusBlockers(status);
432
460
  const byStory = new Map(stories.map((story) => [story.id, story]));
433
461
  const wakes = [];
462
+ if (input.headlineStalenessEnabled && !(lead.story_status === "done" && lead.story_complete === true)) {
463
+ const headline = managerHeadlineFreshness(status, now);
464
+ if (headline) {
465
+ const fingerprint = `stale-headline:${createHash("sha256").update(JSON.stringify([headline.field, headline.measuredAt])).digest("hex")}`;
466
+ wakes.push({
467
+ condition: "stale_headline",
468
+ lead_lane: lead.lane_id,
469
+ story_id: "headline-staleness",
470
+ fingerprint,
471
+ prompt: `The dashboard ${headline.field} field is ${headline.ageMinutes} minutes old. Rewrite headline, headline_at, and now in manager-status.json so the team status is current. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
472
+ });
473
+ }
474
+ }
434
475
  if (input.kpiStalenessEnabled) {
435
476
  const staleKpis = managerKpiFreshness(status, now).filter((kpi) => kpi.stale);
436
477
  if (input.kpiStalenessBatchEnabled && staleKpis.length > 0) {
@@ -748,6 +789,7 @@ async function runLeadAutopilotPassUnlocked(input) {
748
789
  const status = readStatus(hqRoot, registered.status, company.slug);
749
790
  const kpiStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_FLAG, { company });
750
791
  const kpiStalenessBatchEnabled = kpiStalenessEnabled && await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG, { company });
792
+ const headlineStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, { company });
751
793
  if (kpiStalenessEnabled) {
752
794
  result.kpi_status ??= [];
753
795
  result.kpi_status.push(...managerKpiFreshness(status, now).map((kpi) => ({
@@ -772,7 +814,7 @@ async function runLeadAutopilotPassUnlocked(input) {
772
814
  if (nudgeDedupeEnabled && pruneDeliveredIds(state, now))
773
815
  stateChanged = true;
774
816
  currentInputPath = registered.status;
775
- const planned = planLeadAutopilotWakes({
817
+ let planned = planLeadAutopilotWakes({
776
818
  lead,
777
819
  children,
778
820
  stories,
@@ -780,10 +822,26 @@ async function runLeadAutopilotPassUnlocked(input) {
780
822
  managerStatus: status,
781
823
  kpiStalenessEnabled,
782
824
  kpiStalenessBatchEnabled,
825
+ headlineStalenessEnabled,
783
826
  keepAliveResumed: input.keepAliveResumedLeads?.has(lead.lane_id) === true,
784
827
  });
828
+ if (kpiStalenessBatchEnabled) {
829
+ const batch = planned.find((wake) => wake.condition === "stale_kpi" && wake.kpi_id === "batch");
830
+ const headline = planned.find((wake) => wake.condition === "stale_headline");
831
+ if (batch && headline) {
832
+ const priorHeadline = state.sent[`${lead.lane_id}:stale_headline`];
833
+ if (priorHeadline?.fingerprint !== headline.fingerprint) {
834
+ batch.prompt += `\n\n${headline.prompt}`;
835
+ batch.headline_fingerprint = headline.fingerprint;
836
+ }
837
+ planned = planned.filter((wake) => wake !== headline);
838
+ }
839
+ }
785
840
  const stateKey = (wake) => `${lead.lane_id}:${wake.condition}${wake.condition === "stale_kpi" ? `:${wake.kpi_id}` : ""}`;
786
- const activeKeys = new Set(planned.map(stateKey));
841
+ const activeKeys = new Set(planned.flatMap((wake) => [
842
+ stateKey(wake),
843
+ ...(wake.headline_fingerprint ? [`${lead.lane_id}:stale_headline`] : []),
844
+ ]));
787
845
  const suppressedKeys = new Set();
788
846
  for (const key of Object.keys(state.sent)) {
789
847
  if (!key.startsWith(`${lead.lane_id}:`))
@@ -890,6 +948,8 @@ async function runLeadAutopilotPassUnlocked(input) {
890
948
  // repeated wake/escalation cadence used by other conditions.
891
949
  if (wake.condition === "stale_kpi")
892
950
  continue;
951
+ if (wake.condition === "stale_headline")
952
+ continue;
893
953
  const redeliveryDelay = nudgeDedupeEnabled ? LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS : LEAD_AUTOPILOT_FILL_DELAY_MS;
894
954
  if (!current.acted && !current.auto_started && current.escalated_at === undefined && now.getTime() - lastWakeAt >= redeliveryDelay) {
895
955
  const wakeCount = current.wake_count ?? 1;
@@ -931,6 +991,15 @@ async function runLeadAutopilotPassUnlocked(input) {
931
991
  wake_count: 1,
932
992
  last_wake_at: now.toISOString(),
933
993
  };
994
+ if (wake.headline_fingerprint) {
995
+ state.sent[`${lead.lane_id}:stale_headline`] = {
996
+ fingerprint: wake.headline_fingerprint,
997
+ at: now.toISOString(),
998
+ ...(statusUpdatedAt ? { status_updated_at: statusUpdatedAt } : {}),
999
+ wake_count: 1,
1000
+ last_wake_at: now.toISOString(),
1001
+ };
1002
+ }
934
1003
  stateChanged = true;
935
1004
  if (!delivered)
936
1005
  continue;
@@ -3,10 +3,8 @@ import { type ReadLanesFlagOptions } from "./registry-flag.js";
3
3
  import type { LaneRecord, LanesConfig } from "./types.js";
4
4
  export declare const LOW_DISK_GUARD_FLAG = "lanes.low-disk-guard";
5
5
  export declare const CODEX_ROLLOUT_RETENTION_FLAG = "lanes.codex-rollout-retention";
6
- export declare const DEFAULT_LOW_DISK_FREE_BYTES: number;
7
- export declare const DEFAULT_LOW_DISK_FRACTION = 0.02;
8
6
  export declare function lowDiskGuardEnabledForLane(hqRoot: string, lane: LaneRecord, readFlag?: (key: string, options: ReadLanesFlagOptions) => Promise<boolean>): Promise<boolean>;
9
- export interface DiskStats {
7
+ interface DiskStats {
10
8
  bsize: number;
11
9
  blocks: number;
12
10
  bavail: number;
@@ -25,7 +23,7 @@ export declare function checkLaneDiskSpace(hqRoot: string, providerHome: string,
25
23
  hq: DiskSpaceMeasurement;
26
24
  provider: DiskSpaceMeasurement;
27
25
  };
28
- export interface RolloutLane {
26
+ interface RolloutLane {
29
27
  provider: string;
30
28
  state: string;
31
29
  provider_session_id?: string;
@@ -63,4 +61,5 @@ export interface RolloutRetentionResult {
63
61
  export declare function configuredCodexRolloutHomes(hqRoot: string, config: LanesConfig, hostname: string): string[];
64
62
  /** Operates only on rollout files under configured Codex `sessions/` trees. */
65
63
  export declare function retainCodexRollouts(options: RolloutRetentionOptions): Promise<RolloutRetentionResult>;
64
+ export {};
66
65
  //# sourceMappingURL=disk-guard.d.ts.map
@@ -9,8 +9,8 @@ import { effectiveAccountHome } from "./account-home.js";
9
9
  import { lanesConfigPath } from "./config.js";
10
10
  export const LOW_DISK_GUARD_FLAG = "lanes.low-disk-guard";
11
11
  export const CODEX_ROLLOUT_RETENTION_FLAG = "lanes.codex-rollout-retention";
12
- export const DEFAULT_LOW_DISK_FREE_BYTES = 5 * 1024 ** 3;
13
- export const DEFAULT_LOW_DISK_FRACTION = 0.02;
12
+ const DEFAULT_LOW_DISK_FREE_BYTES = 5 * 1024 ** 3;
13
+ const DEFAULT_LOW_DISK_FRACTION = 0.02;
14
14
  const EXCLUDED_SESSION_FILES = new Set(["auth.json", ".credentials.json", "thread_history.sqlite"]);
15
15
  export async function lowDiskGuardEnabledForLane(hqRoot, lane, readFlag = readLanesRegistryFlag) {
16
16
  const company = resolveLaneCompany(hqRoot, lane).company;
@@ -19,4 +19,7 @@ export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
19
19
  /** Batches stale manager-status KPI notices into one lead message per pass. */
20
20
  export declare const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG = "lanes.autopilot-kpi-staleness-batch";
21
21
  export declare const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_DEFAULT_ENABLED = false;
22
+ /** Enables staleness notices for a registered lead's dashboard headline. */
23
+ export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-staleness";
24
+ export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_DEFAULT_ENABLED = false;
22
25
  //# sourceMappingURL=flag-keys.d.ts.map
@@ -19,4 +19,7 @@ export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
19
19
  /** Batches stale manager-status KPI notices into one lead message per pass. */
20
20
  export const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG = "lanes.autopilot-kpi-staleness-batch";
21
21
  export const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_DEFAULT_ENABLED = false;
22
+ /** Enables staleness notices for a registered lead's dashboard headline. */
23
+ export const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-staleness";
24
+ export const LEAD_AUTOPILOT_HEADLINE_STALENESS_DEFAULT_ENABLED = false;
22
25
  //# sourceMappingURL=flag-keys.js.map
@@ -96,6 +96,82 @@ function groupsContainCommand(groups, command) {
96
96
  return group.hooks.some((hook) => isPlainObject(hook) && hook.type === "command" && hook.command === command);
97
97
  });
98
98
  }
99
+ /** Parse the single-quoted argv form emitted by shellQuote without invoking a shell. */
100
+ function parseShellQuotedArguments(command) {
101
+ const args = [];
102
+ let index = 0;
103
+ while (index < command.length) {
104
+ while (/\s/.test(command[index] ?? ""))
105
+ index += 1;
106
+ if (index >= command.length)
107
+ break;
108
+ if (command[index] !== "'")
109
+ return null;
110
+ index += 1;
111
+ let value = "";
112
+ let closed = false;
113
+ while (index < command.length) {
114
+ if (command.startsWith("'\\''", index)) {
115
+ value += "'";
116
+ index += 4;
117
+ }
118
+ else if (command[index] === "'") {
119
+ index += 1;
120
+ closed = true;
121
+ break;
122
+ }
123
+ else {
124
+ value += command[index];
125
+ index += 1;
126
+ }
127
+ }
128
+ if (!closed)
129
+ return null;
130
+ args.push(value);
131
+ if (index < command.length && !/\s/.test(command[index]))
132
+ return null;
133
+ }
134
+ return args;
135
+ }
136
+ function isInstalledDrainEntry(entryPath) {
137
+ if (!path.isAbsolute(entryPath))
138
+ return false;
139
+ const segments = path.resolve(entryPath).split(path.sep);
140
+ return segments.slice(-5).join(path.sep) ===
141
+ ["node_modules", "@indigoai-us", "hq-cli", "dist", "lanes-drain-hook.js"].join(path.sep);
142
+ }
143
+ /**
144
+ * The provider settings can outlive a CLI worktree or global package update.
145
+ * Both paths target the same hq-cli drain entry, so recognize an installed
146
+ * package entry alongside the exact entry belonging to this CLI invocation.
147
+ */
148
+ function drainCommandsMatch(candidate, expected) {
149
+ if (candidate === expected)
150
+ return true;
151
+ const argv = parseShellQuotedArguments(candidate);
152
+ if (argv === null || argv.length !== 2)
153
+ return false;
154
+ if (path.parse(argv[0]).name.toLowerCase() !== "node")
155
+ return false;
156
+ if (!isInstalledDrainEntry(argv[1]))
157
+ return false;
158
+ return fs.existsSync(argv[0]) && fs.existsSync(argv[1]);
159
+ }
160
+ function groupsContainDrainCommand(groups, expected) {
161
+ if (!Array.isArray(groups))
162
+ return false;
163
+ return groups.some((group) => {
164
+ if (!isPlainObject(group) || !Array.isArray(group.hooks))
165
+ return false;
166
+ return group.hooks.some((hook) => isPlainObject(hook) &&
167
+ hook.type === "command" &&
168
+ typeof hook.command === "string" &&
169
+ drainCommandsMatch(hook.command, expected));
170
+ });
171
+ }
172
+ function tomlHasDrainCommand(parsed, expected) {
173
+ return isPlainObject(parsed.hooks) && groupsContainDrainCommand(parsed.hooks.PreToolUse, expected);
174
+ }
99
175
  function removeCommandFromGroups(groups, command) {
100
176
  let changed = false;
101
177
  const next = groups.flatMap((group) => {
@@ -413,7 +489,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
413
489
  const toml = parseToml(text);
414
490
  return {
415
491
  ...base,
416
- registered: isPlainObject(toml) && tomlHasCommand(toml, command) && tomlHooksFeatureEnabled(toml),
492
+ registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command) && tomlHooksFeatureEnabled(toml),
417
493
  };
418
494
  }
419
495
  const json = JSON.parse(text);
@@ -421,7 +497,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
421
497
  ...base,
422
498
  registered: isPlainObject(json) &&
423
499
  isPlainObject(json.hooks) &&
424
- groupsContainCommand(json.hooks.PreToolUse, command),
500
+ groupsContainDrainCommand(json.hooks.PreToolUse, command),
425
501
  };
426
502
  }
427
503
  catch (err) {
@@ -1,10 +1,10 @@
1
- export type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "not_restarted" | "waiting";
2
- export interface KeepAliveAuditRestart {
1
+ type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "not_restarted" | "waiting";
2
+ interface KeepAliveAuditRestart {
3
3
  at: string;
4
4
  trigger: string;
5
5
  caller: string | null;
6
6
  }
7
- export interface KeepAliveAuditRow {
7
+ interface KeepAliveAuditRow {
8
8
  lane: string;
9
9
  project: string;
10
10
  profile: string | null;
@@ -15,7 +15,7 @@ export interface KeepAliveAuditRow {
15
15
  restart: KeepAliveAuditRestart | null;
16
16
  verdict: KeepAliveAuditVerdict;
17
17
  }
18
- export interface KeepAliveAuditSummary {
18
+ interface KeepAliveAuditSummary {
19
19
  by_verdict: Record<KeepAliveAuditVerdict, number>;
20
20
  not_restarted: number;
21
21
  late_over_10m: number;
@@ -23,7 +23,7 @@ export interface KeepAliveAuditSummary {
23
23
  self_wake: number;
24
24
  stopped_and_not_restarted_on_their_own: number;
25
25
  }
26
- export interface KeepAliveAuditError {
26
+ interface KeepAliveAuditError {
27
27
  lane: string;
28
28
  error: string;
29
29
  path: string;
@@ -43,4 +43,5 @@ export declare function buildKeepAliveAudit(input: {
43
43
  laneId?: string;
44
44
  }): KeepAliveAuditResult;
45
45
  export declare function formatKeepAliveAuditText(result: KeepAliveAuditResult): string;
46
+ export {};
46
47
  //# sourceMappingURL=keep-alive-audit.d.ts.map
@@ -95,10 +95,19 @@ export declare const REMOTE_LANE_READ_ENV = "HQ_LANES_REMOTE_READ";
95
95
  export declare function buildOutpostLaneListCommand(input?: {
96
96
  hqBinary?: string;
97
97
  laneIds?: readonly string[];
98
+ laneUlids?: readonly string[];
98
99
  active?: boolean;
99
100
  hierarchy?: string | readonly string[];
100
101
  snapshot?: boolean;
101
102
  }): string;
103
+ /**
104
+ * Older Outpost CLIs do not know --lane-ulid. Filter their lane-list output on
105
+ * the owner host so the exec transport carries only the requested record.
106
+ */
107
+ export declare function buildOutpostLegacyLaneUlidListCommand(input: {
108
+ hqBinary?: string;
109
+ ulid: string;
110
+ }): string;
102
111
  /** Read questions on one Outpost, scoped to a company when supplied. */
103
112
  export declare function buildOutpostQuestionListCommand(input?: {
104
113
  company?: string;
@@ -85,6 +85,8 @@ export function buildOutpostLaneListCommand(input = {}) {
85
85
  ];
86
86
  for (const laneId of input.laneIds ?? [])
87
87
  args.push("--lane", laneId);
88
+ for (const ulid of input.laneUlids ?? [])
89
+ args.push("--lane-ulid", ulid);
88
90
  if (input.active)
89
91
  args.push("--active");
90
92
  for (const laneId of typeof input.hierarchy === "string"
@@ -95,6 +97,26 @@ export function buildOutpostLaneListCommand(input = {}) {
95
97
  args.push("--snapshot");
96
98
  return withRemoteHqDir(joinCommandParts(args));
97
99
  }
100
+ /**
101
+ * Older Outpost CLIs do not know --lane-ulid. Filter their lane-list output on
102
+ * the owner host so the exec transport carries only the requested record.
103
+ */
104
+ export function buildOutpostLegacyLaneUlidListCommand(input) {
105
+ const hqBinary = input.hqBinary?.trim() || "hq";
106
+ const script = [
107
+ "const { spawnSync } = require('node:child_process');",
108
+ `const result = spawnSync(${JSON.stringify(hqBinary)}, ['lanes', 'list', '--json'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ${REMOTE_LANE_READ_ENV}: '1' } });`,
109
+ "if (result.error || result.status !== 0) { console.error(JSON.stringify({ error: result.error?.message || 'legacy Outpost lane list failed', status: result.status, signal: result.signal, stderr_tail: String(result.stderr || '').slice(-4096), stdout_tail: String(result.stdout || '').slice(-4096) })); process.exit(result.status || 1); }",
110
+ "try {",
111
+ "const payload = JSON.parse(result.stdout);",
112
+ "if (!payload || payload.ok !== true || payload.action !== 'list' || !Array.isArray(payload.lanes)) throw new Error('malformed lane list');",
113
+ `const ulid = ${JSON.stringify(input.ulid.toUpperCase())};`,
114
+ "payload.lanes = payload.lanes.filter(row => row && typeof row.lane_id === 'string' && row.lane_id.slice(0, 26).toUpperCase() === ulid);",
115
+ "process.stdout.write(JSON.stringify(payload));",
116
+ "} catch (error) { console.error('Legacy Outpost returned invalid lane-list JSON: ' + (error instanceof Error ? error.message : String(error))); process.exit(1); }",
117
+ ].join("\n");
118
+ return withRemoteHqDir(joinCommandParts(["node", "-e", script]));
119
+ }
98
120
  /** Read questions on one Outpost, scoped to a company when supplied. */
99
121
  export function buildOutpostQuestionListCommand(input = {}) {
100
122
  const args = ["hq", "lanes", "questions", "list"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.315.0",
3
+ "version": "5.316.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {