@indigoai-us/hq-cli 5.345.183 → 5.345.187

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/command-catalog.generated.d.ts +29 -0
  3. package/dist/command-catalog.generated.js +35 -0
  4. package/dist/commands/bot.d.ts +5 -5
  5. package/dist/commands/bot.js +30 -31
  6. package/dist/commands/sources.d.ts +37 -0
  7. package/dist/commands/sources.js +104 -1
  8. package/dist/lib/anywhere-person-setting.js +24 -10
  9. package/dist/lib/bot/fleet-local.d.ts +4 -2
  10. package/dist/lib/bot/fleet-local.js +23 -7
  11. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -7
  12. package/dist/lib/bot/jobs/hot-signal-sync.js +106 -9
  13. package/dist/lib/bot/local-jobs.d.ts +4 -2
  14. package/dist/lib/bot/local-jobs.js +33 -4
  15. package/dist/lib/bot/run.js +2 -2
  16. package/dist/lib/hot-channels/adapters/attio.js +3 -0
  17. package/dist/lib/hot-channels/adapters/linear.js +4 -0
  18. package/dist/lib/hot-channels/adapters/meetings.js +4 -0
  19. package/dist/lib/hot-channels/adapters/slack.js +3 -0
  20. package/dist/lib/hot-channels/contract.d.ts +7 -0
  21. package/dist/lib/hot-channels/ledger.d.ts +8 -0
  22. package/dist/lib/hot-channels/ledger.js +6 -2
  23. package/dist/lib/hot-channels/loop.d.ts +6 -0
  24. package/dist/lib/hot-channels/loop.js +16 -2
  25. package/dist/lib/hot-channels/person-vault-reader.d.ts +41 -0
  26. package/dist/lib/hot-channels/person-vault-reader.js +59 -0
  27. package/dist/lib/hot-channels/run.d.ts +8 -0
  28. package/dist/lib/hot-channels/run.js +19 -0
  29. package/dist/lib/hot-channels/schedule.d.ts +64 -0
  30. package/dist/lib/hot-channels/schedule.js +162 -0
  31. package/dist/lib/hot-channels/selection.d.ts +87 -0
  32. package/dist/lib/hot-channels/selection.js +175 -0
  33. package/dist/utils/shipped-codex-hooks.js +1 -1
  34. package/dist/utils/version-report.d.ts +1 -1
  35. package/dist/utils/version-report.js +20 -7
  36. package/package.json +7 -7
@@ -15,6 +15,7 @@
15
15
  import type { CatchUpWindow, HotTransport, SuggestionRule, TokenScope } from "./contract.js";
16
16
  import { type RunLedgerEntry, type RunTrigger, type SignalsGeneratedFn } from "./ledger.js";
17
17
  import type { HotChannelRegistry } from "./registry.js";
18
+ import { type SelectionLoader } from "./selection.js";
18
19
  /** One item of the catalog API's top-level `hotChannels` array (hq-pro US-002). */
19
20
  export interface HotChannelCatalogItem {
20
21
  entryId?: string;
@@ -96,10 +97,17 @@ export interface OnDemandSyncInput {
96
97
  trigger?: RunTrigger;
97
98
  /** A scheduled run that fired late, after a missed window. */
98
99
  catchUp?: boolean;
100
+ /** The first run after the bot registered its hot-signal-sync job. */
101
+ firstRun?: boolean;
99
102
  /** The signals pass over each adapter's landed sources (US-011); 0 when absent. */
100
103
  signalsGenerated?: SignalsGeneratedFn;
101
104
  /** Checked before each document write (sync-lock ownership); a throw stops the run. */
102
105
  beforeWrite?: () => Promise<void>;
106
+ /**
107
+ * The person's saved hot-channel selections for this run (one loader per
108
+ * run, so nothing is cached across runs). Absent = land everything.
109
+ */
110
+ selections?: SelectionLoader;
103
111
  }
104
112
  export type SyncOutcomeStatus = "ok" | "failed" | "unavailable" | "no-connection" | "gated";
105
113
  export interface SyncOutcome {
@@ -18,6 +18,7 @@ import { bareProvider } from "../integrations/provider-slug.js";
18
18
  import { createVaultCursorStore } from "./cursors.js";
19
19
  import { cursorFilePath } from "./cursors.js";
20
20
  import { runHotChannelSync } from "./ledger.js";
21
+ import { connectionSelection, meetingsSelection } from "./selection.js";
21
22
  /** Token-scope providers served from the HQ vault: no external connection. */
22
23
  /**
23
24
  * The bare, lowercased provider id used to match a connection against a
@@ -174,6 +175,22 @@ function sourcesOnlyCursorStore(vaultRoot, now) {
174
175
  },
175
176
  };
176
177
  }
178
+ /**
179
+ * The saved selection for one planned entry: the entry's own connection (an
180
+ * empty list counts as no choice), or for a connectionless channel with companions (meetings) the include/exclude
181
+ * choice saved on its recorder connections.
182
+ */
183
+ function selectionFor(load) {
184
+ return async (entry) => {
185
+ // An empty saved list on a connection means no choice yet: land everything.
186
+ if (entry.connectionId)
187
+ return connectionSelection(await load(entry.connectionId));
188
+ const companions = entry.companionConnectionIds ?? [];
189
+ if (companions.length === 0)
190
+ return undefined;
191
+ return meetingsSelection(await Promise.all(companions.map((id) => load(id))));
192
+ };
193
+ }
177
194
  export async function runOnDemandSync(input) {
178
195
  const report = {
179
196
  runId: null,
@@ -276,6 +293,7 @@ export async function runOnDemandSync(input) {
276
293
  const sync = await runHotChannelSync({
277
294
  trigger: input.trigger ?? "on-demand",
278
295
  ...(input.catchUp === undefined ? {} : { catchUp: input.catchUp }),
296
+ ...(input.firstRun ? { firstRun: true } : {}),
279
297
  ...(input.signalsGenerated ? { signalsGenerated: input.signalsGenerated } : {}),
280
298
  ...(input.beforeWrite ? { beforeWrite: input.beforeWrite } : {}),
281
299
  registry: input.registry,
@@ -284,6 +302,7 @@ export async function runOnDemandSync(input) {
284
302
  personUid: input.personUid,
285
303
  personKey: input.personKey,
286
304
  cursorStore: sourcesOnlyCursorStore(input.vaultRoot, input.now),
305
+ ...(input.selections ? { selectionFor: selectionFor(input.selections) } : {}),
287
306
  gatewayFor: (entry) => {
288
307
  const app = entry.connectionId ? appsByConnection.get(entry.connectionId) : undefined;
289
308
  return app ? gatewayFor(app, input.deps, reconnects) : undefined;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `hq sources schedule`: find a company's hot-signal-sync job across the
3
+ * local bots, show when it runs and how each adapter's last run went, and
4
+ * reschedule it to a daily time.
5
+ *
6
+ * Jobs live in each bot's `jobs.json` (lib/bot/local-jobs.ts). Reads go
7
+ * straight to the file; the reschedule goes through `rescheduleLocalJob`,
8
+ * which takes the job list's lock.
9
+ */
10
+ import { type LocalJob } from "../bot/local-jobs.js";
11
+ /** How many UTC days of the run ledger `lastRuns` looks back over, today included. */
12
+ export declare const SCHEDULE_LOOKBACK_DAYS = 7;
13
+ export interface ScheduleJobView {
14
+ botName: string;
15
+ jobId: string;
16
+ cron: string;
17
+ timezone: string;
18
+ dailyAt: string | null;
19
+ nextRunAt: string | null;
20
+ lastRunAt: string | null;
21
+ }
22
+ export interface ScheduleLastRun {
23
+ adapter: string;
24
+ finishedAt: string;
25
+ ok: boolean;
26
+ sourcesLanded: number;
27
+ signalsGenerated: number | null;
28
+ }
29
+ export interface ScheduleReport {
30
+ company: string;
31
+ job: ScheduleJobView | null;
32
+ lastRuns: ScheduleLastRun[];
33
+ }
34
+ export interface FoundHotSignalSyncJob {
35
+ botName: string;
36
+ dir: string;
37
+ job: LocalJob;
38
+ }
39
+ /** Parse `HH:MM` (24-hour). Throws a plain error on anything else. */
40
+ export declare function parseDailyAt(at: string): {
41
+ hour: number;
42
+ minute: number;
43
+ };
44
+ /** The stored EventBridge form of "every day at H:M". */
45
+ export declare function dailyCron(hour: number, minute: number): string;
46
+ /** `HH:MM` when the cron fires once a day at a fixed time; null for any other schedule. */
47
+ export declare function dailyAtOf(cron: string): string | null;
48
+ /** Every local bot's hot-signal-sync job for `company`, ordered by bot name. */
49
+ export declare function findHotSignalSyncJobs(botsRoot: string, company: string): FoundHotSignalSyncJob[];
50
+ export declare function toJobView(found: FoundHotSignalSyncJob, now: Date): ScheduleJobView;
51
+ /**
52
+ * Reschedule each found job to daily at `at`, keeping its time zone. Returns
53
+ * the jobs as stored after the change.
54
+ */
55
+ export declare function rescheduleDaily(found: readonly FoundHotSignalSyncJob[], at: string, now: Date): FoundHotSignalSyncJob[];
56
+ /**
57
+ * The newest ledger entry per adapter over the last `SCHEDULE_LOOKBACK_DAYS`
58
+ * UTC days, today included. Skipped runs (another sync held the lock) are not
59
+ * adapter runs and are left out. Sorted by adapter id.
60
+ */
61
+ export declare function lastRunsPerAdapter(vaultRoot: string, personKey: string, now: Date): Promise<ScheduleLastRun[]>;
62
+ /** Plain lines for the terminal. */
63
+ export declare function renderScheduleReport(report: ScheduleReport, extraBots?: readonly string[]): string;
64
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `hq sources schedule`: find a company's hot-signal-sync job across the
3
+ * local bots, show when it runs and how each adapter's last run went, and
4
+ * reschedule it to a daily time.
5
+ *
6
+ * Jobs live in each bot's `jobs.json` (lib/bot/local-jobs.ts). Reads go
7
+ * straight to the file; the reschedule goes through `rescheduleLocalJob`,
8
+ * which takes the job list's lock.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { hotSignalSyncJobCompany } from "../bot/jobs/hot-signal-sync.js";
13
+ import { nextDueFire, readLocalJobs, rescheduleLocalJob } from "../bot/local-jobs.js";
14
+ import { ledgerDay, readRunLedger } from "./ledger.js";
15
+ /** How many UTC days of the run ledger `lastRuns` looks back over, today included. */
16
+ export const SCHEDULE_LOOKBACK_DAYS = 7;
17
+ /** Parse `HH:MM` (24-hour). Throws a plain error on anything else. */
18
+ export function parseDailyAt(at) {
19
+ const match = /^(\d{1,2}):(\d{2})$/.exec(at.trim());
20
+ const hour = match ? Number(match[1]) : NaN;
21
+ const minute = match ? Number(match[2]) : NaN;
22
+ if (!match || hour > 23 || minute > 59) {
23
+ throw new Error(`--at must be a 24-hour time HH:MM, such as 06:30 or 18:05 (got '${at}').`);
24
+ }
25
+ return { hour, minute };
26
+ }
27
+ /** The stored EventBridge form of "every day at H:M". */
28
+ export function dailyCron(hour, minute) {
29
+ return `cron(${minute} ${hour} * * ? *)`;
30
+ }
31
+ function hhmm(hour, minute) {
32
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
33
+ }
34
+ /** `HH:MM` when the cron fires once a day at a fixed time; null for any other schedule. */
35
+ export function dailyAtOf(cron) {
36
+ const match = /^cron\((\d{1,2}) (\d{1,2}) \* \* \? \*\)$/.exec(cron.trim()) ?? /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(cron.trim());
37
+ if (!match)
38
+ return null;
39
+ const minute = Number(match[1]);
40
+ const hour = Number(match[2]);
41
+ return hour <= 23 && minute <= 59 ? hhmm(hour, minute) : null;
42
+ }
43
+ /** Every local bot's hot-signal-sync job for `company`, ordered by bot name. */
44
+ export function findHotSignalSyncJobs(botsRoot, company) {
45
+ let names;
46
+ try {
47
+ names = fs.readdirSync(botsRoot);
48
+ }
49
+ catch (err) {
50
+ if (err.code === "ENOENT")
51
+ return [];
52
+ throw err;
53
+ }
54
+ const found = [];
55
+ for (const botName of names.sort()) {
56
+ const dir = path.join(botsRoot, botName);
57
+ let isDir;
58
+ try {
59
+ isDir = fs.statSync(dir).isDirectory();
60
+ }
61
+ catch {
62
+ // Gone between readdir and stat: not a bot.
63
+ isDir = false;
64
+ }
65
+ if (!isDir)
66
+ continue;
67
+ for (const job of readLocalJobs(dir)) {
68
+ if (hotSignalSyncJobCompany(job.context) === company)
69
+ found.push({ botName, dir, job });
70
+ }
71
+ }
72
+ return found;
73
+ }
74
+ export function toJobView(found, now) {
75
+ const next = nextDueFire(found.job, now);
76
+ return {
77
+ botName: found.botName,
78
+ jobId: found.job.jobId,
79
+ cron: found.job.cron,
80
+ timezone: found.job.timezone,
81
+ dailyAt: dailyAtOf(found.job.cron),
82
+ nextRunAt: next ? next.toISOString() : null,
83
+ lastRunAt: found.job.lastRunAt ?? null,
84
+ };
85
+ }
86
+ /**
87
+ * Reschedule each found job to daily at `at`, keeping its time zone. Returns
88
+ * the jobs as stored after the change.
89
+ */
90
+ export function rescheduleDaily(found, at, now) {
91
+ const { hour, minute } = parseDailyAt(at);
92
+ const cron = dailyCron(hour, minute);
93
+ const out = [];
94
+ for (const f of found) {
95
+ // The time zone is read under the job-list lock, not from the earlier
96
+ // read, so a concurrent change to it is never overwritten.
97
+ const updated = rescheduleLocalJob(f.dir, f.job.jobId, { cron }, now);
98
+ if (!updated)
99
+ throw new Error(`The hot-signal-sync job ${f.job.jobId} on bot ${f.botName} was removed while rescheduling.`);
100
+ out.push({ ...f, job: updated });
101
+ }
102
+ return out;
103
+ }
104
+ /**
105
+ * The newest ledger entry per adapter over the last `SCHEDULE_LOOKBACK_DAYS`
106
+ * UTC days, today included. Skipped runs (another sync held the lock) are not
107
+ * adapter runs and are left out. Sorted by adapter id.
108
+ */
109
+ export async function lastRunsPerAdapter(vaultRoot, personKey, now) {
110
+ const latest = new Map();
111
+ for (let i = 0; i < SCHEDULE_LOOKBACK_DAYS; i += 1) {
112
+ const day = ledgerDay(new Date(now.getTime() - i * 24 * 60 * 60 * 1000));
113
+ const { entries } = await readRunLedger(vaultRoot, personKey, day);
114
+ for (const entry of entries) {
115
+ if (entry.status === "skipped")
116
+ continue;
117
+ const seen = latest.get(entry.adapter);
118
+ if (!seen || entry.finishedAt > seen.finishedAt)
119
+ latest.set(entry.adapter, entry);
120
+ }
121
+ }
122
+ return [...latest.values()]
123
+ .sort((a, b) => (a.adapter < b.adapter ? -1 : a.adapter > b.adapter ? 1 : 0))
124
+ .map((e) => ({
125
+ adapter: e.adapter,
126
+ finishedAt: e.finishedAt,
127
+ ok: e.status === "ok",
128
+ sourcesLanded: e.sourcesLanded,
129
+ signalsGenerated: typeof e.signalsGenerated === "number" ? e.signalsGenerated : null,
130
+ }));
131
+ }
132
+ /** Plain lines for the terminal. */
133
+ export function renderScheduleReport(report, extraBots = []) {
134
+ const lines = [];
135
+ const job = report.job;
136
+ if (!job) {
137
+ lines.push(`No hot-channel sync job for ${report.company} on any local bot.`);
138
+ }
139
+ else if (job.dailyAt) {
140
+ lines.push(`Syncs daily at ${job.dailyAt} (${job.timezone}) on bot ${job.botName}`);
141
+ }
142
+ else {
143
+ lines.push(`Syncs on schedule ${job.cron} (${job.timezone}) on bot ${job.botName}`);
144
+ }
145
+ if (job) {
146
+ if (extraBots.length > 0)
147
+ lines.push(`Also scheduled on bot ${extraBots.join(", ")}`);
148
+ lines.push(`Next run: ${job.nextRunAt ?? "not within the next week"}`);
149
+ lines.push(`Last run: ${job.lastRunAt ?? "never"}`);
150
+ }
151
+ if (report.lastRuns.length === 0) {
152
+ lines.push(`No runs logged in the last ${SCHEDULE_LOOKBACK_DAYS} days.`);
153
+ }
154
+ else {
155
+ for (const run of report.lastRuns) {
156
+ const signals = run.signalsGenerated === null ? "unknown" : String(run.signalsGenerated);
157
+ lines.push(`${run.adapter}: ${run.ok ? "ok" : "failed"} at ${run.finishedAt}, ${run.sourcesLanded} source(s), ${signals} signal(s)`);
158
+ }
159
+ }
160
+ return lines.join("\n");
161
+ }
162
+ //# sourceMappingURL=schedule.js.map
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Saved hot-channel selections (hq-pro US-014).
3
+ *
4
+ * A person can pick which channels, issues or records to watch on each
5
+ * connection. hq-pro stores the pick as
6
+ * `integrations/hot-channels/<connectionId>.json` in that person's own vault
7
+ * bucket (hq-pro `hotChannelSelectionKey`), shaped
8
+ * `{ schemaVersion: 1, connectionId, provider, hotChannelId, selected: [{ id, label, source }], updatedAt }`.
9
+ *
10
+ * The sync reads it once per connection per run. A missing file (404) means
11
+ * "no choice made": the adapter lands everything. A file that may exist but
12
+ * cannot be read (an error, a read slower than 5 seconds, malformed content,
13
+ * or a file naming another connection) fails closed: the loader throws
14
+ * `SelectionUnreadableError`, the loop skips that connection's adapter run
15
+ * and the ledger records it as failed with reason `selection-unreadable`.
16
+ * Nothing lands that the person may have excluded.
17
+ *
18
+ * An empty `selected` list on a connection's own channel (Slack, Linear,
19
+ * Attio) also means "no choice made" and lands everything: hq-pro saves `[]`
20
+ * when the person accepts defaults before any suggestions are listed, and has
21
+ * no delete, so `[]` must not silently stop the channel. Meetings keep their
22
+ * own rule (`meetingsSelection`): an empty pick on every recorder excludes them.
23
+ */
24
+ export declare const HOT_CHANNEL_SELECTION_SCHEMA_VERSION = 1;
25
+ export declare const HOT_CHANNEL_SELECTION_PREFIX = "integrations/hot-channels/";
26
+ /** Upper bound on one selection read. */
27
+ export declare const DEFAULT_SELECTION_READ_TIMEOUT_MS = 5000;
28
+ export interface HotChannelSelectionEntry {
29
+ id: string;
30
+ label: string;
31
+ source: "suggested" | "added";
32
+ }
33
+ export interface HotChannelSelectionFile {
34
+ schemaVersion: typeof HOT_CHANNEL_SELECTION_SCHEMA_VERSION;
35
+ connectionId: string;
36
+ provider: string;
37
+ hotChannelId: string;
38
+ selected: HotChannelSelectionEntry[];
39
+ updatedAt: string;
40
+ }
41
+ /** Bucket-relative key of a connection's selection; null for an id hq-pro would never write. */
42
+ export declare function hotChannelSelectionKey(connectionId: string): string | null;
43
+ /**
44
+ * Parse a stored selection the way hq-pro's `parseSelectionFile` does.
45
+ * Anything unexpected (bad JSON, other schema version, a bad entry) is null.
46
+ */
47
+ export declare function parseHotChannelSelection(text: string | null | undefined): HotChannelSelectionFile | null;
48
+ /**
49
+ * Reads one object from the signed-in person's vault bucket by
50
+ * bucket-relative key. Resolves null when the object does not exist.
51
+ */
52
+ export type PersonVaultObjectReader = (key: string) => Promise<string | null>;
53
+ export interface SelectionLoaderOptions {
54
+ timeoutMs?: number;
55
+ log?: (message: string) => void;
56
+ }
57
+ export declare const SELECTION_UNREADABLE_REASON = "selection-unreadable";
58
+ /** A saved selection may exist but could not be read; the connection is skipped. */
59
+ export declare class SelectionUnreadableError extends Error {
60
+ readonly code = "selection-unreadable";
61
+ constructor(connectionId: string, detail: string);
62
+ }
63
+ /**
64
+ * Selected ids for one connection; undefined means no saved choice (land
65
+ * everything). Rejects with `SelectionUnreadableError` when a saved choice may
66
+ * exist but could not be read.
67
+ */
68
+ export type SelectionLoader = (connectionId: string) => Promise<ReadonlySet<string> | undefined>;
69
+ /**
70
+ * A loader for one run. Each connection's file is read at most once, and the
71
+ * cache dies with the loader, so the next run reads the file again. Rejects
72
+ * only with `SelectionUnreadableError`.
73
+ */
74
+ export declare function createSelectionLoader(read: PersonVaultObjectReader, options?: SelectionLoaderOptions): SelectionLoader;
75
+ /**
76
+ * The selection a connection's own channel lands by: the saved ids, or
77
+ * undefined (land everything) when nothing was saved or the saved list is empty.
78
+ */
79
+ export declare function connectionSelection(saved: ReadonlySet<string> | undefined): ReadonlySet<string> | undefined;
80
+ /**
81
+ * The meetings channel has no connection of its own; its checklist is an
82
+ * include/exclude choice saved on each meeting-recorder connection. Meetings
83
+ * are excluded (an empty set) when at least one recorder connection saved an
84
+ * empty selection and none saved a non-empty one. Otherwise undefined: include.
85
+ */
86
+ export declare function meetingsSelection(selections: ReadonlyArray<ReadonlySet<string> | undefined>): ReadonlySet<string> | undefined;
87
+ //# sourceMappingURL=selection.d.ts.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Saved hot-channel selections (hq-pro US-014).
3
+ *
4
+ * A person can pick which channels, issues or records to watch on each
5
+ * connection. hq-pro stores the pick as
6
+ * `integrations/hot-channels/<connectionId>.json` in that person's own vault
7
+ * bucket (hq-pro `hotChannelSelectionKey`), shaped
8
+ * `{ schemaVersion: 1, connectionId, provider, hotChannelId, selected: [{ id, label, source }], updatedAt }`.
9
+ *
10
+ * The sync reads it once per connection per run. A missing file (404) means
11
+ * "no choice made": the adapter lands everything. A file that may exist but
12
+ * cannot be read (an error, a read slower than 5 seconds, malformed content,
13
+ * or a file naming another connection) fails closed: the loader throws
14
+ * `SelectionUnreadableError`, the loop skips that connection's adapter run
15
+ * and the ledger records it as failed with reason `selection-unreadable`.
16
+ * Nothing lands that the person may have excluded.
17
+ *
18
+ * An empty `selected` list on a connection's own channel (Slack, Linear,
19
+ * Attio) also means "no choice made" and lands everything: hq-pro saves `[]`
20
+ * when the person accepts defaults before any suggestions are listed, and has
21
+ * no delete, so `[]` must not silently stop the channel. Meetings keep their
22
+ * own rule (`meetingsSelection`): an empty pick on every recorder excludes them.
23
+ */
24
+ export const HOT_CHANNEL_SELECTION_SCHEMA_VERSION = 1;
25
+ export const HOT_CHANNEL_SELECTION_PREFIX = "integrations/hot-channels/";
26
+ /** Upper bound on one selection read. */
27
+ export const DEFAULT_SELECTION_READ_TIMEOUT_MS = 5_000;
28
+ const CONNECTION_ID_PATTERN = /^[A-Za-z0-9_-]{1,256}$/;
29
+ const ENTRY_ID_PATTERN = /^[^\s/\\]{1,256}$/;
30
+ /** Bucket-relative key of a connection's selection; null for an id hq-pro would never write. */
31
+ export function hotChannelSelectionKey(connectionId) {
32
+ return CONNECTION_ID_PATTERN.test(connectionId) ? `${HOT_CHANNEL_SELECTION_PREFIX}${connectionId}.json` : null;
33
+ }
34
+ /**
35
+ * Parse a stored selection the way hq-pro's `parseSelectionFile` does.
36
+ * Anything unexpected (bad JSON, other schema version, a bad entry) is null.
37
+ */
38
+ export function parseHotChannelSelection(text) {
39
+ if (!text)
40
+ return null;
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(text);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
49
+ return null;
50
+ const file = parsed;
51
+ if (file.schemaVersion !== HOT_CHANNEL_SELECTION_SCHEMA_VERSION)
52
+ return null;
53
+ if (typeof file.connectionId !== "string" || typeof file.provider !== "string")
54
+ return null;
55
+ if (typeof file.hotChannelId !== "string" || typeof file.updatedAt !== "string")
56
+ return null;
57
+ if (!Array.isArray(file.selected))
58
+ return null;
59
+ const selected = [];
60
+ const seen = new Set();
61
+ for (const item of file.selected) {
62
+ if (!item || typeof item !== "object" || Array.isArray(item))
63
+ return null;
64
+ const { id, label, source } = item;
65
+ if (typeof id !== "string" || !ENTRY_ID_PATTERN.test(id.trim()))
66
+ return null;
67
+ if (source !== "suggested" && source !== "added")
68
+ return null;
69
+ const cleanId = id.trim();
70
+ if (seen.has(cleanId))
71
+ continue;
72
+ seen.add(cleanId);
73
+ selected.push({ id: cleanId, label: typeof label === "string" && label.trim() ? label.trim() : cleanId, source });
74
+ }
75
+ return {
76
+ schemaVersion: HOT_CHANNEL_SELECTION_SCHEMA_VERSION,
77
+ connectionId: file.connectionId,
78
+ provider: file.provider,
79
+ hotChannelId: file.hotChannelId,
80
+ selected,
81
+ updatedAt: file.updatedAt,
82
+ };
83
+ }
84
+ export const SELECTION_UNREADABLE_REASON = "selection-unreadable";
85
+ /** A saved selection may exist but could not be read; the connection is skipped. */
86
+ export class SelectionUnreadableError extends Error {
87
+ code = SELECTION_UNREADABLE_REASON;
88
+ constructor(connectionId, detail) {
89
+ super(`saved selection for ${connectionId} could not be read (${detail}); skipped this connection`);
90
+ this.name = "SelectionUnreadableError";
91
+ }
92
+ }
93
+ class SelectionReadTimeoutError extends Error {
94
+ constructor(ms) {
95
+ super(`timed out after ${ms}ms`);
96
+ this.name = "SelectionReadTimeoutError";
97
+ }
98
+ }
99
+ async function withTimeout(work, ms) {
100
+ let timer;
101
+ const timeout = new Promise((_, reject) => {
102
+ timer = setTimeout(() => reject(new SelectionReadTimeoutError(ms)), ms);
103
+ });
104
+ try {
105
+ return await Promise.race([work, timeout]);
106
+ }
107
+ finally {
108
+ if (timer !== undefined)
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+ /**
113
+ * A loader for one run. Each connection's file is read at most once, and the
114
+ * cache dies with the loader, so the next run reads the file again. Rejects
115
+ * only with `SelectionUnreadableError`.
116
+ */
117
+ export function createSelectionLoader(read, options = {}) {
118
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SELECTION_READ_TIMEOUT_MS;
119
+ const log = options.log ?? ((message) => void process.stderr.write(`${message}\n`));
120
+ const cache = new Map();
121
+ async function load(connectionId) {
122
+ const key = hotChannelSelectionKey(connectionId);
123
+ if (key === null)
124
+ return undefined;
125
+ let text;
126
+ try {
127
+ text = await withTimeout(Promise.resolve().then(() => read(key)), timeoutMs);
128
+ }
129
+ catch (err) {
130
+ const reason = err instanceof Error ? err.message : String(err);
131
+ log(`hot-channel selection for ${connectionId} could not be read (${reason}); skipping this connection`);
132
+ throw new SelectionUnreadableError(connectionId, reason);
133
+ }
134
+ if (text === null)
135
+ return undefined;
136
+ const file = parseHotChannelSelection(text);
137
+ if (!file) {
138
+ log(`hot-channel selection for ${connectionId} is malformed; skipping this connection`);
139
+ throw new SelectionUnreadableError(connectionId, "malformed");
140
+ }
141
+ if (file.connectionId !== connectionId) {
142
+ log(`hot-channel selection for ${connectionId} names connection ${file.connectionId}; skipping this connection`);
143
+ throw new SelectionUnreadableError(connectionId, `names connection ${file.connectionId}`);
144
+ }
145
+ return new Set(file.selected.map((entry) => entry.id));
146
+ }
147
+ return (connectionId) => {
148
+ let hit = cache.get(connectionId);
149
+ if (!hit) {
150
+ hit = load(connectionId);
151
+ cache.set(connectionId, hit);
152
+ }
153
+ return hit;
154
+ };
155
+ }
156
+ /**
157
+ * The selection a connection's own channel lands by: the saved ids, or
158
+ * undefined (land everything) when nothing was saved or the saved list is empty.
159
+ */
160
+ export function connectionSelection(saved) {
161
+ return saved && saved.size > 0 ? saved : undefined;
162
+ }
163
+ /**
164
+ * The meetings channel has no connection of its own; its checklist is an
165
+ * include/exclude choice saved on each meeting-recorder connection. Meetings
166
+ * are excluded (an empty set) when at least one recorder connection saved an
167
+ * empty selection and none saved a non-empty one. Otherwise undefined: include.
168
+ */
169
+ export function meetingsSelection(selections) {
170
+ const saved = selections.filter((s) => s !== undefined);
171
+ if (saved.length === 0)
172
+ return undefined;
173
+ return saved.every((s) => s.size === 0) ? new Set() : undefined;
174
+ }
175
+ //# sourceMappingURL=selection.js.map
@@ -18,7 +18,7 @@ const CORE_SOURCES = [PROD_SOURCE, STAGING_SOURCE];
18
18
  /** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
19
19
  export function resolveGhToken() {
20
20
  try {
21
- const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
21
+ const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 5_000 });
22
22
  if (res.status === 0) {
23
23
  const tok = res.stdout.trim();
24
24
  if (tok)
@@ -16,7 +16,7 @@ export interface VersionReportDeps {
16
16
  printUpdateNudge?: (stderr: Pick<NodeJS.WriteStream, "write">) => Promise<void>;
17
17
  }
18
18
  export declare function normalizeHqCoreTag(tag: string): string | null;
19
- export declare function fetchLatestHqCoreRelease(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;
19
+ export declare function fetchLatestHqCoreRelease(fetchImpl?: typeof fetch, timeoutMs?: number, token?: string | undefined): Promise<string | null>;
20
20
  export declare function collectVersionReport(deps?: VersionReportDeps): Promise<VersionReport>;
21
21
  export declare function formatVersionReport(report: VersionReport): string;
22
22
  export declare function versionReportJson(report: VersionReport): string;
@@ -13,22 +13,35 @@ import { CLI_VERSION } from "../cli-version.js";
13
13
  import { wantsJson } from "../version-request.js";
14
14
  import { findHqRoot } from "./manifest.js";
15
15
  import { readHqVersion } from "./hq-core-yaml.js";
16
+ import { resolveGhToken } from "./shipped-codex-hooks.js";
16
17
  export const HQ_CORE_LATEST_URL = "https://api.github.com/repos/indigoai-us/hq-core/releases/latest";
17
18
  export const LATEST_FETCH_TIMEOUT_MS = 3_000;
18
19
  export function normalizeHqCoreTag(tag) {
19
20
  const version = tag.trim().replace(/^v/i, "");
20
21
  return semver.valid(version) ? version : null;
21
22
  }
22
- export async function fetchLatestHqCoreRelease(fetchImpl = fetch, timeoutMs = LATEST_FETCH_TIMEOUT_MS) {
23
+ export async function fetchLatestHqCoreRelease(fetchImpl = fetch, timeoutMs = LATEST_FETCH_TIMEOUT_MS, token = resolveGhToken()) {
23
24
  try {
24
- const res = await fetchImpl(HQ_CORE_LATEST_URL, {
25
- headers: {
26
- Accept: "application/vnd.github+json",
27
- "User-Agent": "hq-cli-version",
28
- "X-GitHub-Api-Version": "2022-11-28",
29
- },
25
+ const headers = {
26
+ Accept: "application/vnd.github+json",
27
+ "User-Agent": "hq-cli-version",
28
+ "X-GitHub-Api-Version": "2022-11-28",
29
+ };
30
+ if (token)
31
+ headers.Authorization = `Bearer ${token}`;
32
+ let res = await fetchImpl(HQ_CORE_LATEST_URL, {
33
+ headers,
30
34
  signal: AbortSignal.timeout(timeoutMs),
31
35
  });
36
+ // hq-core is public. A stale GH_TOKEN 401/403 should not hide the anonymous
37
+ // lookup that used to work.
38
+ if (token && (res.status === 401 || res.status === 403)) {
39
+ const { Authorization: _drop, ...anonHeaders } = headers;
40
+ res = await fetchImpl(HQ_CORE_LATEST_URL, {
41
+ headers: anonHeaders,
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ }
32
45
  if (!res.ok)
33
46
  return null;
34
47
  const body = (await res.json());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.183",
3
+ "version": "5.345.187",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.183",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.183",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.183",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.183",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.183",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.183"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.187",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.187",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.187",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.187",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.187",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.187"
123
123
  }
124
124
  }