@indigoai-us/hq-cli 5.108.25 → 5.108.26

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 (34) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/commands/integrations-api.d.ts +15 -0
  3. package/dist/commands/integrations-connect.js +84 -3
  4. package/dist/commands/integrations-oauth.js +62 -3
  5. package/dist/commands/mcp-registration.d.ts +17 -7
  6. package/dist/commands/mcp-registration.js +16 -27
  7. package/dist/commands/mesh.js +174 -50
  8. package/dist/commands/pack-install.js +5 -5
  9. package/dist/commands/secrets.d.ts +7 -0
  10. package/dist/commands/secrets.js +26 -2
  11. package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
  12. package/dist/lib/mesh/live/backfill-held.js +95 -13
  13. package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
  14. package/dist/lib/mesh/live/daemon/doctor.js +41 -10
  15. package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
  16. package/dist/lib/mesh/live/daemon/mode.js +88 -0
  17. package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
  18. package/dist/lib/mesh/live/daemon/run.js +39 -28
  19. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  20. package/dist/lib/mesh/live/emit-client.d.ts +99 -0
  21. package/dist/lib/mesh/live/emit-client.js +193 -0
  22. package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
  23. package/dist/lib/mesh/live/emit-evidence.js +77 -0
  24. package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
  25. package/dist/lib/mesh/live/emit-replay.js +157 -0
  26. package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
  27. package/dist/lib/mesh/live/emit-retry.js +79 -0
  28. package/dist/lib/mesh/live/emit.d.ts +54 -0
  29. package/dist/lib/mesh/live/emit.js +153 -0
  30. package/dist/lib/narrow-hint-banner.d.ts +3 -7
  31. package/dist/lib/narrow-hint-banner.js +13 -34
  32. package/dist/lib/plan-limit-nag.d.ts +0 -3
  33. package/dist/lib/plan-limit-nag.js +10 -20
  34. package/package.json +1 -1
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client-side attribution evidence for direct-emit (owner decision 2026-09-08:
3
+ * "sessions post directly; server attributes"). The hook gathers raw signals of
4
+ * the work — cwd, the files a tool touched, the enclosing repo, the session's
5
+ * bound company_slug, and any reported project/task — and the server decides the
6
+ * company. The client NEVER decides; it only reports evidence.
7
+ *
8
+ * Pure apart from reading the session meta (company_slug) and the enclosing git
9
+ * work-tree off disk. Never reads prompts/tokens; never hits the network.
10
+ */
11
+ /** Evidence fields carried on each emitted event (contract direct-emit-v1). */
12
+ export interface EmitEvidence {
13
+ cwd?: string;
14
+ hqRoot?: string;
15
+ /** The session's explicitly bound company (meta company_slug). */
16
+ companySlug?: string;
17
+ /** Distinct file paths the session's tool calls touched (absolute). */
18
+ touchedPaths?: string[];
19
+ /** Enclosing repo work-tree root of the cwd / touched files. */
20
+ repoPath?: string;
21
+ project?: string;
22
+ task?: string;
23
+ }
24
+ export interface BuildEmitEvidenceInput {
25
+ sessionId: string;
26
+ hqRoot?: string;
27
+ cwd?: string;
28
+ /** Raw touched paths from the tool payload (file_path, edits, notebook_path…). */
29
+ touchedPaths?: Array<string | undefined | null>;
30
+ /** Explicit repo path override (else derived from cwd / touched files). */
31
+ repoPath?: string;
32
+ /** Explicit bound company (else read from the session meta). */
33
+ companySlug?: string;
34
+ project?: string;
35
+ task?: string;
36
+ /** Injectable meta reader (tests). */
37
+ readMeta?: (sessionId: string, hqRoot: string | undefined) => string | undefined;
38
+ /** Injectable work-tree finder (tests). */
39
+ findRepo?: (startDir: string) => string | null;
40
+ }
41
+ /** Dedupe absolute-ish paths, preserving first-seen order. */
42
+ export declare function normalizeTouchedPaths(paths: Array<string | undefined | null>): string[];
43
+ /**
44
+ * Assemble the evidence bag for one event. companySlug falls back to the session
45
+ * meta; repoPath falls back to the enclosing git work-tree of the cwd (or the
46
+ * first touched file). All fields are omitted when empty.
47
+ */
48
+ export declare function buildEmitEvidence(input: BuildEmitEvidenceInput): EmitEvidence;
49
+ //# sourceMappingURL=emit-evidence.d.ts.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Client-side attribution evidence for direct-emit (owner decision 2026-09-08:
3
+ * "sessions post directly; server attributes"). The hook gathers raw signals of
4
+ * the work — cwd, the files a tool touched, the enclosing repo, the session's
5
+ * bound company_slug, and any reported project/task — and the server decides the
6
+ * company. The client NEVER decides; it only reports evidence.
7
+ *
8
+ * Pure apart from reading the session meta (company_slug) and the enclosing git
9
+ * work-tree off disk. Never reads prompts/tokens; never hits the network.
10
+ */
11
+ import * as path from "node:path";
12
+ import { readMetaCompanySlug } from "../../work-context/company.js";
13
+ import { findWorkTreeRoot } from "../../work-context/repo-remote.js";
14
+ function clean(v) {
15
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
16
+ }
17
+ /** Dedupe absolute-ish paths, preserving first-seen order. */
18
+ export function normalizeTouchedPaths(paths) {
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const p of paths) {
22
+ const c = clean(p);
23
+ if (!c)
24
+ continue;
25
+ const abs = path.isAbsolute(c) ? path.normalize(c) : c;
26
+ if (seen.has(abs))
27
+ continue;
28
+ seen.add(abs);
29
+ out.push(abs);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Assemble the evidence bag for one event. companySlug falls back to the session
35
+ * meta; repoPath falls back to the enclosing git work-tree of the cwd (or the
36
+ * first touched file). All fields are omitted when empty.
37
+ */
38
+ export function buildEmitEvidence(input) {
39
+ const readMeta = input.readMeta ?? readMetaCompanySlug;
40
+ const findRepo = input.findRepo ?? findWorkTreeRoot;
41
+ const cwd = clean(input.cwd);
42
+ const hqRoot = clean(input.hqRoot);
43
+ const touchedPaths = normalizeTouchedPaths(input.touchedPaths ?? []);
44
+ const companySlug = clean(input.companySlug) ?? clean(readMeta(input.sessionId, hqRoot));
45
+ let repoPath = clean(input.repoPath);
46
+ if (!repoPath) {
47
+ // Search from the cwd (a directory) or the directory of the first touched
48
+ // file for the enclosing git work-tree.
49
+ const startDir = cwd ?? firstFileDir(touchedPaths);
50
+ if (startDir)
51
+ repoPath = clean(findRepo(startDir) ?? undefined);
52
+ }
53
+ const evidence = {};
54
+ if (cwd)
55
+ evidence.cwd = cwd;
56
+ if (hqRoot)
57
+ evidence.hqRoot = hqRoot;
58
+ if (companySlug)
59
+ evidence.companySlug = companySlug;
60
+ if (touchedPaths.length > 0)
61
+ evidence.touchedPaths = touchedPaths;
62
+ if (repoPath)
63
+ evidence.repoPath = repoPath;
64
+ const project = clean(input.project);
65
+ if (project)
66
+ evidence.project = project;
67
+ const task = clean(input.task);
68
+ if (task)
69
+ evidence.task = task;
70
+ return evidence;
71
+ }
72
+ /** Directory of the first absolute touched file, for work-tree discovery. */
73
+ function firstFileDir(touchedPaths) {
74
+ const abs = touchedPaths.find((p) => path.isAbsolute(p));
75
+ return abs ? path.dirname(abs) : undefined;
76
+ }
77
+ //# sourceMappingURL=emit-evidence.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Replay the LEGACY local backlog (spool.jsonl + held.jsonl) through the new
3
+ * direct-emit endpoint (owner decision 2026-09-08 + contract direct-emit-v1 §6.3
4
+ * "deprecated, not deleted"). Each legacy line already carries the attribution
5
+ * evidence the daemon used to hold on (cwd / hqRoot / companySlug / project /
6
+ * task); we lift those into the `evidence` object the server now attributes
7
+ * from, and post with the event's original eventId so the server dedupes.
8
+ *
9
+ * Read-only over the legacy files — the caller (emitEvents) owns disposition and
10
+ * the retry file; this module never deletes spool/held (a follow-up removes the
11
+ * legacy queue once replay is confirmed).
12
+ */
13
+ import { type MeshEmitEvent } from "./emit-client.js";
14
+ /** Convert one legacy spool/held event object into a MeshEmitEvent, or null. */
15
+ export declare function legacyEventToEmit(raw: Record<string, unknown>): MeshEmitEvent | null;
16
+ export interface LegacyBacklog {
17
+ events: MeshEmitEvent[];
18
+ scanned: number;
19
+ skipped: number;
20
+ }
21
+ /**
22
+ * Read spool.jsonl + held.jsonl, convert to emit events, dedupe by eventId.
23
+ * The daemon's held retry may duplicate a spool line; first occurrence wins.
24
+ */
25
+ export declare function readLegacyBacklog(workMeshRoot: string): LegacyBacklog;
26
+ //# sourceMappingURL=emit-replay.d.ts.map
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Replay the LEGACY local backlog (spool.jsonl + held.jsonl) through the new
3
+ * direct-emit endpoint (owner decision 2026-09-08 + contract direct-emit-v1 §6.3
4
+ * "deprecated, not deleted"). Each legacy line already carries the attribution
5
+ * evidence the daemon used to hold on (cwd / hqRoot / companySlug / project /
6
+ * task); we lift those into the `evidence` object the server now attributes
7
+ * from, and post with the event's original eventId so the server dedupes.
8
+ *
9
+ * Read-only over the legacy files — the caller (emitEvents) owns disposition and
10
+ * the retry file; this module never deletes spool/held (a follow-up removes the
11
+ * legacy queue once replay is confirmed).
12
+ */
13
+ import * as fs from "node:fs";
14
+ import { workMeshSpoolPath, workMeshHeldPath } from "./paths.js";
15
+ import { buildMeshEmitEvent } from "./emit-client.js";
16
+ const KINDS = new Set([
17
+ "session_start",
18
+ "turn_start",
19
+ "turn_end",
20
+ "session_end",
21
+ "task_status",
22
+ "blocked",
23
+ "note",
24
+ ]);
25
+ const HARNESSES = new Set([
26
+ "claude-code",
27
+ "claude-desktop",
28
+ "codex",
29
+ "grok",
30
+ "hq-sessions",
31
+ "agent-box",
32
+ "agents-v2",
33
+ ]);
34
+ function str(v) {
35
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
36
+ }
37
+ /** Peel a held envelope { event, heldReason, heldAt } → the inner event. */
38
+ function innerEvent(obj) {
39
+ if (obj.event &&
40
+ typeof obj.event === "object" &&
41
+ !Array.isArray(obj.event) &&
42
+ typeof obj.heldReason === "string") {
43
+ return obj.event;
44
+ }
45
+ return obj;
46
+ }
47
+ /** Convert one legacy spool/held event object into a MeshEmitEvent, or null. */
48
+ export function legacyEventToEmit(raw) {
49
+ const event = innerEvent(raw);
50
+ const eventId = str(event.eventId);
51
+ const kind = str(event.kind);
52
+ const sessionId = str(event.sessionId);
53
+ const harness = str(event.harness);
54
+ const adapterVersion = str(event.adapterVersion);
55
+ const at = str(event.at);
56
+ const seq = typeof event.seq === "number" ? event.seq : Number(event.seq);
57
+ if (!eventId ||
58
+ !kind ||
59
+ !KINDS.has(kind) ||
60
+ !sessionId ||
61
+ !harness ||
62
+ !HARNESSES.has(harness) ||
63
+ !adapterVersion ||
64
+ !at ||
65
+ !Number.isInteger(seq) ||
66
+ seq < 1) {
67
+ return null;
68
+ }
69
+ const evidence = {};
70
+ const cwd = str(event.cwd);
71
+ if (cwd)
72
+ evidence.cwd = cwd;
73
+ const hqRoot = str(event.hqRoot);
74
+ if (hqRoot)
75
+ evidence.hqRoot = hqRoot;
76
+ const companySlug = str(event.companySlug);
77
+ if (companySlug)
78
+ evidence.companySlug = companySlug;
79
+ const project = str(event.project);
80
+ if (project)
81
+ evidence.project = project;
82
+ const task = str(event.task);
83
+ if (task)
84
+ evidence.task = task;
85
+ if (Array.isArray(event.touchedPaths)) {
86
+ const tp = event.touchedPaths.filter((p) => typeof p === "string" && p.trim().length > 0);
87
+ if (tp.length > 0)
88
+ evidence.touchedPaths = tp;
89
+ }
90
+ return buildMeshEmitEvent({
91
+ eventId,
92
+ kind,
93
+ sessionId,
94
+ harness,
95
+ adapterVersion,
96
+ at,
97
+ seq,
98
+ runtimeVersion: str(event.runtimeVersion),
99
+ source: event.source === "transcript" ? "transcript" : "hooks",
100
+ taskId: str(event.taskId),
101
+ status: str(event.status),
102
+ reason: str(event.reason),
103
+ summary: str(event.summary),
104
+ evidence,
105
+ });
106
+ }
107
+ function readJsonl(filePath) {
108
+ let raw;
109
+ try {
110
+ raw = fs.readFileSync(filePath, "utf8");
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ const out = [];
116
+ for (const line of raw.split("\n")) {
117
+ const t = line.trim();
118
+ if (!t)
119
+ continue;
120
+ try {
121
+ const v = JSON.parse(t);
122
+ if (v && typeof v === "object" && !Array.isArray(v)) {
123
+ out.push(v);
124
+ }
125
+ }
126
+ catch {
127
+ /* skip */
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Read spool.jsonl + held.jsonl, convert to emit events, dedupe by eventId.
134
+ * The daemon's held retry may duplicate a spool line; first occurrence wins.
135
+ */
136
+ export function readLegacyBacklog(workMeshRoot) {
137
+ const rows = [
138
+ ...readJsonl(workMeshSpoolPath(workMeshRoot)),
139
+ ...readJsonl(workMeshHeldPath(workMeshRoot)),
140
+ ];
141
+ const events = [];
142
+ const seen = new Set();
143
+ let skipped = 0;
144
+ for (const row of rows) {
145
+ const ev = legacyEventToEmit(row);
146
+ if (!ev) {
147
+ skipped += 1;
148
+ continue;
149
+ }
150
+ if (seen.has(ev.eventId))
151
+ continue;
152
+ seen.add(ev.eventId);
153
+ events.push(ev);
154
+ }
155
+ return { events, scanned: rows.length, skipped };
156
+ }
157
+ //# sourceMappingURL=emit-replay.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Tiny local retry file for direct-emit (owner decision 2026-09-08). When a
3
+ * POST /v1/mesh/events fails (network / 5xx / 429), the batch is appended here
4
+ * and the NEXT `hq mesh emit` invocation drains it. No long-lived process; no
5
+ * spool/held queue. Bounded so a long offline stretch cannot grow unbounded —
6
+ * the oldest overflow is dropped with a log (events are best-effort telemetry,
7
+ * never user data).
8
+ */
9
+ /** Default cap on retained retry events (oldest dropped past this). */
10
+ export declare const EMIT_RETRY_MAX = 5000;
11
+ export declare function emitRetryPath(workMeshRoot: string): string;
12
+ /** Read pending retry events (parseable objects only). Missing file → []. */
13
+ export declare function readEmitRetry(workMeshRoot: string): Record<string, unknown>[];
14
+ /**
15
+ * Atomically replace the retry file with `events` (keeping the most recent
16
+ * EMIT_RETRY_MAX). Returns the number of oldest events dropped by the cap.
17
+ * Empty input removes the file.
18
+ */
19
+ export declare function writeEmitRetry(workMeshRoot: string, events: Record<string, unknown>[], max?: number): {
20
+ written: number;
21
+ dropped: number;
22
+ };
23
+ /** Count of pending retry events (doctor retryDepth). */
24
+ export declare function emitRetryDepth(workMeshRoot: string): number;
25
+ //# sourceMappingURL=emit-retry.d.ts.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tiny local retry file for direct-emit (owner decision 2026-09-08). When a
3
+ * POST /v1/mesh/events fails (network / 5xx / 429), the batch is appended here
4
+ * and the NEXT `hq mesh emit` invocation drains it. No long-lived process; no
5
+ * spool/held queue. Bounded so a long offline stretch cannot grow unbounded —
6
+ * the oldest overflow is dropped with a log (events are best-effort telemetry,
7
+ * never user data).
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ /** Default cap on retained retry events (oldest dropped past this). */
12
+ export const EMIT_RETRY_MAX = 5000;
13
+ export function emitRetryPath(workMeshRoot) {
14
+ return path.join(workMeshRoot, "emit-retry.jsonl");
15
+ }
16
+ function ensureFileDir(filePath) {
17
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
18
+ }
19
+ /** Read pending retry events (parseable objects only). Missing file → []. */
20
+ export function readEmitRetry(workMeshRoot) {
21
+ const p = emitRetryPath(workMeshRoot);
22
+ let raw;
23
+ try {
24
+ raw = fs.readFileSync(p, "utf8");
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const out = [];
30
+ for (const line of raw.split("\n")) {
31
+ const t = line.trim();
32
+ if (!t)
33
+ continue;
34
+ try {
35
+ const v = JSON.parse(t);
36
+ if (v && typeof v === "object" && !Array.isArray(v)) {
37
+ out.push(v);
38
+ }
39
+ }
40
+ catch {
41
+ /* skip malformed */
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Atomically replace the retry file with `events` (keeping the most recent
48
+ * EMIT_RETRY_MAX). Returns the number of oldest events dropped by the cap.
49
+ * Empty input removes the file.
50
+ */
51
+ export function writeEmitRetry(workMeshRoot, events, max = EMIT_RETRY_MAX) {
52
+ const p = emitRetryPath(workMeshRoot);
53
+ let dropped = 0;
54
+ let keep = events;
55
+ if (max > 0 && events.length > max) {
56
+ dropped = events.length - max;
57
+ keep = events.slice(events.length - max);
58
+ }
59
+ if (keep.length === 0) {
60
+ try {
61
+ fs.rmSync(p, { force: true });
62
+ }
63
+ catch {
64
+ /* best-effort */
65
+ }
66
+ return { written: 0, dropped };
67
+ }
68
+ ensureFileDir(p);
69
+ const body = keep.map((e) => JSON.stringify(e)).join("\n") + "\n";
70
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
71
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
72
+ fs.renameSync(tmp, p);
73
+ return { written: keep.length, dropped };
74
+ }
75
+ /** Count of pending retry events (doctor retryDepth). */
76
+ export function emitRetryDepth(workMeshRoot) {
77
+ return readEmitRetry(workMeshRoot).length;
78
+ }
79
+ //# sourceMappingURL=emit-retry.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Direct-emit orchestration (owner decision 2026-09-08). One invocation:
3
+ * 1. drains the local retry file (prior failures),
4
+ * 2. appends the new event(s),
5
+ * 3. POSTs /v1/mesh/events in batches with a short bounded retry,
6
+ * 4. keeps only network/5xx/429-failed or unaccounted events for next time,
7
+ * 5. records lastPostAt + per-event status counts for `hq mesh doctor`.
8
+ *
9
+ * accepted / unassigned / rejected are all terminal (rejected is dropped with a
10
+ * count — never re-posted). No long-lived process, spool, or held queue.
11
+ */
12
+ import { type EmitPoster, type EmitEventStatus } from "./emit-client.js";
13
+ import { type RandomFn, type SleepFn } from "./backoff.js";
14
+ export interface EmitStateFile {
15
+ lastPostAt?: string;
16
+ lastAttemptAt?: string;
17
+ lastAccepted?: number;
18
+ lastUnassigned?: number;
19
+ lastRejected?: number;
20
+ lastRetryDepth?: number;
21
+ lastError?: string;
22
+ }
23
+ export declare function emitStatePath(workMeshRoot: string): string;
24
+ export declare function readEmitState(workMeshRoot: string): EmitStateFile | null;
25
+ export interface EmitEventsDeps {
26
+ workMeshRoot: string;
27
+ poster: EmitPoster;
28
+ /** New events to emit this invocation (may be empty — just drain retry). */
29
+ newEvents?: Record<string, unknown>[];
30
+ /** Attempts per batch (short: default 3). */
31
+ maxAttempts?: number;
32
+ retryMax?: number;
33
+ now?: () => Date;
34
+ sleep?: SleepFn;
35
+ random?: RandomFn;
36
+ log?: (message: string) => void;
37
+ }
38
+ export interface EmitEventsSummary {
39
+ attempted: number;
40
+ accepted: number;
41
+ unassigned: number;
42
+ rejected: number;
43
+ /** Events kept in the retry file for a later invocation. */
44
+ retained: number;
45
+ /** Oldest events dropped by the retry cap. */
46
+ droppedOverflow: number;
47
+ retryDepth: number;
48
+ batches: number;
49
+ statuses: Record<EmitEventStatus, number>;
50
+ lastPostAt?: string;
51
+ }
52
+ /** Drain the retry file, post pending + new events, persist failures. */
53
+ export declare function emitEvents(deps: EmitEventsDeps): Promise<EmitEventsSummary>;
54
+ //# sourceMappingURL=emit.d.ts.map
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Direct-emit orchestration (owner decision 2026-09-08). One invocation:
3
+ * 1. drains the local retry file (prior failures),
4
+ * 2. appends the new event(s),
5
+ * 3. POSTs /v1/mesh/events in batches with a short bounded retry,
6
+ * 4. keeps only network/5xx/429-failed or unaccounted events for next time,
7
+ * 5. records lastPostAt + per-event status counts for `hq mesh doctor`.
8
+ *
9
+ * accepted / unassigned / rejected are all terminal (rejected is dropped with a
10
+ * count — never re-posted). No long-lived process, spool, or held queue.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { MESH_EVENTS_BATCH_MAX, parseEmitResults, } from "./emit-client.js";
15
+ import { EMIT_RETRY_MAX, readEmitRetry, writeEmitRetry, } from "./emit-retry.js";
16
+ import { defaultSleep, fullJitterDelayMs, } from "./backoff.js";
17
+ export function emitStatePath(workMeshRoot) {
18
+ return path.join(workMeshRoot, "emit-state.json");
19
+ }
20
+ export function readEmitState(workMeshRoot) {
21
+ try {
22
+ const raw = fs.readFileSync(emitStatePath(workMeshRoot), "utf8");
23
+ const v = JSON.parse(raw);
24
+ if (v && typeof v === "object" && !Array.isArray(v))
25
+ return v;
26
+ }
27
+ catch {
28
+ /* absent / malformed */
29
+ }
30
+ return null;
31
+ }
32
+ function writeEmitState(workMeshRoot, state) {
33
+ const p = emitStatePath(workMeshRoot);
34
+ try {
35
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
36
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
37
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
38
+ fs.renameSync(tmp, p);
39
+ }
40
+ catch {
41
+ /* best-effort */
42
+ }
43
+ }
44
+ function eventIdOf(e) {
45
+ return typeof e.eventId === "string" && e.eventId.trim() ? e.eventId.trim() : null;
46
+ }
47
+ /** Drain the retry file, post pending + new events, persist failures. */
48
+ export async function emitEvents(deps) {
49
+ const now = deps.now ?? (() => new Date());
50
+ const sleep = deps.sleep ?? defaultSleep;
51
+ const random = deps.random ?? Math.random;
52
+ const maxAttempts = deps.maxAttempts ?? 3;
53
+ const retryMax = deps.retryMax ?? EMIT_RETRY_MAX;
54
+ // Combine pending retry + new events, deduped by eventId (first wins).
55
+ const pending = readEmitRetry(deps.workMeshRoot);
56
+ const combined = [];
57
+ const seen = new Set();
58
+ for (const e of [...pending, ...(deps.newEvents ?? [])]) {
59
+ const id = eventIdOf(e);
60
+ if (id) {
61
+ if (seen.has(id))
62
+ continue;
63
+ seen.add(id);
64
+ }
65
+ combined.push(e);
66
+ }
67
+ const summary = {
68
+ attempted: combined.length,
69
+ accepted: 0,
70
+ unassigned: 0,
71
+ rejected: 0,
72
+ retained: 0,
73
+ droppedOverflow: 0,
74
+ retryDepth: 0,
75
+ batches: 0,
76
+ statuses: { accepted: 0, unassigned: 0, rejected: 0 },
77
+ };
78
+ const keep = [];
79
+ let anyPosted = false;
80
+ for (let i = 0; i < combined.length; i += MESH_EVENTS_BATCH_MAX) {
81
+ const chunk = combined.slice(i, i + MESH_EVENTS_BATCH_MAX);
82
+ summary.batches += 1;
83
+ let result = await deps.poster(chunk);
84
+ for (let attempt = 1; attempt < maxAttempts && !result.ok && result.retryable; attempt += 1) {
85
+ await sleep(fullJitterDelayMs(attempt - 1, { random }));
86
+ result = await deps.poster(chunk);
87
+ }
88
+ if (!result.ok) {
89
+ if (result.retryable) {
90
+ // Network/5xx/429 after retries → keep the whole chunk for next time.
91
+ keep.push(...chunk);
92
+ deps.log?.(`emit batch retained (${chunk.length}) status=${result.status}`);
93
+ }
94
+ else {
95
+ // Non-retryable 4xx (e.g. 401/400) → drop with a log (never user data).
96
+ summary.rejected += chunk.length;
97
+ summary.statuses.rejected += chunk.length;
98
+ deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status}`);
99
+ }
100
+ continue;
101
+ }
102
+ anyPosted = true;
103
+ const results = parseEmitResults(result.body);
104
+ if (!results) {
105
+ // Unparseable 2xx → retain rather than pretend posted.
106
+ keep.push(...chunk);
107
+ deps.log?.(`emit batch retained (${chunk.length}) unparseable 2xx`);
108
+ continue;
109
+ }
110
+ const byId = new Map(results.map((r) => [r.eventId, r]));
111
+ for (const e of chunk) {
112
+ const id = eventIdOf(e);
113
+ const r = id ? byId.get(id) : undefined;
114
+ if (!r) {
115
+ // Unaccounted event in a 2xx → retain (don't lose it).
116
+ keep.push(e);
117
+ continue;
118
+ }
119
+ summary.statuses[r.status] += 1;
120
+ if (r.status === "accepted")
121
+ summary.accepted += 1;
122
+ else if (r.status === "unassigned")
123
+ summary.unassigned += 1;
124
+ else
125
+ summary.rejected += 1;
126
+ }
127
+ }
128
+ const written = writeEmitRetry(deps.workMeshRoot, keep, retryMax);
129
+ summary.retained = written.written;
130
+ summary.droppedOverflow = written.dropped;
131
+ summary.retryDepth = written.written;
132
+ const nowIso = now().toISOString();
133
+ const prior = readEmitState(deps.workMeshRoot) ?? {};
134
+ const nextState = {
135
+ ...prior,
136
+ lastAttemptAt: nowIso,
137
+ lastAccepted: summary.accepted,
138
+ lastUnassigned: summary.unassigned,
139
+ lastRejected: summary.rejected,
140
+ lastRetryDepth: summary.retryDepth,
141
+ };
142
+ if (anyPosted) {
143
+ nextState.lastPostAt = nowIso;
144
+ summary.lastPostAt = nowIso;
145
+ delete nextState.lastError;
146
+ }
147
+ else if (combined.length > 0) {
148
+ nextState.lastError = "emit failed (network/server); retained for retry";
149
+ }
150
+ writeEmitState(deps.workMeshRoot, nextState);
151
+ return summary;
152
+ }
153
+ //# sourceMappingURL=emit.js.map
@@ -22,7 +22,6 @@
22
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
23
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
24
24
  * `syncNarrowHint: 'off'`,
25
- * - the synced flag registry disables `sync.narrow-hint`,
26
25
  * - or the same `{companyUid, level}` pair has already been shown this
27
26
  * process (module-singleton dedupe; the runner imports the same module
28
27
  * once per `hq` invocation so a single invocation prints at most one
@@ -47,7 +46,6 @@
47
46
  * `syncNarrowHintMinBytes` — see `resolveNarrowHintMinBytes`.
48
47
  */
49
48
  import * as fs from "node:fs";
50
- import { type FlagReader } from "./flag-registry.js";
51
49
  export type BannerLevel = "hint" | "warning" | "strict";
52
50
  /**
53
51
  * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
@@ -87,8 +85,6 @@ export interface ShouldShowBannerOpts {
87
85
  readFile?: (p: string) => string;
88
86
  /** Test seam: override `fs.existsSync`. */
89
87
  existsFile?: (p: string) => boolean;
90
- /** Test seam: held registry snapshot reader. */
91
- flagReader?: FlagReader;
92
88
  }
93
89
  /**
94
90
  * Decides whether a banner should be printed AT ALL — independent of
@@ -154,12 +150,12 @@ export declare function companyFolderExceedsThreshold(companyDir: string, thresh
154
150
  * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
155
151
  * folder is under the threshold is never refused.
156
152
  */
157
- export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel, flagReader?: FlagReader): boolean;
153
+ export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel): boolean;
158
154
  /**
159
155
  * Keep the rendered banner honest about the decision that this invocation
160
156
  * actually made. Hint and warning remain local presentation choices. `strict`
161
- * is reserved for a real refusal, so a registry opt-out of an old strict level
162
- * degrades to the local warning presentation instead of claiming a block.
157
+ * is reserved for a real refusal, so a strict level that did NOT produce a
158
+ * refusal degrades to the local warning presentation instead of claiming a block.
163
159
  */
164
160
  export declare function resolveNarrowHintPresentationLevel(level: BannerLevel, strictRefusal: boolean): BannerLevel;
165
161
  /**