@indigoai-us/hq-cli 5.130.0 → 5.131.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.
@@ -30,6 +30,7 @@ export async function meshJson(token, path, init = {}) {
30
30
  method: init.method,
31
31
  body: init.body,
32
32
  query: init.query,
33
+ headers: init.headers,
33
34
  signal: init.signal,
34
35
  });
35
36
  const text = await res.text();
@@ -53,7 +54,11 @@ export async function meshJson(token, path, init = {}) {
53
54
  typeof err.stage === "string" && err.stage ? `stage=${err.stage}` : "",
54
55
  typeof err.recovery === "string" && err.recovery ? `recovery=${err.recovery}` : "",
55
56
  ].filter(Boolean);
56
- throw new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
57
+ const failure = new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
58
+ failure.status = res.status;
59
+ if (typeof err.code === "string" && err.code)
60
+ failure.code = err.code;
61
+ throw failure;
57
62
  }
58
63
  return data;
59
64
  }
@@ -415,9 +420,17 @@ export async function getProjectView(token, companyUid, projectId) {
415
420
  }
416
421
  /** Replace a project's durable Board view and keep the local project cache current. */
417
422
  export async function putProjectView(token, companyUid, projectId, input) {
423
+ const { expectedVersion, ...document } = input;
418
424
  const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}`, {
419
425
  method: "PUT",
420
- body: { companyUid, ...input },
426
+ body: {
427
+ companyUid,
428
+ ...document,
429
+ ...(expectedVersion !== undefined ? { expectedVersion } : {}),
430
+ },
431
+ ...(expectedVersion !== undefined
432
+ ? { headers: { "If-Match": String(expectedVersion) } }
433
+ : {}),
421
434
  }));
422
435
  if (!view)
423
436
  throw new Error("Work mesh returned an invalid project view");
@@ -33,6 +33,10 @@ export interface DaemonDoctorReport {
33
33
  tokenSource: CognitoTokenSource;
34
34
  /** human | agent | unknown — from token claims / machine signal. */
35
35
  actorKind: CognitoActorKind;
36
+ /** Subject in the person login file (`hq whoami`), or empty claims. */
37
+ whoamiSubject: string;
38
+ /** Subject the daemon will authenticate as (machine cache, else person file). */
39
+ daemonSubject: string;
36
40
  /** Credential vend refused (disabled / unsupported / 403). */
37
41
  presenceRefusal?: DaemonStateFile["presenceRefusal"];
38
42
  /** Companies the current presence credential did not include. */
@@ -5,7 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
8
+ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedTokens, readPersonTokenFile, } from "../../../../utils/cognito-session.js";
9
9
  import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
10
10
  import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
@@ -145,6 +145,12 @@ export function collectDaemonDoctor(deps = {}) {
145
145
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
146
146
  }
147
147
  const auth = describeCognitoTokenSource({ home, env });
148
+ const whoamiTokens = readPersonTokenFile(home, env);
149
+ const daemonTokens = auth.tokenSource === "machine"
150
+ ? loadMachineCachedTokens(home, env)
151
+ : whoamiTokens;
152
+ const whoamiSubject = formatCognitoSubject(whoamiTokens);
153
+ const daemonSubject = formatCognitoSubject(daemonTokens);
148
154
  const emitState = readEmitState(meshRoot);
149
155
  const emitRetry = emitRetryDepth(meshRoot);
150
156
  if (emitRetry > 0) {
@@ -169,6 +175,8 @@ export function collectDaemonDoctor(deps = {}) {
169
175
  oldestSpoolAgeMs,
170
176
  tokenSource: auth.tokenSource,
171
177
  actorKind: auth.actorKind,
178
+ whoamiSubject,
179
+ daemonSubject,
172
180
  presenceRefusal,
173
181
  presenceDroppedCompanies,
174
182
  emitMode,
@@ -200,7 +208,7 @@ export function formatDaemonDoctor(report) {
200
208
  lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
201
209
  }
202
210
  lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
203
- `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
211
+ `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`, `identity whoami: ${report.whoamiSubject}`, `identity daemon: ${report.daemonSubject}`);
204
212
  if (report.emitMode === "legacy") {
205
213
  lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
206
214
  ? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
@@ -137,6 +137,9 @@ export async function runMeshDaemon(deps = {}) {
137
137
  // expires after ~1h to every vend/flush forever, so the second credential
138
138
  // renewal (~96 min in) and everything after it failed with HTTP 401 and the
139
139
  // daemon went dark once its IoT credentials expired.
140
+ // ensureCognitoToken refuses a refresh whose subject differs from the
141
+ // active person cache and restores the file. The daemon must not replace
142
+ // the operator login with another cached session.
140
143
  const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
141
144
  const getToken = deps.getToken ?? defaultGetToken;
142
145
  const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
@@ -23,6 +23,8 @@ export interface EmitEventResult {
23
23
  status: EmitEventStatus;
24
24
  /** Present when status != accepted (no_company_evidence | not_a_member | …). */
25
25
  reason?: string;
26
+ /** Server rejection code when status is rejected (`code`, else `reason`). */
27
+ code?: string;
26
28
  /** Resolved company (accepted, or unassigned-into-single-company). */
27
29
  companyUid?: string;
28
30
  }
@@ -58,10 +58,7 @@ export function parseEmitResults(body) {
58
58
  if (!status)
59
59
  continue;
60
60
  const res = { eventId, status };
61
- if (typeof r.reason === "string" && r.reason.trim())
62
- res.reason = r.reason.trim();
63
- else if (typeof r.code === "string" && r.code.trim())
64
- res.reason = r.code.trim();
61
+ applyEmitReasonAndCode(res, r);
65
62
  if (typeof r.companyUid === "string" && r.companyUid.trim()) {
66
63
  res.companyUid = r.companyUid.trim();
67
64
  }
@@ -86,16 +83,26 @@ export function parseEmitResults(body) {
86
83
  if (!eventId)
87
84
  continue;
88
85
  const res = { eventId, status: "rejected" };
89
- if (typeof r.reason === "string" && r.reason.trim())
90
- res.reason = r.reason.trim();
91
- else if (typeof r.code === "string" && r.code.trim())
92
- res.reason = r.code.trim();
86
+ applyEmitReasonAndCode(res, r);
93
87
  out.push(res);
94
88
  }
95
89
  }
96
90
  }
97
91
  return out.length > 0 ? out : null;
98
92
  }
93
+ function applyEmitReasonAndCode(res, raw) {
94
+ const reason = typeof raw.reason === "string" ? raw.reason.trim() : "";
95
+ const code = typeof raw.code === "string" ? raw.code.trim() : "";
96
+ if (reason)
97
+ res.reason = reason;
98
+ else if (code)
99
+ res.reason = code;
100
+ if (res.status !== "rejected")
101
+ return;
102
+ const rejectionCode = code || reason;
103
+ if (rejectionCode)
104
+ res.code = rejectionCode;
105
+ }
99
106
  function normalizeStatus(v) {
100
107
  if (typeof v !== "string")
101
108
  return null;
@@ -47,8 +47,17 @@ export interface EmitEventsSummary {
47
47
  retryDepth: number;
48
48
  batches: number;
49
49
  statuses: Record<EmitEventStatus, number>;
50
+ /** Server code for each terminally rejected event. */
51
+ rejections: Array<{
52
+ eventId: string;
53
+ code: string;
54
+ }>;
50
55
  lastPostAt?: string;
51
56
  }
57
+ export declare function formatEmitRejectionLines(rejections: ReadonlyArray<{
58
+ eventId: string;
59
+ code: string;
60
+ }>): string[];
52
61
  /** Drain the retry file, post pending + new events, persist failures. */
53
62
  export declare function emitEvents(deps: EmitEventsDeps): Promise<EmitEventsSummary>;
54
63
  //# sourceMappingURL=emit.d.ts.map
@@ -41,9 +41,23 @@ function writeEmitState(workMeshRoot, state) {
41
41
  /* best-effort */
42
42
  }
43
43
  }
44
+ function rejectionCodeFromBody(body, status) {
45
+ if (body && typeof body === "object" && !Array.isArray(body)) {
46
+ const rec = body;
47
+ if (typeof rec.code === "string" && rec.code.trim())
48
+ return rec.code.trim();
49
+ if (typeof rec.error === "string" && /^[A-Z0-9_]+$/.test(rec.error.trim())) {
50
+ return rec.error.trim();
51
+ }
52
+ }
53
+ return `HTTP_${status}`;
54
+ }
44
55
  function eventIdOf(e) {
45
56
  return typeof e.eventId === "string" && e.eventId.trim() ? e.eventId.trim() : null;
46
57
  }
58
+ export function formatEmitRejectionLines(rejections) {
59
+ return rejections.map((row) => `work-mesh: rejected eventId=${row.eventId} code=${row.code}`);
60
+ }
47
61
  /** Drain the retry file, post pending + new events, persist failures. */
48
62
  export async function emitEvents(deps) {
49
63
  const now = deps.now ?? (() => new Date());
@@ -74,6 +88,7 @@ export async function emitEvents(deps) {
74
88
  retryDepth: 0,
75
89
  batches: 0,
76
90
  statuses: { accepted: 0, unassigned: 0, rejected: 0 },
91
+ rejections: [],
77
92
  };
78
93
  const keep = [];
79
94
  let anyPosted = false;
@@ -95,7 +110,13 @@ export async function emitEvents(deps) {
95
110
  // Non-retryable 4xx (e.g. 401/400) → drop with a log (never user data).
96
111
  summary.rejected += chunk.length;
97
112
  summary.statuses.rejected += chunk.length;
98
- deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status}`);
113
+ const batchCode = rejectionCodeFromBody(result.body, result.status);
114
+ for (const event of chunk) {
115
+ const id = eventIdOf(event);
116
+ if (id)
117
+ summary.rejections.push({ eventId: id, code: batchCode });
118
+ }
119
+ deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status} code=${batchCode}`);
99
120
  }
100
121
  continue;
101
122
  }
@@ -121,8 +142,13 @@ export async function emitEvents(deps) {
121
142
  summary.accepted += 1;
122
143
  else if (r.status === "unassigned")
123
144
  summary.unassigned += 1;
124
- else
145
+ else if (id) {
125
146
  summary.rejected += 1;
147
+ summary.rejections.push({
148
+ eventId: id,
149
+ code: r.code || r.reason || "rejected",
150
+ });
151
+ }
126
152
  }
127
153
  }
128
154
  const written = writeEmitRetry(deps.workMeshRoot, keep, retryMax);
@@ -92,8 +92,14 @@ function isLocallyReconcilableHeldEvent(event, state, workContextRoot) {
92
92
  repoIdentityKey,
93
93
  }).status === "resolved");
94
94
  }
95
- function deadLetterEnvelope(event, reason, responseCode, at) {
96
- return JSON.stringify({ event, reason, responseCode, at });
95
+ function deadLetterEnvelope(event, reason, responseCode, at, code) {
96
+ return JSON.stringify({
97
+ event,
98
+ reason,
99
+ responseCode,
100
+ at,
101
+ ...(code ? { code } : {}),
102
+ });
97
103
  }
98
104
  function groupBySession(events) {
99
105
  const map = new Map();
@@ -431,7 +437,7 @@ function accountBatchSuccess(body, originalChunk, meshRoot, now, summary, markDi
431
437
  const item = byId.get(row.eventId);
432
438
  if (!item)
433
439
  continue;
434
- appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at), meshRoot);
440
+ appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at, row.code), meshRoot);
435
441
  summary.deadLettered += 1;
436
442
  rejectedIds.add(row.eventId);
437
443
  markDisposed(item.event);
@@ -1,6 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
- import { createRequire } from 'node:module';
4
3
  import * as os from 'node:os';
5
4
  import * as path from 'node:path';
6
5
  import { fileURLToPath } from 'node:url';
@@ -10,9 +9,9 @@ import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js
10
9
  import { isQmdQueryDocumentError } from '../../utils/qmd-query-document-error.js';
11
10
  import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
12
11
  import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
12
+ import { markExpectedUserError } from '../../utils/expected-cli-error.js';
13
13
  import { redactErrorText } from '../../utils/redact-error-text.js';
14
14
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
15
- const require = createRequire(import.meta.url);
16
15
  /** The `path` implementation for a (possibly injected) platform. */
17
16
  function pathFor(platform) {
18
17
  return platform === 'win32' ? path.win32 : path.posix;
@@ -1005,6 +1004,22 @@ function describeQmdTermination(stdout, stderr) {
1005
1004
  return `qmd reported: ${summary.reason}`;
1006
1005
  return '';
1007
1006
  }
1007
+ /**
1008
+ * qmd caller-state failures that the top-level boundary may surface without a
1009
+ * crash report. The match is deliberately against qmd's raw stderr and a
1010
+ * closed subcommand set: a query containing these words must not change the
1011
+ * disposition of an unrelated qmd exit.
1012
+ */
1013
+ function isExpectedQmdCallerFailure(args, stderr) {
1014
+ const subcommand = args[0];
1015
+ if (subcommand === 'get') {
1016
+ return /^Document not found:\s+[^\r\n]+$/m.test(stderr);
1017
+ }
1018
+ if (subcommand === 'collection') {
1019
+ return /(?:^|\b)Failed to parse\s+[^\r\n]+:\s*.+$/m.test(stderr);
1020
+ }
1021
+ return false;
1022
+ }
1008
1023
  /** Normalise a spawn result and raise the typed qmd failures. */
1009
1024
  function finishRunQmd(result, bin, args, attribution) {
1010
1025
  const normalized = {
@@ -1215,7 +1230,11 @@ function finishRunQmd(result, bin, args, attribution) {
1215
1230
  if (alreadyExists) {
1216
1231
  throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
1217
1232
  }
1218
- throw new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
1233
+ const exitError = new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
1234
+ if (isExpectedQmdCallerFailure(args, normalized.stderr)) {
1235
+ throw markExpectedUserError(exitError);
1236
+ }
1237
+ throw exitError;
1219
1238
  }
1220
1239
  /** Run qmd with captured output and typed failures. */
1221
1240
  export function runQmd(args, options = {}) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Offline preflight ContextResult shapes that `hq mesh context reconcile --offline`
3
+ * can emit. migration_pending is a migrate-path status, not a preflight result.
4
+ *
5
+ * contractVersion is WORK_CONTEXT_CONTRACT_VERSION. Bump it only together with
6
+ * contracts/preflight/v<N>/ and the vendored copy in hq-core
7
+ * (core/hooks/SessionStart/preflight-fixtures.json). Each side pins the version
8
+ * in tests; a one-sided bump fails that side until the other is updated.
9
+ */
10
+ import { type ContextResult } from "./contract.js";
11
+ export declare const PREFLIGHT_CONTRACT_VERSION: 1;
12
+ /** Classifications reconcile actually returns on the offline preflight path. */
13
+ export declare const OFFLINE_PREFLIGHT_CLASSIFICATIONS: readonly ["unresolved", "needs_company", "company_conflict", "needs_project", "needs_task", "bound", "untracked"];
14
+ export type OfflinePreflightClassification = (typeof OFFLINE_PREFLIGHT_CLASSIFICATIONS)[number];
15
+ /**
16
+ * One representative result per emitted classification, using the kind and
17
+ * delivery the offline path actually returns (company-resolved results are
18
+ * kind "queued" / delivery "queued"; local-only results are delivery "clean").
19
+ */
20
+ export declare function preflightFixtureFor(classification: OfflinePreflightClassification): ContextResult;
21
+ export interface PreflightFixtureFile {
22
+ contractVersion: typeof PREFLIGHT_CONTRACT_VERSION;
23
+ source: "hq-cli contracts/preflight/v1/fixtures.json";
24
+ classifications: readonly OfflinePreflightClassification[];
25
+ fixtures: ContextResult[];
26
+ }
27
+ export declare function buildPreflightFixtures(): PreflightFixtureFile;
28
+ //# sourceMappingURL=preflight-contract.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Offline preflight ContextResult shapes that `hq mesh context reconcile --offline`
3
+ * can emit. migration_pending is a migrate-path status, not a preflight result.
4
+ *
5
+ * contractVersion is WORK_CONTEXT_CONTRACT_VERSION. Bump it only together with
6
+ * contracts/preflight/v<N>/ and the vendored copy in hq-core
7
+ * (core/hooks/SessionStart/preflight-fixtures.json). Each side pins the version
8
+ * in tests; a one-sided bump fails that side until the other is updated.
9
+ */
10
+ import { WORK_CONTEXT_CONTRACT_VERSION, } from "./contract.js";
11
+ export const PREFLIGHT_CONTRACT_VERSION = WORK_CONTEXT_CONTRACT_VERSION;
12
+ /** Classifications reconcile actually returns on the offline preflight path. */
13
+ export const OFFLINE_PREFLIGHT_CLASSIFICATIONS = [
14
+ "unresolved",
15
+ "needs_company",
16
+ "company_conflict",
17
+ "needs_project",
18
+ "needs_task",
19
+ "bound",
20
+ "untracked",
21
+ ];
22
+ const FIXTURE_SESSION = "sid-preflight-fixture";
23
+ const FIXTURE_OP = "op-preflight-fixture";
24
+ function base(classification, kind, delivery) {
25
+ return {
26
+ contractVersion: PREFLIGHT_CONTRACT_VERSION,
27
+ kind,
28
+ classification,
29
+ delivery,
30
+ lifecycle: "open",
31
+ sessionId: FIXTURE_SESSION,
32
+ clientOperationId: FIXTURE_OP,
33
+ };
34
+ }
35
+ /**
36
+ * One representative result per emitted classification, using the kind and
37
+ * delivery the offline path actually returns (company-resolved results are
38
+ * kind "queued" / delivery "queued"; local-only results are delivery "clean").
39
+ */
40
+ export function preflightFixtureFor(classification) {
41
+ switch (classification) {
42
+ case "unresolved":
43
+ return base("unresolved", "queued", "queued");
44
+ case "needs_company":
45
+ return base("needs_company", "needs_company", "clean");
46
+ case "company_conflict":
47
+ return base("company_conflict", "company_conflict", "clean");
48
+ case "needs_project":
49
+ return {
50
+ ...base("needs_project", "queued", "queued"),
51
+ companySlug: "acme",
52
+ companyUid: "cmp_acme",
53
+ };
54
+ case "needs_task":
55
+ return {
56
+ ...base("needs_task", "queued", "queued"),
57
+ companySlug: "acme",
58
+ companyUid: "cmp_acme",
59
+ projectId: "dispatch-proj",
60
+ };
61
+ case "bound":
62
+ return {
63
+ ...base("bound", "queued", "queued"),
64
+ companySlug: "acme",
65
+ companyUid: "cmp_acme",
66
+ projectId: "dispatch-proj",
67
+ taskId: "US-9",
68
+ };
69
+ case "untracked":
70
+ return base("untracked", "untracked", "clean");
71
+ default: {
72
+ const never = classification;
73
+ return never;
74
+ }
75
+ }
76
+ }
77
+ export function buildPreflightFixtures() {
78
+ return {
79
+ contractVersion: PREFLIGHT_CONTRACT_VERSION,
80
+ source: "hq-cli contracts/preflight/v1/fixtures.json",
81
+ classifications: [...OFFLINE_PREFLIGHT_CLASSIFICATIONS],
82
+ fixtures: OFFLINE_PREFLIGHT_CLASSIFICATIONS.map(preflightFixtureFor),
83
+ };
84
+ }
85
+ //# sourceMappingURL=preflight-contract.js.map
package/dist/main.js CHANGED
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import { Command } from "commander";
12
12
  import { finishSentrySession, initSentry, Sentry } from "./sentry.js";
13
13
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
14
- import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
14
+ import { environmentalFsErrorMessage, filesystemErrorCaptureContext, filesystemErrorMessage, } from "./utils/environmental-error.js";
15
15
  import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
16
16
  import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
17
17
  import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
@@ -690,6 +690,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
690
690
  lockMsg
691
691
  ? null
692
692
  : networkTransportErrorMessage(err);
693
+ // Windows EPERM/EACCES/EBUSY/UNKNOWN filesystem failures are not proven
694
+ // caller-owned conditions. Keep them at error level and attach bounded,
695
+ // installation-independent context so OneDrive/antivirus/path-length
696
+ // hypotheses can be separated without creating one issue per host path.
697
+ const fsContext = filesystemErrorCaptureContext(err);
698
+ const fsMsg = fsContext ? filesystemErrorMessage(err) : null;
693
699
  if (qmdMsg) {
694
700
  deps.stderr.write(`hq: ${qmdMsg}\n`);
695
701
  }
@@ -735,6 +741,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
735
741
  else if (transportMsg) {
736
742
  deps.stderr.write(`hq: ${transportMsg}\n`);
737
743
  }
744
+ else if (fsMsg && fsContext) {
745
+ deps.sentry.captureException(err, fsContext);
746
+ deps.stderr.write(`hq: ${fsMsg}\n`);
747
+ }
738
748
  else {
739
749
  // A genuinely unclassified fault is still captured exactly once. Two
740
750
  // shapes attach bounded, hq-derived context so the next occurrence
@@ -153,6 +153,45 @@ export declare function wantsMachineCognitoTokens(options?: Pick<EnsureCognitoTo
153
153
  * not flip this to machine.
154
154
  */
155
155
  export declare function resolveCognitoTokenSource(env?: NodeJS.ProcessEnv): CognitoTokenSource;
156
+ /**
157
+ * Refresh wrote a different Cognito subject than the active cache.
158
+ * Callers must not fall through to browser login or machine mint — that
159
+ * would replace the operator session the refusal is protecting.
160
+ */
161
+ export declare class IdentitySwapRefusal extends Error {
162
+ constructor(message: string);
163
+ }
164
+ export declare function isIdentitySwapRefusal(err: unknown): err is IdentitySwapRefusal;
165
+ /** `sub` plus `custom:entityUid` from an unverified JWT. Either may be absent. */
166
+ export declare function cognitoSubjectFromToken(token: string | undefined): {
167
+ sub: string | null;
168
+ entityUid: string | null;
169
+ };
170
+ /** Stable subject key. Null when neither sub nor entity uid is present. */
171
+ export declare function cognitoSubjectKey(tokens: {
172
+ idToken?: string;
173
+ accessToken?: string;
174
+ } | null | undefined): string | null;
175
+ /** Doctor/status line. Claims only — never token material. */
176
+ export declare function formatCognitoSubject(tokens: {
177
+ idToken?: string;
178
+ accessToken?: string;
179
+ } | null | undefined): string;
180
+ /**
181
+ * Person cache as stored on disk for `env`. Does not consult hq-cloud's
182
+ * in-memory fallback, which can surface a different session when the file
183
+ * is missing or unreadable.
184
+ */
185
+ export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv): CognitoTokens | null;
186
+ /**
187
+ * Refresh the active person session and refuse a subject change.
188
+ *
189
+ * hq-cloud's `refreshTokens` saves as soon as Cognito responds, and on a
190
+ * cache miss or unreadable file it can refresh an in-memory session for a
191
+ * different subject (same app client). Snapshot the active file first; if
192
+ * the response subject differs, put the bytes back and leave them there.
193
+ */
194
+ export declare function refreshActivePersonTokens(cached: CognitoTokens, env?: NodeJS.ProcessEnv): Promise<CognitoTokens>;
156
195
  /** Derive actor kind from an ID token's custom:entity* claims (no verify). */
157
196
  export declare function actorKindFromIdToken(idToken: string | undefined): CognitoActorKind;
158
197
  /**