@indigoai-us/hq-cli 5.311.4 → 5.312.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.
Files changed (96) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/assets/scaffold/core/scripts/lib/work-mesh-live-hook.sh +378 -0
  3. package/assets/scaffold/core/scripts/lib/work-mesh-live-rebind.sh +141 -0
  4. package/assets/scaffold/core/scripts/migrate-policy-triggers.sh +373 -0
  5. package/assets/scaffold/core/scripts/register-project.sh +262 -0
  6. package/assets/scaffold/core/scripts/work-mesh-live-rebind.sh +59 -0
  7. package/dist/command-catalog.generated.d.ts +160 -0
  8. package/dist/command-catalog.generated.js +200 -0
  9. package/dist/command-registration-plan.d.ts +6 -0
  10. package/dist/command-registration-plan.js +1 -0
  11. package/dist/commands/company-rename-flag.d.ts +4 -0
  12. package/dist/commands/company-rename-flag.js +7 -0
  13. package/dist/commands/company-rename.d.ts +4 -0
  14. package/dist/commands/company-rename.js +90 -0
  15. package/dist/commands/company.js +2 -0
  16. package/dist/commands/core-checkpoint-stop-gate-native.js +12 -0
  17. package/dist/commands/core-eval-trigger-native.d.ts +4 -0
  18. package/dist/commands/core-eval-trigger-native.js +12 -0
  19. package/dist/commands/core-migrate-policy-triggers-native.d.ts +21 -0
  20. package/dist/commands/core-migrate-policy-triggers-native.js +378 -0
  21. package/dist/commands/core-native-hook-helper-commands.d.ts +4 -0
  22. package/dist/commands/core-native-hook-helper-commands.js +56 -0
  23. package/dist/commands/core-native-hook-helpers-flag.d.ts +4 -0
  24. package/dist/commands/core-native-hook-helpers-flag.js +7 -0
  25. package/dist/commands/core-register-project-native.d.ts +30 -0
  26. package/dist/commands/core-register-project-native.js +347 -0
  27. package/dist/commands/core-work-mesh-live-rebind-native.d.ts +18 -0
  28. package/dist/commands/core-work-mesh-live-rebind-native.js +262 -0
  29. package/dist/commands/core.js +7 -0
  30. package/dist/commands/daemon.js +22 -0
  31. package/dist/commands/dm.js +22 -6
  32. package/dist/commands/files-browse.js +16 -4
  33. package/dist/commands/lanes-message.js +17 -2
  34. package/dist/commands/lanes-shared.d.ts +1 -0
  35. package/dist/commands/lanes.js +96 -20
  36. package/dist/commands/vault-retention-flag.d.ts +3 -0
  37. package/dist/commands/vault-retention-flag.js +9 -0
  38. package/dist/commands/vault-retention.d.ts +11 -0
  39. package/dist/commands/vault-retention.js +69 -0
  40. package/dist/fast-core.js +10 -3
  41. package/dist/fast-scaffold.d.ts +2 -0
  42. package/dist/fast-scaffold.js +11 -0
  43. package/dist/lib/agents/model-catalog.d.ts +3 -2
  44. package/dist/lib/company-skill-wrapper.d.ts +0 -8
  45. package/dist/lib/core-utils/codex-skill-bridge.d.ts +2 -1
  46. package/dist/lib/core-utils/sentry-report.js +21 -4
  47. package/dist/lib/daemon/catalog.d.ts +2 -0
  48. package/dist/lib/daemon/catalog.js +13 -1
  49. package/dist/lib/daemon/config.d.ts +2 -0
  50. package/dist/lib/daemon/config.js +4 -0
  51. package/dist/lib/daemon/run.d.ts +2 -0
  52. package/dist/lib/daemon/run.js +5 -0
  53. package/dist/lib/deploy-api.d.ts +0 -1
  54. package/dist/lib/deploy-api.js +1 -1
  55. package/dist/lib/doctor/checks/work-context.js +9 -1
  56. package/dist/lib/lanes/autopilot.d.ts +38 -1
  57. package/dist/lib/lanes/autopilot.js +353 -5
  58. package/dist/lib/lanes/flag-keys.d.ts +9 -1
  59. package/dist/lib/lanes/flag-keys.js +9 -1
  60. package/dist/lib/lanes/keep-alive.d.ts +2 -0
  61. package/dist/lib/lanes/keep-alive.js +2 -0
  62. package/dist/lib/lanes/reconcile.d.ts +5 -0
  63. package/dist/lib/lanes/reconcile.js +57 -4
  64. package/dist/lib/lanes/watcher-main.js +20 -1
  65. package/dist/lib/lanes/watcher.d.ts +9 -0
  66. package/dist/lib/lanes/watcher.js +47 -1
  67. package/dist/lib/mesh/client.js +23 -5
  68. package/dist/lib/mesh/denial-codes.d.ts +5 -0
  69. package/dist/lib/mesh/denial-codes.js +14 -0
  70. package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
  71. package/dist/lib/mesh/live/daemon/doctor.js +11 -1
  72. package/dist/lib/report-run.d.ts +3 -2
  73. package/dist/lib/report-templates.d.ts +0 -1
  74. package/dist/lib/report-templates.js +0 -3
  75. package/dist/lib/scaffold-cli-floor.d.ts +0 -1
  76. package/dist/lib/scaffold-cli-floor.js +1 -1
  77. package/dist/lib/search-index/embed-lock.d.ts +2 -0
  78. package/dist/lib/search-index/embed-lock.js +10 -1
  79. package/dist/lib/work-context/contract.d.ts +3 -0
  80. package/dist/lib/work-context/contract.schema.json +3 -1
  81. package/dist/lib/work-context/outbox.d.ts +14 -5
  82. package/dist/lib/work-context/outbox.js +48 -5
  83. package/dist/lib/work-context/reconcile.js +26 -5
  84. package/dist/main.d.ts +20 -0
  85. package/dist/main.js +8 -1
  86. package/dist/run/discover-schemas.d.ts +2 -1
  87. package/dist/utils/core-eval-trigger.d.ts +4 -0
  88. package/dist/utils/core-eval-trigger.js +88 -0
  89. package/dist/utils/hq-roots.d.ts +0 -2
  90. package/dist/utils/hq-roots.js +1 -8
  91. package/dist/utils/incomplete-install-error.d.ts +7 -6
  92. package/dist/utils/large-file-guard.d.ts +0 -10
  93. package/dist/utils/large-file-guard.js +1 -1
  94. package/dist/utils/package-root-diagnostics.d.ts +1 -1
  95. package/dist/utils/self-update.d.ts +2 -1
  96. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { laneRunsOnHost } from "./admission.js";
@@ -5,14 +6,20 @@ import { isControllerAliasOutpostId } from "./admission-host.js";
5
6
  import { LEAD_REGISTRY_REL, validateOutpostLeadRegistry } from "./autopilot-registry.js";
6
7
  import { appendDropboxMessage } from "./dropbox.js";
7
8
  import { LanesError } from "./errors.js";
9
+ import { LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG } from "./flag-keys.js";
8
10
  import { resolveLaneCompany } from "./lane-company.js";
9
11
  import { readLanesRegistryFlag } from "./registry-flag.js";
12
+ import { LEAD_AUTOPILOT_KPI_STALENESS_FLAG } from "./flag-keys.js";
10
13
  import { readAllLanes } from "./store.js";
11
14
  export const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
12
15
  export const LEAD_AUTOPILOT_DEFAULT_ENABLED = false;
16
+ export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG;
17
+ export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DEFAULT_ENABLED = false;
18
+ export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DELAY_MS = 60 * 60_000;
13
19
  export const LEAD_AUTOPILOT_REVIEW_DELAY_MS = 15 * 60_000;
14
20
  export const LEAD_AUTOPILOT_FILL_DELAY_MS = 15 * 60_000;
15
21
  export const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
22
+ export const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
16
23
  const LIVE_STATES = new Set(["running", "queued", "awaiting_input", "blocked"]);
17
24
  const OPEN_STORY_STATES = new Set(["todo", "queued", "in_progress", "review", "blocked"]);
18
25
  function isOpenProjectStory(story) {
@@ -157,9 +164,112 @@ function projectStories(hqRoot, relativePath, companySlug, projectId) {
157
164
  ...storyLaneIds(story),
158
165
  ...(typeof story.updated_at === "string" ? { updatedAt: story.updated_at } : {}),
159
166
  ...(typeof story.external_blocker === "string" ? { externalBlocker: story.external_blocker } : {}),
167
+ deadlineFields: story,
160
168
  };
161
169
  });
162
170
  }
171
+ const DEADLINE_FIELDS = ["deadline", "due_at", "unblock_by", "unblock_at", "eta"];
172
+ const CLOSED_COMMITMENT_STATES = new Set(["done", "resolved", "closed", "cancelled", "canceled", "dropped", "complete", "completed"]);
173
+ const COMMITMENT_SECTIONS = ["open_blockers", "waiting", "open_decisions", "decisions", "slot_requests", "blockers"];
174
+ function hasDeadlineField(value) {
175
+ return DEADLINE_FIELDS.some((field) => value[field] !== undefined && value[field] !== null && value[field] !== "");
176
+ }
177
+ function commitmentDue(value, context) {
178
+ const dates = [];
179
+ const errors = [];
180
+ for (const field of DEADLINE_FIELDS) {
181
+ const raw = value[field];
182
+ if (raw === undefined || raw === null || raw === "")
183
+ continue;
184
+ if (typeof raw !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d{1,9})?)?(?:Z|[+-]\d\d:\d\d)$/.test(raw) || !Number.isFinite(Date.parse(raw))) {
185
+ // `eta` is also used as free-text planning context in manager and story
186
+ // records. It is a deadline only when it is already a valid ISO instant.
187
+ if (field === "eta")
188
+ continue;
189
+ errors.push({ field, reason: `${context}.${field} must be a valid date-time` });
190
+ continue;
191
+ }
192
+ dates.push({ field, iso: new Date(Date.parse(raw)).toISOString() });
193
+ }
194
+ if (errors.length > 0)
195
+ return { due: null, errors };
196
+ if (dates.length === 0)
197
+ return { due: null, errors };
198
+ if (new Set(dates.map((date) => date.iso)).size > 1) {
199
+ return { due: null, errors: [{ field: "deadline_fields", reason: `${context} has conflicting deadline fields` }] };
200
+ }
201
+ return { due: dates[0].iso, errors };
202
+ }
203
+ function isResolvedCommitment(value, status) {
204
+ return value.passes === true || value.resolved === true || value.open === false ||
205
+ value.resolved_at !== undefined && value.resolved_at !== null ||
206
+ value.completed_at !== undefined && value.completed_at !== null ||
207
+ (typeof status === "string" && CLOSED_COMMITMENT_STATES.has(status.toLowerCase()));
208
+ }
209
+ function optionalLabel(value, fields, fallback) {
210
+ for (const field of fields) {
211
+ const candidate = value[field];
212
+ if (typeof candidate === "string" && candidate.trim())
213
+ return candidate.trim();
214
+ }
215
+ return fallback;
216
+ }
217
+ function deadlineCommitments(status, stories, leadLane) {
218
+ const commitments = [];
219
+ const errors = [];
220
+ for (const section of COMMITMENT_SECTIONS) {
221
+ const rows = status[section];
222
+ if (rows === undefined)
223
+ continue;
224
+ if (!Array.isArray(rows)) {
225
+ errors.push({ lead_lane: leadLane, item_ref: `manager:${section}`, field: section, reason: `Manager status ${section} must be an array` });
226
+ continue;
227
+ }
228
+ rows.forEach((row, index) => {
229
+ if (!isRecord(row) || !hasDeadlineField(row))
230
+ return;
231
+ const itemRef = `manager:${section}[${index}]`;
232
+ const dueResult = commitmentDue(row, `Manager status ${section}[${index}]`);
233
+ errors.push(...dueResult.errors.map((error) => ({ lead_lane: leadLane, item_ref: itemRef, ...error })));
234
+ if (dueResult.errors.length > 0 || !dueResult.due)
235
+ return;
236
+ const id = row.id;
237
+ if (typeof id !== "string" || !id.trim()) {
238
+ errors.push({ lead_lane: leadLane, item_ref: itemRef, field: "id", reason: `Manager status ${section}[${index}] with a deadline needs a non-empty id` });
239
+ return;
240
+ }
241
+ const owner = optionalLabel(row, ["owner", "unblock_owner", "waiting_on", "assigned_to"], "unassigned");
242
+ const state = typeof row.status === "string" ? row.status : typeof row.state === "string" ? row.state : undefined;
243
+ commitments.push({
244
+ key: `manager:${section}:${id}`,
245
+ item: id,
246
+ owner,
247
+ due: dueResult.due,
248
+ description: optionalLabel(row, ["what", "question", "request", "title", "line"], id),
249
+ resolved: isResolvedCommitment(row, state),
250
+ });
251
+ });
252
+ }
253
+ for (const story of stories) {
254
+ const raw = story.deadlineFields;
255
+ if (!raw || !hasDeadlineField(raw))
256
+ continue;
257
+ const dueResult = commitmentDue(raw, `Project story ${story.id}`);
258
+ errors.push(...dueResult.errors.map((error) => ({ lead_lane: leadLane, item_ref: `story:${story.id}`, ...error })));
259
+ if (dueResult.errors.length > 0 || !dueResult.due)
260
+ continue;
261
+ const state = typeof raw.status === "string" ? raw.status : typeof raw.state === "string" ? raw.state : story.status;
262
+ commitments.push({
263
+ key: `story:${story.id}`,
264
+ item: story.id,
265
+ owner: optionalLabel(raw, ["owner", "assigned_to"], "lead"),
266
+ due: dueResult.due,
267
+ description: optionalLabel(raw, ["title", "name", "what"], story.id),
268
+ resolved: isResolvedCommitment(raw, state),
269
+ });
270
+ }
271
+ return { commitments: commitments.sort((a, b) => a.key.localeCompare(b.key)), errors };
272
+ }
163
273
  function readStatus(hqRoot, relativePath, companySlug) {
164
274
  if (path.isAbsolute(relativePath)) {
165
275
  throw new LanesError("lead_autopilot_status_path_invalid", `Registered status path must be relative to the HQ root: ${relativePath}`, { path: relativePath });
@@ -232,6 +342,72 @@ function statusBlockers(status) {
232
342
  return value;
233
343
  });
234
344
  }
345
+ function managerKpiFreshness(status, now) {
346
+ const rawKpis = status.kpis;
347
+ if (rawKpis === undefined)
348
+ return [];
349
+ if (!Array.isArray(rawKpis)) {
350
+ return [{
351
+ name: "kpis",
352
+ measuredAt: null,
353
+ ageMinutes: null,
354
+ stale: false,
355
+ staleAfterMinutes: LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES,
356
+ fingerprint: "",
357
+ error: "invalid_kpi",
358
+ }];
359
+ }
360
+ return rawKpis.map((value, index) => {
361
+ const invalid = () => {
362
+ let name = `KPI ${index + 1}`;
363
+ if (isRecord(value)) {
364
+ const rawName = typeof value.name === "string" ? value.name : value.id;
365
+ if (typeof rawName === "string" && rawName.trim())
366
+ name = rawName.trim();
367
+ }
368
+ return {
369
+ name,
370
+ measuredAt: null,
371
+ ageMinutes: null,
372
+ stale: false,
373
+ staleAfterMinutes: LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES,
374
+ fingerprint: "",
375
+ ...(isRecord(value) && typeof value.status === "string" ? { status: value.status } : {}),
376
+ error: "invalid_kpi",
377
+ };
378
+ };
379
+ if (!isRecord(value)) {
380
+ return invalid();
381
+ }
382
+ const rawName = typeof value.name === "string" ? value.name : value.id;
383
+ if (typeof rawName !== "string" || !rawName.trim()) {
384
+ return invalid();
385
+ }
386
+ const staleAfterMinutes = value.stale_after_minutes === undefined
387
+ ? LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES
388
+ : value.stale_after_minutes;
389
+ if (typeof staleAfterMinutes !== "number" || !Number.isSafeInteger(staleAfterMinutes) || staleAfterMinutes < 1 || staleAfterMinutes > 525_600) {
390
+ return invalid();
391
+ }
392
+ const measuredAt = typeof value.measured_at === "string" && Number.isFinite(Date.parse(value.measured_at))
393
+ ? new Date(Date.parse(value.measured_at)).toISOString()
394
+ : null;
395
+ const elapsedMs = measuredAt === null ? null : Math.max(0, now.getTime() - Date.parse(measuredAt));
396
+ const ageMinutes = elapsedMs === null ? null : Math.floor(elapsedMs / 60_000);
397
+ const terminalStatus = typeof value.status === "string" && ["superseded", "retired", "dropped"].includes(value.status.toLowerCase());
398
+ const stale = !terminalStatus && (elapsedMs === null || elapsedMs > staleAfterMinutes * 60_000);
399
+ const fingerprint = `stale-kpi:${createHash("sha256").update(JSON.stringify([rawName.trim(), measuredAt, value.value ?? null])).digest("hex")}`;
400
+ return {
401
+ name: rawName.trim(),
402
+ measuredAt,
403
+ ageMinutes,
404
+ stale,
405
+ staleAfterMinutes,
406
+ fingerprint,
407
+ ...(typeof value.status === "string" ? { status: value.status } : {}),
408
+ };
409
+ });
410
+ }
235
411
  function leastMoved(lines, priorLines = []) {
236
412
  const priorById = new Map(priorLines.map((line) => [line.id, line]));
237
413
  return [...lines].sort((a, b) => {
@@ -249,6 +425,23 @@ export function planLeadAutopilotWakes(input) {
249
425
  const blockers = statusBlockers(status);
250
426
  const byStory = new Map(stories.map((story) => [story.id, story]));
251
427
  const wakes = [];
428
+ if (input.kpiStalenessEnabled) {
429
+ for (const kpi of managerKpiFreshness(status, now)) {
430
+ if (!kpi.stale)
431
+ continue;
432
+ const age = kpi.ageMinutes === null
433
+ ? "its measurement age is unknown"
434
+ : `its latest measurement is ${kpi.ageMinutes} minutes old`;
435
+ wakes.push({
436
+ condition: "stale_kpi",
437
+ lead_lane: lead.lane_id,
438
+ story_id: "kpi-staleness",
439
+ kpi_id: kpi.name,
440
+ fingerprint: kpi.fingerprint,
441
+ prompt: `Re-measure KPI ${kpi.name}: ${age} (stale after ${kpi.staleAfterMinutes} minutes). Update measured_at in manager-status.json. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
442
+ });
443
+ }
444
+ }
252
445
  const finishedReviews = children.flatMap((child) => {
253
446
  if (!["done", "blocked", "failed", "interrupted"].includes(child.state))
254
447
  return [];
@@ -384,9 +577,10 @@ export function planLeadAutopilotWakes(input) {
384
577
  }
385
578
  function readState(file) {
386
579
  if (!fs.existsSync(file))
387
- return { schema_version: "hq-lanes-autopilot-state.v1", sent: {} };
580
+ return { schema_version: "hq-lanes-autopilot-state.v1", sent: {}, deadline_misses: {} };
388
581
  const value = readJsonFile(file, "lead_autopilot_state_invalid");
389
- if (!isRecord(value) || value.schema_version !== "hq-lanes-autopilot-state.v1" || !isRecord(value.sent)) {
582
+ if (!isRecord(value) || value.schema_version !== "hq-lanes-autopilot-state.v1" || !isRecord(value.sent) ||
583
+ (value.deadline_misses !== undefined && !isRecord(value.deadline_misses))) {
390
584
  throw new LanesError("lead_autopilot_state_invalid", `Invalid autopilot state ${file}`, { path: file });
391
585
  }
392
586
  for (const [key, row] of Object.entries(value.sent)) {
@@ -401,6 +595,16 @@ function readState(file) {
401
595
  throw new LanesError("lead_autopilot_state_invalid", `Invalid autopilot state entry ${key}`, { path: file, key });
402
596
  }
403
597
  }
598
+ for (const [key, row] of Object.entries(value.deadline_misses ?? {})) {
599
+ if (!isRecord(row) || typeof row.item !== "string" || typeof row.owner !== "string" ||
600
+ typeof row.due_at !== "string" || !Number.isFinite(Date.parse(row.due_at)) ||
601
+ !Number.isSafeInteger(row.miss_count) || row.miss_count < 1 ||
602
+ typeof row.first_missed_at !== "string" || !Number.isFinite(Date.parse(row.first_missed_at)) ||
603
+ typeof row.currently_missed !== "boolean" ||
604
+ (row.escalated_at !== undefined && (typeof row.escalated_at !== "string" || !Number.isFinite(Date.parse(row.escalated_at))))) {
605
+ throw new LanesError("lead_autopilot_state_invalid", `Invalid missed-deadline state entry ${key}`, { path: file, key });
606
+ }
607
+ }
404
608
  return value;
405
609
  }
406
610
  function writeState(file, state) {
@@ -465,26 +669,44 @@ async function runLeadAutopilotPassUnlocked(input) {
465
669
  }
466
670
  let stateChanged = false;
467
671
  try {
468
- const enabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_FLAG, { company });
672
+ const readFlag = input.readFlag ?? readLanesRegistryFlag;
673
+ const enabled = await readFlag(LEAD_AUTOPILOT_FLAG, { company });
469
674
  if (!enabled) {
470
675
  result.skipped.push({ lead_lane: lead.lane_id, reason: "flag_off" });
471
676
  continue;
472
677
  }
473
678
  const status = readStatus(hqRoot, registered.status, company.slug);
679
+ const kpiStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_FLAG, { company });
680
+ if (kpiStalenessEnabled) {
681
+ result.kpi_status ??= [];
682
+ result.kpi_status.push(...managerKpiFreshness(status, now).map((kpi) => ({
683
+ lead_lane: lead.lane_id,
684
+ team: registered.team,
685
+ kpi: kpi.name,
686
+ age_minutes: kpi.ageMinutes,
687
+ stale: kpi.stale,
688
+ stale_after_minutes: kpi.staleAfterMinutes,
689
+ ...(kpi.status === undefined ? {} : { status: kpi.status }),
690
+ ...(kpi.error === undefined ? {} : { error: kpi.error }),
691
+ })));
692
+ }
474
693
  const statusUpdatedAt = typeof status.updated_at === "string" && Number.isFinite(Date.parse(status.updated_at))
475
694
  ? new Date(Date.parse(status.updated_at)).toISOString()
476
695
  : undefined;
477
696
  const stories = projectStories(hqRoot, registered.prd, company.slug, lead.project_id);
478
697
  const children = lanes.filter((lane) => lane.senior.kind === "lane" && lane.senior.id === lead.lane_id);
698
+ const deadlineEscalationEnabled = await (input.readDeadlineFlag ?? readFlag)(LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, { company });
479
699
  const planned = planLeadAutopilotWakes({
480
700
  lead,
481
701
  children,
482
702
  stories,
483
703
  now,
484
704
  managerStatus: status,
705
+ kpiStalenessEnabled,
485
706
  keepAliveResumed: input.keepAliveResumedLeads?.has(lead.lane_id) === true,
486
707
  });
487
- const activeKeys = new Set(planned.map((wake) => `${lead.lane_id}:${wake.condition}`));
708
+ const stateKey = (wake) => `${lead.lane_id}:${wake.condition}${wake.condition === "stale_kpi" ? `:${wake.kpi_id}` : ""}`;
709
+ const activeKeys = new Set(planned.map(stateKey));
488
710
  for (const key of Object.keys(state.sent)) {
489
711
  if (key.startsWith(`${lead.lane_id}:`) && !activeKeys.has(key)) {
490
712
  delete state.sent[key];
@@ -492,7 +714,7 @@ async function runLeadAutopilotPassUnlocked(input) {
492
714
  }
493
715
  }
494
716
  for (const wake of planned) {
495
- const key = `${lead.lane_id}:${wake.condition}`;
717
+ const key = stateKey(wake);
496
718
  const prior = state.sent[key];
497
719
  if (prior?.fingerprint === wake.fingerprint) {
498
720
  if (input.dryRun)
@@ -535,6 +757,10 @@ async function runLeadAutopilotPassUnlocked(input) {
535
757
  }
536
758
  }
537
759
  }
760
+ // Stale KPIs get one notice per measurement fingerprint, not the
761
+ // repeated wake/escalation cadence used by other conditions.
762
+ if (wake.condition === "stale_kpi")
763
+ continue;
538
764
  if (!current.acted && !current.auto_started && current.escalated_at === undefined && now.getTime() - lastWakeAt >= LEAD_AUTOPILOT_FILL_DELAY_MS) {
539
765
  const wakeCount = current.wake_count ?? 1;
540
766
  if (wakeCount < 2) {
@@ -578,6 +804,128 @@ async function runLeadAutopilotPassUnlocked(input) {
578
804
  }
579
805
  result.sent.push(wake);
580
806
  }
807
+ // Planned wakes have already been delivered. Persist their dedupe state
808
+ // before deadline parsing/recording so an unexpected deadline failure
809
+ // cannot make those wakes repeat on the next pass.
810
+ if (!input.dryRun && stateChanged) {
811
+ writeState(stateFile, state);
812
+ stateChanged = false;
813
+ }
814
+ if (deadlineEscalationEnabled) {
815
+ result.missed_deadlines ??= [];
816
+ const parsedCommitments = deadlineCommitments(status, stories, lead.lane_id);
817
+ if (parsedCommitments.errors.length > 0) {
818
+ result.deadline_errors ??= [];
819
+ result.deadline_errors.push(...parsedCommitments.errors);
820
+ }
821
+ const commitments = parsedCommitments.commitments;
822
+ const currentCommitmentKeys = new Set(commitments.map((item) => `${lead.lane_id}:${item.key}`));
823
+ const deadlineState = state.deadline_misses ?? (state.deadline_misses = {});
824
+ for (const key of Object.keys(deadlineState)) {
825
+ if (key.startsWith(`${lead.lane_id}:`) && !currentCommitmentKeys.has(key)) {
826
+ delete deadlineState[key];
827
+ stateChanged = true;
828
+ }
829
+ }
830
+ const escalationDelay = input.missedDeadlineEscalationDelayMs ?? LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DELAY_MS;
831
+ if (!Number.isFinite(escalationDelay) || escalationDelay < 0) {
832
+ throw new LanesError("lead_autopilot_deadline_config_invalid", "Missed-deadline escalation delay must be a non-negative number");
833
+ }
834
+ for (const commitment of commitments) {
835
+ const key = `${lead.lane_id}:${commitment.key}`;
836
+ const prior = deadlineState[key];
837
+ if (commitment.resolved) {
838
+ if (prior) {
839
+ delete deadlineState[key];
840
+ stateChanged = true;
841
+ }
842
+ continue;
843
+ }
844
+ const isMissed = now.getTime() > Date.parse(commitment.due);
845
+ if (!isMissed) {
846
+ if (prior && (prior.due_at !== commitment.due || prior.owner !== commitment.owner || prior.currently_missed)) {
847
+ deadlineState[key] = { ...prior, due_at: commitment.due, owner: commitment.owner, currently_missed: false };
848
+ stateChanged = true;
849
+ }
850
+ continue;
851
+ }
852
+ if (!prior || prior.miss_count === 0) {
853
+ const firstMiss = {
854
+ item: commitment.item,
855
+ owner: commitment.owner,
856
+ due_at: commitment.due,
857
+ miss_count: 1,
858
+ first_missed_at: now.toISOString(),
859
+ currently_missed: true,
860
+ };
861
+ if (!input.dryRun) {
862
+ const fingerprint = `missed-deadline:${commitment.key}:${commitment.owner}:${commitment.due}`;
863
+ const wake = {
864
+ condition: "missed_deadline",
865
+ lead_lane: lead.lane_id,
866
+ story_id: commitment.item,
867
+ fingerprint,
868
+ 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}.`,
869
+ };
870
+ appendDropboxMessage(hqRoot, lead.lane_id, {
871
+ at: now.toISOString(),
872
+ message_id: `lead-autopilot:${fingerprint}`,
873
+ kind: "system_notice",
874
+ text: wake.prompt,
875
+ });
876
+ stateChanged = true;
877
+ result.sent.push(wake);
878
+ deadlineState[key] = firstMiss;
879
+ }
880
+ else {
881
+ deadlineState[key] = firstMiss;
882
+ }
883
+ }
884
+ else {
885
+ const dueChanged = prior.due_at !== commitment.due || prior.owner !== commitment.owner;
886
+ const reMissed = !prior.currently_missed;
887
+ const firstMissAt = Date.parse(prior.first_missed_at);
888
+ if (!Number.isFinite(firstMissAt)) {
889
+ throw new LanesError("lead_autopilot_state_invalid", `Invalid first missed-deadline time for ${commitment.item}`, { lead_lane: lead.lane_id, item: commitment.item });
890
+ }
891
+ const stillLateAgain = now.getTime() - firstMissAt >= escalationDelay;
892
+ const secondMiss = !prior.escalated_at && (dueChanged || reMissed || stillLateAgain);
893
+ const next = {
894
+ ...prior,
895
+ item: commitment.item,
896
+ owner: commitment.owner,
897
+ due_at: commitment.due,
898
+ miss_count: secondMiss ? prior.miss_count + 1 : prior.miss_count,
899
+ currently_missed: true,
900
+ ...(secondMiss ? { escalated_at: now.toISOString() } : {}),
901
+ };
902
+ if (secondMiss) {
903
+ const fingerprint = `missed-deadline:${commitment.key}:${commitment.owner}:${commitment.due}:miss-${next.miss_count}`;
904
+ const escalation = {
905
+ team: registered.team,
906
+ lead_lane: lead.lane_id,
907
+ condition: "missed_deadline",
908
+ story_id: commitment.item,
909
+ at: now.toISOString(),
910
+ fingerprint,
911
+ owner: commitment.owner,
912
+ due: commitment.due,
913
+ miss_count: next.miss_count,
914
+ };
915
+ if (!input.dryRun)
916
+ recordOperatorEscalation(hqRoot, escalation);
917
+ if (!input.dryRun)
918
+ stateChanged = true;
919
+ result.escalated.push(escalation);
920
+ }
921
+ if (!input.dryRun && JSON.stringify(prior) !== JSON.stringify(next))
922
+ stateChanged = true;
923
+ deadlineState[key] = next;
924
+ }
925
+ const current = deadlineState[key] ?? prior;
926
+ result.missed_deadlines.push({ lead_lane: lead.lane_id, item: commitment.item, owner: commitment.owner, due: commitment.due, miss_count: current?.miss_count ?? 1 });
927
+ }
928
+ }
581
929
  if (!input.dryRun && stateChanged)
582
930
  writeState(stateFile, state);
583
931
  result.evaluated += 1;
@@ -1,5 +1,13 @@
1
- /** hq-flags keys used by lane list and placement behavior. */
1
+ /** hq-flags keys used by lane command behavior. */
2
2
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
3
  /** Enables one automatic continuation after a missing-envelope repair. */
4
4
  export declare const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
5
+ /** Enables missed-deadline lead nudges and second-miss operator escalation. */
6
+ export declare const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
7
+ /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
8
+ export declare const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
9
+ export declare const LANE_MESSAGE_ROUTE_OWNER_HOST_DEFAULT_ENABLED = false;
10
+ /** Enables stale manager-status KPI reporting and one-time lead nudges. */
11
+ export declare const LEAD_AUTOPILOT_KPI_STALENESS_FLAG = "lanes.autopilot-kpi-staleness";
12
+ export declare const LEAD_AUTOPILOT_KPI_STALENESS_DEFAULT_ENABLED = false;
5
13
  //# sourceMappingURL=flag-keys.d.ts.map
@@ -1,5 +1,13 @@
1
- /** hq-flags keys used by lane list and placement behavior. */
1
+ /** hq-flags keys used by lane command behavior. */
2
2
  export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
3
  /** Enables one automatic continuation after a missing-envelope repair. */
4
4
  export const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
5
+ /** Enables missed-deadline lead nudges and second-miss operator escalation. */
6
+ export const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
7
+ /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
8
+ export const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
9
+ export const LANE_MESSAGE_ROUTE_OWNER_HOST_DEFAULT_ENABLED = false;
10
+ /** Enables stale manager-status KPI reporting and one-time lead nudges. */
11
+ export const LEAD_AUTOPILOT_KPI_STALENESS_FLAG = "lanes.autopilot-kpi-staleness";
12
+ export const LEAD_AUTOPILOT_KPI_STALENESS_DEFAULT_ENABLED = false;
5
13
  //# sourceMappingURL=flag-keys.js.map
@@ -7,6 +7,8 @@ export declare const LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_DEFAULT_ENABLED = false
7
7
  export declare const LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_FLAG = "lanes.keep-alive-immediate-first-resume";
8
8
  export declare const LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_DEFAULT_ENABLED = false;
9
9
  export declare const LEAD_LANE_KEEP_ALIVE_FIRST_RESUME_SETTLE_MS = 30000;
10
+ export declare const LEAD_LANE_KEEP_ALIVE_TERMINAL_TRIGGER_FLAG = "lanes.keep-alive-terminal-trigger";
11
+ export declare const LEAD_LANE_KEEP_ALIVE_TERMINAL_TRIGGER_DEFAULT_ENABLED = false;
10
12
  export declare const LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS: number;
11
13
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
12
14
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
@@ -12,6 +12,8 @@ export const LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_DEFAULT_ENABLED = false;
12
12
  export const LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_FLAG = "lanes.keep-alive-immediate-first-resume";
13
13
  export const LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_DEFAULT_ENABLED = false;
14
14
  export const LEAD_LANE_KEEP_ALIVE_FIRST_RESUME_SETTLE_MS = 30_000;
15
+ export const LEAD_LANE_KEEP_ALIVE_TERMINAL_TRIGGER_FLAG = "lanes.keep-alive-terminal-trigger";
16
+ export const LEAD_LANE_KEEP_ALIVE_TERMINAL_TRIGGER_DEFAULT_ENABLED = false;
15
17
  export const LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS = 15 * 60 * 1000;
16
18
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
17
19
  export const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
@@ -95,6 +95,11 @@ export interface ReconcileDeps {
95
95
  mesh?: LaneMesh | null;
96
96
  /** Bound on one terminal mesh emit during dead-host recovery. Default 10 s. */
97
97
  meshEmitTimeoutMs?: number;
98
+ /** Restrict keep-alive execution to the terminal-triggered lane version. */
99
+ terminalKeepAliveCandidate?: {
100
+ laneId: string;
101
+ version: number;
102
+ };
98
103
  /** Fresh keep-alive eligibility read; injected to exercise opt-in changes before queueing. */
99
104
  readKeepAliveRecheckLane?: (hqRoot: string, laneId: string) => LaneRecord;
100
105
  /** Reuse the command's Claude resume selection and session-move path for keep-alive. */
@@ -895,6 +895,9 @@ export async function runPassBody(hqRoot, deps) {
895
895
  markError(summary, err);
896
896
  return summary;
897
897
  }
898
+ if (deps.terminalKeepAliveCandidate) {
899
+ return runTerminalKeepAliveCandidatePass(hqRoot, lanes, summary, deps, now, deps.terminalKeepAliveCandidate);
900
+ }
898
901
  let presenceConnected;
899
902
  try {
900
903
  presenceConnected =
@@ -1159,7 +1162,7 @@ export async function runPassBody(hqRoot, deps) {
1159
1162
  const freshLanes = loadLaneRecords(hqRoot, summary);
1160
1163
  const freshQuestions = loadQuestionRecords(hqRoot, summary);
1161
1164
  await closeInterruptedDoneStoryWorkers(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, storyCloseEnabledByCompany);
1162
- await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany);
1165
+ await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany, deps.terminalKeepAliveCandidate);
1163
1166
  const drainLanes = loadLaneRecords(hqRoot, summary);
1164
1167
  await admitQueued(hqRoot, drainLanes, summary, admissionDeps, now);
1165
1168
  await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, admissionDeps);
@@ -1635,13 +1638,15 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1635
1638
  }
1636
1639
  }
1637
1640
  }
1638
- async function admitQueued(hqRoot, lanes, summary, deps, now) {
1639
- expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now);
1641
+ async function admitQueued(hqRoot, lanes, summary, deps, now, onlyLaneIds) {
1642
+ if (onlyLaneIds === undefined)
1643
+ expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now);
1640
1644
  // A lane whose pickup probe is still out is not admitted: its record is
1641
1645
  // about to be bound or failed by the pickup, and a worker spawned now
1642
1646
  // would keep running behind that write.
1643
1647
  const localQueued = lanes
1644
1648
  .filter((lane) => lane.state === "queued" &&
1649
+ (onlyLaneIds === undefined || onlyLaneIds.has(lane.lane_id)) &&
1645
1650
  lane.pickup_validation_pending !== true &&
1646
1651
  laneRunsHere(lane, deps.host))
1647
1652
  .filter((lane) => !hasFreshAdmissionReservation(lane, now.getTime()))
@@ -2238,7 +2243,7 @@ function appendBlockedKeepAliveHistory(hqRoot, lane, terminalAt, at, reason, dep
2238
2243
  }),
2239
2244
  }), false);
2240
2245
  }
2241
- async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany) {
2246
+ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany, terminalKeepAliveCandidate) {
2242
2247
  const nowMs = now.getTime();
2243
2248
  const nowIso = now.toISOString();
2244
2249
  const config = loadLanesConfig(hqRoot);
@@ -2262,6 +2267,10 @@ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, fla
2262
2267
  }
2263
2268
  }
2264
2269
  for (const originalLane of lanes) {
2270
+ if (terminalKeepAliveCandidate && (originalLane.lane_id !== terminalKeepAliveCandidate.laneId ||
2271
+ originalLane.version !== terminalKeepAliveCandidate.version ||
2272
+ originalLane.state !== "done"))
2273
+ continue;
2265
2274
  if (!laneRunsHere(originalLane, deps.host))
2266
2275
  continue;
2267
2276
  if (originalLane.state === "killed" ||
@@ -3082,6 +3091,50 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
3082
3091
  export async function runReconcilePass(hqRoot, deps) {
3083
3092
  return withReconcileLock(hqRoot, async () => runPassBody(hqRoot, deps));
3084
3093
  }
3094
+ async function runTerminalKeepAliveCandidatePass(hqRoot, lanes, summary, deps, now, candidate) {
3095
+ const lane = lanes.find((row) => row.lane_id === candidate.laneId);
3096
+ if (!lane || lane.version !== candidate.version || lane.state !== "done" ||
3097
+ lane.keep_alive !== true || !laneRunsHere(lane, deps.host))
3098
+ return summary;
3099
+ let company;
3100
+ try {
3101
+ company = resolveLaneCompany(hqRoot, lane).company;
3102
+ }
3103
+ catch (err) {
3104
+ markDirty(summary, { kind: "lane", id: lane.lane_id, why: `keep-alive company lookup failed: ${err instanceof Error ? err.message : String(err)}` });
3105
+ return summary;
3106
+ }
3107
+ if (company === null)
3108
+ return summary;
3109
+ const readFlag = deps.readRegistryFlag ?? readLanesRegistryFlag;
3110
+ const options = { company, env: deps.env ?? process.env };
3111
+ let keepAliveEnabled;
3112
+ let lineageAware;
3113
+ let immediateFirstResumeEnabled;
3114
+ try {
3115
+ [keepAliveEnabled, lineageAware, immediateFirstResumeEnabled] = await Promise.all([
3116
+ readFlag(LEAD_LANE_KEEP_ALIVE_FLAG, options),
3117
+ readFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, options),
3118
+ readFlag(LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_FLAG, options),
3119
+ ]);
3120
+ }
3121
+ catch (err) {
3122
+ markDirty(summary, { kind: "lane", id: lane.lane_id, why: `keep-alive flag read failed: ${err instanceof Error ? err.message : String(err)}` });
3123
+ return summary;
3124
+ }
3125
+ if (!keepAliveEnabled)
3126
+ return summary;
3127
+ await withAllLaneAdmissionLocks(hqRoot, async () => {
3128
+ const freshLanes = loadLaneRecords(hqRoot, summary);
3129
+ const freshQuestions = loadQuestionRecords(hqRoot, summary);
3130
+ const current = freshLanes.find((row) => row.lane_id === candidate.laneId);
3131
+ if (!current || current.version !== candidate.version || current.state !== "done")
3132
+ return;
3133
+ await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, deps, now, new Map([[keepAliveCompanyKey(company), true]]), new Map([[keepAliveCompanyKey(company), lineageAware]]), new Map(), new Map([[keepAliveCompanyKey(company), immediateFirstResumeEnabled]]), candidate);
3134
+ await admitQueued(hqRoot, loadLaneRecords(hqRoot, summary), summary, deps, now, new Set([candidate.laneId]));
3135
+ });
3136
+ return summary;
3137
+ }
3085
3138
  /** Run the reconcile answer-resume path for one lane before questions answer returns. */
3086
3139
  export async function wakeAnsweredLaneNow(hqRoot, laneId, deps) {
3087
3140
  const summary = emptySummary(deps.dryRun);