@indigoai-us/hq-cli 5.344.1 → 5.345.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.
@@ -62,6 +62,8 @@ import { splitBody } from "./split.js";
62
62
  import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
64
64
  import { loadSetupExplainer, patchSetupIntroState, readSetupIntroState, readSetupProgressIntro, replyOffersExplain, SETUP_EXPLAINED_NOTE, setupExplainDue, } from "./setup-explain.js";
65
+ import { detectCodingToolActivity, SIGNIFICANT_CODING_TOOL_ACTIVITY, } from "./coding-tool-activity.js";
66
+ import { setupKeptHereNote, setupOfferAnsweredInWordsNote, setupToolOfferAnswer, setupToolOfferText, setupWelcomeOpening, } from "./setup-tool-offer.js";
65
67
  import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
66
68
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
67
69
  import { botAttachmentsDir, dmSessionScope } from "./paths.js";
@@ -319,6 +321,57 @@ export async function runBot(deps) {
319
321
  log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
320
322
  }
321
323
  };
324
+ /** The tool the pending "continue setup there?" offer named, while the kickoff waits on it. */
325
+ const pendingToolOffer = () => {
326
+ try {
327
+ const current = readSetupIntroState(dir);
328
+ return current.kickoffDeferred && current.toolOffer ? current.toolOffer.tool : null;
329
+ }
330
+ catch {
331
+ return null;
332
+ }
333
+ };
334
+ /**
335
+ * Setup bot, first message: when the person has the Claude app or the Codex
336
+ * app and uses it a lot (coding-tool-activity.ts), the first message is the
337
+ * fixed "continue setup there?" offer instead of the kickoff turn, and the
338
+ * kickoff waits for their answer (setup-tool-offer.ts). Made once, and only
339
+ * before setup has started. Null (run the kickoff as usual) when nothing
340
+ * qualifies or anything goes wrong.
341
+ */
342
+ const codingToolToOffer = () => {
343
+ if (!isSetupBot || !(config.kickoff ?? "").trim())
344
+ return null;
345
+ try {
346
+ if (readSetupIntroState(dir).toolOffer)
347
+ return null;
348
+ if (readSetupProgressIntro(resolveBotMemoryDir(config)) !== null)
349
+ return null;
350
+ const detect = deps.detectCodingToolActivity ?? detectCodingToolActivity;
351
+ const activity = detect({ now: now(), extraHqRoots: [config.hqRoot] });
352
+ const describe = (a) => a.installed ? `${a.sessions} sessions on ${a.days} days` : "not installed";
353
+ log("info", `desktop app use in the last ${SIGNIFICANT_CODING_TOOL_ACTIVITY.windowDays} days: ` +
354
+ `Claude ${describe(activity.claude)}, Codex ${describe(activity.codex)}`);
355
+ return activity.pick;
356
+ }
357
+ catch (err) {
358
+ log("warn", `coding tool check failed (${err instanceof Error ? err.message : String(err)}); running the kickoff`);
359
+ return null;
360
+ }
361
+ };
362
+ /** The offer went out: the kickoff now waits for the answer. */
363
+ const recordToolOffer = (tool) => {
364
+ try {
365
+ patchSetupIntroState(dir, { toolOffer: { tool, offeredAt: now().toISOString() }, kickoffDeferred: true });
366
+ }
367
+ catch (err) {
368
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
369
+ }
370
+ if (readBotConfig(dir)?.kickoffPendingSignIn)
371
+ patchBotConfig(dir, { kickoffPendingSignIn: false });
372
+ setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
373
+ log("info", `offered to continue setup in the ${tool} app (no model turn); the kickoff waits for the answer`);
374
+ };
322
375
  // Scheduled runs are reported in the owner's own zone: "every weekday at 9am"
323
376
  // was set up here, so "due 13:00 UTC" would read as the wrong time.
324
377
  const timeZone = machineTimeZone();
@@ -455,16 +508,33 @@ export async function runBot(deps) {
455
508
  kickoffAfterIntro = {};
456
509
  }
457
510
  else if (!config.introSentAt) {
458
- try {
459
- const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro, { kind: effectiveBotKind(config), companies: effectiveBotCompanies(config) }) });
460
- patchBotConfig(dir, { introSentAt: now().toISOString() });
461
- log("info", "intro DM sent to owner");
462
- if (config.kickoff?.trim())
463
- kickoffAfterIntro = { ...(sent?.eventId ? { introEventId: sent.eventId } : {}) };
464
- }
465
- catch (err) {
466
- log("warn", `intro DM failed (will retry next start): ${err instanceof Error ? err.message : String(err)}`);
511
+ // Setup bot with an app to offer: the welcome leads straight into the
512
+ // offer, without its "checking your computer" and Launch-button lines.
513
+ const offerTool = codingToolToOffer();
514
+ let offered = false;
515
+ if (offerTool) {
516
+ try {
517
+ await api.sendDm({ toPersonUid: config.ownerUid, body: setupToolOfferText(offerTool, setupWelcomeOpening(config.intro)) });
518
+ patchBotConfig(dir, { introSentAt: now().toISOString() });
519
+ log("info", "intro DM sent to owner, leading into the app offer");
520
+ recordToolOffer(offerTool);
521
+ offered = true;
522
+ }
523
+ catch (err) {
524
+ log("warn", `coding tool offer could not be sent as the welcome (${err instanceof Error ? err.message : String(err)}); sending the usual welcome`);
525
+ }
467
526
  }
527
+ if (!offered)
528
+ try {
529
+ const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro, { kind: effectiveBotKind(config), companies: effectiveBotCompanies(config) }) });
530
+ patchBotConfig(dir, { introSentAt: now().toISOString() });
531
+ log("info", "intro DM sent to owner");
532
+ if (config.kickoff?.trim())
533
+ kickoffAfterIntro = { ...(sent?.eventId ? { introEventId: sent.eventId } : {}) };
534
+ }
535
+ catch (err) {
536
+ log("warn", `intro DM failed (will retry next start): ${err instanceof Error ? err.message : String(err)}`);
537
+ }
468
538
  }
469
539
  // ── Heartbeat loop ─────────────────────────────────────────────────────────
470
540
  const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
@@ -1097,10 +1167,31 @@ export async function runBot(deps) {
1097
1167
  * in-between messages). The thinking reaction goes on the intro message
1098
1168
  * (there is no owner message to mark). A failure never crashes the loop.
1099
1169
  */
1170
+ /**
1171
+ * Setup bot whose welcome already went out (a kickoff held for a sign-in):
1172
+ * the offer as its own message instead of the kickoff turn. False (run the
1173
+ * kickoff) when there is nothing to offer or it cannot be sent.
1174
+ */
1175
+ const offerCodingTool = async () => {
1176
+ const tool = codingToolToOffer();
1177
+ if (!tool)
1178
+ return false;
1179
+ try {
1180
+ await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, setupToolOfferText(tool), undefined, "human");
1181
+ }
1182
+ catch (err) {
1183
+ log("warn", `coding tool offer could not be sent (${err instanceof Error ? err.message : String(err)}); running the kickoff`);
1184
+ return false;
1185
+ }
1186
+ recordToolOffer(tool);
1187
+ return true;
1188
+ };
1100
1189
  const runKickoff = async (introEventId) => {
1101
1190
  const prompt = (config.kickoff ?? "").trim();
1102
1191
  if (!prompt || stopping || state !== "running")
1103
1192
  return;
1193
+ if (isSetupBot && (await offerCodingTool()))
1194
+ return;
1104
1195
  const peerUid = config.ownerUid;
1105
1196
  const tReceived = now().getTime();
1106
1197
  log("info", `kickoff turn starting (${prompt.length} chars)`);
@@ -1530,7 +1621,18 @@ export async function runBot(deps) {
1530
1621
  const sessionScope = target.kind === "room" ? target.sessionScope : dmSessionScope(target.peerUid, config.ownerUid);
1531
1622
  // The turn after the app sent the explanation: tell the model it went out.
1532
1623
  const setupNote = isSetupBot && ownerDm && readSetupIntroState(dir).notePending === true;
1533
- const ownerText = setupNote ? `${SETUP_EXPLAINED_NOTE}\n\n${text}` : text;
1624
+ // The first answer to the coding tool offer: "Keep going here" runs the
1625
+ // waiting kickoff; anything else runs with a note that setup has not started.
1626
+ const toolOffer = isSetupBot && ownerDm ? pendingToolOffer() : null;
1627
+ const toolAnswer = toolOffer ? setupToolOfferAnswer(messageText, attachmentRefs.length) : null;
1628
+ const kickoffPrompt = (config.kickoff ?? "").trim();
1629
+ const ownerText = toolOffer && toolAnswer === "kept_here" && kickoffPrompt
1630
+ ? `${setupKeptHereNote(toolOffer)}\n\n${kickoffPrompt}`
1631
+ : toolOffer
1632
+ ? `${setupOfferAnsweredInWordsNote(toolOffer)}\n\n${text}`
1633
+ : setupNote
1634
+ ? `${SETUP_EXPLAINED_NOTE}\n\n${text}`
1635
+ : text;
1534
1636
  const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? ownerText :
1535
1637
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
1536
1638
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
@@ -1555,7 +1657,7 @@ export async function runBot(deps) {
1555
1657
  });
1556
1658
  // The model saw the note once its turn ran. A turn that never started, or
1557
1659
  // one held for a coding-tool sign-in, runs again and keeps the note for it.
1558
- if (setupNote && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1660
+ if (setupNote && !toolOffer && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1559
1661
  try {
1560
1662
  patchSetupIntroState(dir, { notePending: false });
1561
1663
  }
@@ -1563,6 +1665,16 @@ export async function runBot(deps) {
1563
1665
  log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
1564
1666
  }
1565
1667
  }
1668
+ // Same rule for the offer: answered once the model saw the answer.
1669
+ if (toolOffer && toolAnswer && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1670
+ try {
1671
+ patchSetupIntroState(dir, { kickoffDeferred: false, toolOfferAnswer: toolAnswer });
1672
+ log("info", `coding tool offer answered (${toolAnswer === "kept_here" ? "kept setup here" : "wrote a message"}); setup runs here`);
1673
+ }
1674
+ catch (err) {
1675
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
1676
+ }
1677
+ }
1566
1678
  if (outcome.ok && ownerDm)
1567
1679
  noteSetupReply(outcome.reply);
1568
1680
  if (outcome.ok) {
@@ -44,6 +44,18 @@ export interface SetupIntroState {
44
44
  explainedAt?: string;
45
45
  /** The model has not yet been told that the explanation went out. */
46
46
  notePending?: boolean;
47
+ /**
48
+ * The first message offered to continue setup in this coding tool
49
+ * (setup-tool-offer.ts). Set once; the offer is never made again.
50
+ */
51
+ toolOffer?: {
52
+ tool: "claude" | "codex";
53
+ offeredAt: string;
54
+ };
55
+ /** The kickoff turn is waiting for the person's answer to that offer. */
56
+ kickoffDeferred?: boolean;
57
+ /** How the person answered the offer: the "Keep going here" button, or a message of their own. */
58
+ toolOfferAnswer?: "kept_here" | "wrote";
47
59
  }
48
60
  export declare function readSetupIntroState(dir: string): SetupIntroState;
49
61
  export declare function patchSetupIntroState(dir: string, patch: Partial<Omit<SetupIntroState, "v">>): SetupIntroState;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The setup bot's "continue in your coding tool" offer.
3
+ *
4
+ * Before the setup bot's first message, the runner checks whether the person
5
+ * has the Claude app or the Codex app installed and has used it a lot lately
6
+ * (coding-tool-activity.ts). When one qualifies, the first message is not a
7
+ * model turn: in place of the usual welcome, the runner sends the welcome's
8
+ * opening sentence and one fixed line,
9
+ *
10
+ * "Looks like you use the Claude app. Want to continue setup there?"
11
+ *
12
+ * with an hq-block the HQ desktop app draws as two buttons: "Continue in
13
+ * Claude" (the app opens that desktop app in the HQ folder with the setup
14
+ * request ready) and "Keep going here". The kickoff turn waits:
15
+ *
16
+ * - "Keep going here" (exactly that, nothing attached) runs the waiting
17
+ * kickoff as the reply to it, so setup carries on as it always did.
18
+ * - Any other message from the person runs as a normal turn, with a note
19
+ * saying the offer was made and setup has not started yet.
20
+ *
21
+ * The offer is made once per bot, only when setup has not started (no
22
+ * progress note). Anything unexpected falls back to the normal kickoff turn.
23
+ */
24
+ import type { CodingTool } from "./coding-tool-activity.js";
25
+ /** The reply that keeps setup in the HQ app. Exact match only. */
26
+ export declare const KEEP_GOING_HERE_REPLY = "Keep going here";
27
+ /** The hq-block kind the desktop app draws as the two-button card. */
28
+ export declare const CONTINUE_IN_TOOL_BLOCK_KIND = "continueInTool";
29
+ /** The desktop apps' product names. */
30
+ export declare const CODING_TOOL_NAMES: Readonly<Record<CodingTool, string>>;
31
+ /** The offer's one line. */
32
+ export declare function setupToolOfferLine(tool: CodingTool): string;
33
+ /**
34
+ * The opening of the app's welcome, kept when the offer replaces it: its
35
+ * first sentence (who the bot is and what setup covers). The rest of the
36
+ * welcome says the bot is checking the computer and will post its first
37
+ * question, and points at the Launch button; with the offer showing, none of
38
+ * that is true yet, so it is dropped. A first sentence that itself talks about
39
+ * checking or the Launch button is dropped too.
40
+ */
41
+ export declare function setupWelcomeOpening(intro: string | undefined): string | null;
42
+ /**
43
+ * The whole first message. The block also carries "Keep going here" as a
44
+ * suggested reply, so an app that predates the card still shows that button.
45
+ */
46
+ export declare function setupToolOfferText(tool: CodingTool, welcome?: string | null): string;
47
+ /** Put in front of the waiting kickoff when the person clicked "Keep going here". */
48
+ export declare function setupKeptHereNote(tool: CodingTool): string;
49
+ /** Put in front of the person's message when they wrote something instead of clicking. */
50
+ export declare function setupOfferAnsweredInWordsNote(tool: CodingTool): string;
51
+ export type SetupToolOfferAnswer = "kept_here" | "wrote";
52
+ /** How the person answered a pending offer: the button, or anything else. */
53
+ export declare function setupToolOfferAnswer(text: string, attachments: number): SetupToolOfferAnswer;
54
+ //# sourceMappingURL=setup-tool-offer.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The setup bot's "continue in your coding tool" offer.
3
+ *
4
+ * Before the setup bot's first message, the runner checks whether the person
5
+ * has the Claude app or the Codex app installed and has used it a lot lately
6
+ * (coding-tool-activity.ts). When one qualifies, the first message is not a
7
+ * model turn: in place of the usual welcome, the runner sends the welcome's
8
+ * opening sentence and one fixed line,
9
+ *
10
+ * "Looks like you use the Claude app. Want to continue setup there?"
11
+ *
12
+ * with an hq-block the HQ desktop app draws as two buttons: "Continue in
13
+ * Claude" (the app opens that desktop app in the HQ folder with the setup
14
+ * request ready) and "Keep going here". The kickoff turn waits:
15
+ *
16
+ * - "Keep going here" (exactly that, nothing attached) runs the waiting
17
+ * kickoff as the reply to it, so setup carries on as it always did.
18
+ * - Any other message from the person runs as a normal turn, with a note
19
+ * saying the offer was made and setup has not started yet.
20
+ *
21
+ * The offer is made once per bot, only when setup has not started (no
22
+ * progress note). Anything unexpected falls back to the normal kickoff turn.
23
+ */
24
+ /** The reply that keeps setup in the HQ app. Exact match only. */
25
+ export const KEEP_GOING_HERE_REPLY = "Keep going here";
26
+ /** The hq-block kind the desktop app draws as the two-button card. */
27
+ export const CONTINUE_IN_TOOL_BLOCK_KIND = "continueInTool";
28
+ /** The desktop apps' product names. */
29
+ export const CODING_TOOL_NAMES = Object.freeze({
30
+ claude: "Claude",
31
+ codex: "Codex",
32
+ });
33
+ /** The offer's one line. */
34
+ export function setupToolOfferLine(tool) {
35
+ return `Looks like you use the ${CODING_TOOL_NAMES[tool]} app. Want to continue setup there?`;
36
+ }
37
+ /**
38
+ * The opening of the app's welcome, kept when the offer replaces it: its
39
+ * first sentence (who the bot is and what setup covers). The rest of the
40
+ * welcome says the bot is checking the computer and will post its first
41
+ * question, and points at the Launch button; with the offer showing, none of
42
+ * that is true yet, so it is dropped. A first sentence that itself talks about
43
+ * checking or the Launch button is dropped too.
44
+ */
45
+ export function setupWelcomeOpening(intro) {
46
+ const text = (intro ?? "").trim();
47
+ if (!text)
48
+ return null;
49
+ const m = /^[\s\S]*?[.!?](?=\s|$)/.exec(text);
50
+ const first = (m ? m[0] : text).trim();
51
+ if (!first || /\bcheck(ing)?\b|\blaunch\b|first question/i.test(first))
52
+ return null;
53
+ return first;
54
+ }
55
+ /**
56
+ * The whole first message. The block also carries "Keep going here" as a
57
+ * suggested reply, so an app that predates the card still shows that button.
58
+ */
59
+ export function setupToolOfferText(tool, welcome) {
60
+ const envelope = {
61
+ v: 1,
62
+ blocks: [
63
+ { kind: CONTINUE_IN_TOOL_BLOCK_KIND, tool },
64
+ { kind: "suggestions", items: [KEEP_GOING_HERE_REPLY] },
65
+ ],
66
+ };
67
+ return ((welcome ? `${welcome}\n\n` : "") +
68
+ `${setupToolOfferLine(tool)}\n\n` +
69
+ "```hq-block\n" +
70
+ `${JSON.stringify(envelope)}\n` +
71
+ "```");
72
+ }
73
+ /** Put in front of the waiting kickoff when the person clicked "Keep going here". */
74
+ export function setupKeptHereNote(tool) {
75
+ return (`[Note from the HQ app: before setup started, the app offered to continue setup in the ${CODING_TOOL_NAMES[tool]} app, ` +
76
+ `because the person uses that app a lot on this computer. They clicked "${KEEP_GOING_HERE_REPLY}", so setup happens here. ` +
77
+ "Do not mention the offer. The kickoff follows.]");
78
+ }
79
+ /** Put in front of the person's message when they wrote something instead of clicking. */
80
+ export function setupOfferAnsweredInWordsNote(tool) {
81
+ return (`[Note from the HQ app: before setup started, the app said the person seems to use the ${CODING_TOOL_NAMES[tool]} app a lot ` +
82
+ `and offered to continue setup there, with a button that opens the ${CODING_TOOL_NAMES[tool]} app in their HQ folder and a "${KEEP_GOING_HERE_REPLY}" button. ` +
83
+ "They wrote the message below instead. Setup has not started and there was no kickoff. " +
84
+ `If they want to continue in the ${CODING_TOOL_NAMES[tool]} app, tell them in plain words to click the button above to open it. ` +
85
+ "Otherwise begin setup here as your instructions say for a first message.]");
86
+ }
87
+ /** How the person answered a pending offer: the button, or anything else. */
88
+ export function setupToolOfferAnswer(text, attachments) {
89
+ return text === KEEP_GOING_HERE_REPLY && attachments === 0 ? "kept_here" : "wrote";
90
+ }
91
+ //# sourceMappingURL=setup-tool-offer.js.map
@@ -3,7 +3,6 @@ import type { LaneRecord } from "./types.js";
3
3
  export declare const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
4
4
  export declare const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
5
5
  export declare const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DELAY_MS: number;
6
- export declare const LEAD_AUTOPILOT_NUDGE_DEDUPE_FLAG = "lanes.autopilot-nudge-dedupe";
7
6
  export declare const LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS: number;
8
7
  export declare const LEAD_AUTOPILOT_NUDGE_STATE_RETENTION_MS: number;
9
8
  export declare const LEAD_AUTOPILOT_REVIEW_DELAY_MS: number;
@@ -11,8 +10,6 @@ export declare const LEAD_AUTOPILOT_FILL_DELAY_MS: number;
11
10
  export declare const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
12
11
  export declare const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
13
12
  export declare const LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES = 55;
14
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
15
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
16
13
  export interface RegisteredLead {
17
14
  team: string;
18
15
  lane: string;
@@ -115,7 +112,6 @@ export declare function planLeadAutopilotWakes(input: {
115
112
  managerStatus?: Record<string, unknown>;
116
113
  keepAliveResumed?: boolean;
117
114
  kpiStalenessEnabled?: boolean;
118
- kpiStalenessBatchEnabled?: boolean;
119
115
  headlineStalenessEnabled?: boolean;
120
116
  }): AutopilotWake[];
121
117
  export declare function runLeadAutopilotPass(input: {
@@ -126,7 +122,6 @@ export declare function runLeadAutopilotPass(input: {
126
122
  now: Date;
127
123
  dryRun?: boolean;
128
124
  readFlag?: typeof readLanesRegistryFlag;
129
- readNudgeDedupeFlag?: typeof readLanesRegistryFlag;
130
125
  readDeadlineFlag?: typeof readLanesRegistryFlag;
131
126
  keepAliveResumedLeads?: ReadonlySet<string>;
132
127
  missedDeadlineEscalationDelayMs?: number;
@@ -6,14 +6,13 @@ 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_HEADLINE_STALENESS_FLAG, LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG, LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG as PER_LEAD_ERRORS_FLAG, } from "./flag-keys.js";
9
+ import { LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_FLAG, LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_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";
13
13
  export const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
14
14
  export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG;
15
15
  export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DELAY_MS = 60 * 60_000;
16
- export const LEAD_AUTOPILOT_NUDGE_DEDUPE_FLAG = LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG;
17
16
  export const LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS = 30 * 60_000;
18
17
  export const LEAD_AUTOPILOT_NUDGE_STATE_RETENTION_MS = 24 * 60 * 60_000;
19
18
  const LEAD_AUTOPILOT_DELIVERED_ID_RETENTION_MS = 7 * 24 * 60 * 60_000;
@@ -22,8 +21,6 @@ export const LEAD_AUTOPILOT_FILL_DELAY_MS = 15 * 60_000;
22
21
  export const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
23
22
  export const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
24
23
  export const LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES = 55;
25
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = PER_LEAD_ERRORS_FLAG;
26
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
27
24
  const LIVE_STATES = new Set(["running", "queued", "awaiting_input", "blocked"]);
28
25
  const OPEN_STORY_STATES = new Set(["todo", "queued", "in_progress", "review", "blocked"]);
29
26
  function isOpenProjectStory(story) {
@@ -478,7 +475,7 @@ export function planLeadAutopilotWakes(input) {
478
475
  }
479
476
  if (input.kpiStalenessEnabled) {
480
477
  const staleKpis = managerKpiFreshness(status, now).filter((kpi) => kpi.stale);
481
- if (input.kpiStalenessBatchEnabled && staleKpis.length > 0) {
478
+ if (staleKpis.length > 0) {
482
479
  const byOldest = [...staleKpis].sort((a, b) => (b.ageMinutes ?? Number.MAX_SAFE_INTEGER) - (a.ageMinutes ?? Number.MAX_SAFE_INTEGER) ||
483
480
  a.name.localeCompare(b.name));
484
481
  const identity = staleKpis
@@ -688,13 +685,11 @@ function readState(file) {
688
685
  };
689
686
  }
690
687
  function appendAutopilotMessage(input) {
691
- if (input.dedupeEnabled && input.state.delivered_ids?.[input.message.message_id])
688
+ if (input.state.delivered_ids?.[input.message.message_id])
692
689
  return false;
693
690
  appendDropboxMessage(input.hqRoot, input.leadLane, input.message);
694
- if (input.dedupeEnabled) {
695
- input.state.delivered_ids ??= {};
696
- input.state.delivered_ids[input.message.message_id] = input.message.at;
697
- }
691
+ input.state.delivered_ids ??= {};
692
+ input.state.delivered_ids[input.message.message_id] = input.message.at;
698
693
  return true;
699
694
  }
700
695
  function removeDeliveredIdsForFingerprint(state, fingerprint) {
@@ -799,7 +794,6 @@ async function runLeadAutopilotPassUnlocked(input) {
799
794
  continue;
800
795
  }
801
796
  let stateChanged = false;
802
- let perLeadErrorsEnabled = false;
803
797
  let currentInputPath = registered.status;
804
798
  try {
805
799
  const readFlag = input.readFlag ?? readLanesRegistryFlag;
@@ -808,11 +802,9 @@ async function runLeadAutopilotPassUnlocked(input) {
808
802
  result.skipped.push({ lead_lane: lead.lane_id, reason: "flag_off" });
809
803
  continue;
810
804
  }
811
- perLeadErrorsEnabled = await readFlag(PER_LEAD_ERRORS_FLAG, { company });
812
805
  currentInputPath = registered.status;
813
806
  const status = readStatus(hqRoot, registered.status, company.slug);
814
807
  const kpiStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_FLAG, { company });
815
- const kpiStalenessBatchEnabled = kpiStalenessEnabled && await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG, { company });
816
808
  const headlineStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, { company });
817
809
  if (kpiStalenessEnabled) {
818
810
  result.kpi_status ??= [];
@@ -834,8 +826,7 @@ async function runLeadAutopilotPassUnlocked(input) {
834
826
  const stories = projectStories(hqRoot, registered.prd, company.slug, lead.project_id);
835
827
  const children = lanes.filter((lane) => lane.senior.kind === "lane" && lane.senior.id === lead.lane_id);
836
828
  const deadlineEscalationEnabled = await (input.readDeadlineFlag ?? readFlag)(LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, { company });
837
- const nudgeDedupeEnabled = await (input.readNudgeDedupeFlag ?? readFlag)(LEAD_AUTOPILOT_NUDGE_DEDUPE_FLAG, { company });
838
- if (nudgeDedupeEnabled && pruneDeliveredIds(state, now))
829
+ if (pruneDeliveredIds(state, now))
839
830
  stateChanged = true;
840
831
  currentInputPath = registered.status;
841
832
  let planned = planLeadAutopilotWakes({
@@ -845,11 +836,10 @@ async function runLeadAutopilotPassUnlocked(input) {
845
836
  now,
846
837
  managerStatus: status,
847
838
  kpiStalenessEnabled,
848
- kpiStalenessBatchEnabled,
849
839
  headlineStalenessEnabled,
850
840
  keepAliveResumed: input.keepAliveResumedLeads?.has(lead.lane_id) === true,
851
841
  });
852
- if (kpiStalenessBatchEnabled) {
842
+ if (kpiStalenessEnabled) {
853
843
  const batch = planned.find((wake) => wake.condition === "stale_kpi" && wake.kpi_id === "batch");
854
844
  const headline = planned.find((wake) => wake.condition === "stale_headline");
855
845
  if (batch && headline) {
@@ -872,13 +862,6 @@ async function runLeadAutopilotPassUnlocked(input) {
872
862
  continue;
873
863
  const entry = state.sent[key];
874
864
  const isActive = activeKeys.has(key);
875
- if (!nudgeDedupeEnabled) {
876
- if (!isActive) {
877
- delete state.sent[key];
878
- stateChanged = true;
879
- }
880
- continue;
881
- }
882
865
  if (!isActive && entry.inactive_since === undefined) {
883
866
  state.sent[key] = { ...entry, inactive_since: now.toISOString() };
884
867
  stateChanged = true;
@@ -915,7 +898,7 @@ async function runLeadAutopilotPassUnlocked(input) {
915
898
  if (suppressedKeys.has(key))
916
899
  continue;
917
900
  const prior = state.sent[key];
918
- if (nudgeDedupeEnabled && prior && prior.fingerprint !== wake.fingerprint && !prior.acted) {
901
+ if (prior && prior.fingerprint !== wake.fingerprint && !prior.acted) {
919
902
  const leadActedSincePrior = statusUpdatedAt !== undefined && prior.status_updated_at !== undefined &&
920
903
  Date.parse(statusUpdatedAt) > Date.parse(prior.status_updated_at);
921
904
  if (!leadActedSincePrior) {
@@ -974,11 +957,11 @@ async function runLeadAutopilotPassUnlocked(input) {
974
957
  continue;
975
958
  if (wake.condition === "stale_headline")
976
959
  continue;
977
- const redeliveryDelay = nudgeDedupeEnabled ? LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS : LEAD_AUTOPILOT_FILL_DELAY_MS;
960
+ const redeliveryDelay = LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS;
978
961
  if (!current.acted && !current.auto_started && current.escalated_at === undefined && now.getTime() - lastWakeAt >= redeliveryDelay) {
979
962
  const wakeCount = current.wake_count ?? 1;
980
963
  if (wakeCount < 2) {
981
- const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, dedupeEnabled: nudgeDedupeEnabled, message: {
964
+ const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, message: {
982
965
  at: now.toISOString(),
983
966
  message_id: `lead-autopilot:${wake.fingerprint}:wake-2`,
984
967
  kind: "system_notice",
@@ -1002,7 +985,7 @@ async function runLeadAutopilotPassUnlocked(input) {
1002
985
  continue;
1003
986
  }
1004
987
  if (!input.dryRun) {
1005
- const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, dedupeEnabled: nudgeDedupeEnabled, message: {
988
+ const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, message: {
1006
989
  at: now.toISOString(),
1007
990
  message_id: `lead-autopilot:${wake.fingerprint}`,
1008
991
  kind: "system_notice",
@@ -1093,7 +1076,7 @@ async function runLeadAutopilotPassUnlocked(input) {
1093
1076
  fingerprint,
1094
1077
  prompt: `Deadline missed for ${commitment.item} (${commitment.description}), owner ${commitment.owner}, due ${commitment.due}. Deliver it or re-date with a reason. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
1095
1078
  };
1096
- const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, dedupeEnabled: nudgeDedupeEnabled, message: {
1079
+ const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, message: {
1097
1080
  at: now.toISOString(),
1098
1081
  message_id: `lead-autopilot:${fingerprint}`,
1099
1082
  kind: "system_notice",
@@ -1160,7 +1143,7 @@ async function runLeadAutopilotPassUnlocked(input) {
1160
1143
  catch (error) {
1161
1144
  if (!input.dryRun && stateChanged)
1162
1145
  writeState(stateFile, state);
1163
- if (perLeadErrorsEnabled && error instanceof LanesError && LEAD_AUTOPILOT_PER_LEAD_ERROR_CODES.has(error.error)) {
1146
+ if (error instanceof LanesError && LEAD_AUTOPILOT_PER_LEAD_ERROR_CODES.has(error.error)) {
1164
1147
  const errorPath = typeof error.details.path === "string" ? error.details.path : currentInputPath;
1165
1148
  const messageId = `lead-autopilot-error:${error.error}:${createHash("sha256").update(`${error.error}\0${errorPath}`).digest("hex")}`;
1166
1149
  result.skipped.push({ lane_id: lead.lane_id, error: error.error, path: errorPath, message: error.message });
@@ -2,7 +2,6 @@ import * as fs from "node:fs";
2
2
  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
- export declare const CODEX_ROLLOUT_RETENTION_FLAG = "lanes.codex-rollout-retention";
6
5
  export declare function lowDiskGuardEnabledForLane(hqRoot: string, lane: LaneRecord, readFlag?: (key: string, options: ReadLanesFlagOptions) => Promise<boolean>): Promise<boolean>;
7
6
  interface DiskStats {
8
7
  bsize: number;
@@ -8,7 +8,6 @@ import { resolveLaneCompany } from "./lane-company.js";
8
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
- export const CODEX_ROLLOUT_RETENTION_FLAG = "lanes.codex-rollout-retention";
12
11
  const DEFAULT_LOW_DISK_FREE_BYTES = 5 * 1024 ** 3;
13
12
  const DEFAULT_LOW_DISK_FRACTION = 0.02;
14
13
  const EXCLUDED_SESSION_FILES = new Set(["auth.json", ".credentials.json", "thread_history.sqlite"]);
@@ -12,17 +12,10 @@ export declare const LANE_HIRE_GRANT_FINISHED_LANES_FLAG = "lanes.hire-grant-cou
12
12
  export declare const LANE_LIST_SKIP_REMOTE_REFRESH_FLAG = "lanes.list-skip-remote-refresh";
13
13
  /** Enables missed-deadline lead nudges and second-miss operator escalation. */
14
14
  export declare const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
15
- export declare const LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG = "lanes.autopilot-nudge-dedupe";
16
15
  /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
17
16
  export declare const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
18
17
  /** Enables stale manager-status KPI reporting and one-time lead nudges. */
19
18
  export declare const LEAD_AUTOPILOT_KPI_STALENESS_FLAG = "lanes.autopilot-kpi-staleness";
20
- /** Skips malformed lead inputs individually instead of aborting the pass. */
21
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
22
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
23
- /** Batches stale manager-status KPI notices into one lead message per pass. */
24
- export declare const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG = "lanes.autopilot-kpi-staleness-batch";
25
- export declare const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_DEFAULT_ENABLED = false;
26
19
  /** Enables staleness notices for a registered lead's dashboard headline. */
27
20
  export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-staleness";
28
21
  /** Lets an Outpost's autopilot wake leads created on that Outpost, which record a local host. */
@@ -12,17 +12,10 @@ export const LANE_HIRE_GRANT_FINISHED_LANES_FLAG = "lanes.hire-grant-count-live-
12
12
  export const LANE_LIST_SKIP_REMOTE_REFRESH_FLAG = "lanes.list-skip-remote-refresh";
13
13
  /** Enables missed-deadline lead nudges and second-miss operator escalation. */
14
14
  export const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
15
- export const LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG = "lanes.autopilot-nudge-dedupe";
16
15
  /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
17
16
  export const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
18
17
  /** Enables stale manager-status KPI reporting and one-time lead nudges. */
19
18
  export const LEAD_AUTOPILOT_KPI_STALENESS_FLAG = "lanes.autopilot-kpi-staleness";
20
- /** Skips malformed lead inputs individually instead of aborting the pass. */
21
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
22
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
23
- /** Batches stale manager-status KPI notices into one lead message per pass. */
24
- export const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG = "lanes.autopilot-kpi-staleness-batch";
25
- export const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_DEFAULT_ENABLED = false;
26
19
  /** Enables staleness notices for a registered lead's dashboard headline. */
27
20
  export const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-staleness";
28
21
  /** Lets an Outpost's autopilot wake leads created on that Outpost, which record a local host. */
@@ -955,21 +955,8 @@ export async function runPassBody(hqRoot, deps) {
955
955
  }
956
956
  const config = loadLanesConfig(hqRoot);
957
957
  try {
958
- const companyRefs = new Map();
959
- for (const lane of lanes) {
960
- const company = resolveLaneCompany(hqRoot, lane).company;
961
- if (company)
962
- companyRefs.set(company.uid ?? company.slug, company);
963
- }
964
- const readFlag = deps.readRegistryFlag ?? readLanesRegistryFlag;
965
958
  const configuredHomes = configuredCodexRolloutHomes(hqRoot, config, deps.host.hostname);
966
- let retentionEnabled = false;
967
- for (const company of configuredHomes.length > 0 ? companyRefs.values() : []) {
968
- retentionEnabled ||= await readFlag("lanes.codex-rollout-retention", { company });
969
- if (retentionEnabled)
970
- break;
971
- }
972
- if (retentionEnabled) {
959
+ if (configuredHomes.length > 0) {
973
960
  const presentHomes = [];
974
961
  for (const home of configuredHomes) {
975
962
  try {