@indigoai-us/hq-cli 5.345.178 → 5.345.180

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 (117) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/command-catalog.generated.d.ts +197 -1
  3. package/dist/command-catalog.generated.js +241 -1
  4. package/dist/commands/bot-jobs.d.ts +11 -9
  5. package/dist/commands/bot-jobs.js +56 -21
  6. package/dist/commands/bot.d.ts +37 -1
  7. package/dist/commands/bot.js +289 -13
  8. package/dist/commands/channels.d.ts +18 -1
  9. package/dist/commands/channels.js +185 -1
  10. package/dist/commands/daemon.d.ts +2 -0
  11. package/dist/commands/daemon.js +15 -4
  12. package/dist/commands/integrations-core.d.ts +2 -0
  13. package/dist/commands/integrations-core.js +1 -0
  14. package/dist/commands/lanes.d.ts +1 -1
  15. package/dist/commands/lanes.js +6 -2
  16. package/dist/commands/mesh.js +19 -0
  17. package/dist/commands/sources.d.ts +89 -0
  18. package/dist/commands/sources.js +363 -1
  19. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2285 -1743
  20. package/dist/hq-cloud-sync-runner/package.json +1 -1
  21. package/dist/lib/bot/config.d.ts +8 -0
  22. package/dist/lib/bot/fleet-local.d.ts +1 -1
  23. package/dist/lib/bot/fleet-local.js +82 -30
  24. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
  25. package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
  26. package/dist/lib/bot/local-jobs.d.ts +40 -4
  27. package/dist/lib/bot/local-jobs.js +72 -5
  28. package/dist/lib/bot/run.d.ts +15 -0
  29. package/dist/lib/bot/run.js +99 -1
  30. package/dist/lib/bot/runtime-auth.d.ts +119 -0
  31. package/dist/lib/bot/runtime-auth.js +251 -0
  32. package/dist/lib/daemon/catalog.d.ts +1 -0
  33. package/dist/lib/daemon/catalog.js +1 -1
  34. package/dist/lib/daemon/handoff.d.ts +4 -0
  35. package/dist/lib/daemon/handoff.js +6 -2
  36. package/dist/lib/daemon/install.d.ts +70 -2
  37. package/dist/lib/daemon/install.js +185 -27
  38. package/dist/lib/daemon/legacy.d.ts +9 -3
  39. package/dist/lib/daemon/legacy.js +11 -4
  40. package/dist/lib/daemon/migrate.js +1 -1
  41. package/dist/lib/daemon/run.d.ts +5 -2
  42. package/dist/lib/daemon/run.js +74 -22
  43. package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
  44. package/dist/lib/doctor/checks/anywhere.js +35 -6
  45. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  46. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  47. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
  48. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
  49. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
  50. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
  51. package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
  52. package/dist/lib/hot-channels/adapters/attio.js +374 -0
  53. package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
  54. package/dist/lib/hot-channels/adapters/gmail.js +413 -0
  55. package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
  56. package/dist/lib/hot-channels/adapters/index.js +39 -0
  57. package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
  58. package/dist/lib/hot-channels/adapters/linear.js +321 -0
  59. package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
  60. package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
  61. package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
  62. package/dist/lib/hot-channels/adapters/meetings.js +525 -0
  63. package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
  64. package/dist/lib/hot-channels/adapters/slack.js +277 -0
  65. package/dist/lib/hot-channels/alert.d.ts +78 -0
  66. package/dist/lib/hot-channels/alert.js +179 -0
  67. package/dist/lib/hot-channels/audience.d.ts +15 -0
  68. package/dist/lib/hot-channels/audience.js +25 -0
  69. package/dist/lib/hot-channels/contract.d.ts +146 -0
  70. package/dist/lib/hot-channels/contract.js +20 -0
  71. package/dist/lib/hot-channels/cursors.d.ts +41 -0
  72. package/dist/lib/hot-channels/cursors.js +84 -0
  73. package/dist/lib/hot-channels/index.d.ts +10 -0
  74. package/dist/lib/hot-channels/index.js +10 -0
  75. package/dist/lib/hot-channels/landing.d.ts +83 -0
  76. package/dist/lib/hot-channels/landing.js +175 -0
  77. package/dist/lib/hot-channels/ledger.d.ts +101 -0
  78. package/dist/lib/hot-channels/ledger.js +203 -0
  79. package/dist/lib/hot-channels/loop.d.ts +74 -0
  80. package/dist/lib/hot-channels/loop.js +221 -0
  81. package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
  82. package/dist/lib/hot-channels/registry.d.ts +28 -0
  83. package/dist/lib/hot-channels/registry.js +121 -0
  84. package/dist/lib/hot-channels/run.d.ts +137 -0
  85. package/dist/lib/hot-channels/run.js +324 -0
  86. package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
  87. package/dist/lib/hot-channels/signals-pass.js +581 -0
  88. package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
  89. package/dist/lib/hot-channels/sync-lock.js +224 -0
  90. package/dist/lib/install/hook-target.d.ts +6 -2
  91. package/dist/lib/install/hook-target.js +9 -4
  92. package/dist/lib/lanes/accounts.d.ts +1 -17
  93. package/dist/lib/lanes/accounts.js +5 -45
  94. package/dist/lib/lanes/loop.d.ts +10 -0
  95. package/dist/lib/lanes/loop.js +18 -1
  96. package/dist/lib/lanes/provider-home.d.ts +24 -0
  97. package/dist/lib/lanes/provider-home.js +52 -0
  98. package/dist/lib/lanes/spawn.d.ts +12 -0
  99. package/dist/lib/lanes/spawn.js +27 -0
  100. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  101. package/dist/lib/mesh/live/daemon/doctor.js +13 -0
  102. package/dist/lib/mesh/live/daemon/owner-inbox-push.d.ts +94 -0
  103. package/dist/lib/mesh/live/daemon/owner-inbox-push.js +273 -0
  104. package/dist/lib/mesh/live/daemon/run.js +31 -0
  105. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +14 -0
  106. package/dist/lib/mesh/live/daemon/work-subscribe.js +210 -3
  107. package/dist/lib/mesh/live/paths.d.ts +2 -0
  108. package/dist/lib/mesh/live/paths.js +2 -0
  109. package/dist/lib/sync-core/flag.d.ts +1 -1
  110. package/dist/lib/sync-core/flag.js +1 -1
  111. package/dist/lib/sync-core/reconcile.d.ts +1 -1
  112. package/dist/lib/sync-core/reconcile.js +9 -7
  113. package/dist/lib/sync-core/release.d.ts +3 -3
  114. package/dist/lib/sync-core/release.js +4 -4
  115. package/dist/lib/sync-core/rollout-state.d.ts +1 -0
  116. package/dist/lib/sync-core/rollout-state.js +1 -0
  117. package/package.json +8 -8
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Slack hot channel (hot-signal-sync US-006).
3
+ *
4
+ * The live Slack events webhook in hq-pro-agents
5
+ * (`src/sources/inbound/slack-events.ts`) already lands one document per
6
+ * thread in the company vault under `sources/slack/`. From the local loop's
7
+ * side those threads are in the vault before the sync starts, so this adapter
8
+ * is a poll adapter over that folder, the same shape as meetings (US-008). It
9
+ * lists the thread documents ingested after the cursor that belong to the
10
+ * signed-in person, oldest first, and the cursor is the newest `ingested_at`.
11
+ *
12
+ * The hq-pro catalog entry for slack still says `transport: push`. run.ts
13
+ * plans by the registered adapter's transport, so this adapter runs once per
14
+ * usable Slack connection either way; the catalog correction is an hq-pro
15
+ * follow-up.
16
+ *
17
+ * Each landed document is a pointer. Its body is a short header (channel,
18
+ * time, owner, message count, audience) and `payload_ref` names the thread
19
+ * document. No message text is copied: the `.raw.json` sidecar holds only
20
+ * thread metadata.
21
+ *
22
+ * `audience_source` says how the audience was derived: `channel_members` (a
23
+ * `channel_members` or `audience` list on the thread document) or
24
+ * `person_uid` (only the person whose connection landed the thread). Today's
25
+ * writer records no channel membership, so the audience is the person until it
26
+ * does. The Slack user ids of the people in the thread are kept as
27
+ * `slack_participants`.
28
+ *
29
+ * Slack pauses event delivery to an app that exceeds its rate limit and sends
30
+ * `app_rate_limited`. A marker newer than the cursor (`_app_rate_limited.json`
31
+ * or a document with `type: app_rate_limited` in `sources/slack/`, without a
32
+ * `resolved_at`) fails the adapter with `SlackAppRateLimitedError`, whose
33
+ * message lands in the run ledger's `error`. The cursor does not move.
34
+ * Writing that marker from the webhook is an hq-pro-agents follow-up.
35
+ */
36
+ import * as fs from "node:fs/promises";
37
+ import * as path from "node:path";
38
+ import * as yaml from "js-yaml";
39
+ import { MAX_CATCH_UP_WINDOW_MS } from "../contract.js";
40
+ import { HOT_SIGNAL_ORIGIN } from "../landing.js";
41
+ export const SLACK_ADAPTER_ID = "slack";
42
+ export const SLACK_CHANNEL = "slack-events";
43
+ export const SLACK_PROVIDER = "slack";
44
+ export const SLACK_THREAD_EVENT = "slack.thread";
45
+ export const SLACK_THREAD_DIR = "sources/slack";
46
+ export const SLACK_RATE_LIMIT_MARKER = "_app_rate_limited.json";
47
+ export const SLACK_APP_RATE_LIMITED = "app_rate_limited";
48
+ const SUGGESTION_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
49
+ const DEFAULT_PAGE_SIZE = 100;
50
+ /** Slack paused event delivery, so threads after `pausedAt` may be missing. */
51
+ export class SlackAppRateLimitedError extends Error {
52
+ pausedAt;
53
+ code = SLACK_APP_RATE_LIMITED;
54
+ constructor(pausedAt) {
55
+ super(`Slack paused event delivery (app_rate_limited) at ${pausedAt}: retry \`hq sources sync\` in a minute, or reconnect Slack if it persists: hq integrations connect slack`);
56
+ this.pausedAt = pausedAt;
57
+ this.name = "SlackAppRateLimitedError";
58
+ }
59
+ }
60
+ /** Thread metadata copied to the raw sidecar. Anything else on the document stays put. */
61
+ const RAW_FIELDS = [
62
+ "person_uid",
63
+ "account_id",
64
+ "slack_team_id",
65
+ "channel_id",
66
+ "channel_name",
67
+ "thread_ts",
68
+ "source_channel_type",
69
+ "message_count",
70
+ "first_message_at",
71
+ "last_message_at",
72
+ "ingested_at",
73
+ ];
74
+ function nonEmptyString(value) {
75
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
76
+ }
77
+ function idString(value) {
78
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : nonEmptyString(value);
79
+ }
80
+ function stringList(value) {
81
+ if (!Array.isArray(value))
82
+ return [];
83
+ return [...new Set(value.map(nonEmptyString).filter((v) => v !== undefined))];
84
+ }
85
+ function parseTime(value) {
86
+ const stamp = nonEmptyString(value);
87
+ return stamp === undefined ? NaN : Date.parse(stamp);
88
+ }
89
+ /** Split `---\n{yaml}\n---\n{body}` and return the frontmatter. Unparseable or non-object is null. */
90
+ function readFrontmatter(text) {
91
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
92
+ if (!match)
93
+ return null;
94
+ let parsed;
95
+ try {
96
+ parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
102
+ return null;
103
+ return parsed;
104
+ }
105
+ function toThread(file, fm) {
106
+ if (fm.origin === HOT_SIGNAL_ORIGIN)
107
+ return null;
108
+ const channelId = nonEmptyString(fm.channel_id);
109
+ const threadTs = idString(fm.thread_ts);
110
+ const owner = nonEmptyString(fm.person_uid);
111
+ const tsMs = parseTime(fm.ingested_at);
112
+ if (!channelId || !threadTs || !owner || Number.isNaN(tsMs))
113
+ return null;
114
+ const members = stringList(fm.channel_members).length > 0 ? stringList(fm.channel_members) : stringList(fm.audience);
115
+ return {
116
+ file,
117
+ tsMs,
118
+ channelId,
119
+ threadTs,
120
+ frontmatter: fm,
121
+ audience: members.length > 0 ? members : [owner],
122
+ audienceSource: members.length > 0 ? "channel_members" : "person_uid",
123
+ };
124
+ }
125
+ function toEvent(t) {
126
+ const fm = t.frontmatter;
127
+ const owner = nonEmptyString(fm.person_uid);
128
+ const channelName = nonEmptyString(fm.channel_name);
129
+ const where = channelName ? `#${channelName.replace(/^#/, "")}` : t.channelId;
130
+ const lastMs = parseTime(fm.last_message_at);
131
+ const when = new Date(Number.isNaN(lastMs) ? t.tsMs : lastMs).toISOString();
132
+ const count = typeof fm.message_count === "number" && fm.message_count >= 0 ? fm.message_count : null;
133
+ const participants = stringList(fm.participants);
134
+ const payloadRef = `${SLACK_THREAD_DIR}/${t.file}`;
135
+ const title = `Slack thread in ${where}`;
136
+ const body = [
137
+ `# ${title}`,
138
+ "",
139
+ `- When: ${when}`,
140
+ `- Who: ${owner}`,
141
+ `- Messages: ${count ?? "unknown"}`,
142
+ `- Audience: ${t.audience.join(", ")}`,
143
+ `- Where: ${where}`,
144
+ `- Thread: ${payloadRef}`,
145
+ "",
146
+ ].join("\n");
147
+ const raw = {};
148
+ for (const field of RAW_FIELDS)
149
+ if (fm[field] !== undefined)
150
+ raw[field] = fm[field];
151
+ return {
152
+ providerId: `${t.channelId}:${t.threadTs}`,
153
+ // A thread that gains replies is landed again: new message count or last message.
154
+ version: `${count ?? 0}.${Number.isNaN(lastMs) ? t.tsMs : lastMs}`,
155
+ who: owner,
156
+ what: SLACK_THREAD_EVENT,
157
+ where,
158
+ when,
159
+ audience: [...t.audience],
160
+ title,
161
+ body,
162
+ payloadRef,
163
+ frontmatter: {
164
+ audience_source: t.audienceSource,
165
+ slack_channel_id: t.channelId,
166
+ slack_thread_ts: t.threadTs,
167
+ ...(participants.length > 0 ? { slack_participants: participants.join(",") } : {}),
168
+ },
169
+ raw: { thread: payloadRef, ...raw, participants, audience: [...t.audience], audience_source: t.audienceSource },
170
+ };
171
+ }
172
+ function byTimeThenName(a, b) {
173
+ return a.tsMs - b.tsMs || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
174
+ }
175
+ /** When an unresolved `app_rate_limited` marker is set, the time it was set. */
176
+ function rateLimitedAt(record) {
177
+ if (record.type !== SLACK_APP_RATE_LIMITED && record.error !== SLACK_APP_RATE_LIMITED)
178
+ return NaN;
179
+ if (nonEmptyString(record.resolved_at) !== undefined)
180
+ return NaN;
181
+ const at = parseTime(record.at);
182
+ return Number.isNaN(at) ? parseTime(record.ingested_at) : at;
183
+ }
184
+ async function readMarker(file) {
185
+ try {
186
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
187
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
188
+ ? rateLimitedAt(parsed)
189
+ : NaN;
190
+ }
191
+ catch {
192
+ return NaN;
193
+ }
194
+ }
195
+ export function createSlackAdapter(options) {
196
+ const dir = path.join(options.vaultRoot, SLACK_THREAD_DIR);
197
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
198
+ if (!Number.isInteger(pageSize) || pageSize < 1)
199
+ throw new Error("slack pageSize must be a positive integer");
200
+ async function fetch(cursor, ctx) {
201
+ let names;
202
+ try {
203
+ names = await fs.readdir(dir);
204
+ }
205
+ catch (err) {
206
+ if (err.code === "ENOENT")
207
+ return { events: [], cursor, hasMore: false };
208
+ throw err;
209
+ }
210
+ const me = new Set([ctx.personUid, ...(options.identities ?? []), ...(options.accountIds ?? []), ctx.gateway?.connectionId]
211
+ .filter((id) => typeof id === "string" && id !== "")
212
+ .map((id) => id.toLowerCase()));
213
+ const sinceMs = Date.parse(ctx.since);
214
+ const cursorMs = cursor === null ? NaN : Date.parse(cursor);
215
+ const floorMs = Number.isNaN(cursorMs) ? sinceMs : cursorMs;
216
+ let pausedMs = names.includes(SLACK_RATE_LIMIT_MARKER) ? await readMarker(path.join(dir, SLACK_RATE_LIMIT_MARKER)) : NaN;
217
+ const candidates = [];
218
+ for (const file of names.filter((n) => n.endsWith(".md")).sort()) {
219
+ let text;
220
+ try {
221
+ text = await fs.readFile(path.join(dir, file), "utf8");
222
+ }
223
+ catch {
224
+ continue;
225
+ }
226
+ const fm = readFrontmatter(text);
227
+ if (!fm)
228
+ continue;
229
+ const marker = rateLimitedAt(fm);
230
+ if (!Number.isNaN(marker)) {
231
+ if (Number.isNaN(pausedMs) || marker > pausedMs)
232
+ pausedMs = marker;
233
+ continue;
234
+ }
235
+ const t = toThread(file, fm);
236
+ if (!t || t.tsMs < sinceMs)
237
+ continue;
238
+ const owners = [nonEmptyString(fm.person_uid), nonEmptyString(fm.account_id)];
239
+ if (!owners.some((id) => id !== undefined && me.has(id.toLowerCase())))
240
+ continue;
241
+ candidates.push(t);
242
+ }
243
+ if (!Number.isNaN(pausedMs) && pausedMs > floorMs) {
244
+ throw new SlackAppRateLimitedError(new Date(pausedMs).toISOString());
245
+ }
246
+ candidates.sort(byTimeThenName);
247
+ // Threads at the cursor time are re-read every page: a thread that lands
248
+ // later with the same ingest time is not lost, and duplicates are dropped
249
+ // by landing. A page never splits one timestamp.
250
+ const atCursor = candidates.filter((t) => t.tsMs === cursorMs);
251
+ const newer = Number.isNaN(cursorMs) ? candidates : candidates.filter((t) => t.tsMs > cursorMs);
252
+ let end = Math.min(pageSize, newer.length);
253
+ while (end > 0 && end < newer.length && newer[end].tsMs === newer[end - 1].tsMs)
254
+ end += 1;
255
+ const page = newer.slice(0, end);
256
+ const last = page[page.length - 1];
257
+ return {
258
+ events: [...atCursor, ...page].map(toEvent),
259
+ cursor: last ? new Date(last.tsMs).toISOString() : cursor,
260
+ hasMore: end < newer.length,
261
+ };
262
+ }
263
+ return {
264
+ id: SLACK_ADAPTER_ID,
265
+ channel: SLACK_CHANNEL,
266
+ transport: "poll",
267
+ hotEventTypes: [SLACK_THREAD_EVENT],
268
+ catchUpWindow: { maxLookbackMs: MAX_CATCH_UP_WINDOW_MS },
269
+ tokenScope: { provider: SLACK_PROVIDER, scopes: [] },
270
+ // Channels with a thread in the last 14 days. The evaluator (US-014)
271
+ // orders suggested channels by message count, highest first: each landed
272
+ // event carries the thread's `message_count` in its raw sidecar.
273
+ suggestionRule: { kind: "recent-activity", withinMs: SUGGESTION_WINDOW_MS, minEvents: 1 },
274
+ fetch,
275
+ };
276
+ }
277
+ //# sourceMappingURL=slack.js.map
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The signals-failure alert (US-012).
3
+ *
4
+ * The owner's health rule, verbatim: "sync and build feels wrong. we should be
5
+ * logging sources and signals generated." A sync that lands sources but
6
+ * generates no signals is a failure, whatever the ledger line's status says.
7
+ * After each run the ledger lines are checked for that shape and the owner is
8
+ * told: first by a direct message (the primary alert, sent even when the job
9
+ * is quiet), then by a Slack post to the company channel when the company set
10
+ * `signals_alert_channel` in `settings/signals/preferences.yaml`.
11
+ *
12
+ * Alerts are deduped per adapter and connection for an hour. The dedupe state
13
+ * sits next to the ledger in the vault (`sources/_runs/@{personKey}/_alerts.json`)
14
+ * so the scheduled job and `hq sources sync` share it across machines.
15
+ */
16
+ import type { RunLedgerEntry } from "./ledger.js";
17
+ import type { OnDemandSyncDeps } from "./run.js";
18
+ export declare const SIGNALS_HEALTH_RULE: string;
19
+ /** One alert per adapter and connection per hour. */
20
+ export declare const SIGNALS_ALERT_DEDUPE_MS: number;
21
+ export declare const SIGNALS_ALERT_STATE_FILE = "_alerts.json";
22
+ /** The company setting that names the Slack channel for signals alerts. */
23
+ export declare const SIGNALS_ALERT_CHANNEL_FIELD = "signals_alert_channel";
24
+ /** The gateway's Slack post tool. */
25
+ export declare const SLACK_POST_TOOL = "slack.messages.post";
26
+ /** Ledger lines that landed sources and generated no signals, whatever their status. */
27
+ export declare function findSignalsFailures(entries: readonly RunLedgerEntry[]): RunLedgerEntry[];
28
+ /** The dedupe key: one adapter on one connection. */
29
+ export declare function signalsAlertKey(entry: Pick<RunLedgerEntry, "adapter" | "connectionId">): string;
30
+ export declare function formatSignalsFailureAlert(entry: RunLedgerEntry, options?: {
31
+ personName?: string;
32
+ }): string;
33
+ /** `{vaultRoot}/sources/_runs/@{personKey}/_alerts.json` */
34
+ export declare function signalsAlertStatePath(vaultRoot: string, personKey: string): string;
35
+ export interface RaiseSignalsFailureAlertsInput {
36
+ vaultRoot: string;
37
+ personKey: string;
38
+ entries: readonly RunLedgerEntry[];
39
+ now: () => Date;
40
+ personName?: string;
41
+ /** The primary alert: a direct message to the owner. Throws when it was not delivered. */
42
+ sendOwnerDm: (text: string) => Promise<void>;
43
+ /** The optional company-channel post; unset when no channel is configured. */
44
+ postCompanyChannel?: (text: string) => Promise<void>;
45
+ log?: (message: string) => void;
46
+ }
47
+ export interface RaiseSignalsFailureAlertsResult {
48
+ /** Dedupe keys alerted this run. */
49
+ sent: string[];
50
+ /** Dedupe keys skipped because an alert went out within the hour. */
51
+ suppressed: string[];
52
+ /** Dedupe keys whose owner DM failed; not recorded, so the next run tries again. */
53
+ failed: string[];
54
+ }
55
+ /**
56
+ * Alert the owner for each signals failure in this run's ledger lines. Never
57
+ * throws: a failed send is logged and left unrecorded so the next run retries.
58
+ */
59
+ export declare function raiseSignalsFailureAlerts(input: RaiseSignalsFailureAlertsInput): Promise<RaiseSignalsFailureAlertsResult>;
60
+ /** `{hqRoot}/companies/{company}/settings/signals/preferences.yaml` */
61
+ export declare function signalsPreferencesPath(hqRoot: string, company: string): string;
62
+ /**
63
+ * The Slack channel id for signals alerts, from the company's
64
+ * `settings/signals/preferences.yaml` (`signals_alert_channel: C0123`), or
65
+ * undefined when the file or the field is missing.
66
+ */
67
+ export declare function readSignalsAlertChannel(hqRoot: string, company: string): string | undefined;
68
+ /**
69
+ * The company-channel poster for signals alerts: posts through the person's
70
+ * usable Slack connection on the HQ integrations gateway. Undefined when no
71
+ * channel is configured.
72
+ */
73
+ export declare function createSignalsAlertChannelPoster(input: {
74
+ hqRoot: string;
75
+ company: string;
76
+ deps: Pick<OnDemandSyncDeps, "fetchUsableApps" | "callTool">;
77
+ }): ((text: string) => Promise<void>) | undefined;
78
+ //# sourceMappingURL=alert.d.ts.map
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The signals-failure alert (US-012).
3
+ *
4
+ * The owner's health rule, verbatim: "sync and build feels wrong. we should be
5
+ * logging sources and signals generated." A sync that lands sources but
6
+ * generates no signals is a failure, whatever the ledger line's status says.
7
+ * After each run the ledger lines are checked for that shape and the owner is
8
+ * told: first by a direct message (the primary alert, sent even when the job
9
+ * is quiet), then by a Slack post to the company channel when the company set
10
+ * `signals_alert_channel` in `settings/signals/preferences.yaml`.
11
+ *
12
+ * Alerts are deduped per adapter and connection for an hour. The dedupe state
13
+ * sits next to the ledger in the vault (`sources/_runs/@{personKey}/_alerts.json`)
14
+ * so the scheduled job and `hq sources sync` share it across machines.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
19
+ import { writeFileAtomic } from "./landing.js";
20
+ export const SIGNALS_HEALTH_RULE = 'Owner rule: "sync and build feels wrong. we should be logging sources and signals generated." ' +
21
+ "A run that lands sources and generates no signals is a failure.";
22
+ /** One alert per adapter and connection per hour. */
23
+ export const SIGNALS_ALERT_DEDUPE_MS = 60 * 60 * 1000;
24
+ export const SIGNALS_ALERT_STATE_FILE = "_alerts.json";
25
+ /** The company setting that names the Slack channel for signals alerts. */
26
+ export const SIGNALS_ALERT_CHANNEL_FIELD = "signals_alert_channel";
27
+ /** The gateway's Slack post tool. */
28
+ export const SLACK_POST_TOOL = "slack.messages.post";
29
+ /** Ledger lines that landed sources and generated no signals, whatever their status. */
30
+ export function findSignalsFailures(entries) {
31
+ return entries.filter((e) => e.sourcesLanded > 0 && e.signalsGenerated === 0);
32
+ }
33
+ /** The dedupe key: one adapter on one connection. */
34
+ export function signalsAlertKey(entry) {
35
+ return `${entry.adapter}:${entry.connectionId ?? "-"}`;
36
+ }
37
+ export function formatSignalsFailureAlert(entry, options = {}) {
38
+ const who = options.personName ?? entry.personUid;
39
+ const lines = [
40
+ `Signals failure for ${who}: ${entry.adapter}${entry.connectionId ? ` (${entry.connectionId})` : ""} ` +
41
+ `landed ${entry.sourcesLanded} source${entry.sourcesLanded === 1 ? "" : "s"} and generated 0 signals.`,
42
+ `Run ${entry.runId} (${entry.trigger}, status ${entry.status}) finished ${entry.finishedAt}.`,
43
+ ];
44
+ if (entry.error)
45
+ lines.push(`Error: ${entry.error}`);
46
+ lines.push(SIGNALS_HEALTH_RULE);
47
+ return lines.join("\n");
48
+ }
49
+ /** `{vaultRoot}/sources/_runs/@{personKey}/_alerts.json` */
50
+ export function signalsAlertStatePath(vaultRoot, personKey) {
51
+ const file = path.join(vaultRoot, "sources", "_runs", personDirName(personKey), SIGNALS_ALERT_STATE_FILE);
52
+ assertNotIntegrationSecretsPath(file);
53
+ return file;
54
+ }
55
+ async function readAlertState(file) {
56
+ try {
57
+ const parsed = JSON.parse(await fs.promises.readFile(file, "utf8"));
58
+ if (parsed && parsed.v === 1 && parsed.lastAlertAt && typeof parsed.lastAlertAt === "object") {
59
+ return { v: 1, lastAlertAt: { ...parsed.lastAlertAt } };
60
+ }
61
+ }
62
+ catch {
63
+ // Missing or unreadable: no alert sent yet.
64
+ }
65
+ return { v: 1, lastAlertAt: {} };
66
+ }
67
+ /**
68
+ * Alert the owner for each signals failure in this run's ledger lines. Never
69
+ * throws: a failed send is logged and left unrecorded so the next run retries.
70
+ */
71
+ export async function raiseSignalsFailureAlerts(input) {
72
+ const result = { sent: [], suppressed: [], failed: [] };
73
+ const failures = findSignalsFailures(input.entries);
74
+ if (failures.length === 0)
75
+ return result;
76
+ const log = input.log ?? (() => { });
77
+ let file;
78
+ try {
79
+ file = signalsAlertStatePath(input.vaultRoot, input.personKey);
80
+ }
81
+ catch (err) {
82
+ log(`signals alert skipped: ${err instanceof Error ? err.message : String(err)}`);
83
+ return result;
84
+ }
85
+ const state = await readAlertState(file);
86
+ const seen = new Set();
87
+ let changed = false;
88
+ for (const entry of failures) {
89
+ const key = signalsAlertKey(entry);
90
+ if (seen.has(key))
91
+ continue;
92
+ seen.add(key);
93
+ const at = input.now();
94
+ const last = state.lastAlertAt[key] ? Date.parse(state.lastAlertAt[key]) : Number.NaN;
95
+ if (!Number.isNaN(last) && at.getTime() - last < SIGNALS_ALERT_DEDUPE_MS) {
96
+ result.suppressed.push(key);
97
+ continue;
98
+ }
99
+ const text = formatSignalsFailureAlert(entry, input.personName ? { personName: input.personName } : {});
100
+ try {
101
+ await input.sendOwnerDm(text);
102
+ }
103
+ catch (err) {
104
+ log(`signals alert for ${key} was not delivered: ${err instanceof Error ? err.message : String(err)}`);
105
+ result.failed.push(key);
106
+ continue;
107
+ }
108
+ state.lastAlertAt[key] = at.toISOString();
109
+ changed = true;
110
+ result.sent.push(key);
111
+ if (input.postCompanyChannel) {
112
+ try {
113
+ await input.postCompanyChannel(text);
114
+ }
115
+ catch (err) {
116
+ log(`signals alert for ${key} was not posted to the company channel: ${err instanceof Error ? err.message : String(err)}`);
117
+ }
118
+ }
119
+ }
120
+ if (changed) {
121
+ try {
122
+ await writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
123
+ }
124
+ catch (err) {
125
+ log(`signals alert state was not saved: ${err instanceof Error ? err.message : String(err)}`);
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+ /** `{hqRoot}/companies/{company}/settings/signals/preferences.yaml` */
131
+ export function signalsPreferencesPath(hqRoot, company) {
132
+ return path.join(hqRoot, "companies", company, "settings", "signals", "preferences.yaml");
133
+ }
134
+ /**
135
+ * The Slack channel id for signals alerts, from the company's
136
+ * `settings/signals/preferences.yaml` (`signals_alert_channel: C0123`), or
137
+ * undefined when the file or the field is missing.
138
+ */
139
+ export function readSignalsAlertChannel(hqRoot, company) {
140
+ let body;
141
+ try {
142
+ body = fs.readFileSync(signalsPreferencesPath(hqRoot, company), "utf8");
143
+ }
144
+ catch (error) {
145
+ const code = error.code;
146
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "ELOOP")
147
+ return undefined;
148
+ throw error;
149
+ }
150
+ const field = SIGNALS_ALERT_CHANNEL_FIELD;
151
+ for (const line of body.split(/\n/)) {
152
+ if (!line.startsWith(`${field}:`))
153
+ continue;
154
+ let value = line.slice(field.length + 1).replace(/^\s+/, "");
155
+ value = value.replace(/\s+#.*$/, "");
156
+ value = value.replace(/^["']|["']$/g, "").trim();
157
+ if (value.length > 0)
158
+ return value;
159
+ }
160
+ return undefined;
161
+ }
162
+ /**
163
+ * The company-channel poster for signals alerts: posts through the person's
164
+ * usable Slack connection on the HQ integrations gateway. Undefined when no
165
+ * channel is configured.
166
+ */
167
+ export function createSignalsAlertChannelPoster(input) {
168
+ const channelId = readSignalsAlertChannel(input.hqRoot, input.company);
169
+ if (!channelId)
170
+ return undefined;
171
+ return async (text) => {
172
+ const apps = await input.deps.fetchUsableApps();
173
+ const slack = apps.find((app) => app.provider === "slack");
174
+ if (!slack)
175
+ throw new Error("no usable Slack connection for the signals alert channel");
176
+ await input.deps.callTool(slack, SLACK_POST_TOOL, { channelId, text });
177
+ };
178
+ }
179
+ //# sourceMappingURL=alert.js.map
@@ -0,0 +1,15 @@
1
+ /** Audience key for content visible to the whole company. */
2
+ export declare const COMPANY_AUDIENCE_KEY = "company";
3
+ /**
4
+ * The vault audience key from the hq-ontology-local spec
5
+ * (core/knowledge/public/hq-core/ontology-local-spec.md): the first 16 hex
6
+ * characters of `sha256(join(",", sort(unique(principal))))`. Each principal is
7
+ * trimmed, emails are lowercased, and person/agent uids keep their case. The
8
+ * literal `company` is the company-wide key. For one person this is the hash of
9
+ * their own lowercased email, which is the `@{personKey}` path segment.
10
+ *
11
+ * Matches `core/scripts/ontology-candidate.sh key` byte for byte, including its
12
+ * `LC_ALL=C sort` byte ordering.
13
+ */
14
+ export declare function audienceKey(principals: readonly string[]): string;
15
+ //# sourceMappingURL=audience.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Audience key for content visible to the whole company. */
3
+ export const COMPANY_AUDIENCE_KEY = "company";
4
+ /**
5
+ * The vault audience key from the hq-ontology-local spec
6
+ * (core/knowledge/public/hq-core/ontology-local-spec.md): the first 16 hex
7
+ * characters of `sha256(join(",", sort(unique(principal))))`. Each principal is
8
+ * trimmed, emails are lowercased, and person/agent uids keep their case. The
9
+ * literal `company` is the company-wide key. For one person this is the hash of
10
+ * their own lowercased email, which is the `@{personKey}` path segment.
11
+ *
12
+ * Matches `core/scripts/ontology-candidate.sh key` byte for byte, including its
13
+ * `LC_ALL=C sort` byte ordering.
14
+ */
15
+ export function audienceKey(principals) {
16
+ const trimmed = principals.map((p) => p.trim()).filter((p) => p.length > 0);
17
+ if (trimmed.length === 0)
18
+ throw new Error("audience key needs at least one principal");
19
+ if (trimmed.length === 1 && trimmed[0].toLowerCase() === COMPANY_AUDIENCE_KEY) {
20
+ return COMPANY_AUDIENCE_KEY;
21
+ }
22
+ const normalized = [...new Set(trimmed.map((p) => (p.includes("@") ? p.toLowerCase() : p)))].sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
23
+ return createHash("sha256").update(normalized.join(",")).digest("hex").slice(0, 16);
24
+ }
25
+ //# sourceMappingURL=audience.js.map