@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,581 @@
1
+ /**
2
+ * The local signals pass (US-011). After a hot-channel run lands sources, the
3
+ * pass takes the run's landed documents through the ontology worklist, asks
4
+ * `claude -p` for signals one document at a time, and writes those signals to
5
+ * `companies/{co}/signals/`. It returns the count for the run ledger.
6
+ *
7
+ * A landed document with both `payload_ref` and `raw_ref` is a pointer (the
8
+ * meetings, Slack and Attio adapters land these). For it, claude gets the
9
+ * pointer followed by the document `payload_ref` names, capped at
10
+ * MAX_PAYLOAD_BYTES. A payload outside `sources/`, missing or unreadable falls
11
+ * back to the pointer alone, and the reason is warned and kept on the signal.
12
+ *
13
+ * Billing: policy hq-no-headless-print-subscription-billing forbids headless
14
+ * `claude -p` in general. This is its allowed case, per the owner decision of
15
+ * 2026-10-21: the owner's own `claude` login, on their own machine, in a
16
+ * bounded single-turn call. Cloud Claude is not the bot brain, and no model is
17
+ * called from the poller itself. Do not reuse this pass for a shared, fleet or
18
+ * hosted identity.
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import { createHash } from "node:crypto";
22
+ import * as fs from "node:fs/promises";
23
+ import * as os from "node:os";
24
+ import * as path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import * as yaml from "js-yaml";
27
+ import { audienceKey, COMPANY_AUDIENCE_KEY } from "./audience.js";
28
+ import { assertPayloadRef, assertSafeSegment, writeFileAtomic } from "./landing.js";
29
+ /**
30
+ * A pass failure the run ledger records as `status: failed` with `error` set
31
+ * to the code. Any other error thrown from the pass is a crash.
32
+ */
33
+ export class SignalsPassError extends Error {
34
+ code;
35
+ constructor(code, message) {
36
+ super(message);
37
+ this.name = "SignalsPassError";
38
+ this.code = code;
39
+ }
40
+ }
41
+ /** Signal types the pass accepts; the same type folders existing signals use. */
42
+ export const SIGNAL_TYPES = [
43
+ "action_item",
44
+ "commitment",
45
+ "decision",
46
+ "key_point",
47
+ "participant_contribution",
48
+ "question",
49
+ "risk",
50
+ "summary",
51
+ ];
52
+ /**
53
+ * Flags that cut the signals call off from everything but the model: no
54
+ * built-in tools, no MCP servers, and no user, project or local settings (so
55
+ * user hooks and permission rules do not apply). The documents are untrusted
56
+ * provider content, so a prompt injection must have nothing to call.
57
+ * `--safe-mode` also stops CLAUDE.md and user memory from loading, so private
58
+ * context never reaches a prompt the documents can steer. `--bare` would do the
59
+ * same but reads only ANTHROPIC_API_KEY, which breaks subscription (OAuth)
60
+ * sign-in; `--safe-mode` keeps auth working (checked on Claude Code 2.1.308).
61
+ */
62
+ export const CLAUDE_ISOLATION_ARGS = [
63
+ "--safe-mode",
64
+ "--tools",
65
+ "",
66
+ "--strict-mcp-config",
67
+ "--mcp-config",
68
+ '{"mcpServers":{}}',
69
+ "--setting-sources",
70
+ "",
71
+ ];
72
+ /** Per-document `claude -p` budget (policy indigo-executors-bound-all-waits). */
73
+ export const DEFAULT_SIGNALS_TIMEOUT_MS = 120_000;
74
+ /** Budget for the `claude auth status` preflight. */
75
+ export const AUTH_PREFLIGHT_TIMEOUT_MS = 10_000;
76
+ /** Budget for one worklist script call. */
77
+ export const WORKLIST_TIMEOUT_MS = 60_000;
78
+ /** Documents one pass sends to claude across all adapters of a run. */
79
+ export const DEFAULT_MAX_SIGNAL_DOCS = 20;
80
+ /** Payload bytes sent to claude per document (200 KB). A longer payload is cut with a marker. */
81
+ export const MAX_PAYLOAD_BYTES = 200 * 1024;
82
+ /** Items the worklist lists per channel; landed documents must fall inside it. */
83
+ const WORKLIST_LIMIT = 500;
84
+ /** Output kept per child stream. A longer reply is bad output. */
85
+ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
86
+ /** Signals kept per document. */
87
+ const MAX_SIGNALS_PER_DOC = 12;
88
+ const KILL_GRACE_MS = 2_000;
89
+ const SIGNAL_ORIGIN = "hot-signal-sync";
90
+ /**
91
+ * Run a command with a hard timeout. The child leads its own process group;
92
+ * on timeout the whole group gets SIGTERM, then SIGKILL after a short grace
93
+ * (policy indigo-subprocess-wrapping-qmd-use-killpg-on-timeout), so a node
94
+ * child of `claude` cannot outlive the call.
95
+ */
96
+ export function runBounded(command, args, options) {
97
+ return new Promise((resolve) => {
98
+ let child;
99
+ try {
100
+ child = spawn(command, args, {
101
+ cwd: options.cwd,
102
+ env: options.env ?? process.env,
103
+ detached: true,
104
+ stdio: ["pipe", "pipe", "pipe"],
105
+ });
106
+ }
107
+ catch (err) {
108
+ resolve({ code: null, stdout: "", stderr: "", timedOut: false, spawnError: String(err) });
109
+ return;
110
+ }
111
+ const out = [];
112
+ const errOut = [];
113
+ let outBytes = 0;
114
+ let errBytes = 0;
115
+ let timedOut = false;
116
+ let spawnError;
117
+ let settled = false;
118
+ const killGroup = (signal) => {
119
+ if (child.pid === undefined)
120
+ return;
121
+ try {
122
+ process.kill(-child.pid, signal);
123
+ }
124
+ catch {
125
+ // The group is already gone.
126
+ }
127
+ };
128
+ let graceTimer;
129
+ const timer = setTimeout(() => {
130
+ timedOut = true;
131
+ killGroup("SIGTERM");
132
+ graceTimer = setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS);
133
+ graceTimer.unref();
134
+ }, options.timeoutMs);
135
+ child.stdout.on("data", (chunk) => {
136
+ outBytes += chunk.length;
137
+ if (outBytes <= MAX_OUTPUT_BYTES)
138
+ out.push(chunk);
139
+ });
140
+ child.stderr.on("data", (chunk) => {
141
+ errBytes += chunk.length;
142
+ if (errBytes <= MAX_OUTPUT_BYTES)
143
+ errOut.push(chunk);
144
+ });
145
+ child.stdin.on("error", () => {
146
+ // The child exited before reading stdin; its exit code tells the story.
147
+ });
148
+ child.on("error", (err) => {
149
+ spawnError = err.message;
150
+ });
151
+ child.on("close", (code) => {
152
+ if (settled)
153
+ return;
154
+ settled = true;
155
+ clearTimeout(timer);
156
+ if (graceTimer)
157
+ clearTimeout(graceTimer);
158
+ // Reap anything the child left behind in its group.
159
+ killGroup("SIGKILL");
160
+ resolve({
161
+ code,
162
+ stdout: outBytes > MAX_OUTPUT_BYTES ? "" : Buffer.concat(out).toString("utf8"),
163
+ stderr: Buffer.concat(errOut).toString("utf8"),
164
+ timedOut,
165
+ ...(spawnError !== undefined ? { spawnError } : {}),
166
+ });
167
+ });
168
+ child.stdin.end(options.input ?? "");
169
+ });
170
+ }
171
+ export function defaultPromptFile() {
172
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts", "signals-pass.md");
173
+ }
174
+ const LOGGED_OUT = /not logged in|log ?in|\/login|invalid api key|authenticat|oauth token/i;
175
+ function channelOf(key) {
176
+ const parts = key.split("/");
177
+ if (parts.length !== 3 || parts[0] !== "sources" || !parts[2].endsWith(".md"))
178
+ return null;
179
+ try {
180
+ assertSafeSegment(parts[1], "channel");
181
+ assertSafeSegment(parts[2], "document");
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ return parts[1];
187
+ }
188
+ function splitFrontmatter(text) {
189
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
190
+ if (!match)
191
+ return { frontmatter: {}, body: text };
192
+ const parsed = yaml.load(match[1]);
193
+ return {
194
+ frontmatter: parsed && typeof parsed === "object" ? parsed : {},
195
+ body: match[2],
196
+ };
197
+ }
198
+ function isMissing(err) {
199
+ const code = err.code;
200
+ return code === "ENOENT" || code === "ENOTDIR";
201
+ }
202
+ /** Read at most MAX_PAYLOAD_BYTES of a file, cut on a UTF-8 boundary, with a marker when cut. */
203
+ async function readCapped(file) {
204
+ const handle = await fs.open(file, "r");
205
+ try {
206
+ const stat = await handle.stat();
207
+ if (!stat.isFile())
208
+ throw new Error(`${file} is not a regular file`);
209
+ const buf = Buffer.alloc(MAX_PAYLOAD_BYTES + 1);
210
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
211
+ if (bytesRead <= MAX_PAYLOAD_BYTES)
212
+ return buf.subarray(0, bytesRead).toString("utf8");
213
+ let end = MAX_PAYLOAD_BYTES;
214
+ // Step back over UTF-8 continuation bytes so no character is split.
215
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
216
+ end -= 1;
217
+ const total = Math.max(stat.size, bytesRead);
218
+ return `${buf.subarray(0, end).toString("utf8")}\n[payload truncated at ${MAX_PAYLOAD_BYTES} bytes of ${total}]\n`;
219
+ }
220
+ finally {
221
+ await handle.close();
222
+ }
223
+ }
224
+ /**
225
+ * The text claude gets for one landed document. A pointer (both `payload_ref`
226
+ * and `raw_ref` set) gets its payload appended after a separator line. Any
227
+ * other document is sent as landed: without `raw_ref`, `payload_ref` names the
228
+ * document's own `.raw.json` sidecar and the body is the content.
229
+ */
230
+ export async function buildSignalsInput(vaultRoot, file) {
231
+ const pointer = await fs.readFile(path.join(vaultRoot, file), "utf8");
232
+ const { frontmatter } = splitFrontmatter(pointer);
233
+ const ref = frontmatter.payload_ref;
234
+ if (frontmatter.raw_ref === undefined || frontmatter.raw_ref === null || ref === undefined || ref === null) {
235
+ return { text: pointer };
236
+ }
237
+ const fallback = (reason) => ({
238
+ text: pointer,
239
+ fallback: reason,
240
+ fallbackRef: String(ref),
241
+ });
242
+ if (typeof ref !== "string" || path.isAbsolute(ref))
243
+ return fallback("payload-out-of-bounds");
244
+ try {
245
+ assertPayloadRef(ref);
246
+ }
247
+ catch {
248
+ return fallback("payload-out-of-bounds");
249
+ }
250
+ const sourcesRoot = path.resolve(vaultRoot, "sources");
251
+ const target = path.resolve(vaultRoot, ref);
252
+ if (!target.startsWith(`${sourcesRoot}${path.sep}`))
253
+ return fallback("payload-out-of-bounds");
254
+ let payload;
255
+ try {
256
+ // A symlink inside sources/ must not lead outside it.
257
+ const [realSources, realTarget] = await Promise.all([fs.realpath(sourcesRoot), fs.realpath(target)]);
258
+ if (!realTarget.startsWith(`${realSources}${path.sep}`))
259
+ return fallback("payload-out-of-bounds");
260
+ payload = await readCapped(realTarget);
261
+ }
262
+ catch (err) {
263
+ return fallback(isMissing(err) ? "payload-missing" : "payload-unreadable");
264
+ }
265
+ const head = pointer.endsWith("\n") ? pointer : `${pointer}\n`;
266
+ return { text: `${head}--- payload: ${ref} ---\n${payload}`, payloadRef: ref };
267
+ }
268
+ /**
269
+ * Parse `claude -p --output-format json` output into signals. The envelope's
270
+ * `result` must itself be one JSON object, optionally inside a single code
271
+ * fence; nothing is scraped out of prose.
272
+ */
273
+ export function parseClaudeSignals(stdout) {
274
+ let envelope;
275
+ try {
276
+ envelope = JSON.parse(stdout);
277
+ }
278
+ catch {
279
+ throw new SignalsPassError("claude-bad-output", "claude did not print a JSON envelope");
280
+ }
281
+ if (!envelope || typeof envelope !== "object") {
282
+ throw new SignalsPassError("claude-bad-output", "claude printed a JSON value that is not an envelope");
283
+ }
284
+ const env = envelope;
285
+ if (env.is_error === true) {
286
+ const text = typeof env.result === "string" ? env.result : "";
287
+ if (LOGGED_OUT.test(text))
288
+ throw new SignalsPassError("claude-logged-out", "claude reported it is not logged in");
289
+ throw new SignalsPassError("claude-bad-output", "claude returned an error result");
290
+ }
291
+ if (typeof env.result !== "string") {
292
+ throw new SignalsPassError("claude-bad-output", "claude envelope has no result text");
293
+ }
294
+ let text = env.result.trim();
295
+ const fence = /^```(?:json)?\n([\s\S]*)\n```$/.exec(text);
296
+ if (fence)
297
+ text = fence[1].trim();
298
+ let body;
299
+ try {
300
+ body = JSON.parse(text);
301
+ }
302
+ catch {
303
+ throw new SignalsPassError("claude-bad-output", "claude result is not JSON");
304
+ }
305
+ const signals = body?.signals;
306
+ if (!Array.isArray(signals)) {
307
+ throw new SignalsPassError("claude-bad-output", "claude result has no signals list");
308
+ }
309
+ return signals.slice(0, MAX_SIGNALS_PER_DOC).map((raw, i) => {
310
+ const s = raw;
311
+ if (!s ||
312
+ typeof s !== "object" ||
313
+ !SIGNAL_TYPES.includes(s.type) ||
314
+ typeof s.title !== "string" ||
315
+ s.title.trim().length === 0 ||
316
+ typeof s.body !== "string") {
317
+ throw new SignalsPassError("claude-bad-output", `claude signal ${i} has the wrong shape`);
318
+ }
319
+ return { type: s.type, title: s.title.trim(), body: s.body.trim() };
320
+ });
321
+ }
322
+ /** `sha256(source_ref \n type \n title)`: the same signal from the same source keeps its id. */
323
+ export function signalId(sourceRef, type, title) {
324
+ return createHash("sha256").update(`${sourceRef}\n${type}\n${title}`).digest("hex");
325
+ }
326
+ /**
327
+ * Vault-relative directory for one signal type. Company-wide signals keep the
328
+ * existing `signals/{type}/` layout; person-scoped signals live under
329
+ * `signals/@{audienceKey}/{type}/`.
330
+ */
331
+ export function signalTypeDir(key, type) {
332
+ if (key === COMPANY_AUDIENCE_KEY)
333
+ return `signals/${type}`;
334
+ assertSafeSegment(key, "audience key");
335
+ return `signals/@${key}/${type}`;
336
+ }
337
+ function heading(type) {
338
+ const words = type.split("_").join(" ");
339
+ return words.charAt(0).toUpperCase() + words.slice(1);
340
+ }
341
+ async function exists(file) {
342
+ try {
343
+ await fs.access(file);
344
+ return true;
345
+ }
346
+ catch {
347
+ return false;
348
+ }
349
+ }
350
+ /** Write `signals/@{key}/_audience.yaml` once, when the partition is first created. */
351
+ export async function ensureAudienceFile(vaultRoot, item) {
352
+ if (item.audienceKey === COMPANY_AUDIENCE_KEY)
353
+ return;
354
+ assertSafeSegment(item.audienceKey, "audience key");
355
+ const file = path.join(vaultRoot, "signals", `@${item.audienceKey}`, "_audience.yaml");
356
+ if (await exists(file))
357
+ return;
358
+ const principals = [...new Set(item.principals.map((p) => p.trim()).filter((p) => p.length > 0))].sort();
359
+ const text = yaml.dump({ principals, audience_source: item.audienceSource }, { lineWidth: -1 });
360
+ await fs.mkdir(path.dirname(file), { recursive: true });
361
+ try {
362
+ await fs.writeFile(file, text, { encoding: "utf8", flag: "wx" });
363
+ }
364
+ catch (err) {
365
+ if (err.code !== "EEXIST")
366
+ throw err;
367
+ }
368
+ }
369
+ async function appendSignalIndex(vaultRoot, day, rows) {
370
+ if (rows.length === 0)
371
+ return;
372
+ const file = path.join(vaultRoot, "signals", "_index", `${day}.json`);
373
+ let existing = [];
374
+ try {
375
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
376
+ if (Array.isArray(parsed))
377
+ existing = parsed;
378
+ }
379
+ catch (err) {
380
+ if (err.code !== "ENOENT")
381
+ throw err;
382
+ }
383
+ const seen = new Set(existing.map((row) => row.signal_id));
384
+ const added = rows.filter((row) => !seen.has(row.signal_id));
385
+ if (added.length === 0)
386
+ return;
387
+ await writeFileAtomic(file, `${JSON.stringify([...existing, ...added], null, 2)}\n`);
388
+ }
389
+ /**
390
+ * Build the claude signals pass for one company. The pass keeps state for one
391
+ * run: the auth preflight runs once, and the document cap counts across every
392
+ * adapter of the run, so create a new pass per run.
393
+ */
394
+ export function createClaudeSignalsPass(options) {
395
+ assertSafeSegment(options.company, "company");
396
+ const claude = options.claudeBin ?? "claude";
397
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SIGNALS_TIMEOUT_MS;
398
+ const authTimeoutMs = options.authTimeoutMs ?? AUTH_PREFLIGHT_TIMEOUT_MS;
399
+ const maxDocs = Math.max(0, options.maxDocs ?? DEFAULT_MAX_SIGNAL_DOCS);
400
+ const worklist = options.worklistScript ?? path.join(options.hqRoot, "core", "scripts", "ontology-source-worklist.sh");
401
+ const promptFile = options.promptFile ?? defaultPromptFile();
402
+ const now = options.now ?? (() => new Date());
403
+ const env = options.env ?? process.env;
404
+ const warn = options.warn ?? ((message) => void process.stderr.write(`${message}\n`));
405
+ const vaultRoot = path.join(options.hqRoot, "companies", options.company);
406
+ // claude runs outside the HQ tree so it loads no HQ project context or hooks.
407
+ const claudeCwd = os.tmpdir();
408
+ let preflight;
409
+ let prompt;
410
+ let docsSent = 0;
411
+ const checkLogin = async () => {
412
+ const run = await runBounded(claude, ["auth", "status"], { timeoutMs: authTimeoutMs, cwd: claudeCwd, env });
413
+ if (run.timedOut)
414
+ throw new SignalsPassError("claude-timeout", "claude auth status timed out");
415
+ if (run.spawnError !== undefined || run.code !== 0) {
416
+ throw new SignalsPassError("claude-logged-out", "claude is not logged in (claude auth status failed)");
417
+ }
418
+ try {
419
+ const status = JSON.parse(run.stdout);
420
+ if (status.loggedIn === false)
421
+ throw new SignalsPassError("claude-logged-out", "claude is not logged in");
422
+ }
423
+ catch (err) {
424
+ if (err instanceof SignalsPassError)
425
+ throw err;
426
+ // A plain-text status with exit 0 means logged in.
427
+ }
428
+ };
429
+ const runWorklist = async (args) => runBounded("bash", [worklist, ...args], {
430
+ timeoutMs: WORKLIST_TIMEOUT_MS,
431
+ cwd: options.hqRoot,
432
+ env: { ...env, HQ_ROOT: options.hqRoot },
433
+ });
434
+ /** Work items for the landed documents of one channel. */
435
+ const itemsFor = async (channel, files) => {
436
+ const wanted = new Set(files);
437
+ const run = await runWorklist(["--company", options.company, "--channel", channel, "--limit", String(WORKLIST_LIMIT)]);
438
+ if (run.timedOut)
439
+ throw new Error(`ontology worklist timed out for channel ${channel}`);
440
+ // Exit 4: the source is assigned to the cloud agent. Its documents are not this pass's to process.
441
+ if (run.code === 4)
442
+ return [];
443
+ if (run.code === 0) {
444
+ const items = [];
445
+ for (const line of run.stdout.split("\n")) {
446
+ if (line.trim().length === 0)
447
+ continue;
448
+ let row;
449
+ try {
450
+ row = JSON.parse(line);
451
+ }
452
+ catch {
453
+ continue;
454
+ }
455
+ if (typeof row.file !== "string" || !wanted.has(row.file) || row.skip !== undefined)
456
+ continue;
457
+ if (typeof row.audience_key !== "string" || !Array.isArray(row.audience))
458
+ continue;
459
+ items.push({
460
+ file: row.file,
461
+ channel,
462
+ principals: row.audience.filter((a) => typeof a === "string"),
463
+ audienceKey: row.audience_key,
464
+ audienceSource: "worklist",
465
+ fromWorklist: true,
466
+ });
467
+ }
468
+ return items;
469
+ }
470
+ // Any other exit (usually 2: the channel has no source.yaml yet, which is
471
+ // normal for a hot channel) falls back to the landed document's own
472
+ // audience frontmatter, which landing always writes.
473
+ const items = [];
474
+ for (const file of files) {
475
+ const { frontmatter } = splitFrontmatter(await fs.readFile(path.join(vaultRoot, file), "utf8"));
476
+ const audience = Array.isArray(frontmatter.audience)
477
+ ? frontmatter.audience.filter((a) => typeof a === "string" && a.trim().length > 0)
478
+ : [];
479
+ // No audience: skipped, never widened to company.
480
+ if (audience.length === 0)
481
+ continue;
482
+ items.push({
483
+ file,
484
+ channel,
485
+ principals: audience,
486
+ audienceKey: audienceKey(audience),
487
+ audienceSource: typeof frontmatter.audience_source === "string" ? frontmatter.audience_source : "frontmatter",
488
+ fromWorklist: false,
489
+ });
490
+ }
491
+ return items;
492
+ };
493
+ const extract = async (item, input) => {
494
+ prompt ??= fs.readFile(promptFile, "utf8");
495
+ const run = await runBounded(claude, ["-p", await prompt, ...CLAUDE_ISOLATION_ARGS, "--output-format", "json", "--max-turns", "1"], {
496
+ timeoutMs,
497
+ input: input.text,
498
+ cwd: claudeCwd,
499
+ env,
500
+ });
501
+ if (run.timedOut)
502
+ throw new SignalsPassError("claude-timeout", `claude timed out after ${timeoutMs}ms on ${item.file}`);
503
+ if (run.spawnError !== undefined)
504
+ throw new SignalsPassError("claude-logged-out", `claude could not start: ${run.spawnError}`);
505
+ if (run.code !== 0 && LOGGED_OUT.test(`${run.stderr}\n${run.stdout}`) && !run.stdout.trim().startsWith("{")) {
506
+ throw new SignalsPassError("claude-logged-out", "claude reported it is not logged in");
507
+ }
508
+ return parseClaudeSignals(run.stdout);
509
+ };
510
+ const write = async (item, input, signals, beforeWrite) => {
511
+ const at = now().toISOString();
512
+ const rows = [];
513
+ let written = 0;
514
+ if (signals.length > 0) {
515
+ await beforeWrite();
516
+ await ensureAudienceFile(vaultRoot, item);
517
+ }
518
+ for (const signal of signals) {
519
+ const id = signalId(item.file, signal.type, signal.title);
520
+ const key = `${signalTypeDir(item.audienceKey, signal.type)}/${id}.md`;
521
+ rows.push({ signal_id: id, type: signal.type, source_ref: item.file, key, written_at: at });
522
+ const file = path.join(vaultRoot, key);
523
+ if (await exists(file))
524
+ continue;
525
+ const frontmatter = {
526
+ signal_id: id,
527
+ type: signal.type,
528
+ source_ref: item.file,
529
+ canonical_content: signal.title,
530
+ audience_key: item.audienceKey,
531
+ origin: SIGNAL_ORIGIN,
532
+ written_at: at,
533
+ };
534
+ if (input.payloadRef !== undefined)
535
+ frontmatter.payload_ref = input.payloadRef;
536
+ if (input.fallback !== undefined)
537
+ frontmatter.payload_fallback = input.fallback;
538
+ const text = `---\n${yaml.dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd()}\n---\n## ${heading(signal.type)}\n\n${signal.body || signal.title}\n`;
539
+ await beforeWrite();
540
+ await writeFileAtomic(file, text);
541
+ written += 1;
542
+ }
543
+ await beforeWrite();
544
+ await appendSignalIndex(vaultRoot, at.slice(0, 10), rows);
545
+ return written;
546
+ };
547
+ return async (result, guard) => {
548
+ const beforeWrite = guard?.beforeWrite ?? (async () => undefined);
549
+ const byChannel = new Map();
550
+ for (const key of result.landedKeys ?? []) {
551
+ const channel = channelOf(key);
552
+ if (!channel)
553
+ continue;
554
+ byChannel.set(channel, [...(byChannel.get(channel) ?? []), key]);
555
+ }
556
+ // Nothing landed: there is no pass to run, so claude is not called.
557
+ if (byChannel.size === 0)
558
+ return 0;
559
+ preflight ??= checkLogin();
560
+ await preflight;
561
+ let total = 0;
562
+ for (const [channel, files] of byChannel) {
563
+ for (const item of await itemsFor(channel, files)) {
564
+ // Documents over the cap stay unmarked in the worklist for a later pass.
565
+ if (docsSent >= maxDocs)
566
+ return total;
567
+ docsSent += 1;
568
+ const input = await buildSignalsInput(vaultRoot, item.file);
569
+ if (input.fallback !== undefined) {
570
+ warn(`signals pass: ${item.file}: ${input.fallback} for payload_ref ${JSON.stringify(input.fallbackRef)}; sent the pointer only`);
571
+ }
572
+ total += await write(item, input, await extract(item, input), beforeWrite);
573
+ if (item.fromWorklist) {
574
+ await runWorklist(["--company", options.company, "--channel", channel, "--mark-done", item.file]);
575
+ }
576
+ }
577
+ }
578
+ return total;
579
+ };
580
+ }
581
+ //# sourceMappingURL=signals-pass.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * One hot-channel sync at a time per person per company. The bot's scheduled
3
+ * run and a manual `hq sources sync` can fire together; the index files are
4
+ * read-modify-write, so two runs at once can lose rows and process the same
5
+ * event twice. The lock is `sources/_runs/@{personKey}/.lock`, created with
6
+ * O_EXCL and holding the pid, host, start time and last heartbeat.
7
+ *
8
+ * A lock is stale when its holder on this host is no longer alive, or when its
9
+ * last heartbeat is older than `SYNC_LOCK_STALE_MS`. The holder heartbeats the
10
+ * lock while it runs, so a long signals pass is never taken over mid-write.
11
+ * Takeover runs under an O_EXCL `.takeover` guard: the guard holder renames the
12
+ * stale file to a unique name, checks it moved the file it judged stale, and
13
+ * every contender then competes on the O_EXCL create of a fresh lock.
14
+ * Release removes the file only while it still names this holder.
15
+ */
16
+ /** A lock whose last heartbeat is older than this is stale. */
17
+ export declare const SYNC_LOCK_STALE_MS: number;
18
+ /** How often a held lock is refreshed; well under the stale limit. */
19
+ export declare const SYNC_LOCK_HEARTBEAT_MS: number;
20
+ export declare const SYNC_LOCK_BUSY_REASON = "another sync is running";
21
+ /**
22
+ * The run no longer holds its sync lock (another run took it over, for example
23
+ * after this machine slept past the stale limit). The run stops before its
24
+ * next write.
25
+ */
26
+ export declare class SyncLockLostError extends Error {
27
+ constructor();
28
+ }
29
+ export interface SyncLock {
30
+ file: string;
31
+ /** Throw `SyncLockLostError` when the lock no longer names this holder. Call before each write. */
32
+ assertHeld: () => Promise<void>;
33
+ /** Refresh the heartbeat. A no-op when the lock no longer names this holder. */
34
+ heartbeat: () => Promise<void>;
35
+ /** Stop the heartbeat and remove the lock if it still names this holder. */
36
+ release: () => Promise<void>;
37
+ }
38
+ export declare function syncLockPath(vaultRoot: string, personKey: string): string;
39
+ /** True when `pid` names a live process on this host. EPERM means it exists. */
40
+ export declare function pidAlive(pid: number): boolean;
41
+ /**
42
+ * Take the sync lock, or return null when a live run holds it. A stale lock is
43
+ * moved aside and taken over once; a race lost during takeover returns null.
44
+ */
45
+ export declare function acquireSyncLock(vaultRoot: string, personKey: string, options?: {
46
+ now?: () => Date;
47
+ staleMs?: number;
48
+ heartbeatMs?: number;
49
+ pidAlive?: (pid: number) => boolean;
50
+ }): Promise<SyncLock | null>;
51
+ //# sourceMappingURL=sync-lock.d.ts.map