@indigoai-us/hq-cli 5.130.0 → 5.132.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 (79) hide show
  1. package/CHANGELOG.md +155 -1
  2. package/contracts/preflight/v1/fixtures.json +87 -0
  3. package/contracts/preflight/v1/schema.json +58 -0
  4. package/dist/command-catalog.generated.d.ts +39 -0
  5. package/dist/command-catalog.generated.js +50 -0
  6. package/dist/commands/dm.d.ts +2 -0
  7. package/dist/commands/dm.js +1 -1
  8. package/dist/commands/files.js +137 -9
  9. package/dist/commands/lanes.d.ts +13 -2
  10. package/dist/commands/lanes.js +394 -42
  11. package/dist/commands/mesh.js +66 -13
  12. package/dist/commands/update.js +1 -1
  13. package/dist/lib/bot/paths.d.ts +7 -5
  14. package/dist/lib/bot/paths.js +8 -1
  15. package/dist/lib/bot/prompt.d.ts +0 -2
  16. package/dist/lib/bot/prompt.js +5 -11
  17. package/dist/lib/bot/room-policy.d.ts +1 -24
  18. package/dist/lib/bot/room-policy.js +2 -35
  19. package/dist/lib/bot/run.d.ts +7 -8
  20. package/dist/lib/bot/run.js +21 -33
  21. package/dist/lib/bot/session.d.ts +1 -1
  22. package/dist/lib/bot/session.js +1 -1
  23. package/dist/lib/lanes/accounts.d.ts +6 -0
  24. package/dist/lib/lanes/accounts.js +2 -1
  25. package/dist/lib/lanes/config.d.ts +4 -0
  26. package/dist/lib/lanes/config.js +9 -0
  27. package/dist/lib/lanes/index.d.ts +10 -3
  28. package/dist/lib/lanes/index.js +5 -1
  29. package/dist/lib/lanes/mesh-events.js +4 -0
  30. package/dist/lib/lanes/models.d.ts +78 -0
  31. package/dist/lib/lanes/models.js +205 -0
  32. package/dist/lib/lanes/move-account.d.ts +56 -0
  33. package/dist/lib/lanes/move-account.js +193 -0
  34. package/dist/lib/lanes/provider-rejection.d.ts +86 -0
  35. package/dist/lib/lanes/provider-rejection.js +258 -0
  36. package/dist/lib/lanes/quota.d.ts +17 -0
  37. package/dist/lib/lanes/quota.js +24 -0
  38. package/dist/lib/lanes/reconcile-install.d.ts +24 -3
  39. package/dist/lib/lanes/reconcile-install.js +48 -12
  40. package/dist/lib/lanes/reconcile.js +41 -11
  41. package/dist/lib/lanes/spawn.js +26 -5
  42. package/dist/lib/lanes/store.js +10 -0
  43. package/dist/lib/lanes/types.d.ts +49 -2
  44. package/dist/lib/lanes/types.js +4 -3
  45. package/dist/lib/lanes/watcher.d.ts +27 -0
  46. package/dist/lib/lanes/watcher.js +68 -5
  47. package/dist/lib/mesh/api.d.ts +2 -0
  48. package/dist/lib/mesh/api.js +15 -2
  49. package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
  50. package/dist/lib/mesh/live/backfill-held.js +37 -14
  51. package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
  52. package/dist/lib/mesh/live/daemon/doctor.js +15 -4
  53. package/dist/lib/mesh/live/daemon/install.d.ts +5 -0
  54. package/dist/lib/mesh/live/daemon/install.js +84 -1
  55. package/dist/lib/mesh/live/daemon/run.d.ts +5 -0
  56. package/dist/lib/mesh/live/daemon/run.js +17 -4
  57. package/dist/lib/mesh/live/emit-client.d.ts +2 -0
  58. package/dist/lib/mesh/live/emit-client.js +15 -8
  59. package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
  60. package/dist/lib/mesh/live/emit-evidence.js +20 -2
  61. package/dist/lib/mesh/live/emit.d.ts +9 -0
  62. package/dist/lib/mesh/live/emit.js +28 -2
  63. package/dist/lib/mesh/live/flush.js +9 -3
  64. package/dist/lib/search-index/index.js +22 -3
  65. package/dist/lib/work-context/company.d.ts +16 -0
  66. package/dist/lib/work-context/company.js +46 -14
  67. package/dist/lib/work-context/preflight-contract.d.ts +28 -0
  68. package/dist/lib/work-context/preflight-contract.js +85 -0
  69. package/dist/main.js +11 -1
  70. package/dist/utils/cognito-session.d.ts +39 -0
  71. package/dist/utils/cognito-session.js +119 -3
  72. package/dist/utils/environmental-error.d.ts +18 -0
  73. package/dist/utils/environmental-error.js +240 -7
  74. package/dist/utils/expected-cli-error.d.ts +2 -0
  75. package/dist/utils/expected-cli-error.js +4 -0
  76. package/dist/utils/self-update.js +4 -1
  77. package/dist/utils/vault-api.d.ts +1 -0
  78. package/dist/utils/vault-api.js +11 -3
  79. package/package.json +3 -2
@@ -101,7 +101,12 @@ export declare function restartDaemonServiceIfInstalled(opts?: {
101
101
  platform?: DaemonInstallPlatform;
102
102
  home?: string;
103
103
  uid?: number;
104
+ /** HQ root the daemon was serving, or the root this update was run from. */
105
+ hqRoot?: string;
106
+ env?: NodeJS.ProcessEnv;
104
107
  existsSync?: typeof fs.existsSync;
108
+ readFileSync?: typeof fs.readFileSync;
109
+ writeFileSync?: typeof fs.writeFileSync;
105
110
  run?: (command: string, args: string[]) => {
106
111
  status: number | null;
107
112
  };
@@ -17,7 +17,7 @@ import * as path from "node:path";
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { resolveDaemonAssetDir } from "./log.js";
19
19
  import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
20
- import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
20
+ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
21
21
  import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
22
22
  /**
23
23
  * `schtasks /End` terminates the task's root process only. The daemon is
@@ -49,6 +49,86 @@ export const defaultSchtasks = (args) => {
49
49
  stderr: r.error ? r.error.message : (r.stderr ?? ""),
50
50
  };
51
51
  };
52
+ function readServiceText(file, existsSync, readFileSync) {
53
+ if (!existsSync(file))
54
+ return "";
55
+ const raw = readFileSync(file);
56
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw));
57
+ if ((buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) || buf.includes(0)) {
58
+ return buf.toString("utf16le").replace(/^\uFEFF/, "");
59
+ }
60
+ return buf.toString("utf8");
61
+ }
62
+ function hqRootFromUnitText(platform, text) {
63
+ if (platform === "win32")
64
+ return parseWindowsTaskHqRoot(text);
65
+ if (platform === "darwin") {
66
+ const match = /<key>HQ_ROOT<\/key>\s*<string>([^<]*)<\/string>/.exec(text);
67
+ return match?.[1] ?? null;
68
+ }
69
+ const match = /^Environment=HQ_ROOT=(.*)$/m.exec(text);
70
+ if (!match)
71
+ return null;
72
+ const value = match[1].trim();
73
+ return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
74
+ }
75
+ /**
76
+ * Record the HQ root the daemon was serving and replace a pre-allowlist unit
77
+ * before the process is restarted. A unit with no HQ_ROOT (or Restart=on-failure)
78
+ * comes back with an empty root allowlist unless this runs first.
79
+ */
80
+ function migrateLegacyDaemonUnit(opts) {
81
+ const { platform, home } = opts;
82
+ if (platform !== "darwin" && platform !== "linux" && platform !== "win32")
83
+ return;
84
+ const unitPath = platform === "darwin"
85
+ ? path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
86
+ : platform === "linux"
87
+ ? path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME)
88
+ : path.join(daemonDir(undefined, home, opts.env), `${WINDOWS_TASK_NAME}.xml`);
89
+ if (!opts.existsSync(unitPath))
90
+ return;
91
+ const text = readServiceText(unitPath, opts.existsSync, opts.readFileSync);
92
+ const fromUnit = hqRootFromUnitText(platform, text);
93
+ const verified = verifyHqRoot(fromUnit ?? undefined)
94
+ ?? verifyHqRoot(opts.hqRoot)
95
+ ?? verifyHqRoot(opts.env?.HQ_ROOT);
96
+ if (!verified)
97
+ return;
98
+ registerTrustedHqRoot(daemonDir(undefined, home, opts.env), verified);
99
+ const legacy = platform === "linux"
100
+ ? !text.includes("Environment=HQ_ROOT=") || /Restart=on-failure/.test(text)
101
+ : platform === "darwin"
102
+ ? !/<key>HQ_ROOT<\/key>/.test(text)
103
+ : !fromUnit;
104
+ if (!legacy)
105
+ return;
106
+ const paths = buildInstallPaths({
107
+ home,
108
+ env: opts.env,
109
+ hqRoot: verified,
110
+ hqBinary: "/usr/bin/hq",
111
+ nodeBinary: process.execPath || "/usr/bin/node",
112
+ });
113
+ try {
114
+ paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
115
+ }
116
+ catch {
117
+ // The rendered command only has to name a binary; install already resolved one.
118
+ }
119
+ if (platform === "darwin") {
120
+ opts.writeFileSync(unitPath, renderLaunchdPlist(paths), { mode: 0o600 });
121
+ return;
122
+ }
123
+ if (platform === "linux") {
124
+ opts.writeFileSync(unitPath, renderSystemdUserUnit(paths), { mode: 0o600 });
125
+ opts.run("systemctl", ["--user", "daemon-reload"]);
126
+ return;
127
+ }
128
+ const rendered = renderWindowsTaskXml(paths);
129
+ opts.writeFileSync(unitPath, encodeWindowsTaskXml(rendered));
130
+ opts.run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", unitPath, "/F"]);
131
+ }
52
132
  /**
53
133
  * Restart the installed user daemon after a CLI upgrade. This is deliberately
54
134
  * best-effort: an update must remain successful if a user has no service,
@@ -58,8 +138,11 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
58
138
  const platform = opts.platform ?? detectPlatform();
59
139
  const home = opts.home ?? os.homedir();
60
140
  const existsSync = opts.existsSync ?? fs.existsSync;
141
+ const readFileSync = opts.readFileSync ?? fs.readFileSync;
142
+ const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
61
143
  const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
62
144
  try {
145
+ migrateLegacyDaemonUnit({ platform, home, hqRoot: opts.hqRoot, env: opts.env, existsSync, readFileSync, writeFileSync, run });
63
146
  if (platform === "darwin") {
64
147
  const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
65
148
  if (!existsSync(plist))
@@ -90,6 +90,11 @@ export interface DaemonRunDeps {
90
90
  resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
91
91
  companyUid: string;
92
92
  } | null>;
93
+ /**
94
+ * Machine credential (fleet box or local bot). A refused or empty membership
95
+ * probe defers; it does not clear the company. Defaults to isMachineIdentity().
96
+ */
97
+ machineIdentity?: boolean;
93
98
  /** Test seam immediately before a device-default revocation's guarded commit. */
94
99
  beforeDeviceDefaultRevocationCommit?: () => void;
95
100
  log?: (dir: string, line: string) => void;
@@ -14,7 +14,7 @@ import * as path from "node:path";
14
14
  import { CLI_VERSION } from "../../../../cli-version.js";
15
15
  import { createWorkSessionDeliverer } from "../../client.js";
16
16
  import { listActiveMembershipCompanies, meshJson, resolveActiveMembershipCompany } from "../../api.js";
17
- import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
17
+ import { ensureCognitoToken, isMachineIdentity } from "../../../../utils/cognito-session.js";
18
18
  import { replayOutbox } from "../../../work-context/outbox.js";
19
19
  import { workContextRoot } from "../../../work-context/paths.js";
20
20
  import { ensureDefaultCompany } from "../../../work-context/default-company.js";
@@ -137,9 +137,13 @@ 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;
146
+ const machineIdentity = deps.machineIdentity ?? isMachineIdentity();
143
147
  /** The sole authorization seam for daemon tenant I/O derived from a device default. */
144
148
  const tenantGate = {
145
149
  authorize: async (input) => {
@@ -158,9 +162,18 @@ export async function runMeshDaemon(deps = {}) {
158
162
  authorization = await Promise.race([
159
163
  (async () => {
160
164
  const membership = await resolveMembership(await getToken(), slug, controller.signal);
161
- return membership
162
- ? { status: "authorized", uid: membership.companyUid }
163
- : { status: "denied" };
165
+ if (membership)
166
+ return { status: "authorized", uid: membership.companyUid };
167
+ // A machine token often cannot read /membership/me (401/403, or an
168
+ // empty person-membership list). That is "cannot tell", not "not
169
+ // a member". Leave the company in place and retry.
170
+ if (machineIdentity) {
171
+ return {
172
+ status: "unavailable",
173
+ error: "membership probe on a machine token did not confirm a company; company left unchanged",
174
+ };
175
+ }
176
+ return { status: "denied" };
164
177
  })(),
165
178
  new Promise((resolve) => {
166
179
  controller.signal.addEventListener("abort", () => resolve({
@@ -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;
@@ -37,6 +37,17 @@ export interface BuildEmitEvidenceInput {
37
37
  readMeta?: (sessionId: string, hqRoot: string | undefined) => string | undefined;
38
38
  /** Injectable work-tree finder (tests). */
39
39
  findRepo?: (startDir: string) => string | null;
40
+ /**
41
+ * Env for HQ_SPAWN_COMPANY and the identity file. Defaults to empty so a
42
+ * unit test does not inherit the developer shell. Callers that emit pass
43
+ * `process.env`.
44
+ */
45
+ env?: NodeJS.ProcessEnv;
46
+ /** Injectable machine-company reader (tests). */
47
+ readMachineCompany?: (env: NodeJS.ProcessEnv) => {
48
+ slug?: string;
49
+ uid?: string;
50
+ } | undefined;
40
51
  }
41
52
  /** Dedupe absolute-ish paths, preserving first-seen order. */
42
53
  export declare function normalizeTouchedPaths(paths: Array<string | undefined | null>): string[];
@@ -46,4 +57,12 @@ export declare function normalizeTouchedPaths(paths: Array<string | undefined |
46
57
  * first touched file). All fields are omitted when empty.
47
58
  */
48
59
  export declare function buildEmitEvidence(input: BuildEmitEvidenceInput): EmitEvidence;
60
+ /**
61
+ * A machine credential with no company must not be posted. The server would
62
+ * file it as company NONE. Hold it instead.
63
+ */
64
+ export declare function machineEmitDisposition(input: {
65
+ machine: boolean;
66
+ companySlug?: string;
67
+ }): "post" | "hold";
49
68
  //# sourceMappingURL=emit-evidence.d.ts.map
@@ -9,7 +9,7 @@
9
9
  * work-tree off disk. Never reads prompts/tokens; never hits the network.
10
10
  */
11
11
  import * as path from "node:path";
12
- import { readMetaCompanySlug } from "../../work-context/company.js";
12
+ import { readMachineCompanyBinding, readMetaCompanySlug } from "../../work-context/company.js";
13
13
  import { findWorkTreeRoot } from "../../work-context/repo-remote.js";
14
14
  function clean(v) {
15
15
  return typeof v === "string" && v.trim() ? v.trim() : undefined;
@@ -41,7 +41,16 @@ export function buildEmitEvidence(input) {
41
41
  const cwd = clean(input.cwd);
42
42
  const hqRoot = clean(input.hqRoot);
43
43
  const touchedPaths = normalizeTouchedPaths(input.touchedPaths ?? []);
44
- const companySlug = clean(input.companySlug) ?? clean(readMeta(input.sessionId, hqRoot));
44
+ const env = input.env ?? {};
45
+ const machine = (input.readMachineCompany ?? readMachineCompanyBinding)(env);
46
+ const spawned = clean(env.HQ_SPAWN_COMPANY);
47
+ // Explicit, spawn, session meta, then the box binding (slug, else uid).
48
+ // A uid with no slug still rides companySlug so the event is not company NONE.
49
+ const companySlug = clean(input.companySlug) ??
50
+ spawned ??
51
+ clean(readMeta(input.sessionId, hqRoot)) ??
52
+ (machine && machine.slug !== spawned ? clean(machine.slug) : undefined) ??
53
+ clean(machine?.uid);
45
54
  let repoPath = clean(input.repoPath);
46
55
  if (!repoPath) {
47
56
  // Search from the cwd (a directory) or the directory of the first touched
@@ -69,6 +78,15 @@ export function buildEmitEvidence(input) {
69
78
  evidence.task = task;
70
79
  return evidence;
71
80
  }
81
+ /**
82
+ * A machine credential with no company must not be posted. The server would
83
+ * file it as company NONE. Hold it instead.
84
+ */
85
+ export function machineEmitDisposition(input) {
86
+ if (input.machine && !clean(input.companySlug))
87
+ return "hold";
88
+ return "post";
89
+ }
72
90
  /** Directory of the first absolute touched file, for work-tree discovery. */
73
91
  function firstFileDir(touchedPaths) {
74
92
  const abs = touchedPaths.find((p) => path.isAbsolute(p));
@@ -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 = {}) {
@@ -77,7 +77,23 @@ export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.j
77
77
  * Missing/unreadable/malformed → undefined
78
78
  * (never throws; never logs file contents).
79
79
  */
80
+ export interface AgentIdentityCompany {
81
+ uid?: string;
82
+ slug?: string;
83
+ }
84
+ /** Company uid and slug from the fleet identity file. Missing file → undefined. */
85
+ export declare function readAgentIdentityCompany(env?: NodeJS.ProcessEnv): AgentIdentityCompany | undefined;
80
86
  export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
87
+ /**
88
+ * Company named for a machine session.
89
+ * Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
90
+ * Session meta is not included; callers place it between spawn and the file.
91
+ * No company → undefined. Never invents NONE.
92
+ */
93
+ export declare function readMachineCompanyBinding(env?: NodeJS.ProcessEnv): {
94
+ slug?: string;
95
+ uid?: string;
96
+ } | undefined;
81
97
  /**
82
98
  * Agent boxes must never infer a human device default. The file itself is the
83
99
  * machine marker; legacy identity files do not necessarily contain a company
@@ -38,13 +38,7 @@ function normalizeSlug(value) {
38
38
  return undefined;
39
39
  return s;
40
40
  }
41
- /**
42
- * Read `companyUid` from the agent identity file when
43
- * `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
44
- * Missing/unreadable/malformed → undefined
45
- * (never throws; never logs file contents).
46
- */
47
- export function readAgentIdentityCompanyUid(env = process.env) {
41
+ function readAgentIdentityRecord(env) {
48
42
  // An explicit override (including empty/missing paths) never falls back to
49
43
  // another identity. This prevents accidental attribution to a different box.
50
44
  const file = (env[HQ_AGENT_IDENTITY_FILE_ENV] ?? DEFAULT_AGENT_IDENTITY_FILE).trim();
@@ -54,15 +48,49 @@ export function readAgentIdentityCompanyUid(env = process.env) {
54
48
  if (!fs.existsSync(file))
55
49
  return undefined;
56
50
  const raw = JSON.parse(fs.readFileSync(file, "utf8"));
57
- const uid = raw.companyUid;
58
- return typeof uid === "string" && uid.trim().length > 0
59
- ? uid.trim()
60
- : undefined;
51
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
52
+ return undefined;
53
+ return raw;
61
54
  }
62
55
  catch {
63
56
  return undefined;
64
57
  }
65
58
  }
59
+ /** Company uid and slug from the fleet identity file. Missing file → undefined. */
60
+ export function readAgentIdentityCompany(env = process.env) {
61
+ const raw = readAgentIdentityRecord(env);
62
+ if (!raw)
63
+ return undefined;
64
+ const uid = typeof raw.companyUid === "string" ? raw.companyUid.trim() : "";
65
+ const slug = normalizeSlug(typeof raw.companySlug === "string" ? raw.companySlug : undefined);
66
+ if (!uid && !slug)
67
+ return undefined;
68
+ return {
69
+ ...(uid ? { uid } : {}),
70
+ ...(slug ? { slug } : {}),
71
+ };
72
+ }
73
+ export function readAgentIdentityCompanyUid(env = process.env) {
74
+ return readAgentIdentityCompany(env)?.uid;
75
+ }
76
+ /**
77
+ * Company named for a machine session.
78
+ * Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
79
+ * Session meta is not included; callers place it between spawn and the file.
80
+ * No company → undefined. Never invents NONE.
81
+ */
82
+ export function readMachineCompanyBinding(env = process.env) {
83
+ const spawn = normalizeSlug(env.HQ_SPAWN_COMPANY);
84
+ const identity = readAgentIdentityCompany(env);
85
+ if (spawn)
86
+ return { slug: spawn, ...(identity?.uid ? { uid: identity.uid } : {}) };
87
+ if (!identity?.slug && !identity?.uid)
88
+ return undefined;
89
+ return {
90
+ ...(identity?.slug ? { slug: identity.slug } : {}),
91
+ ...(identity?.uid ? { uid: identity.uid } : {}),
92
+ };
93
+ }
66
94
  /**
67
95
  * Agent boxes must never infer a human device default. The file itself is the
68
96
  * machine marker; legacy identity files do not necessarily contain a company
@@ -273,11 +301,15 @@ export function resolveCompany(input) {
273
301
  // 4. Agent-box identity file (HQ_AGENT_IDENTITY_FILE). Prefer as the
274
302
  // device-default role with source trusted_explicit — never device_default /
275
303
  // "default company mode". Below HQ_SPAWN_COMPANY; above device default.
276
- const identityUid = readAgentIdentityCompanyUid(env);
277
- if (identityUid) {
304
+ const identity = readAgentIdentityCompany(env);
305
+ if (identity?.uid || identity?.slug) {
278
306
  return {
279
307
  status: "resolved",
280
- company: { uid: identityUid, source: "trusted_explicit" },
308
+ company: {
309
+ ...(identity.slug ? { slug: identity.slug } : {}),
310
+ ...(identity.uid ? { uid: identity.uid } : {}),
311
+ source: "trusted_explicit",
312
+ },
281
313
  };
282
314
  }
283
315
  // 4b. Persisted per-repo company map (gap 4). A person answered once for this
@@ -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