@indigoai-us/hq-cli 5.345.43 → 5.345.45

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 (54) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/command-catalog.generated.d.ts +17 -1
  3. package/dist/command-catalog.generated.js +21 -1
  4. package/dist/commands/access.js +6 -4
  5. package/dist/commands/bot.js +1 -1
  6. package/dist/commands/db-destroy.js +5 -4
  7. package/dist/commands/db-remote-deps.js +4 -0
  8. package/dist/commands/lanes-checks.d.ts +1 -0
  9. package/dist/commands/lanes-checks.js +6 -1
  10. package/dist/commands/lanes-message.js +84 -51
  11. package/dist/commands/lanes-report.d.ts +2 -1
  12. package/dist/commands/lanes-report.js +63 -6
  13. package/dist/commands/lanes-resume.js +44 -0
  14. package/dist/commands/lanes-shared.d.ts +25 -0
  15. package/dist/commands/lanes.d.ts +4 -0
  16. package/dist/commands/lanes.js +228 -9
  17. package/dist/commands/meetings.js +4 -1
  18. package/dist/index.js +43 -7
  19. package/dist/lib/core-utils/sentry-report.js +8 -1
  20. package/dist/lib/db/control-plane.d.ts +5 -0
  21. package/dist/lib/db/control-plane.js +7 -0
  22. package/dist/lib/flag-snapshot-file.js +33 -25
  23. package/dist/lib/lanes/flag-keys.d.ts +4 -0
  24. package/dist/lib/lanes/flag-keys.js +4 -0
  25. package/dist/lib/lanes/focus-guard.d.ts +65 -0
  26. package/dist/lib/lanes/focus-guard.js +91 -0
  27. package/dist/lib/lanes/grants.js +1 -1
  28. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  29. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  30. package/dist/lib/lanes/reconcile.d.ts +2 -0
  31. package/dist/lib/lanes/reconcile.js +39 -1
  32. package/dist/lib/lanes/spawn.d.ts +4 -0
  33. package/dist/lib/lanes/spawn.js +132 -6
  34. package/dist/lib/lanes/store.js +64 -0
  35. package/dist/lib/lanes/story-ledger.d.ts +14 -0
  36. package/dist/lib/lanes/story-ledger.js +46 -0
  37. package/dist/lib/lanes/tracker-report.d.ts +46 -0
  38. package/dist/lib/lanes/tracker-report.js +85 -1
  39. package/dist/lib/lanes/types.d.ts +12 -2
  40. package/dist/lib/lanes/types.js +5 -3
  41. package/dist/main.js +5 -1
  42. package/dist/startup-registration.d.ts +3 -0
  43. package/dist/startup-registration.js +11 -4
  44. package/dist/utils/package-use-lease.d.ts +1 -0
  45. package/dist/utils/package-use-lease.js +29 -0
  46. package/dist/utils/self-update-backoff.d.ts +17 -0
  47. package/dist/utils/self-update-backoff.js +73 -0
  48. package/dist/utils/self-update.d.ts +7 -0
  49. package/dist/utils/self-update.js +36 -0
  50. package/dist/utils/vault-api.d.ts +33 -1
  51. package/dist/utils/vault-api.js +55 -5
  52. package/dist/utils/version-gate.d.ts +13 -0
  53. package/dist/utils/version-gate.js +35 -5
  54. package/package.json +1 -1
@@ -392,6 +392,10 @@ export function parseLaneRecord(raw, filePath) {
392
392
  path: filePath,
393
393
  });
394
394
  }
395
+ // Company attribution was added after lane records already existed on disk.
396
+ // Treat its historical absence as the same unbound state as an explicit null.
397
+ if (raw.company === undefined)
398
+ raw.company = null;
395
399
  for (const field of REQUIRED_LANE_RECORD_FIELDS) {
396
400
  if (!(field in raw)) {
397
401
  throw new LaneSchemaError(`Missing required lane record field ${field}`, { path: filePath, field });
@@ -423,6 +427,31 @@ export function parseLaneRecord(raw, filePath) {
423
427
  requireInt(raw.version, "version");
424
428
  requireInt(raw.depth, "depth");
425
429
  requireInt(raw.priority, "priority");
430
+ if (raw.focus_overrides !== undefined) {
431
+ if (!Array.isArray(raw.focus_overrides) || raw.focus_overrides.length > 100) {
432
+ throw new LaneSchemaError("focus_overrides must be an array with at most 100 entries", {
433
+ path: filePath,
434
+ field: "focus_overrides",
435
+ });
436
+ }
437
+ for (const [index, entry] of raw.focus_overrides.entries()) {
438
+ if (!isPlainObject(entry)) {
439
+ throw new LaneSchemaError("focus_overrides entries must be objects", {
440
+ path: filePath,
441
+ field: `focus_overrides[${index}]`,
442
+ });
443
+ }
444
+ for (const field of ["reason", "by_lane", "story"]) {
445
+ if (typeof entry[field] !== "string" || !entry[field].trim()) {
446
+ throw new LaneSchemaError(`focus_overrides[${index}].${field} must be non-empty`, {
447
+ path: filePath,
448
+ field: `focus_overrides[${index}].${field}`,
449
+ });
450
+ }
451
+ }
452
+ requireTimestamp(entry.at, `focus_overrides[${index}].at`, filePath);
453
+ }
454
+ }
426
455
  if (raw.ended_at !== undefined) {
427
456
  requireTimestamp(raw.ended_at, "ended_at", filePath);
428
457
  }
@@ -442,6 +471,16 @@ export function parseLaneRecord(raw, filePath) {
442
471
  value: raw.hire_grant,
443
472
  });
444
473
  }
474
+ if (raw.released_hire_grant !== undefined &&
475
+ (typeof raw.released_hire_grant !== "number" ||
476
+ !Number.isSafeInteger(raw.released_hire_grant) ||
477
+ raw.released_hire_grant < 0)) {
478
+ throw new LaneSchemaError("Expected non-negative safe integer for released_hire_grant", {
479
+ path: filePath,
480
+ field: "released_hire_grant",
481
+ value: raw.released_hire_grant,
482
+ });
483
+ }
445
484
  if (raw.keep_alive !== undefined && typeof raw.keep_alive !== "boolean") {
446
485
  throw new LaneSchemaError("keep_alive must be a boolean", {
447
486
  path: filePath,
@@ -1603,6 +1642,31 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
1603
1642
  throw new LanesError("lane_writer_unauthorized", `Writer ${writer} cannot write lane field ${key}`, { writer, field: key });
1604
1643
  }
1605
1644
  }
1645
+ if (!isTerminalLaneState(current.state) && isTerminalLaneState(updated.state)) {
1646
+ const releasedGrant = updated.hire_grant ?? current.hire_grant ?? 0;
1647
+ return {
1648
+ ...updated,
1649
+ released_hire_grant: releasedGrant,
1650
+ hire_grant: 0,
1651
+ };
1652
+ }
1653
+ if (isTerminalLaneState(current.state) && !isTerminalLaneState(updated.state) &&
1654
+ current.released_hire_grant !== undefined) {
1655
+ return {
1656
+ ...updated,
1657
+ hire_grant: current.released_hire_grant,
1658
+ released_hire_grant: undefined,
1659
+ };
1660
+ }
1661
+ if (isTerminalLaneState(updated.state) &&
1662
+ (updated.hire_grant !== current.hire_grant ||
1663
+ updated.released_hire_grant !== current.released_hire_grant)) {
1664
+ return {
1665
+ ...updated,
1666
+ hire_grant: 0,
1667
+ released_hire_grant: updated.released_hire_grant ?? updated.hire_grant ?? 0,
1668
+ };
1669
+ }
1606
1670
  return updated;
1607
1671
  }, deps, {
1608
1672
  beforeWrite: (current) => assertLaneStoreWriteAllowed(current, laneId, writer),
@@ -24,6 +24,20 @@ export interface StoryLedgerQuestionRef {
24
24
  project_id: string;
25
25
  story_id: string;
26
26
  }
27
+ export interface ProjectFocusOverride {
28
+ reason: string;
29
+ by_lane: string;
30
+ at: string;
31
+ story: string;
32
+ }
33
+ export declare function readProjectStoryFocusData(hqRoot: string, projectId: string, companySlug?: string): {
34
+ path: string;
35
+ metadata: Record<string, unknown>;
36
+ userStories: Array<Record<string, unknown>>;
37
+ focusOverrides: ProjectFocusOverride[];
38
+ };
39
+ export declare function appendProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
40
+ export declare function removeProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
27
41
  export interface LaneStatusSnapshot {
28
42
  schema_version: "hq-lanes-status.v1";
29
43
  project_id: string;
@@ -11,6 +11,52 @@ const LOCK_RETRY_MS = 10;
11
11
  const STORY_STATES = ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
12
12
  const MANAGER_OWNED_STATES = new Set(["review", "done", "dropped"]);
13
13
  const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
14
+ function projectFocusOverrides(value) {
15
+ if (value === undefined)
16
+ return [];
17
+ if (!Array.isArray(value) || value.length > 100) {
18
+ throw new LanesError("project_focus_overrides_invalid", "Project focus_overrides must be an array with at most 100 entries", {});
19
+ }
20
+ return value.map((item, index) => {
21
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
22
+ throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} must be an object`, {});
23
+ }
24
+ const entry = item;
25
+ if (["reason", "by_lane", "at", "story"].some((key) => typeof entry[key] !== "string" || !entry[key].trim())) {
26
+ throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} is incomplete`, {});
27
+ }
28
+ if (!Number.isFinite(Date.parse(entry.at))) {
29
+ throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} has an invalid timestamp`, {});
30
+ }
31
+ return entry;
32
+ });
33
+ }
34
+ export function readProjectStoryFocusData(hqRoot, projectId, companySlug) {
35
+ const ledger = resolveProjectStoryLedger(hqRoot, projectId, companySlug);
36
+ const parsed = readLedger(ledger);
37
+ const metadata = parsed.metadata;
38
+ return {
39
+ path: ledger.path,
40
+ metadata: metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
41
+ ? metadata
42
+ : {},
43
+ userStories: parsed.userStories,
44
+ focusOverrides: projectFocusOverrides(parsed.focus_overrides),
45
+ };
46
+ }
47
+ export function appendProjectFocusOverride(hqRoot, projectId, companySlug, override) {
48
+ updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
49
+ const existing = projectFocusOverrides(current.focus_overrides);
50
+ return { ...current, focus_overrides: [...existing, override].slice(-100) };
51
+ });
52
+ }
53
+ export function removeProjectFocusOverride(hqRoot, projectId, companySlug, override) {
54
+ updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
55
+ const remaining = projectFocusOverrides(current.focus_overrides).filter((entry) => entry.reason !== override.reason || entry.by_lane !== override.by_lane ||
56
+ entry.at !== override.at || entry.story !== override.story);
57
+ return { ...current, focus_overrides: remaining };
58
+ });
59
+ }
14
60
  export function laneStoryLedgerEnabled(companyUid) {
15
61
  return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false, companyUid);
16
62
  }
@@ -12,10 +12,20 @@ interface TrackerReportStory {
12
12
  id: string;
13
13
  title: string;
14
14
  status: ReportStoryStatus;
15
+ focus_id?: unknown;
16
+ kpi?: unknown;
17
+ owner?: string | null;
18
+ deadline?: string | null;
15
19
  references?: string[];
16
20
  updated_at?: string;
17
21
  source_path: string;
18
22
  }
23
+ export interface TrackerReportFocusOverride {
24
+ reason: string;
25
+ by_lane: string;
26
+ at: string;
27
+ story: string;
28
+ }
19
29
  export interface TrackerReportIssue {
20
30
  path: string;
21
31
  field: string;
@@ -47,6 +57,7 @@ export interface TrackerReportTeamInput {
47
57
  team: string;
48
58
  project_id: string;
49
59
  stories: readonly TrackerReportStory[];
60
+ focus_overrides?: readonly TrackerReportFocusOverride[];
50
61
  pull_requests: readonly TrackerReportPullRequest[];
51
62
  releases: readonly TrackerReportRelease[];
52
63
  blockers: readonly TrackerReportRecord[];
@@ -61,8 +72,42 @@ export interface TrackerReportInput {
61
72
  generated_at: string;
62
73
  since: string;
63
74
  since_source: "explicit" | "previous_report" | "first_run";
75
+ focus_dashboard_enabled?: boolean;
64
76
  teams: readonly TrackerReportTeamInput[];
65
77
  }
78
+ interface TrackerFocusItem {
79
+ team: string;
80
+ project_id: string;
81
+ story_id: string;
82
+ title: string;
83
+ owner: string | null;
84
+ deadline: string | null;
85
+ state: ReportStoryStatus;
86
+ focus_id: unknown;
87
+ kpi: unknown;
88
+ override_reason: string | null;
89
+ }
90
+ interface TrackerTeamFocusShare {
91
+ team: string;
92
+ project_id: string;
93
+ window_start: string;
94
+ window_end: string;
95
+ work_story_count: number;
96
+ focused_story_count: number;
97
+ share_percent: number | null;
98
+ status: "red" | "ok" | "no_work";
99
+ }
100
+ interface TrackerFocusOverride extends TrackerReportFocusOverride {
101
+ team: string;
102
+ project_id: string;
103
+ }
104
+ interface TrackerFocusDashboard {
105
+ window_start: string;
106
+ window_end: string;
107
+ focus_list: TrackerFocusItem[];
108
+ team_focus_shares: TrackerTeamFocusShare[];
109
+ overrides: TrackerFocusOverride[];
110
+ }
66
111
  interface ReportTimedStory {
67
112
  id: string;
68
113
  title: string;
@@ -140,6 +185,7 @@ export interface TrackerOwnerReport {
140
185
  generated_at: string;
141
186
  since: string;
142
187
  since_source: TrackerReportInput["since_source"];
188
+ focus_dashboard?: TrackerFocusDashboard;
143
189
  teams: TrackerOwnerTeamReport[];
144
190
  }
145
191
  interface TrackerTeamKpi {
@@ -231,6 +231,73 @@ function validatedWindow(input) {
231
231
  }
232
232
  return { generatedAt, since, sinceTime, generatedAtTime };
233
233
  }
234
+ function hasFocus(story) {
235
+ const present = (value) => typeof value === "string"
236
+ ? value.trim().length > 0
237
+ : value !== undefined && value !== null;
238
+ return present(story.focus_id) || present(story.kpi);
239
+ }
240
+ function recentlyWorked(story, sinceTime, throughTime) {
241
+ if (story.updated_at === undefined)
242
+ return false;
243
+ const at = Date.parse(validTimestamp(story.updated_at, "updated_at", story.source_path, { story_id: story.id }));
244
+ return at >= sinceTime && at <= throughTime;
245
+ }
246
+ function buildFocusDashboard(teams, generatedAt, generatedAtTime) {
247
+ const windowStartTime = generatedAtTime - 24 * 60 * 60 * 1000;
248
+ const windowStart = new Date(windowStartTime).toISOString();
249
+ const focusList = [];
250
+ const teamFocusShares = [];
251
+ const overrides = [];
252
+ for (const team of teams) {
253
+ const teamOverrides = [...(team.focus_overrides ?? [])]
254
+ .filter((override) => Number.isFinite(Date.parse(override.at)) && Date.parse(override.at) <= generatedAtTime)
255
+ .sort((a, b) => a.at.localeCompare(b.at) || a.story.localeCompare(b.story));
256
+ overrides.push(...teamOverrides.map((override) => ({ ...override, team: team.team, project_id: team.project_id })));
257
+ const latestOverrideByStory = new Map();
258
+ for (const override of teamOverrides)
259
+ latestOverrideByStory.set(override.story, override.reason);
260
+ for (const story of team.stories) {
261
+ if (story.status === "dropped")
262
+ continue;
263
+ const activeOrRecentlyWorked = story.status === "in_progress" || recentlyWorked(story, windowStartTime, generatedAtTime);
264
+ const overrideReason = latestOverrideByStory.get(story.id) ?? null;
265
+ if (activeOrRecentlyWorked && (hasFocus(story) || overrideReason !== null)) {
266
+ focusList.push({
267
+ team: team.team,
268
+ project_id: team.project_id,
269
+ story_id: story.id,
270
+ title: story.title,
271
+ owner: story.owner ?? null,
272
+ deadline: story.deadline ?? null,
273
+ state: story.status,
274
+ focus_id: story.focus_id ?? null,
275
+ kpi: story.kpi ?? null,
276
+ override_reason: overrideReason,
277
+ });
278
+ }
279
+ }
280
+ const workStories = team.stories.filter((story) => story.status === "in_progress" || (story.status !== "dropped" && recentlyWorked(story, windowStartTime, generatedAtTime)));
281
+ const focusedStoryCount = workStories.filter(hasFocus).length;
282
+ const sharePercent = workStories.length === 0
283
+ ? null
284
+ : Math.round((focusedStoryCount / workStories.length) * 1000) / 10;
285
+ teamFocusShares.push({
286
+ team: team.team,
287
+ project_id: team.project_id,
288
+ window_start: windowStart,
289
+ window_end: generatedAt,
290
+ work_story_count: workStories.length,
291
+ focused_story_count: focusedStoryCount,
292
+ share_percent: sharePercent,
293
+ status: sharePercent === null ? "no_work" : focusedStoryCount / workStories.length < 0.8 ? "red" : "ok",
294
+ });
295
+ }
296
+ focusList.sort((a, b) => (a.deadline ?? "\uffff").localeCompare(b.deadline ?? "\uffff") || a.team.localeCompare(b.team) || a.story_id.localeCompare(b.story_id));
297
+ teamFocusShares.sort((a, b) => a.team.localeCompare(b.team));
298
+ overrides.sort((a, b) => a.at.localeCompare(b.at) || a.team.localeCompare(b.team) || a.story.localeCompare(b.story));
299
+ return { window_start: windowStart, window_end: generatedAt, focus_list: focusList, team_focus_shares: teamFocusShares, overrides };
300
+ }
234
301
  function removeInvalidEntity(team, err) {
235
302
  if (!(err instanceof LanesError))
236
303
  return null;
@@ -316,7 +383,7 @@ export function buildTrackerOwnerReport(input) {
316
383
  }
317
384
  seen.add(team.team);
318
385
  }
319
- return {
386
+ const report = {
320
387
  schema_version: "hq-lanes-owner-report.v1",
321
388
  company: input.company,
322
389
  generated_at: window.generatedAt,
@@ -326,6 +393,10 @@ export function buildTrackerOwnerReport(input) {
326
393
  .map((team) => withRecoverableTeamErrors(team, (safeTeam) => teamOwnerReport(safeTeam, input.company, window.sinceTime, window.generatedAtTime)))
327
394
  .sort((a, b) => a.team.localeCompare(b.team)),
328
395
  };
396
+ if (input.focus_dashboard_enabled === true) {
397
+ report.focus_dashboard = buildFocusDashboard(eligibleTeams, window.generatedAt, window.generatedAtTime);
398
+ }
399
+ return report;
329
400
  }
330
401
  function teamKpis(team, company, throughTime) {
331
402
  return team.kpis
@@ -460,6 +531,19 @@ export function renderTrackerOwnerReportText(report) {
460
531
  `Owner report for ${report.company}`,
461
532
  `Reporting window: ${report.since} through ${report.generated_at} (${report.since_source.replaceAll("_", " ")})`,
462
533
  ];
534
+ if (report.focus_dashboard) {
535
+ lines.push("", `Focus dashboard (last 24h: ${report.focus_dashboard.window_start} through ${report.focus_dashboard.window_end})`, "Focus list:");
536
+ lines.push(...listOrNone(report.focus_dashboard.focus_list, (item) => `${item.team}/${item.story_id} — ${item.title}; owner: ${item.owner ?? "unknown"}; deadline: ${item.deadline ?? "none"}; state: ${item.state}${item.override_reason ? `; override: ${item.override_reason}` : ""}`));
537
+ lines.push("Team focus share:");
538
+ lines.push(...listOrNone(report.focus_dashboard.team_focus_shares, (share) => {
539
+ if (share.share_percent === null)
540
+ return `${share.team}: no in-progress or worked stories`;
541
+ const color = share.status === "red" ? "RED under 80%" : "OK";
542
+ return `${share.team}: ${share.share_percent}% (${share.focused_story_count}/${share.work_story_count}); ${color}`;
543
+ }));
544
+ lines.push("Focus guard overrides:");
545
+ lines.push(...listOrNone(report.focus_dashboard.overrides, (override) => `${override.team}/${override.story} by ${override.by_lane} (${override.at}): ${override.reason}`));
546
+ }
463
547
  for (const team of report.teams) {
464
548
  lines.push("", `${team.team} (${team.project_id})`, "Progress:", " Stories moved to done:");
465
549
  lines.push(...listOrNone(team.progress.stories_moved_to_done, (story) => `${story.id} — ${story.title} (${story.at})${story.references.length ? `; references: ${story.references.join(", ")}` : ""}`));
@@ -525,6 +525,13 @@ export interface LaneRecord {
525
525
  story_id: string;
526
526
  /** Explicit reason a lane was created without a project story. */
527
527
  no_story_reason?: string;
528
+ /** Human-authored focus guard overrides used to launch this lane onto a story. */
529
+ focus_overrides?: Array<{
530
+ reason: string;
531
+ by_lane: string;
532
+ at: string;
533
+ story: string;
534
+ }>;
528
535
  /** Last failed story-ledger synchronization, cleared after a successful retry. */
529
536
  ledger_sync_error?: string;
530
537
  /** Pending question ids owned by this lane. */
@@ -584,6 +591,8 @@ export interface LaneRecord {
584
591
  keep_alive_exhausted_at?: string;
585
592
  /** Maximum number of direct child lanes this lane may keep alive; old records default to 0. */
586
593
  hire_grant?: number;
594
+ /** Hire grant returned to the senior while this terminal lane is inactive. */
595
+ released_hire_grant?: number;
587
596
  /** Content hash of the profile last applied to this lane. */
588
597
  worker_profile_hash?: string;
589
598
  /** Registry persona selected by the profile, pinned at lane creation. */
@@ -756,8 +765,9 @@ export interface LaneRecord {
756
765
  /** Last successful lane_progress mesh delivery, preserved across watcher restarts. */
757
766
  last_progress_event_at?: string;
758
767
  }
759
- /** The v1 fields that must exist on every durable lane record (spec §3). */
760
- export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "company", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
768
+ /** The v1 fields that must exist on every durable lane record (spec §3).
769
+ * `company` is normalized from absence for pre-attribution legacy records. */
770
+ export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
761
771
  export declare const LANE_WRITERS: readonly ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
762
772
  export type LaneWriter = (typeof LANE_WRITERS)[number];
763
773
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
@@ -215,9 +215,10 @@ export function parseSeniorKind(value) {
215
215
  return value;
216
216
  throw new LanesError("unknown_senior_kind", `Unknown senior kind ${JSON.stringify(value)}. Accepted: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value });
217
217
  }
218
- /** The v1 fields that must exist on every durable lane record (spec §3). */
218
+ /** The v1 fields that must exist on every durable lane record (spec §3).
219
+ * `company` is normalized from absence for pre-attribution legacy records. */
219
220
  export const REQUIRED_LANE_RECORD_FIELDS = [
220
- "schema_version", "lane_id", "company", "project_id", "story_id", "depth",
221
+ "schema_version", "lane_id", "project_id", "story_id", "depth",
221
222
  "senior", "senior_history", "state", "priority", "provider", "host",
222
223
  "version", "created_at", "updated_at", "attribution_history",
223
224
  ];
@@ -225,9 +226,10 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
225
226
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
226
227
  export const LANE_FIELD_WRITERS = {
227
228
  schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator", "command"], company_history: ["command"],
228
- project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
229
+ project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], focus_overrides: ["command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
229
230
  depth: ["command"], senior: ["command"], senior_history: ["command"],
230
231
  hire_grant: ["creator", "command"],
232
+ released_hire_grant: ["command"],
231
233
  state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_details: ["command", "reconcile", "spawn", "watcher"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
232
234
  pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
233
235
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
package/dist/main.js CHANGED
@@ -43,7 +43,7 @@ import { isCompanySelectionError } from "./utils/company-selection-error.js";
43
43
  import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
44
44
  import { upgradeToTeam } from "./utils/team-upgrade.js";
45
45
  import { printCachedVersionNudge, printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
46
- import { enforceVersionGate, isLocalDependencyInstall, resolveRunningInstall, shouldSkipGate, } from "./utils/version-gate.js";
46
+ import { enforceVersionGate, getVersionGatePhase, isLocalDependencyInstall, resolveRunningInstall, shouldSkipGate, } from "./utils/version-gate.js";
47
47
  import { autoUpdateAndReexec } from "./utils/self-update.js";
48
48
  import { CLI_VERSION } from "./cli-version.js";
49
49
  import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
@@ -262,6 +262,10 @@ export async function runCli() {
262
262
  env: process.env,
263
263
  entryPath: RUNNING_ENTRY_PATH,
264
264
  stderr: process.stderr,
265
+ deps: {
266
+ resolveInstall: resolveRunningInstall,
267
+ versionGatePhase: getVersionGatePhase,
268
+ },
265
269
  });
266
270
  if (registration.reexecStatus !== undefined) {
267
271
  // The re-exec'd child ran the command on the settled tree; carry its exit
@@ -16,6 +16,7 @@
16
16
  * style of handleTopLevelError so the whole seam is unit-testable without real
17
17
  * spawns, waits, or a real install tree.
18
18
  */
19
+ import type { VersionGatePhase } from "./utils/version-gate.js";
19
20
  import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
20
21
  /**
21
22
  * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
@@ -74,6 +75,8 @@ export interface RegisterRecoveryDeps {
74
75
  waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
75
76
  /** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
76
77
  rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
78
+ /** Current version-gate phase for diagnostics; defaults to unknown for early entry recovery. */
79
+ versionGatePhase?: () => VersionGatePhase;
77
80
  /** node flags to forward to the re-exec child (defaults to process.execArgv). */
78
81
  execArgv?: readonly string[];
79
82
  }
@@ -17,7 +17,6 @@
17
17
  * spawns, waits, or a real install tree.
18
18
  */
19
19
  import { spawnSync } from "node:child_process";
20
- import { getVersionGatePhase, resolveRunningInstall } from "./utils/version-gate.js";
21
20
  import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
22
21
  import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
23
22
  import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
@@ -122,7 +121,7 @@ export async function registerCommandsWithRecovery(args) {
122
121
  cause: err,
123
122
  classified,
124
123
  packageRoot,
125
- versionGatePhase: getVersionGatePhase(),
124
+ versionGatePhase: readVersionGatePhase(deps),
126
125
  outcome: "guarded",
127
126
  treeState: env[INSTALL_TREE_RECOVERY_TREE_STATE_ENV] === "stale" ? "stale" : "in_flight",
128
127
  attempt: 2,
@@ -193,7 +192,7 @@ export async function registerCommandsWithRecovery(args) {
193
192
  cause: err,
194
193
  classified,
195
194
  packageRoot,
196
- versionGatePhase: getVersionGatePhase(),
195
+ versionGatePhase: readVersionGatePhase(deps),
197
196
  outcome: "reexec-failed",
198
197
  treeState,
199
198
  attempt: 1,
@@ -225,11 +224,19 @@ export async function registerCommandsWithRecovery(args) {
225
224
  function resolvePackageRoot(deps) {
226
225
  let root;
227
226
  try {
228
- root = (deps.resolveInstall ?? resolveRunningInstall)().packageRoot;
227
+ root = deps.resolveInstall?.().packageRoot ?? null;
229
228
  }
230
229
  catch {
231
230
  root = null;
232
231
  }
233
232
  return root ?? (deps.deriveInstallRoot ?? stringDerivedPackageRoot)();
234
233
  }
234
+ function readVersionGatePhase(deps) {
235
+ try {
236
+ return deps.versionGatePhase?.() ?? "unknown";
237
+ }
238
+ catch {
239
+ return "unknown";
240
+ }
241
+ }
235
242
  //# sourceMappingURL=startup-registration.js.map
@@ -48,6 +48,7 @@ export declare function currentPackageUseUpdatePending(): boolean;
48
48
  export declare function currentPackageUseUpdateRequested(): boolean;
49
49
  export declare const __test__: {
50
50
  waitForUpdateRequest: typeof waitForUpdateRequest;
51
+ setPackageUseLeaseHeartbeatIntervalMs(intervalMs: number): void;
51
52
  setCurrentPackageUseLease(lease: PackageUseLease | null): void;
52
53
  };
53
54
  /**
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { utimes } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -8,6 +9,8 @@ const CLI_NAME = "@indigoai-us/hq-cli";
8
9
  const UPDATE_REQUEST = "update.pending.json";
9
10
  export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
10
11
  const HANDOFF_TIMEOUT_MS = 2_000;
12
+ const PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
13
+ let packageUseLeaseHeartbeatIntervalMs = PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS;
11
14
  export const PACKAGE_USE_LEASE_PURPOSES = [
12
15
  "command",
13
16
  "resident-preload",
@@ -180,10 +183,32 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
180
183
  const leasePath = path.join(directory, `${record.pid}-${record.start_time_ms}.json`);
181
184
  const tempPath = `${leasePath}.${Math.random().toString(36).slice(2)}.tmp`;
182
185
  let released = false;
186
+ let heartbeatInFlight = false;
187
+ let heartbeat = null;
188
+ const startHeartbeat = () => {
189
+ heartbeat = setInterval(() => {
190
+ if (released || heartbeatInFlight)
191
+ return;
192
+ heartbeatInFlight = true;
193
+ const now = new Date();
194
+ void utimes(leasePath, now, now)
195
+ .catch((error) => {
196
+ if (!released) {
197
+ process.stderr.write(`hq-cli package-use lease heartbeat failed (${errorLabel(error)})\n`);
198
+ }
199
+ })
200
+ .finally(() => {
201
+ heartbeatInFlight = false;
202
+ });
203
+ }, packageUseLeaseHeartbeatIntervalMs);
204
+ heartbeat.unref();
205
+ };
183
206
  const release = () => {
184
207
  if (released)
185
208
  return;
186
209
  released = true;
210
+ if (heartbeat)
211
+ clearInterval(heartbeat);
187
212
  process.removeListener("exit", releaseOnExit);
188
213
  try {
189
214
  rmSync(leasePath, { force: true });
@@ -204,6 +229,7 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
204
229
  throw new Error(`Unable to publish the HQ CLI package-use lease (${errorLabel(error)})`, { cause: error });
205
230
  }
206
231
  if (!exists(requestPath)) {
232
+ startHeartbeat();
207
233
  process.once("exit", releaseOnExit);
208
234
  return { leasePath, release };
209
235
  }
@@ -409,6 +435,9 @@ export function currentPackageUseUpdateRequested() {
409
435
  }
410
436
  export const __test__ = {
411
437
  waitForUpdateRequest,
438
+ setPackageUseLeaseHeartbeatIntervalMs(intervalMs) {
439
+ packageUseLeaseHeartbeatIntervalMs = intervalMs;
440
+ },
412
441
  setCurrentPackageUseLease(lease) {
413
442
  currentPackageUseLease = lease;
414
443
  },
@@ -0,0 +1,17 @@
1
+ export declare const SELF_UPDATE_FAILURE_BACKOFF_MS: number;
2
+ export interface SelfUpdateFailureRecord {
3
+ /** Version the failed attempt tried to install. */
4
+ target: string;
5
+ /** Epoch milliseconds of the failed attempt. */
6
+ failedAt: number;
7
+ }
8
+ export interface SelfUpdateBackoff {
9
+ /** The recorded failure when it is still inside the backoff window, else null. */
10
+ active(now?: number): SelfUpdateFailureRecord | null;
11
+ record(target: string, now?: number): void;
12
+ clear(): void;
13
+ }
14
+ export declare function selfUpdateBackoffPath(homeDir?: string): string;
15
+ /** File-backed backoff under `~/.hq`. Every operation is best-effort. */
16
+ export declare function fileSelfUpdateBackoff(homeDir?: string): SelfUpdateBackoff;
17
+ //# sourceMappingURL=self-update-backoff.d.ts.map