@indigoai-us/hq-cli 5.124.0 → 5.125.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 (31) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/command-catalog.generated.d.ts +7 -1
  3. package/dist/command-catalog.generated.js +9 -1
  4. package/dist/commands/login.js +49 -6
  5. package/dist/commands/mesh.js +96 -19
  6. package/dist/lib/doctor/checks/work-context.js +4 -2
  7. package/dist/lib/mesh/live/backfill-held.d.ts +2 -0
  8. package/dist/lib/mesh/live/backfill-held.js +8 -4
  9. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +3 -0
  10. package/dist/lib/mesh/live/daemon/board-refresh.js +6 -0
  11. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  12. package/dist/lib/mesh/live/daemon/doctor.js +21 -1
  13. package/dist/lib/mesh/live/daemon/run.d.ts +11 -0
  14. package/dist/lib/mesh/live/daemon/run.js +129 -10
  15. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  16. package/dist/lib/mesh/live/flush.d.ts +3 -0
  17. package/dist/lib/mesh/live/flush.js +11 -0
  18. package/dist/lib/work-context/company.d.ts +8 -0
  19. package/dist/lib/work-context/company.js +62 -18
  20. package/dist/lib/work-context/config.d.ts +35 -0
  21. package/dist/lib/work-context/config.js +307 -62
  22. package/dist/lib/work-context/contract.d.ts +2 -0
  23. package/dist/lib/work-context/default-company.d.ts +43 -0
  24. package/dist/lib/work-context/default-company.js +83 -0
  25. package/dist/lib/work-context/outbox.d.ts +5 -1
  26. package/dist/lib/work-context/outbox.js +5 -0
  27. package/dist/lib/work-context/reconcile.d.ts +4 -0
  28. package/dist/lib/work-context/reconcile.js +59 -3
  29. package/dist/lib/work-context/state.d.ts +2 -0
  30. package/dist/lib/work-context/state.js +14 -0
  31. package/package.json +1 -1
@@ -13,10 +13,13 @@ import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { CLI_VERSION } from "../../../../cli-version.js";
15
15
  import { createWorkSessionDeliverer } from "../../client.js";
16
- import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
16
+ import { listActiveMembershipCompanies, meshJson, resolveActiveMembershipCompany } from "../../api.js";
17
17
  import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
18
18
  import { replayOutbox } from "../../../work-context/outbox.js";
19
19
  import { workContextRoot } from "../../../work-context/paths.js";
20
+ import { ensureDefaultCompany } from "../../../work-context/default-company.js";
21
+ import { invalidateDefaultCompanyMemberships, readDeviceConfig, } from "../../../work-context/config.js";
22
+ import { readSessionState, writeSessionState, } from "../../../work-context/state.js";
20
23
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
21
24
  import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
22
25
  import { backfillHeldSessions, } from "../backfill-held.js";
@@ -28,7 +31,7 @@ import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
28
31
  import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
29
32
  import { daemonDir } from "./paths.js";
30
33
  import { PresenceClient, } from "./presence.js";
31
- import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state.js";
34
+ import { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
32
35
  import { workMeshHeldPath } from "../paths.js";
33
36
  import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
34
37
  import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
@@ -38,6 +41,7 @@ export const SPOOL_DEBOUNCE_MS = 2_000;
38
41
  export const FLUSH_INTERVAL_MS = 10_000;
39
42
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
40
43
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
44
+ export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
41
45
  /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
42
46
  export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
43
47
  /**
@@ -111,12 +115,69 @@ export async function runMeshDaemon(deps = {}) {
111
115
  // daemon went dark once its IoT credentials expired.
112
116
  const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
113
117
  const getToken = deps.getToken ?? defaultGetToken;
118
+ const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
119
+ /** The sole authorization seam for daemon tenant I/O derived from a device default. */
120
+ const tenantGate = {
121
+ authorize: async (input) => {
122
+ const state = input.state ?? (input.sessionId
123
+ ? readSessionState(input.sessionId, ctxRoot)
124
+ : null);
125
+ const slug = input.companySlug ?? state?.companySlug;
126
+ const membership = await (async () => {
127
+ try {
128
+ if (!slug)
129
+ return null;
130
+ const controller = new AbortController();
131
+ const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
132
+ try {
133
+ return await Promise.race([
134
+ resolveMembership(await getToken(), slug, controller.signal),
135
+ new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(null), { once: true })),
136
+ ]);
137
+ }
138
+ finally {
139
+ clearTimeout(timeout);
140
+ }
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ })();
146
+ const uid = membership?.companyUid;
147
+ if (!input.deviceDefault)
148
+ return uid ? { uid } : false;
149
+ if (uid &&
150
+ (!input.companyUid || input.companyUid === uid) &&
151
+ (!state?.companyUid || state.companyUid === uid)) {
152
+ return { uid };
153
+ }
154
+ try {
155
+ invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
156
+ }
157
+ catch (err) {
158
+ log(dir, `default membership cache invalidation failed: ${String(err).slice(0, 160)}`);
159
+ }
160
+ if (state) {
161
+ writeSessionState({
162
+ ...state,
163
+ companyUid: undefined,
164
+ companySlug: undefined,
165
+ contextStatus: "needs_company",
166
+ updatedAt: now().toISOString(),
167
+ }, ctxRoot);
168
+ }
169
+ const prior = readDaemonState(dir)?.deviceDefaultAuthorizationDenials ?? 0;
170
+ patchDaemonState(dir, { deviceDefaultAuthorizationDenials: prior + 1 }, now);
171
+ log(dir, `device-default membership denied${slug ? ` company=${slug}` : ""}; work held`);
172
+ return false;
173
+ },
174
+ };
114
175
  /** In-memory held re-attempt throttle (survives watch-triggered flushes). */
115
176
  let lastHeldRetryAtMs = 0;
116
177
  const flushFn = deps.flush ??
117
178
  (async () => {
118
179
  const t = await getToken();
119
- const poster = createSessionEventsPoster({
180
+ const poster = deps.poster ?? createSessionEventsPoster({
120
181
  token: t,
121
182
  baseUrl: resolveVaultApiBase(env),
122
183
  });
@@ -130,32 +191,81 @@ export async function runMeshDaemon(deps = {}) {
130
191
  lastHeldRetryAtMs = atMs;
131
192
  },
132
193
  log: (message) => log(dir, message),
194
+ authorizeDeviceDefaultTenant: async (state) => Boolean(await tenantGate.authorize({
195
+ state,
196
+ companySlug: state.companySlug,
197
+ companyUid: state.companyUid,
198
+ deviceDefault: true,
199
+ })),
133
200
  });
134
201
  });
135
202
  const replayFn = deps.replayOutbox ??
136
203
  (async () => {
137
204
  const t = await getToken();
138
- const deliver = createWorkSessionDeliverer({ token: t });
139
- return replayOutbox(ctxRoot, deliver);
205
+ const deliver = deps.deliver ?? createWorkSessionDeliverer({ token: t });
206
+ return replayOutbox(ctxRoot, async (op) => {
207
+ const state = readSessionState(op.sessionId, ctxRoot);
208
+ // Provenance must survive session-state loss. For pre-upgrade outbox
209
+ // entries without provenance, absent state is unknown and therefore
210
+ // requires the same membership check before delivery.
211
+ const requiresMembershipCheck = op.companyConfidence === "device_default" ||
212
+ state?.companyConfidence === "device_default" ||
213
+ // Pre-provenance entries are untrusted even if an equally legacy
214
+ // session state happens to exist. A current membership read is the
215
+ // only authority that may release them.
216
+ !op.companyConfidence;
217
+ if (requiresMembershipCheck) {
218
+ const allowed = await tenantGate.authorize({
219
+ state: state ?? undefined,
220
+ companySlug: op.companySlug ?? state?.companySlug,
221
+ companyUid: op.companyUid ?? state?.companyUid,
222
+ deviceDefault: true,
223
+ });
224
+ if (!allowed) {
225
+ return { ok: false, retryable: false, code: "DEVICE_DEFAULT_MEMBERSHIP_DENIED" };
226
+ }
227
+ }
228
+ return deliver(op);
229
+ });
140
230
  });
231
+ const activateDefaultCompany = async () => {
232
+ try {
233
+ const token = await getToken();
234
+ await ensureDefaultCompany({
235
+ root: ctxRoot,
236
+ env,
237
+ listMemberships: () => listActiveMembershipCompanies(token),
238
+ log: (message) => log(dir, message),
239
+ });
240
+ }
241
+ catch (err) {
242
+ const message = err instanceof Error ? err.message : String(err);
243
+ log(dir, `default company activation skipped: ${message.slice(0, 160)}`);
244
+ }
245
+ };
141
246
  const backfillFn = deps.backfillHeld ??
142
247
  (async () => {
248
+ await activateDefaultCompany();
143
249
  const token = await getToken();
144
250
  const deliver = createWorkSessionDeliverer({ token });
251
+ const useDeviceDefault = readDeviceConfig({ root: ctxRoot }).repairHeldWithDefault === true;
145
252
  return backfillHeldSessions({
146
253
  workMeshRoot: meshRoot,
147
254
  workContextRoot: ctxRoot,
148
255
  hqRoots: trustedRoots,
149
256
  limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
150
257
  env,
258
+ useDeviceDefault,
151
259
  reconcile: (observation) => reconcileObservation(observation, {
152
260
  root: ctxRoot,
153
261
  env,
154
262
  deliver,
155
- validateMembership: async (slug) => {
156
- const membership = await resolveActiveMembershipCompany(token, slug);
157
- return membership ? { uid: membership.companyUid } : false;
158
- },
263
+ validateMembership: async (slug) => tenantGate.authorize({
264
+ companySlug: slug,
265
+ deviceDefault: useDeviceDefault,
266
+ }),
267
+ allowDeviceDefault: useDeviceDefault,
268
+ deviceDefaultConfidence: useDeviceDefault,
159
269
  }),
160
270
  });
161
271
  });
@@ -163,7 +273,7 @@ export async function runMeshDaemon(deps = {}) {
163
273
  const refreshFn = deps.refreshBoards ??
164
274
  (async () => {
165
275
  const t = await getToken();
166
- const reader = createVaultBoardReader({
276
+ const reader = deps.readBoard ?? createVaultBoardReader({
167
277
  getJson: (p) => meshJson(t, p),
168
278
  });
169
279
  return refreshBoundSessionBoards({
@@ -172,6 +282,12 @@ export async function runMeshDaemon(deps = {}) {
172
282
  lastRefresh: boardLast,
173
283
  intervalMs: BOARD_REFRESH_INTERVAL_MS,
174
284
  now: () => timers.now(),
285
+ authorizeDeviceDefaultTenant: async (_sessionId, state) => Boolean(await tenantGate.authorize({
286
+ state,
287
+ companySlug: state.companySlug,
288
+ companyUid: state.companyUid,
289
+ deviceDefault: true,
290
+ })),
175
291
  });
176
292
  });
177
293
  let presence = null;
@@ -490,6 +606,9 @@ export async function runMeshDaemon(deps = {}) {
490
606
  if (!requestRestartForVersionDrift() && emitPath) {
491
607
  void doFlushCycle("start");
492
608
  }
609
+ else {
610
+ void activateDefaultCompany();
611
+ }
493
612
  void refreshFn().catch(() => undefined);
494
613
  if (emitPath) {
495
614
  void runTranscriptTick();
@@ -27,6 +27,8 @@ export interface DaemonStateFile {
27
27
  presenceRefusal?: PresenceRefusalState | null;
28
28
  /** Daemon emit mode: "legacy" (spool/flush) or "direct" (receive-only). */
29
29
  emitMode?: "legacy" | "direct";
30
+ /** Authoritative membership denials for device-default-derived tenant I/O. */
31
+ deviceDefaultAuthorizationDenials?: number;
30
32
  updatedAt: string;
31
33
  }
32
34
  export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
@@ -8,6 +8,7 @@
8
8
  * Orphan `*.claimed` files from a prior crash are recovered first.
9
9
  * 2xx responses are accounted per eventId (accepted / duplicates / rejected).
10
10
  */
11
+ import { type SessionStateFile } from "../../work-context/state.js";
11
12
  import { type RandomFn, type SleepFn } from "./backoff.js";
12
13
  import { type SessionEventsPoster } from "./session-events-client.js";
13
14
  export declare const HELD_TTL_MS: number;
@@ -43,6 +44,8 @@ export interface FlushDeps {
43
44
  heldMaxLines?: number;
44
45
  /** Optional logger (daemon wires this); used once per overflow trim. */
45
46
  log?: (message: string) => void;
47
+ /** Authoritative gate for state derived from a device default. */
48
+ authorizeDeviceDefaultTenant?: (state: SessionStateFile) => Promise<boolean>;
46
49
  }
47
50
  export interface FlushSummary {
48
51
  claimed: number;
@@ -218,6 +218,17 @@ export async function flushSessionEvents(deps) {
218
218
  };
219
219
  for (const [sessionId, items] of bySession) {
220
220
  const state = readSessionState(sessionId, deps.workContextRoot);
221
+ if (state?.companyConfidence === "device_default" &&
222
+ deps.authorizeDeviceDefaultTenant &&
223
+ !(await deps.authorizeDeviceDefaultTenant(state))) {
224
+ const heldAt = now().toISOString();
225
+ for (const item of items) {
226
+ appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
227
+ summary.held += 1;
228
+ markDisposed(item.event);
229
+ }
230
+ continue;
231
+ }
221
232
  const decision = classifySession(state);
222
233
  if (decision.action === "drop") {
223
234
  appendDroppedReceipt({
@@ -65,6 +65,8 @@ export interface CompanyResolveInput {
65
65
  * session-meta. Omitted (or unmapped) → the ladder falls through unchanged.
66
66
  */
67
67
  repoIdentityKey?: string | null;
68
+ /** Explicit historical repair may disable weak device-default evidence. */
69
+ allowDeviceDefault?: boolean;
68
70
  }
69
71
  /** Env naming the on-box identity.json (fleet agent boxes). */
70
72
  export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
@@ -76,6 +78,12 @@ export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.j
76
78
  * (never throws; never logs file contents).
77
79
  */
78
80
  export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
81
+ /**
82
+ * Agent boxes must never infer a human device default. The file itself is the
83
+ * machine marker; legacy identity files do not necessarily contain a company
84
+ * UID, so parsing for that field is not an eligibility check.
85
+ */
86
+ export declare function hasAgentIdentityFile(env?: NodeJS.ProcessEnv): boolean;
79
87
  /**
80
88
  * Compare companies: UIDs when both sides have them; otherwise normalized
81
89
  * slugs when both have slugs. Never treat uid+slug vs slug-only as a
@@ -63,6 +63,23 @@ export function readAgentIdentityCompanyUid(env = process.env) {
63
63
  return undefined;
64
64
  }
65
65
  }
66
+ /**
67
+ * Agent boxes must never infer a human device default. The file itself is the
68
+ * machine marker; legacy identity files do not necessarily contain a company
69
+ * UID, so parsing for that field is not an eligibility check.
70
+ */
71
+ export function hasAgentIdentityFile(env = process.env) {
72
+ const configured = env[HQ_AGENT_IDENTITY_FILE_ENV];
73
+ const identityPath = configured === undefined ? DEFAULT_AGENT_IDENTITY_FILE : configured.trim();
74
+ if (!identityPath)
75
+ return false;
76
+ try {
77
+ return fs.existsSync(identityPath);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
66
83
  /**
67
84
  * Compare companies: UIDs when both sides have them; otherwise normalized
68
85
  * slugs when both have slugs. Never treat uid+slug vs slug-only as a
@@ -178,6 +195,16 @@ export function resolveDeterministicCompany(input) {
178
195
  }
179
196
  return null;
180
197
  }
198
+ function conflictingDeterministicEvidence(input) {
199
+ const cwd = companySlugFromCwd(input.cwd, input.hqRoot);
200
+ const remote = normalizeSlug(input.remoteOwnerSlug ?? undefined);
201
+ if (!cwd || !remote || cwd.toLowerCase() === remote.toLowerCase())
202
+ return null;
203
+ return {
204
+ cwd: { slug: cwd, source: "deterministic_cwd" },
205
+ remote: { slug: remote, source: "deterministic_remote" },
206
+ };
207
+ }
181
208
  export function resolveCompany(input) {
182
209
  const env = input.env ?? process.env;
183
210
  // 1. Trusted explicit: HQ_SPAWN_COMPANY or observation trusted/explicit.
@@ -205,28 +232,31 @@ export function resolveCompany(input) {
205
232
  uid: prior.uid,
206
233
  source: "existing_scope",
207
234
  };
208
- // After a binding exists, a suspected other-company mismatch cannot
209
- // auto-switch — return company_conflict with the US-017B correction path.
235
+ // Deterministic evidence from another company never silently replaces a
236
+ // scoped session, even before it has a project binding. Preserve the
237
+ // original scope and surface a conflict before tenant I/O.
210
238
  const hadBinding = Boolean(state &&
211
239
  (state.contextStatus === "bound" ||
212
240
  state.contextStatus === "needs_task" ||
213
241
  state.contextStatus === "needs_project") &&
214
242
  (state.projectId || state.bindingEpisodeId));
215
- if (hadBinding) {
216
- const deterministic = resolveDeterministicCompany({
217
- cwd: input.cwd,
218
- hqRoot: input.hqRoot,
219
- remoteOwnerSlug: input.remoteOwnerSlug,
220
- });
221
- if (deterministic && !sameCompany(scoped, deterministic)) {
222
- return {
223
- status: "company_conflict",
224
- defaultCompany: scoped,
225
- evidenceCompany: deterministic,
226
- afterBinding: true,
227
- correctionPath: companyCorrectionPath(input.sessionId),
228
- };
229
- }
243
+ const deterministic = resolveDeterministicCompany({
244
+ cwd: input.cwd,
245
+ hqRoot: input.hqRoot,
246
+ remoteOwnerSlug: input.remoteOwnerSlug,
247
+ });
248
+ if (deterministic && !sameCompany(scoped, deterministic)) {
249
+ return {
250
+ status: "company_conflict",
251
+ defaultCompany: scoped,
252
+ evidenceCompany: deterministic,
253
+ ...(hadBinding
254
+ ? {
255
+ afterBinding: true,
256
+ correctionPath: companyCorrectionPath(input.sessionId),
257
+ }
258
+ : {}),
259
+ };
230
260
  }
231
261
  return { status: "resolved", company: scoped };
232
262
  }
@@ -264,12 +294,26 @@ export function resolveCompany(input) {
264
294
  }
265
295
  }
266
296
  // 5 + 6. Device default vs deterministic evidence.
267
- const deviceDefault = getDefaultCompany({ root: input.root });
297
+ const deviceDefault = input.allowDeviceDefault === false ? null : getDefaultCompany({ root: input.root });
268
298
  const deterministic = resolveDeterministicCompany({
269
299
  cwd: input.cwd,
270
300
  hqRoot: input.hqRoot,
271
301
  remoteOwnerSlug: input.remoteOwnerSlug,
272
302
  });
303
+ const deterministicConflict = conflictingDeterministicEvidence({
304
+ cwd: input.cwd,
305
+ hqRoot: input.hqRoot,
306
+ remoteOwnerSlug: input.remoteOwnerSlug,
307
+ });
308
+ // Cwd and remote are both deterministic evidence. Their disagreement is
309
+ // never permission to fall through to a weaker device preference.
310
+ if (deterministicConflict) {
311
+ return {
312
+ status: "company_conflict",
313
+ defaultCompany: deterministicConflict.cwd,
314
+ evidenceCompany: deterministicConflict.remote,
315
+ };
316
+ }
273
317
  if (deviceDefault?.enabled && deviceDefault.slug) {
274
318
  const def = {
275
319
  slug: deviceDefault.slug,
@@ -8,6 +8,17 @@ export interface DeviceDefaultCompany {
8
8
  slug: string;
9
9
  uid?: string;
10
10
  enabled: boolean;
11
+ /** Whether this device chose the value or safely inferred its only membership. */
12
+ source?: "auto_single" | "chosen" | "disabled";
13
+ }
14
+ export interface DefaultCompanyMembership {
15
+ slug: string;
16
+ uid: string;
17
+ }
18
+ /** Cached active memberships. This avoids a network lookup for each hook. */
19
+ export interface DefaultCompanyMembershipSnapshot {
20
+ checkedAt: string;
21
+ memberships: DefaultCompanyMembership[];
11
22
  }
12
23
  /** Last probe of GET /v1/work-mesh/capabilities across memberships (US-017B). */
13
24
  export interface MigrationCapabilitySnapshot {
@@ -36,10 +47,16 @@ export interface WorkContextDeviceConfig {
36
47
  schemaVersion: typeof DEVICE_CONFIG_SCHEMA_VERSION;
37
48
  /** Explicit device default. Never copied from activeCompany. */
38
49
  defaultCompany?: DeviceDefaultCompany | null;
50
+ /** Marks a null default written by the automatic-activation implementation. */
51
+ defaultCompanyAutomaticEligible?: true;
39
52
  /** Last-known migration capability across memberships (doctor reports this). */
40
53
  migrationCapability?: MigrationCapabilitySnapshot | null;
41
54
  /** Per-repo company map (gap 4). Remembers each repo's filed-under company. */
42
55
  repoCompanyMap?: RepoCompanyMap | null;
56
+ /** Membership cache used to activate an unconfigured device default. */
57
+ defaultCompanyMemberships?: DefaultCompanyMembershipSnapshot | null;
58
+ /** Historical held-session repair stays opt-in because a default is weak evidence. */
59
+ repairHeldWithDefault?: boolean;
43
60
  updatedAt: string;
44
61
  }
45
62
  /** true / { uid } = member; false = not a member. */
@@ -58,8 +75,16 @@ export interface DeviceConfigDeps {
58
75
  validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
59
76
  now?: () => Date;
60
77
  }
78
+ export declare const CONFIG_LOCK_STALE_MS = 30000;
79
+ /** Acquire an interprocess config lock; exported for direct lock regression tests. */
80
+ export declare function acquireDeviceConfigLock(root: string, options?: {
81
+ timeoutMs?: number;
82
+ staleMs?: number;
83
+ }): () => void;
61
84
  export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
62
85
  export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
86
+ /** A clear is a durable opt-out, not an invitation for automatic reactivation. */
87
+ export declare function isDefaultCompanyDisabled(deps: Pick<DeviceConfigDeps, "root">): boolean;
63
88
  export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps): Promise<WorkContextDeviceConfig>;
64
89
  export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
65
90
  /**
@@ -77,6 +102,16 @@ export declare function setRepoCompany(key: string, mapping: {
77
102
  }, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
78
103
  /** Persist the last memberships-wide migration capability probe (US-017B). */
79
104
  export declare function recordMigrationCapabilitySnapshot(snapshot: MigrationCapabilitySnapshot, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
105
+ export declare function recordDefaultCompanyMemberships(snapshot: DefaultCompanyMembershipSnapshot, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
106
+ /** Drop cached membership evidence immediately after an authoritative denial. */
107
+ export declare function invalidateDefaultCompanyMemberships(deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
108
+ /** Persist a default only after the complete active-membership list verified it. */
109
+ export declare function setAutoDefaultCompany(membership: DefaultCompanyMembership, deps: Pick<DeviceConfigDeps, "root" | "now"> & {
110
+ expectedUpdatedAt?: string;
111
+ /** Test seam: runs after the guarded read while the config lock is held. */
112
+ afterCompareAndSwap?: () => void;
113
+ }): WorkContextDeviceConfig;
114
+ export declare function setRepairHeldWithDefault(enabled: boolean, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
80
115
  /** Convenience: build deps from HOME / injectable root. */
81
116
  export declare function deviceConfigDepsFromHome(home?: string, extras?: Partial<DeviceConfigDeps>): DeviceConfigDeps;
82
117
  /** Re-export for callers that stamp contractVersion on related state. */