@indigoai-us/hq-cli 5.345.198 → 5.345.200

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 (129) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +6 -4
  3. package/dist/command-catalog.generated.d.ts +150 -6
  4. package/dist/command-catalog.generated.js +182 -6
  5. package/dist/commands/bot-events.d.ts +19 -0
  6. package/dist/commands/bot-events.js +46 -0
  7. package/dist/commands/bot-jobs.d.ts +2 -0
  8. package/dist/commands/bot-jobs.js +43 -2
  9. package/dist/commands/bot-runtime.d.ts +35 -0
  10. package/dist/commands/bot-runtime.js +115 -0
  11. package/dist/commands/bot.d.ts +11 -0
  12. package/dist/commands/bot.js +72 -11
  13. package/dist/commands/creators.js +2 -1
  14. package/dist/commands/lanes-report.d.ts +2 -1
  15. package/dist/commands/lanes-report.js +44 -1
  16. package/dist/commands/lanes.d.ts +9 -0
  17. package/dist/commands/lanes.js +95 -9
  18. package/dist/commands/library-remove.js +2 -1
  19. package/dist/commands/library.js +5 -4
  20. package/dist/commands/meetings.js +1 -21
  21. package/dist/commands/mesh.d.ts +0 -7
  22. package/dist/commands/mesh.js +5 -1
  23. package/dist/commands/signals-run.d.ts +2 -0
  24. package/dist/commands/signals-run.js +6 -1
  25. package/dist/commands/sync-held.js +2 -2
  26. package/dist/lanes-drain-hook.js +9 -0
  27. package/dist/lib/bot/api.d.ts +5 -0
  28. package/dist/lib/bot/claude-login.d.ts +31 -0
  29. package/dist/lib/bot/claude-login.js +160 -0
  30. package/dist/lib/bot/events.d.ts +18 -0
  31. package/dist/lib/bot/events.js +160 -0
  32. package/dist/lib/bot/executive-settings.d.ts +10 -0
  33. package/dist/lib/bot/executive-settings.js +43 -0
  34. package/dist/lib/bot/fleet-cron.js +4 -0
  35. package/dist/lib/bot/fleet-download.d.ts +27 -0
  36. package/dist/lib/bot/fleet-download.js +162 -0
  37. package/dist/lib/bot/fleet-local.d.ts +51 -2
  38. package/dist/lib/bot/fleet-local.js +179 -76
  39. package/dist/lib/bot/fleet-python.d.ts +2 -0
  40. package/dist/lib/bot/fleet-python.js +29 -0
  41. package/dist/lib/bot/job-run.js +1 -1
  42. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  43. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  44. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  45. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  46. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  47. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  48. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  49. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  50. package/dist/lib/bot/local-jobs.d.ts +15 -2
  51. package/dist/lib/bot/local-jobs.js +24 -7
  52. package/dist/lib/bot/run.d.ts +7 -49
  53. package/dist/lib/bot/run.js +100 -4
  54. package/dist/lib/bot/status.d.ts +21 -0
  55. package/dist/lib/doctor/checks/bots.d.ts +6 -0
  56. package/dist/lib/doctor/checks/bots.js +47 -1
  57. package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
  58. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  59. package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
  60. package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
  61. package/dist/lib/hot-channels/schedule.js +2 -2
  62. package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
  63. package/dist/lib/hot-channels/signals-pass.js +51 -17
  64. package/dist/lib/lanes/admission.d.ts +2 -0
  65. package/dist/lib/lanes/admission.js +4 -0
  66. package/dist/lib/lanes/approval-gate.js +4 -1
  67. package/dist/lib/lanes/links.d.ts +8 -1
  68. package/dist/lib/lanes/links.js +84 -3
  69. package/dist/lib/lanes/mesh-events.js +3 -1
  70. package/dist/lib/lanes/outcome.d.ts +154 -0
  71. package/dist/lib/lanes/outcome.js +151 -0
  72. package/dist/lib/lanes/reconcile.js +1 -0
  73. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  74. package/dist/lib/lanes/signal-brief-template.js +40 -0
  75. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  76. package/dist/lib/lanes/signal-lane.js +99 -0
  77. package/dist/lib/lanes/source-report.d.ts +37 -0
  78. package/dist/lib/lanes/source-report.js +66 -0
  79. package/dist/lib/lanes/spawn.d.ts +6 -0
  80. package/dist/lib/lanes/spawn.js +21 -0
  81. package/dist/lib/lanes/store.d.ts +3 -0
  82. package/dist/lib/lanes/store.js +64 -0
  83. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  84. package/dist/lib/lanes/story-ledger.js +1 -0
  85. package/dist/lib/lanes/types.d.ts +137 -0
  86. package/dist/lib/lanes/types.js +17 -2
  87. package/dist/lib/lanes/watcher.js +4 -0
  88. package/dist/lib/live-meetings.d.ts +19 -0
  89. package/dist/lib/live-meetings.js +23 -0
  90. package/dist/lib/mesh/api.js +2 -1
  91. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  92. package/dist/lib/mesh/live/daemon/run.d.ts +0 -10
  93. package/dist/lib/mesh/live/daemon/run.js +3 -0
  94. package/dist/lib/mesh/live/emit.d.ts +2 -11
  95. package/dist/lib/mesh/live/emit.js +20 -2
  96. package/dist/lib/mesh/live/flush.d.ts +2 -0
  97. package/dist/lib/mesh/live/flush.js +34 -3
  98. package/dist/lib/mesh/live/reporting-privacy.d.ts +7 -0
  99. package/dist/lib/mesh/live/reporting-privacy.js +47 -0
  100. package/dist/lib/mesh/live/spool.d.ts +8 -0
  101. package/dist/lib/mesh/live/spool.js +59 -40
  102. package/dist/lib/plan-limit-nag.d.ts +30 -4
  103. package/dist/lib/plan-limit-nag.js +68 -17
  104. package/dist/lib/signals/engine.d.ts +4 -0
  105. package/dist/lib/signals/engine.js +25 -5
  106. package/dist/lib/signals/incremental.d.ts +42 -0
  107. package/dist/lib/signals/incremental.js +191 -0
  108. package/dist/lib/signals/prior-signals.d.ts +11 -0
  109. package/dist/lib/signals/prior-signals.js +44 -0
  110. package/dist/lib/signals/reply.js +6 -1
  111. package/dist/lib/signals/settings.d.ts +5 -0
  112. package/dist/lib/signals/settings.js +17 -0
  113. package/dist/lib/signals/steps/extract.d.ts +23 -2
  114. package/dist/lib/signals/steps/extract.js +142 -9
  115. package/dist/lib/signals/steps/types.d.ts +1 -0
  116. package/dist/lib/signals/write-guard.js +7 -0
  117. package/dist/lib/work-context/company.d.ts +2 -3
  118. package/dist/lib/work-context/company.js +4 -22
  119. package/dist/secrets-env-action.js +5 -2
  120. package/dist/utils/cognito-callback-port.d.ts +17 -0
  121. package/dist/utils/cognito-callback-port.js +56 -0
  122. package/dist/utils/cognito-config.js +7 -3
  123. package/dist/utils/cognito-session.d.ts +7 -6
  124. package/dist/utils/cognito-session.js +19 -14
  125. package/dist/utils/interactive-caller.d.ts +23 -0
  126. package/dist/utils/interactive-caller.js +80 -0
  127. package/dist/utils/login-browser.js +5 -2
  128. package/dist/utils/resident-package-preload.js +4 -0
  129. package/package.json +7 -7
@@ -24,6 +24,8 @@ export declare const HELD_MAX_LINES = 20000;
24
24
  export declare const PROJECT_REBIND_INTERVAL_MS = 60000;
25
25
  export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "NEEDS_PROJECT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
26
26
  export interface FlushDeps {
27
+ /** Production callers re-read the shared person preference before each flush. */
28
+ reportingAllowed?: () => Promise<boolean>;
27
29
  workMeshRoot: string;
28
30
  workContextRoot: string;
29
31
  poster: SessionEventsPoster;
@@ -9,9 +9,10 @@
9
9
  * 2xx responses are accounted per eventId (accepted / duplicates / rejected).
10
10
  */
11
11
  import * as fs from "node:fs";
12
+ import { reportingLocallyDisabled } from "./reporting-privacy.js";
12
13
  import * as path from "node:path";
13
14
  import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
14
- import { companyBindingChanged, reboundSessionProjection, } from "../../work-context/company-rebind.js";
15
+ import { companyBindingChanged, isNonCloudCompanySlug, reboundSessionProjection, } from "../../work-context/company-rebind.js";
15
16
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
16
17
  import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
17
18
  import { readSessionState, updateSessionState, writeSessionState } from "../../work-context/state.js";
@@ -172,6 +173,12 @@ async function postBatchWithRetries(poster, body, deps) {
172
173
  * Recovers orphan `*.claimed` files first; restores unprocessed lines on throw.
173
174
  */
174
175
  export async function flushSessionEvents(inputDeps) {
176
+ const env = inputDeps.env ?? process.env;
177
+ if (reportingLocallyDisabled(env) ||
178
+ (inputDeps.reportingAllowed && !(await inputDeps.reportingAllowed()))) {
179
+ return { claimed: 0, posted: 0, held: 0, dropped: 0, deadLettered: 0,
180
+ restored: 0, batches: 0, heldClaimed: false, heldOverflow: 0 };
181
+ }
175
182
  // One resolver per flush: git lookups are cached per cwd for this flush only.
176
183
  const deps = {
177
184
  ...inputDeps,
@@ -268,6 +275,30 @@ export async function flushSessionEvents(inputDeps) {
268
275
  break sessionLoop;
269
276
  let state = readSessionState(sessionId, deps.workContextRoot);
270
277
  state = applySessionMetaCompanyRebind(sessionId, items, state, deps, now);
278
+ // Personal is the documented non-cloud scope, even if an old projection
279
+ // still contains a company UID or a pending presence leave.
280
+ if (isNonCloudCompanySlug(state?.companySlug) || items.some(item => isNonCloudCompanySlug(eventText(item.event, "companySlug")))) {
281
+ summary.dropped += items.length;
282
+ for (const item of items)
283
+ markDisposed(item.event);
284
+ continue;
285
+ }
286
+ if (state?.companyConfidence === "device_default" && deps.authorizeDeviceDefaultTenant) {
287
+ await deps.authorizeDeviceDefaultTenant(state);
288
+ state = readSessionState(sessionId, deps.workContextRoot) ?? state;
289
+ }
290
+ if (state?.companyConfidence === "device_default") {
291
+ summary.held += appendHeldLinesIfAbsent(items.map(item => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? now().toISOString())), deps.workMeshRoot);
292
+ for (const item of items)
293
+ markDisposed(item.event);
294
+ continue;
295
+ }
296
+ if (isNonCloudCompanySlug(state?.companySlug)) {
297
+ summary.dropped += items.length;
298
+ for (const item of items)
299
+ markDisposed(item.event);
300
+ continue;
301
+ }
271
302
  const leaveResult = await postPresenceLeaveIfNeeded({
272
303
  sessionId,
273
304
  items,
@@ -726,7 +757,7 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now, uidCache)
726
757
  // Stay synchronous unless a uid lookup is needed: the flush loop's
727
758
  // microtask ordering is relied on by retry/back-pressure timing.
728
759
  const company = resolveHeldCompany(sessionId, items, state, deps);
729
- if (!company)
760
+ if (!company || isNonCloudCompanySlug(company.slug))
730
761
  return state;
731
762
  if (company.uid) {
732
763
  return writeHeldCompanyBinding(sessionId, state, { uid: company.uid, slug: company.slug, source: company.source }, deps, now);
@@ -996,7 +1027,7 @@ function classifySession(state) {
996
1027
  state.companyUid === state.ownerMismatch.companyUid) {
997
1028
  return { action: "drop", reason: "owner_mismatch" };
998
1029
  }
999
- if (state.contextStatus === "needs_company") {
1030
+ if (state.companyConfidence === "device_default" || state.contextStatus === "needs_company") {
1000
1031
  return { action: "hold", heldReason: "NEEDS_COMPANY" };
1001
1032
  }
1002
1033
  if (state.contextStatus === "company_conflict") {
@@ -0,0 +1,7 @@
1
+ /** Match the off controls used by the hook and handoff command. */
2
+ export declare function reportingLocallyDisabled(env?: NodeJS.ProcessEnv): boolean;
3
+ /** Legacy emit must not let the server infer a company for conversation text. */
4
+ export declare function mayEmitConversation(event: Record<string, unknown>, env?: NodeJS.ProcessEnv): boolean;
5
+ /** Fleet identities have no person preference; operator and local controls still apply. */
6
+ export declare function reportingRuntimeAllowed(machine: boolean, env?: NodeJS.ProcessEnv): Promise<boolean>;
7
+ //# sourceMappingURL=reporting-privacy.d.ts.map
@@ -0,0 +1,47 @@
1
+ import { anywhereRuntimeAllowed } from "../../anywhere-person-setting.js";
2
+ import { anywhereRuntimeEnabledFromSnapshot } from "../../anywhere-runtime-fast-flag.js";
3
+ import { hqAnywhereRuntimeLocallyDisabled } from "../../anywhere-runtime-flag-key.js";
4
+ import { isNonCloudCompanySlug } from "../../work-context/company-rebind.js";
5
+ import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
6
+ import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "../../work-context/repo-remote.js";
7
+ import { workContextRoot } from "../../work-context/paths.js";
8
+ import { readSessionState } from "../../work-context/state.js";
9
+ /** Match the off controls used by the hook and handoff command. */
10
+ export function reportingLocallyDisabled(env = process.env) {
11
+ const hooks = (env.HQ_DISABLED_HOOKS ?? "").split(",").map(value => value.trim());
12
+ return ["1", "true", "TRUE", "yes", "YES", "on", "ON"].includes(env.HQ_WORK_MESH_DISABLED ?? "") ||
13
+ hooks.some(value => ["*", "work-mesh", "work-mesh-live"].includes(value)) ||
14
+ hqAnywhereRuntimeLocallyDisabled(env);
15
+ }
16
+ /** Legacy emit must not let the server infer a company for conversation text. */
17
+ export function mayEmitConversation(event, env = process.env) {
18
+ if (event.kind !== "session_end" && typeof event.summary !== "string")
19
+ return true;
20
+ if (typeof event.sessionId !== "string" || !event.sessionId.trim())
21
+ return false;
22
+ const evidence = event.evidence && typeof event.evidence === "object"
23
+ ? event.evidence : {};
24
+ const text = (key) => typeof evidence[key] === "string" ? evidence[key] : undefined;
25
+ const root = workContextRoot(undefined, env);
26
+ const state = readSessionState(event.sessionId, root);
27
+ const meta = readMetaCompanySlug(event.sessionId, text("hqRoot"));
28
+ if (isNonCloudCompanySlug(meta) || isNonCloudCompanySlug(state?.companySlug) || isNonCloudCompanySlug(text("companySlug")) ||
29
+ state?.companyConfidence === "device_default" || state?.contextStatus === "untracked")
30
+ return false;
31
+ // The flushing process is not the originating session. Its spawn/company
32
+ // identity cannot authorize historical conversation text.
33
+ const resolved = resolveCompany({ root, sessionId: event.sessionId,
34
+ env: { HQ_AGENT_IDENTITY_FILE: "" },
35
+ cwd: text("cwd"), hqRoot: text("hqRoot"),
36
+ remoteOwnerSlug: deriveRemoteOwnerSlug({ cwd: text("cwd"), hqRoot: text("hqRoot") }) ?? undefined,
37
+ repoIdentityKey: text("cwd") ? deriveRepoIdentityKey({ cwd: text("cwd") }) ?? undefined : undefined,
38
+ trusted: { companySlug: text("companySlug") }, existingState: state });
39
+ return resolved.status === "resolved" && !isNonCloudCompanySlug(resolved.company.slug);
40
+ }
41
+ /** Fleet identities have no person preference; operator and local controls still apply. */
42
+ export async function reportingRuntimeAllowed(machine, env = process.env) {
43
+ if (reportingLocallyDisabled(env))
44
+ return false;
45
+ return machine ? anywhereRuntimeEnabledFromSnapshot(env) : anywhereRuntimeAllowed(env);
46
+ }
47
+ //# sourceMappingURL=reporting-privacy.js.map
@@ -44,6 +44,14 @@ export declare function recoverOrphanClaims(root: string): {
44
44
  };
45
45
  export declare function removeClaimFile(claimPath: string | null): void;
46
46
  export declare function deadLetterNonEmpty(root?: string): boolean;
47
+ /**
48
+ * Visit each line of a JSONL file without loading the whole file into one
49
+ * string. Queue files (dead-letter especially) can grow past V8's ~512 MB
50
+ * string ceiling, where readFileSync(..., "utf8") throws. Splits on the raw
51
+ * newline byte so multi-byte UTF-8 sequences are never cut. Returns false when
52
+ * the file cannot be opened.
53
+ */
54
+ export declare function forEachJsonlLine(filePath: string, visit: (line: string) => void): boolean;
47
55
  export declare function countJsonlLines(filePath: string): number;
48
56
  /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
49
57
  export declare function countDistinctHeldEvents(filePath: string): number;
@@ -334,40 +334,71 @@ export function deadLetterNonEmpty(root) {
334
334
  return false;
335
335
  }
336
336
  }
337
+ /** Chunk size for streaming JSONL reads. */
338
+ const JSONL_READ_CHUNK_BYTES = 1024 * 1024;
339
+ /**
340
+ * Visit each line of a JSONL file without loading the whole file into one
341
+ * string. Queue files (dead-letter especially) can grow past V8's ~512 MB
342
+ * string ceiling, where readFileSync(..., "utf8") throws. Splits on the raw
343
+ * newline byte so multi-byte UTF-8 sequences are never cut. Returns false when
344
+ * the file cannot be opened.
345
+ */
346
+ export function forEachJsonlLine(filePath, visit) {
347
+ let fd;
348
+ try {
349
+ fd = fs.openSync(filePath, "r");
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ try {
355
+ const chunk = Buffer.alloc(JSONL_READ_CHUNK_BYTES);
356
+ let carry = Buffer.alloc(0);
357
+ for (;;) {
358
+ const n = fs.readSync(fd, chunk, 0, chunk.length, null);
359
+ if (n <= 0)
360
+ break;
361
+ let data = carry.length > 0 ? Buffer.concat([carry, chunk.subarray(0, n)]) : chunk.subarray(0, n);
362
+ let start = 0;
363
+ for (let nl = data.indexOf(0x0a, start); nl !== -1; nl = data.indexOf(0x0a, start)) {
364
+ visit(data.toString("utf8", start, nl));
365
+ start = nl + 1;
366
+ }
367
+ carry = Buffer.from(data.subarray(start));
368
+ data = Buffer.alloc(0);
369
+ }
370
+ if (carry.length > 0)
371
+ visit(carry.toString("utf8"));
372
+ }
373
+ finally {
374
+ fs.closeSync(fd);
375
+ }
376
+ return true;
377
+ }
337
378
  export function countJsonlLines(filePath) {
338
- if (!fs.existsSync(filePath))
339
- return 0;
340
- const raw = fs.readFileSync(filePath, "utf8");
341
379
  let n = 0;
342
- for (const line of raw.split("\n")) {
380
+ forEachJsonlLine(filePath, (line) => {
343
381
  if (line.trim())
344
382
  n += 1;
345
- }
383
+ });
346
384
  return n;
347
385
  }
348
386
  /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
349
387
  export function countDistinctHeldEvents(filePath) {
350
- let raw;
351
- try {
352
- raw = fs.readFileSync(filePath, "utf8");
353
- }
354
- catch {
355
- // A concurrent claim can rename held.jsonl between existence and read checks.
356
- return 0;
357
- }
358
388
  const seen = new Set();
359
389
  let count = 0;
360
- for (const line of raw.split("\n")) {
390
+ // A concurrent claim can rename held.jsonl away; a missing file counts as 0.
391
+ forEachJsonlLine(filePath, (line) => {
361
392
  if (!line.trim())
362
- continue;
393
+ return;
363
394
  const eventId = heldEventId(line);
364
395
  if (eventId) {
365
396
  if (seen.has(eventId))
366
- continue;
397
+ return;
367
398
  seen.add(eventId);
368
399
  }
369
400
  count += 1;
370
- }
401
+ });
371
402
  return count;
372
403
  }
373
404
  function record(value) {
@@ -413,17 +444,12 @@ function isUnattributedLine(line) {
413
444
  }
414
445
  }
415
446
  function countUnattributedJsonlLines(filePath) {
416
- if (!fs.existsSync(filePath))
417
- return 0;
418
- try {
419
- return fs
420
- .readFileSync(filePath, "utf8")
421
- .split("\n")
422
- .filter((line) => line.trim() && isUnattributedLine(line)).length;
423
- }
424
- catch {
425
- return 0;
426
- }
447
+ let n = 0;
448
+ forEachJsonlLine(filePath, (line) => {
449
+ if (line.trim() && isUnattributedLine(line))
450
+ n += 1;
451
+ });
452
+ return n;
427
453
  }
428
454
  /**
429
455
  * Count the actionable, unfiled events across all local delivery queues.
@@ -438,27 +464,20 @@ export function countUnattributedEvents(root) {
438
464
  return { spool, held, deadLetter, total: spool + held + deadLetter };
439
465
  }
440
466
  function countUnattributedHeldEvents(filePath) {
441
- let raw;
442
- try {
443
- raw = fs.readFileSync(filePath, "utf8");
444
- }
445
- catch {
446
- // A concurrent held claim may rename this file between the read and open.
447
- return 0;
448
- }
449
467
  const seen = new Set();
450
468
  let count = 0;
451
- for (const line of raw.split("\n")) {
469
+ // A concurrent held claim may rename this file away; a missing file counts as 0.
470
+ forEachJsonlLine(filePath, (line) => {
452
471
  if (!line.trim() || !isUnattributedLine(line))
453
- continue;
472
+ return;
454
473
  const eventId = heldEventId(line);
455
474
  if (eventId) {
456
475
  if (seen.has(eventId))
457
- continue;
476
+ return;
458
477
  seen.add(eventId);
459
478
  }
460
479
  count += 1;
461
- }
480
+ });
462
481
  return count;
463
482
  }
464
483
  //# sourceMappingURL=spool.js.map
@@ -49,6 +49,28 @@ export declare const OVER_NAG_INTERVAL_MS: number;
49
49
  * as the bare key rather than a guess.
50
50
  */
51
51
  export declare const PLAN_LIMIT_DIMENSION_LABELS: Record<string, string>;
52
+ /**
53
+ * The account a nag payload is about, as hq-pro labels it (`account` beside
54
+ * `planLimits`). One command can touch a personal vault and a company vault,
55
+ * so the line names which one is over (feedback_b1867fe6).
56
+ */
57
+ export type PlanLimitAccount = {
58
+ kind: "person";
59
+ } | {
60
+ kind: "company";
61
+ slug: string | null;
62
+ };
63
+ /**
64
+ * Render one amount of a resource. Storage arrives in bytes; printed raw it
65
+ * reads as "47012700666 of 10737418240". It is shown in GB with one decimal,
66
+ * so a 10 GB ceiling reads "10.0 GB", matching the plan's own wording.
67
+ */
68
+ export declare function formatPlanLimitAmount(key: string, n: number): string;
69
+ /**
70
+ * The account as a noun phrase: "your personal account", "the acme company",
71
+ * or "this account" when an older hq-pro sent no label.
72
+ */
73
+ export declare function planLimitAccountPhrase(account: PlanLimitAccount | null | undefined): string;
52
74
  export interface PlanLimitEntry {
53
75
  used: number;
54
76
  limit: number;
@@ -75,10 +97,13 @@ export declare function recordPlanLimitStatus(body: unknown, opts?: {
75
97
  /**
76
98
  * The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
77
99
  * now repeats up to four times a day, and a multi-line box at that cadence
78
- * reads as breakage rather than as a nag. It states the two paused things and
79
- * never claims the workspace has stopped working.
100
+ * reads as breakage rather than as a nag. It names the account and states the
101
+ * facts. It claims no pause: being over is not being refused. A refusal comes
102
+ * back from hq-pro as its own error with its own sentence, and that is the
103
+ * only place a pause is reported (feedback_b1867fe6, where this line told a
104
+ * person their uploads were paused right after two uploads succeeded).
80
105
  */
81
- export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string): string;
106
+ export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string, account?: PlanLimitAccount | null): string;
82
107
  /**
83
108
  * The notice a personal feature refusal prints. `upgradeUrl` is the server's
84
109
  * own checkout target; an older hq-pro sends none, and the line then names the
@@ -95,7 +120,7 @@ export declare function renderPersonalPlanFeatureNotice(feature: string, upgrade
95
120
  * their own story, and a notice that announces a pause nobody is experiencing
96
121
  * reads as a bug.
97
122
  */
98
- export declare function renderThresholdNotice(crossing: PlanLimitThresholdCrossing, upgradeUrl: string): string;
123
+ export declare function renderThresholdNotice(crossing: PlanLimitThresholdCrossing, upgradeUrl: string, account?: PlanLimitAccount | null): string;
99
124
  /**
100
125
  * Print every crossing this machine has not already printed for this episode.
101
126
  *
@@ -106,6 +131,7 @@ export declare function renderThresholdNotice(crossing: PlanLimitThresholdCrossi
106
131
  export declare function emitPlanLimitThresholdNotice(crossings: readonly PlanLimitThresholdCrossing[], upgradeUrl: string | null, opts?: {
107
132
  write?: (s: string) => void;
108
133
  statePath?: string;
134
+ account?: PlanLimitAccount | null;
109
135
  }): void;
110
136
  /**
111
137
  * Emit a plan-limit nag to stderr at command completion.
@@ -63,6 +63,52 @@ export const PLAN_LIMIT_DIMENSION_LABELS = {
63
63
  deployments: "deployments",
64
64
  storageBytes: "storage",
65
65
  };
66
+ /**
67
+ * Bytes in one GB as plan limits count them: 2^30. hq-pro stores the "10 GB"
68
+ * and "2 GB" storage ceilings as GiB and its limit emails print them as
69
+ * "10 GB" / "2 GB"; `lib/billing/usage-cost.ts` uses the same unit
70
+ * (BYTES_PER_GB). Kept local so this hot-path module loads no billing code.
71
+ */
72
+ const STORAGE_BYTES_PER_GB = 1024 ** 3;
73
+ /**
74
+ * Render one amount of a resource. Storage arrives in bytes; printed raw it
75
+ * reads as "47012700666 of 10737418240". It is shown in GB with one decimal,
76
+ * so a 10 GB ceiling reads "10.0 GB", matching the plan's own wording.
77
+ */
78
+ export function formatPlanLimitAmount(key, n) {
79
+ if (key !== "storageBytes")
80
+ return String(n);
81
+ return `${(n / STORAGE_BYTES_PER_GB).toFixed(1)} GB`;
82
+ }
83
+ /**
84
+ * The account as a noun phrase: "your personal account", "the acme company",
85
+ * or "this account" when an older hq-pro sent no label.
86
+ */
87
+ export function planLimitAccountPhrase(account) {
88
+ if (account?.kind === "person")
89
+ return "your personal account";
90
+ if (account?.kind === "company" && account.slug)
91
+ return `the ${account.slug} company`;
92
+ return "this account";
93
+ }
94
+ function parseAccount(body) {
95
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
96
+ return null;
97
+ }
98
+ const raw = body.account;
99
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
100
+ return null;
101
+ const rec = raw;
102
+ if (rec.kind === "person")
103
+ return { kind: "person" };
104
+ if (rec.kind === "company") {
105
+ const slug = typeof rec.slug === "string" && rec.slug.trim().length > 0
106
+ ? rec.slug.trim()
107
+ : null;
108
+ return { kind: "company", slug };
109
+ }
110
+ return null;
111
+ }
66
112
  function dimensionSuffix(key) {
67
113
  const label = PLAN_LIMIT_DIMENSION_LABELS[key];
68
114
  return label && label !== key ? ` (${label})` : "";
@@ -252,14 +298,15 @@ export function recordPlanLimitStatus(body, opts = {}) {
252
298
  legacyUrl: status?.upgradeUrl ?? PLAN_LIMIT_UPGRADE_URL,
253
299
  });
254
300
  }
301
+ const account = parseAccount(body);
255
302
  if (status !== null) {
256
303
  const anyOver = Object.values(status.limits).some((e) => e.over);
257
- lastSeen = { ...status, anyOver, upgradeUrl: resolvedUpgradeUrl };
304
+ lastSeen = { ...status, anyOver, upgradeUrl: resolvedUpgradeUrl, account };
258
305
  }
259
306
  // Independent of `planLimits`: the server can announce a crossing on a
260
307
  // response whose nag map this CLI could not parse, and the notice is the
261
308
  // one surface that must land inside a session that is already running.
262
- emitPlanLimitThresholdNotice(crossings, resolvedUpgradeUrl, opts);
309
+ emitPlanLimitThresholdNotice(crossings, resolvedUpgradeUrl, { ...opts, account });
263
310
  }
264
311
  catch {
265
312
  // Never throw from record path.
@@ -287,7 +334,7 @@ function formatPct(entry) {
287
334
  return `${Math.round((entry.used / entry.limit) * 100)}%`;
288
335
  }
289
336
  function formatEntryLine(key, entry) {
290
- return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})${dimensionSuffix(key)}`;
337
+ return `${key} at ${formatPlanLimitAmount(key, entry.used)}/${formatPlanLimitAmount(key, entry.limit)} (${formatPct(entry)})${dimensionSuffix(key)}`;
291
338
  }
292
339
  /**
293
340
  * Read the whole state file. A missing, unreadable or malformed file is an
@@ -360,16 +407,18 @@ function withinNagWindow(shownAt, nowMs) {
360
407
  /**
361
408
  * The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
362
409
  * now repeats up to four times a day, and a multi-line box at that cadence
363
- * reads as breakage rather than as a nag. It states the two paused things and
364
- * never claims the workspace has stopped working.
410
+ * reads as breakage rather than as a nag. It names the account and states the
411
+ * facts. It claims no pause: being over is not being refused. A refusal comes
412
+ * back from hq-pro as its own error with its own sentence, and that is the
413
+ * only place a pause is reported (feedback_b1867fe6, where this line told a
414
+ * person their uploads were paused right after two uploads succeeded).
365
415
  */
366
- export function buildOverLine(overEntries, upgradeUrl) {
416
+ export function buildOverLine(overEntries, upgradeUrl, account = null) {
367
417
  const facts = overEntries
368
- .map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${entry.used} of ${entry.limit}`)
418
+ .map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${formatPlanLimitAmount(key, entry.used)} of ${formatPlanLimitAmount(key, entry.limit)}`)
369
419
  .join(", ");
370
420
  // US-024: names no plan and offers no upgrade; the link is the Billing page.
371
- return (`⚠ Over this account's limits: ${facts}. New files and new secrets are paused. ` +
372
- `Billing: ${upgradeUrl}`);
421
+ return `⚠ Over ${planLimitAccountPhrase(account)}'s limits: ${facts}. Billing: ${upgradeUrl}`;
373
422
  }
374
423
  // ---------------------------------------------------------------------------
375
424
  // Personal feature notice (US-054, copy per US-024)
@@ -424,12 +473,14 @@ function dimensionName(resource) {
424
473
  * their own story, and a notice that announces a pause nobody is experiencing
425
474
  * reads as a bug.
426
475
  */
427
- export function renderThresholdNotice(crossing, upgradeUrl) {
476
+ export function renderThresholdNotice(crossing, upgradeUrl, account = null) {
428
477
  const name = dimensionName(crossing.resource);
429
- const counts = `${crossing.used} of ${crossing.limit}`;
478
+ const counts = `${formatPlanLimitAmount(crossing.resource, crossing.used)} of ${formatPlanLimitAmount(crossing.resource, crossing.limit)}`;
479
+ // An unlabelled payload (older hq-pro) keeps the original wording.
480
+ const where = account ? ` for ${planLimitAccountPhrase(account)}` : "";
430
481
  const headline = crossing.band === "over"
431
- ? `You are past your ${name} limit (${counts}).`
432
- : `You have reached 80% of your ${name} limit (${counts}).`;
482
+ ? `You are past your ${name} limit${where} (${counts}).`
483
+ : `You have reached 80% of your ${name} limit${where} (${counts}).`;
433
484
  return `${headline} Billing: ${upgradeUrl}`;
434
485
  }
435
486
  /**
@@ -461,7 +512,7 @@ export function emitPlanLimitThresholdNotice(crossings, upgradeUrl, opts = {}) {
461
512
  continue;
462
513
  thresholdsShownThisSession.set(key, crossing.firstSeenAt);
463
514
  printed[key] = crossing.firstSeenAt;
464
- write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl)) + "\n");
515
+ write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl, opts.account ?? null)) + "\n");
465
516
  if (isCliLimitResourceKind(crossing.resource)) {
466
517
  queueCliLimitNoticeExposure(crossing.resource, "advisory");
467
518
  }
@@ -489,7 +540,7 @@ export function emitPlanLimitNag(opts = {}) {
489
540
  const write = opts.write ?? ((s) => process.stderr.write(s));
490
541
  const now = opts.now ?? (() => new Date());
491
542
  const statePath = opts.statePath ?? defaultStatePath();
492
- const { limits, anyOver, upgradeUrl } = lastSeen;
543
+ const { limits, anyOver, upgradeUrl, account } = lastSeen;
493
544
  const resolvedUpgradeUrl = upgradeUrl ??
494
545
  planLimitUpgradeFallbackUrl(resolveProcessFlagCompanyContext().companySlug, PLAN_LIMIT_UPGRADE_URL);
495
546
  const entries = Object.entries(limits);
@@ -504,7 +555,7 @@ export function emitPlanLimitNag(opts = {}) {
504
555
  return;
505
556
  overShownThisSession = true;
506
557
  const overEntries = entries.filter(([, e]) => e.over);
507
- const line = buildOverLine(overEntries, resolvedUpgradeUrl);
558
+ const line = buildOverLine(overEntries, resolvedUpgradeUrl, account);
508
559
  write(chalk.yellow(line) + "\n");
509
560
  const resourceKind = overEntries[0]?.[0];
510
561
  if (resourceKind && isCliLimitResourceKind(resourceKind)) {
@@ -520,7 +571,7 @@ export function emitPlanLimitNag(opts = {}) {
520
571
  if (worst === null)
521
572
  return;
522
573
  warningShownThisSession = true;
523
- const line = `⚠ Nearing this account's limits: ${formatEntryLine(worst.key, worst.entry)}. Make room before you hit the cap. Billing: ${resolvedUpgradeUrl}`;
574
+ const line = `⚠ Nearing ${planLimitAccountPhrase(account)}'s limits: ${formatEntryLine(worst.key, worst.entry)}. Make room before you hit the cap. Billing: ${resolvedUpgradeUrl}`;
524
575
  write(chalk.yellow(line) + "\n");
525
576
  if (isCliLimitResourceKind(worst.key)) {
526
577
  queueCliLimitNoticeExposure(worst.key, "advisory");
@@ -1,3 +1,4 @@
1
+ import { type IncrementalOptions } from "./incremental.js";
1
2
  import { localDate } from "./local-date.js";
2
3
  import { type LockTakeover } from "./run-lock.js";
3
4
  import { type UploadDeps } from "./steps/upload.js";
@@ -130,6 +131,9 @@ export interface RunSignalsResult {
130
131
  alerts?: Record<string, RaiseSignalsFailureAlertsResult>;
131
132
  }
132
133
  export interface RunSignalsOptions {
134
+ source?: string;
135
+ incremental?: boolean;
136
+ incrementalOptions?: IncrementalOptions;
133
137
  hqRoot: string;
134
138
  company?: string;
135
139
  dryRun?: boolean;
@@ -12,7 +12,8 @@ import { spawnSync } from "node:child_process";
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import yaml from "js-yaml";
15
- import { loadSignalsSettings } from "./settings.js";
15
+ import { loadSignalsSettings, loadIncrementalSettings } from "./settings.js";
16
+ import { createIncrementalStep } from "./incremental.js";
16
17
  import { localDate } from "./local-date.js";
17
18
  import { guardedWrite, WriteGuardError } from "./write-guard.js";
18
19
  import { acquireCompanyLock, withSummaryLock } from "./run-lock.js";
@@ -46,6 +47,7 @@ export const defaultSpawn = (cmd, args, opts) => {
46
47
  cwd: opts.cwd,
47
48
  env: opts.env ?? process.env,
48
49
  encoding: "utf8",
50
+ input: opts.input,
49
51
  maxBuffer: 64 * 1024 * 1024,
50
52
  ...(opts.timeoutMs !== undefined ? { timeout: opts.timeoutMs, killSignal: "SIGKILL" } : {}),
51
53
  });
@@ -246,9 +248,22 @@ export async function runSignals(opts) {
246
248
  const stepChoice = opts.step ?? "all";
247
249
  const dryRun = opts.dryRun === true;
248
250
  const settings = loadSignalsSettings(hqRoot);
249
- const maxTurns = opts.maxTurns ?? settings.maxTurns;
250
- const maxBudgetUsd = opts.maxBudgetUsd ?? settings.maxBudgetUsd;
251
+ if (opts.source !== undefined || opts.incremental) {
252
+ if (!opts.source || !opts.incremental || !opts.company)
253
+ throw new SignalsUsageError("--source and --incremental require each other and --company");
254
+ if ((opts.step && opts.step !== "all" && opts.step !== "extract") || opts.redo?.length)
255
+ throw new SignalsUsageError("--incremental cannot be combined with --redo or another step");
256
+ if (!/^[a-zA-Z0-9_-]+:[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(opts.source))
257
+ throw new SignalsUsageError("invalid --source: expected <channel>:<file>");
258
+ }
259
+ const limits = opts.incremental ? loadIncrementalSettings(hqRoot) : settings;
260
+ const maxTurns = opts.maxTurns ?? limits.maxTurns;
261
+ const maxBudgetUsd = opts.maxBudgetUsd ?? limits.maxBudgetUsd;
262
+ if (!Number.isInteger(maxTurns) || maxTurns < 1 || !Number.isFinite(maxBudgetUsd) || maxBudgetUsd < 0)
263
+ throw new SignalsUsageError("invalid model cost limits");
251
264
  const stepImpls = { ...DEFAULT_STEPS, ...(opts.steps ?? {}) };
265
+ if (opts.incremental)
266
+ stepImpls.extract = createIncrementalStep(opts.source, opts.incrementalOptions);
252
267
  if (opts.company !== undefined && !SLUG_RE.test(opts.company)) {
253
268
  throw new SignalsUsageError(`invalid company slug: ${opts.company}`);
254
269
  }
@@ -266,12 +281,14 @@ export async function runSignals(opts) {
266
281
  const companies = opts.company !== undefined ? [opts.company] : listCompanies(hqRoot);
267
282
  const start = now();
268
283
  const date = localDate(start);
269
- const lateStart = trigger === "job" && start.getHours() >= settings.lateStartHour;
284
+ const lateStart = !opts.incremental && trigger === "job" && start.getHours() >= settings.lateStartHour;
270
285
  let plan = stepChoice === "all" ? STEP_ORDER : STEP_ORDER.filter((s) => s === stepChoice);
271
286
  if (stepChoice !== "all")
272
287
  plan = plan.slice(0, 1);
273
288
  if (lateStart)
274
289
  plan = plan.filter((s) => s === "garden").slice(0, 1);
290
+ if (opts.incremental)
291
+ plan = ["extract"];
275
292
  const records = [];
276
293
  const locked = [];
277
294
  for (const company of companies) {
@@ -324,7 +341,10 @@ export async function runSignals(opts) {
324
341
  };
325
342
  const finish = () => {
326
343
  const decidedSkip = record.skipReason;
344
+ const previousJobRanAt = record.jobRanAt;
327
345
  finalizeRecord(record, { lateStart, lateStartHour: settings.lateStartHour, finishedAt: now() });
346
+ if (opts.incremental)
347
+ record.jobRanAt = previousJobRanAt;
328
348
  if (dryRun) {
329
349
  if (!decidedSkip) {
330
350
  record.status = "dry-run";
@@ -343,7 +363,7 @@ export async function runSignals(opts) {
343
363
  // 2. The day's job already ran: do nothing and leave the record as it is.
344
364
  const prior = readJson(hqRoot, rel);
345
365
  const priorJobRanAt = typeof prior?.jobRanAt === "string" ? prior.jobRanAt : null;
346
- if (trigger === "job" && !opts.force && prior && priorJobRanAt) {
366
+ if (!opts.incremental && trigger === "job" && !opts.force && prior && priorJobRanAt) {
347
367
  process.stderr.write(`hq signals: ${company}: already-ran (the job ran at ${priorJobRanAt})\n`);
348
368
  records.push({ ...prior, alreadyRanToday: true });
349
369
  continue;
@@ -0,0 +1,42 @@
1
+ import { type PriorSignal } from "./steps/extract.js";
2
+ import type { SignalsStep, StepContext } from "./steps/types.js";
3
+ export { meetingSignalSourceRef } from "../hot-channels/meeting-source-ref.js";
4
+ export interface IncrementalCursor {
5
+ revision: number;
6
+ segment_count: number;
7
+ last_run_at: string;
8
+ signal_ids: string[];
9
+ digest: string;
10
+ pending_events?: SignalWritten[];
11
+ }
12
+ export interface SourceSnapshot {
13
+ sourceRef: string;
14
+ text: string;
15
+ segmentCount: number;
16
+ revision?: number;
17
+ audience: string[];
18
+ }
19
+ export type SourceReader = (ctx: StepContext, id: string) => Promise<SourceSnapshot>;
20
+ export type SignalWritten = {
21
+ event: "signal.written";
22
+ signal_id: string;
23
+ type: string;
24
+ source_ref: string;
25
+ company: string;
26
+ key: string;
27
+ };
28
+ export interface IncrementalOptions {
29
+ readers?: Readonly<Record<string, SourceReader>>;
30
+ /** US-004/005 integration seam: one callback per newly persisted signal. */
31
+ onSignalWritten?: (event: SignalWritten) => Promise<void>;
32
+ }
33
+ export declare const SOURCE_READERS: Readonly<Record<string, SourceReader>>;
34
+ export declare function readIncrementalSource(ctx: StepContext, ref: string, readers?: Readonly<Record<string, SourceReader>>): Promise<SourceSnapshot>;
35
+ export declare function cursorPath(company: string, sourceRef: string): string;
36
+ /** Only read the current audience partition, never another audience's signals. */
37
+ export declare function priorSignalsFor(ctx: StepContext, sourceRef: string, audience: string[]): Array<PriorSignal & {
38
+ key: string;
39
+ live: boolean;
40
+ }>;
41
+ export declare function createIncrementalStep(source: string, options?: IncrementalOptions): SignalsStep;
42
+ //# sourceMappingURL=incremental.d.ts.map