@indigoai-us/hq-cli 5.275.0 → 5.276.0

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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/dist/commands/core-checkpoint.d.ts +4 -2
  5. package/dist/commands/core-checkpoint.js +34 -3
  6. package/dist/commands/core-command-catalog.d.ts +4 -2
  7. package/dist/commands/core-command-catalog.js +5 -2
  8. package/dist/commands/core-ontology.js +3 -3
  9. package/dist/commands/core.js +1 -1
  10. package/dist/commands/daemon.d.ts +1 -0
  11. package/dist/commands/daemon.js +2 -1
  12. package/dist/commands/feedback.d.ts +6 -0
  13. package/dist/commands/feedback.js +86 -37
  14. package/dist/commands/lanes.js +3 -2
  15. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  16. package/dist/commands/run-feature-telemetry.js +6 -0
  17. package/dist/commands/run.js +3 -2
  18. package/dist/commands/secrets.js +7 -1
  19. package/dist/commands/whoami.d.ts +4 -6
  20. package/dist/commands/whoami.js +39 -8
  21. package/dist/fast-core.d.ts +6 -7
  22. package/dist/fast-core.js +22 -8
  23. package/dist/fast-scaffold.d.ts +16 -7
  24. package/dist/fast-scaffold.js +13 -8
  25. package/dist/index.js +16 -1
  26. package/dist/lib/billing/plan-lock.d.ts +3 -1
  27. package/dist/lib/billing/plan-lock.js +7 -1
  28. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  29. package/dist/lib/checkpoint-ontology/company.js +15 -7
  30. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  31. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  32. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  33. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  34. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  35. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  36. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  37. package/dist/lib/core-utils/timeout-guard.js +28 -13
  38. package/dist/lib/flag-registry.d.ts +12 -7
  39. package/dist/lib/flag-registry.js +66 -75
  40. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  41. package/dist/lib/flag-snapshot-file.js +91 -0
  42. package/dist/lib/lanes/reconcile.js +20 -4
  43. package/dist/lib/process-flag-company-context.d.ts +8 -0
  44. package/dist/lib/process-flag-company-context.js +67 -0
  45. package/dist/main.js +3 -1
  46. package/dist/native-fast-route.d.ts +3 -0
  47. package/dist/native-fast-route.js +39 -0
  48. package/dist/utils/cli-telemetry.d.ts +26 -2
  49. package/dist/utils/cli-telemetry.js +178 -3
  50. package/dist/utils/hq-root-resolver.d.ts +13 -0
  51. package/dist/utils/hq-root-resolver.js +103 -0
  52. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  53. package/dist/utils/vault-api.d.ts +8 -0
  54. package/dist/utils/vault-api.js +13 -1
  55. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Company files touched by checkpoints that were queued behind a busy sibling.
3
+ *
4
+ * `hq core checkpoint` queues its payload when a sibling is already running,
5
+ * and the next run claims the queue. That run renders the ontology step only
6
+ * for its own payload's company, and the ontology plan records any changed
7
+ * file the run's own payload did not touch as seen. Without this file, the
8
+ * files a queued company checkpoint touched never reach the ontology (CO-59).
9
+ *
10
+ * The checkpoint parent performs no network I/O, so it cannot read the
11
+ * `checkpoint.ontology.queued-touches` flag. It always records the touches in
12
+ * the company's local, unsynced state directory; the ontology plan consumes
13
+ * them only while the flag is on. The file is capped so it stays small while
14
+ * the flag is off.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { assertSafeTarget, resolveOntologyCompany, safeWriteFile, WriteBoundsError, } from "./company.js";
19
+ export const QUEUED_TOUCHES_FILE = "queued-touches.jsonl";
20
+ export const QUEUED_TOUCHES_CLAIM_FILE = "queued-touches-claimed.jsonl";
21
+ export const QUEUED_TOUCHES_BORROWED_FILE = "queued-touches-borrowed-at";
22
+ export const MAX_QUEUED_TOUCHES = 200;
23
+ function ontologyStateBase(hqRoot) {
24
+ return path.join(hqRoot, "workspace", "checkpoints", "ontology");
25
+ }
26
+ function companyStateDir(hqRoot, slug) {
27
+ return path.join(ontologyStateBase(hqRoot), slug);
28
+ }
29
+ export function queuedTouchesPath(hqRoot, slug) {
30
+ return path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_FILE);
31
+ }
32
+ /**
33
+ * Record the files a queued checkpoint touched inside its company. Files
34
+ * outside `companies/<slug>/` are dropped. Returns the number of files
35
+ * recorded; 0 when the company is not a manifest company or nothing is inside
36
+ * it. The queue keeps its newest {@link MAX_QUEUED_TOUCHES} lines.
37
+ */
38
+ export function recordQueuedTouches(hqRoot, slug, files, now) {
39
+ if (!slug)
40
+ return 0;
41
+ const resolution = resolveOntologyCompany(hqRoot, slug);
42
+ if (!resolution.ok)
43
+ return 0;
44
+ const inside = files.filter((file) => {
45
+ if (typeof file !== "string" || !file.trim())
46
+ return false;
47
+ const rel = path.relative(resolution.company.dir, path.resolve(hqRoot, file.trim()));
48
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
49
+ });
50
+ if (inside.length === 0)
51
+ return 0;
52
+ const target = queuedTouchesPath(hqRoot, slug);
53
+ try {
54
+ assertSafeTarget(hqRoot, [companyStateDir(hqRoot, slug)], target);
55
+ }
56
+ catch (error) {
57
+ if (error instanceof WriteBoundsError)
58
+ return 0;
59
+ throw error;
60
+ }
61
+ const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW);
62
+ try {
63
+ fs.writeSync(fd, `${JSON.stringify({ queued_at: now.toISOString(), files: inside })}\n`);
64
+ }
65
+ finally {
66
+ fs.closeSync(fd);
67
+ }
68
+ const lines = fs.readFileSync(target, "utf8").split("\n").filter((line) => line.trim());
69
+ if (lines.length > MAX_QUEUED_TOUCHES) {
70
+ // Best-effort trim, as for the sibling's pending queue: it can race a
71
+ // concurrent append, but only once the file is already over its cap.
72
+ safeWriteFile(hqRoot, [companyStateDir(hqRoot, slug)], target, `${lines.slice(-MAX_QUEUED_TOUCHES).join("\n")}\n`);
73
+ }
74
+ return inside.length;
75
+ }
76
+ /**
77
+ * Record that a company-less run borrowed `slug`, so the next such run prefers
78
+ * a company that has waited longer.
79
+ */
80
+ export function markBorrowed(hqRoot, slug, now) {
81
+ const target = path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_BORROWED_FILE);
82
+ safeWriteFile(hqRoot, [companyStateDir(hqRoot, slug)], target, `${now.toISOString()}\n`);
83
+ }
84
+ /** `queued_at` of the oldest record, or the file mtime when no line has one. */
85
+ function firstQueuedMs(file, mtimeMs) {
86
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
87
+ if (!line.trim())
88
+ continue;
89
+ try {
90
+ const parsed = JSON.parse(line);
91
+ if (typeof parsed.queued_at !== "string")
92
+ continue;
93
+ const ms = Date.parse(parsed.queued_at);
94
+ if (!Number.isNaN(ms))
95
+ return ms;
96
+ }
97
+ catch {
98
+ // A partial line is not a record; a later valid line still counts.
99
+ }
100
+ }
101
+ return mtimeMs;
102
+ }
103
+ /** When this company was last borrowed, or -Infinity when it never was. */
104
+ function borrowedMs(hqRoot, slug) {
105
+ const file = path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_BORROWED_FILE);
106
+ let stat;
107
+ try {
108
+ stat = fs.lstatSync(file);
109
+ }
110
+ catch {
111
+ return Number.NEGATIVE_INFINITY;
112
+ }
113
+ if (!stat.isFile())
114
+ return Number.NEGATIVE_INFINITY;
115
+ const ms = Date.parse(fs.readFileSync(file, "utf8").trim());
116
+ return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
117
+ }
118
+ /**
119
+ * The manifest company whose queued touches have waited longest, or null.
120
+ * The order key is the later of the first queued record and the last borrow,
121
+ * so a company a company-less run just borrowed goes to the back. A run whose
122
+ * own payload names no company uses it for its ontology step.
123
+ */
124
+ export function oldestQueuedCompany(hqRoot) {
125
+ let entries;
126
+ try {
127
+ entries = fs.readdirSync(ontologyStateBase(hqRoot));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ let oldest = null;
133
+ for (const slug of entries.sort()) {
134
+ let stat;
135
+ try {
136
+ stat = fs.lstatSync(queuedTouchesPath(hqRoot, slug));
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ if (!stat.isFile() || stat.size === 0)
142
+ continue;
143
+ if (!resolveOntologyCompany(hqRoot, slug).ok)
144
+ continue;
145
+ const key = Math.max(firstQueuedMs(queuedTouchesPath(hqRoot, slug), stat.mtimeMs), borrowedMs(hqRoot, slug));
146
+ if (!oldest || key < oldest.key)
147
+ oldest = { slug, key };
148
+ }
149
+ return oldest?.slug ?? null;
150
+ }
151
+ /**
152
+ * Take the company's queued touches by renaming the file into the run
153
+ * directory, then return the HQ-relative paths it lists. Renaming is atomic,
154
+ * so a touch recorded during the claim lands in a fresh file for a later run.
155
+ */
156
+ export function claimQueuedTouches(hqRoot, company, runDir) {
157
+ const queuedPath = queuedTouchesPath(hqRoot, company.slug);
158
+ try {
159
+ assertSafeTarget(hqRoot, [companyStateDir(hqRoot, company.slug)], queuedPath);
160
+ }
161
+ catch (error) {
162
+ if (error instanceof WriteBoundsError)
163
+ return [];
164
+ throw error;
165
+ }
166
+ const claimPath = path.join(runDir, QUEUED_TOUCHES_CLAIM_FILE);
167
+ try {
168
+ fs.renameSync(queuedPath, claimPath);
169
+ }
170
+ catch (error) {
171
+ if (error?.code === "ENOENT")
172
+ return [];
173
+ throw error;
174
+ }
175
+ const files = [];
176
+ for (const line of fs.readFileSync(claimPath, "utf8").split("\n")) {
177
+ if (!line.trim())
178
+ continue;
179
+ try {
180
+ const parsed = JSON.parse(line);
181
+ if (Array.isArray(parsed.files)) {
182
+ for (const file of parsed.files)
183
+ if (typeof file === "string")
184
+ files.push(file);
185
+ }
186
+ }
187
+ catch {
188
+ // A partial line must not drop the valid lines around it.
189
+ }
190
+ }
191
+ return files;
192
+ }
193
+ //# sourceMappingURL=queued-touches.js.map
@@ -0,0 +1,7 @@
1
+ /** Native fast-route adapter for the shared timeout-guard command. */
2
+ import type { FlagReader } from "../flag-registry.js";
3
+ import { type PersistedFlagSnapshot } from "../flag-snapshot-file.js";
4
+ import { type TimeoutGuardDeps } from "./timeout-guard.js";
5
+ export declare function createNativeSnapshotFlagReader(persistedSnapshot: PersistedFlagSnapshot | null, companyIdentifiers: readonly string[], env: NodeJS.ProcessEnv): FlagReader;
6
+ export declare function runTimeoutGuardFast(_argv: readonly string[], stdin: string, _cwd: string, _hqRoot: string | undefined, dependencies?: TimeoutGuardDeps): Promise<number>;
7
+ //# sourceMappingURL=timeout-guard-native.d.ts.map
@@ -0,0 +1,40 @@
1
+ /** Native fast-route adapter for the shared timeout-guard command. */
2
+ import { readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
3
+ import { resolveProcessFlagCompanyContext } from "../process-flag-company-context.js";
4
+ import { readFlagSnapshotFile, } from "../flag-snapshot-file.js";
5
+ import { timeoutGuardCommand } from "./timeout-guard.js";
6
+ export function createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env) {
7
+ const snapshot = persistedSnapshot
8
+ ? { version: 0, flags: persistedSnapshot.flags }
9
+ : null;
10
+ return {
11
+ isEnabled(flagKey, lookup = {}) {
12
+ if (snapshot) {
13
+ return resolveFlag({
14
+ flagKey,
15
+ env,
16
+ snapshot,
17
+ companyIdentifiers,
18
+ lookup,
19
+ }).value;
20
+ }
21
+ return readLocalOverride(flagKey, env) ?? (lookup.fallback ?? false);
22
+ },
23
+ };
24
+ }
25
+ function snapshotFlagReader(companyUid, companySlug, env) {
26
+ const persistedSnapshot = companyUid ? readFlagSnapshotFile(companyUid, { env }) : null;
27
+ const companyIdentifiers = [companyUid, companySlug].filter((value) => Boolean(value));
28
+ return createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env);
29
+ }
30
+ export async function runTimeoutGuardFast(_argv, stdin, _cwd, _hqRoot, dependencies = {}) {
31
+ const env = dependencies.env ?? process.env;
32
+ const company = resolveProcessFlagCompanyContext(env);
33
+ const flagReader = dependencies.flagReader ?? snapshotFlagReader(company.companyUid, company.companySlug, env);
34
+ return timeoutGuardCommand(stdin, {
35
+ ...dependencies,
36
+ env,
37
+ flagReader,
38
+ });
39
+ }
40
+ //# sourceMappingURL=timeout-guard-native.js.map
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Exit codes: 0 = allow, 2 = block.
22
22
  */
23
- import { type FlagReader } from "../flag-registry.js";
23
+ import type { FlagReader } from "../flag-registry.js";
24
24
  /**
25
25
  * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
26
26
  * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
@@ -55,11 +55,11 @@ export interface TimeoutGuardDeps {
55
55
  flagReader?: FlagReader;
56
56
  }
57
57
  /** Whether the guard is active for this identity (rollout gate). */
58
- export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): boolean;
58
+ export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): Promise<boolean>;
59
59
  /**
60
60
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
61
61
  * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
62
62
  * missing identity fails OPEN (allow), because a hook must not break tool calls.
63
63
  */
64
- export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number;
64
+ export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): Promise<number>;
65
65
  //# sourceMappingURL=timeout-guard.d.ts.map
@@ -20,9 +20,6 @@
20
20
  *
21
21
  * Exit codes: 0 = allow, 2 = block.
22
22
  */
23
- import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
- import { peekIdToken } from "../../utils/id-token.js";
25
- import { resolveFlagGate, resolveProcessFlagGate, } from "../flag-registry.js";
26
23
  // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
27
24
  // deadline up to the max; with none declared the default applies.
28
25
  const HARNESS_DEFAULT_MS = 120_000; // 2 min
@@ -153,8 +150,14 @@ supervise yourself.)
153
150
 
154
151
  Policy: hq-foreground-timeout-killed-by-harness-deadline.
155
152
  Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
156
- function defaultEmail() {
153
+ async function defaultEmail() {
157
154
  try {
155
+ // The cached identity is needed only for commands that would otherwise
156
+ // block. Keep hq-cloud and the JWT decoder out of the common allow path.
157
+ const [{ loadCachedTokens }, { peekIdToken }] = await Promise.all([
158
+ import("@indigoai-us/hq-cloud"),
159
+ import("../../utils/id-token.js"),
160
+ ]);
158
161
  const cached = loadCachedTokens();
159
162
  if (!cached)
160
163
  return undefined;
@@ -166,23 +169,35 @@ function defaultEmail() {
166
169
  }
167
170
  }
168
171
  /** Whether the guard is active for this identity (rollout gate). */
169
- export function isGatedUser(email, flagReader) {
172
+ export async function isGatedUser(email, flagReader) {
170
173
  const legacyFallback = () => typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
171
174
  const lookup = {
172
175
  defaultEmailDomains: ["getindigo.ai"],
173
176
  personEmail: email,
174
177
  fallback: legacyFallback(),
175
178
  };
176
- return flagReader
177
- ? resolveFlagGate(flagReader, "cli.foreground-timeout-guard", lookup, legacyFallback)
178
- : resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
179
+ if (flagReader) {
180
+ try {
181
+ return flagReader.isEnabled("cli.foreground-timeout-guard", lookup);
182
+ }
183
+ catch {
184
+ return legacyFallback();
185
+ }
186
+ }
187
+ try {
188
+ const { resolveProcessFlagGate } = await import("../flag-registry.js");
189
+ return resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
190
+ }
191
+ catch {
192
+ return legacyFallback();
193
+ }
179
194
  }
180
195
  /**
181
196
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
182
197
  * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
183
198
  * missing identity fails OPEN (allow), because a hook must not break tool calls.
184
199
  */
185
- export function timeoutGuardCommand(stdin, deps = {}) {
200
+ export async function timeoutGuardCommand(stdin, deps = {}) {
186
201
  const env = deps.env ?? process.env;
187
202
  const err = deps.stderr ?? process.stderr;
188
203
  let payload;
@@ -201,15 +216,15 @@ export function timeoutGuardCommand(stdin, deps = {}) {
201
216
  return 0;
202
217
  if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
203
218
  return 0;
204
- // Rollout gate: only act for @getindigo.ai identities.
205
- const getEmail = deps.getEmail ?? defaultEmail;
206
- if (!isGatedUser(getEmail(), deps.flagReader))
207
- return 0;
208
219
  const runInBackground = ti.run_in_background === true;
209
220
  const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
210
221
  const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
211
222
  if (!verdict.block)
212
223
  return 0;
224
+ // Rollout gate: only act for @getindigo.ai identities.
225
+ const getEmail = deps.getEmail ?? defaultEmail;
226
+ if (!await isGatedUser(await getEmail(), deps.flagReader))
227
+ return 0;
213
228
  err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
214
229
  return 2;
215
230
  }
@@ -6,7 +6,9 @@
6
6
  * keep a short-lived, offline CLI alive until the client's network deadline.
7
7
  */
8
8
  import { type WorkerOptions } from "node:worker_threads";
9
- import { type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
9
+ import { createFlagClient, type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
10
+ export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
11
+ export type { ProcessFlagCompanyContext } from "./process-flag-company-context.js";
10
12
  export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
11
13
  /** The synchronous capability that command gate call sites need. */
12
14
  export type FlagReader = Pick<FlagClient, "isEnabled">;
@@ -22,12 +24,13 @@ export interface FlagRegistryDependencies {
22
24
  workerSource?: string;
23
25
  captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
24
26
  }
25
- export interface ProcessFlagCompanyContext {
26
- companyUid?: string;
27
- companySlug?: string;
27
+ export interface CompanyFlagGateDependencies {
28
+ endpoint?: string;
29
+ requestTimeoutMs?: number;
30
+ createClient?: typeof createFlagClient;
28
31
  }
29
- /** Resolve the same company identity used by the process-wide flag reader. */
30
- export declare function resolveProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
32
+ /** Create the same snapshot-backed reader used by process gates. */
33
+ export declare function createProcessSnapshotReader(companyIdentifiers: string[], getSnapshot?: () => FlagSnapshot | null, env?: NodeJS.ProcessEnv): FlagReader;
31
34
  /**
32
35
  * Begin one registry refresh without awaiting it or retaining the CLI process.
33
36
  *
@@ -43,7 +46,9 @@ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDep
43
46
  * `isEnabled` itself is synchronous and snapshot-only; any reader failure
44
47
  * falls through to the exact legacy predicate owned by the caller.
45
48
  */
46
- export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
49
+ export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, env?: NodeJS.ProcessEnv): boolean;
50
+ /** Resolve one explicit company's flag without relying on process-wide binding. */
51
+ export declare function resolveCompanyFlagGate(companyUid: string, idToken: string, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, dependencies?: CompanyFlagGateDependencies): Promise<boolean>;
47
52
  /** Resolve a process gate against the shared snapshot reader, when configured. */
48
53
  export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
49
54
  /**
@@ -7,99 +7,43 @@
7
7
  */
8
8
  import { Worker } from "node:worker_threads";
9
9
  import * as Sentry from "@sentry/node";
10
- import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
11
- import { readMachineCompanyBinding } from "./work-context/company.js";
12
- import { readSessionState } from "./work-context/state.js";
13
- import { workContextRoot } from "./work-context/paths.js";
14
- import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
15
- import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
10
+ import { createFlagClient, isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
16
11
  import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
17
12
  import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
13
+ import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
14
+ import { writeFlagSnapshotFile } from "./flag-snapshot-file.js";
15
+ export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
18
16
  export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
19
17
  const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
20
- const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
21
- let heldSnapshot = null;
22
- let processReader = null;
23
- function processLocalOverride(flagKey) {
24
- return readLocalOverride(flagKey, process.env);
25
- }
26
- function localOverrideOrFallback(flagKey, legacyFallback) {
27
- return processLocalOverride(flagKey) ?? legacyFallback();
28
- }
29
18
  function normalizedCompanyUid(value) {
30
19
  const candidate = value?.trim() ?? "";
31
20
  return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
32
21
  }
33
- function normalizedCompanySlug(value) {
34
- const candidate = value?.trim() ?? "";
35
- return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
36
- }
37
- /**
38
- * Resolve only an explicit machine/session company binding. Device defaults
39
- * and cwd/repository heuristics do not scope process-wide feature flags.
40
- */
41
- function processCompanyContext(env = process.env) {
42
- const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
43
- const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
44
- if (explicitUid || explicitSlug) {
45
- return { companyUid: explicitUid, companySlug: explicitSlug };
46
- }
47
- const machine = readMachineCompanyBinding(env);
48
- const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
49
- const spawnSlug = normalizedCompanySlug(spawnValue);
50
- const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
51
- ? { companyUid: spawnValue }
52
- : spawnSlug
53
- ? { companySlug: spawnSlug }
54
- : {
55
- companyUid: normalizedCompanyUid(machine?.uid),
56
- companySlug: normalizedCompanySlug(machine?.slug),
57
- };
58
- if (machineContext.companyUid || machineContext.companySlug)
59
- return machineContext;
60
- const sessionId = env.HQ_SESSION_ID?.trim();
61
- if (!sessionId)
62
- return {};
63
- const session = readSessionState(sessionId, workContextRoot(undefined, env));
64
- if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
65
- return {};
66
- }
67
- return {
68
- companyUid: normalizedCompanyUid(session.companyUid),
69
- companySlug: normalizedCompanySlug(session.companySlug),
70
- };
71
- }
72
- function companyUidForContext(context, env = process.env) {
73
- if (context.companyUid)
74
- return context.companyUid;
75
- if (!context.companySlug)
76
- return undefined;
77
- const hqRoot = resolveDefaultHqRoot({ env });
78
- const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
79
- return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
22
+ let heldSnapshot = null;
23
+ let processReader = null;
24
+ function processLocalOverride(flagKey, env = process.env) {
25
+ return readLocalOverride(flagKey, env);
80
26
  }
81
- /** Resolve the same company identity used by the process-wide flag reader. */
82
- export function resolveProcessFlagCompanyContext(env = process.env) {
83
- const context = processCompanyContext(env);
84
- const companyUid = companyUidForContext(context, env);
85
- return {
86
- ...(companyUid ? { companyUid } : {}),
87
- ...(context.companySlug ? { companySlug: context.companySlug } : {}),
88
- };
27
+ function localOverrideOrFallback(flagKey, legacyFallback, env = process.env) {
28
+ return processLocalOverride(flagKey, env) ?? legacyFallback();
89
29
  }
90
- function processSnapshotReader(companyIdentifiers) {
30
+ /** Create the same snapshot-backed reader used by process gates. */
31
+ export function createProcessSnapshotReader(companyIdentifiers, getSnapshot = () => heldSnapshot, env = process.env) {
91
32
  return {
92
33
  isEnabled(flagKey, lookup = {}) {
93
34
  return resolveFlag({
94
35
  flagKey,
95
- env: process.env,
96
- snapshot: heldSnapshot,
36
+ env,
37
+ snapshot: getSnapshot(),
97
38
  companyIdentifiers,
98
39
  lookup,
99
40
  }).value;
100
41
  },
101
42
  };
102
43
  }
44
+ function processSnapshotReader(companyIdentifiers) {
45
+ return createProcessSnapshotReader(companyIdentifiers);
46
+ }
103
47
  /**
104
48
  * Begin one registry refresh without awaiting it or retaining the CLI process.
105
49
  *
@@ -150,6 +94,19 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
150
94
  worker.on("message", (value) => {
151
95
  if (isFlagSnapshot(value)) {
152
96
  heldSnapshot = value;
97
+ if (companyUid) {
98
+ try {
99
+ writeFlagSnapshotFile(companyUid, value.flags);
100
+ }
101
+ catch (error) {
102
+ Sentry.captureException(error, {
103
+ tags: {
104
+ component: "flag-snapshot-cache-write",
105
+ company_uid_present: "true",
106
+ },
107
+ });
108
+ }
109
+ }
153
110
  }
154
111
  });
155
112
  // A failed background refresh must stay invisible to an offline CLI.
@@ -169,15 +126,49 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
169
126
  * `isEnabled` itself is synchronous and snapshot-only; any reader failure
170
127
  * falls through to the exact legacy predicate owned by the caller.
171
128
  */
172
- export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
129
+ export function resolveFlagGate(reader, flagKey, lookup, legacyFallback, env = process.env) {
173
130
  if (!reader)
174
- return localOverrideOrFallback(flagKey, legacyFallback);
131
+ return localOverrideOrFallback(flagKey, legacyFallback, env);
175
132
  try {
176
133
  return reader.isEnabled(flagKey, lookup);
177
134
  }
178
135
  catch {
136
+ return localOverrideOrFallback(flagKey, legacyFallback, env);
137
+ }
138
+ }
139
+ /** Resolve one explicit company's flag without relying on process-wide binding. */
140
+ export async function resolveCompanyFlagGate(companyUid, idToken, flagKey, lookup, legacyFallback, dependencies = {}) {
141
+ const localOverride = processLocalOverride(flagKey);
142
+ if (localOverride !== null)
143
+ return localOverride;
144
+ const targetCompanyUid = normalizedCompanyUid(companyUid);
145
+ if (!targetCompanyUid || idToken.trim() === "") {
179
146
  return localOverrideOrFallback(flagKey, legacyFallback);
180
147
  }
148
+ let client;
149
+ try {
150
+ client = (dependencies.createClient ?? createFlagClient)({
151
+ endpoint: resolveFlagRegistryEndpoint(dependencies.endpoint, process.env),
152
+ getToken: () => idToken,
153
+ companyUid: targetCompanyUid,
154
+ refreshIntervalMs: 0,
155
+ requestTimeoutMs: dependencies.requestTimeoutMs ?? 1_200,
156
+ onError: (error) => {
157
+ const errorClass = error instanceof Error ? error.name : "unknown";
158
+ console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
159
+ },
160
+ });
161
+ await client.ready();
162
+ return resolveFlagGate(client, flagKey, lookup, legacyFallback);
163
+ }
164
+ catch (error) {
165
+ const errorClass = error instanceof Error ? error.name : "unknown";
166
+ console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
167
+ return localOverrideOrFallback(flagKey, legacyFallback);
168
+ }
169
+ finally {
170
+ client?.close();
171
+ }
181
172
  }
182
173
  /** Resolve a process gate against the shared snapshot reader, when configured. */
183
174
  export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
@@ -0,0 +1,20 @@
1
+ /** Private, synchronous, offline snapshot used by the native CLI fast route. */
2
+ export declare const FLAG_SNAPSHOT_MAX_AGE_MS: number;
3
+ export interface PersistedFlagSnapshot {
4
+ company_uid: string;
5
+ fetched_at: string;
6
+ flags: Record<string, boolean>;
7
+ }
8
+ export declare function flagSnapshotPath(companyUid: string, env?: NodeJS.ProcessEnv): string | undefined;
9
+ /** Write just the validated flag decisions and company scope, never auth data. */
10
+ export declare function writeFlagSnapshotFile(companyUid: string, flags: Readonly<Record<string, unknown>>, options?: {
11
+ env?: NodeJS.ProcessEnv;
12
+ fetchedAt?: string;
13
+ }): string;
14
+ /** Return null for every miss, malformed file, company mismatch, or stale file. */
15
+ export declare function readFlagSnapshotFile(companyUid: string, options?: {
16
+ env?: NodeJS.ProcessEnv;
17
+ now?: number;
18
+ maxAgeMs?: number;
19
+ }): PersistedFlagSnapshot | null;
20
+ //# sourceMappingURL=flag-snapshot-file.d.ts.map