@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.125.0] — 2026-09-20
6
+
7
+ ### Fixed
8
+
9
+ - Work Mesh now preserves an acknowledged company binding through later
10
+ company-less hook writes, preventing repeated registrations and held events.
11
+ Held recovery accepts only durable registrations, canonical metadata under a
12
+ verified HQ root, or manifest-backed repository ownership; conflicting or
13
+ event-only hints stay unresolved. Its batch cursor rotates across restarts.
14
+ Both legacy and direct-mode daemons detect an upgraded CLI, and systemd units
15
+ use `Restart=always` so the installed service reloads the new code.
16
+
17
+ - Held-session recovery now accepts session metadata only from the daemon's
18
+ configured HQ root, requires current-session evidence before restoring an
19
+ acknowledged registration, and retains batch position when held records are
20
+ compacted. Repeated hook activity no longer re-registers an unchanged binding.
21
+ CLI updates report installed system units that need their host service manager
22
+ to restart them. Daemon installs persist verified HQ-root allowlists, inject
23
+ the primary root into service environments, and rewrite/reload existing user
24
+ units so upgraded daemons restart reliably.
25
+
26
+ ### Added
27
+
28
+ - Work Mesh now activates a device default company automatically when a signed-in
29
+ person has exactly one active membership. Devices with several memberships ask
30
+ for a choice in interactive CLI paths and show the stored state in `hq mesh
31
+ context default get --json`. Clearing the default is a durable opt-out until
32
+ the person explicitly sets one again. Historical held sessions remain unchanged
33
+ unless `hq mesh context backfill-held --use-device-default` is supplied; those
34
+ repaired records are labeled lower-confidence and counted by daemon doctor.
35
+
5
36
  ## [5.124.0] — 2026-09-20
6
37
 
7
38
  ### Added
@@ -5729,7 +5729,10 @@ export declare const COMMAND_CATALOG: readonly [{
5729
5729
  readonly description: "Cap sessions processed (default 0 = no cap)";
5730
5730
  }, {
5731
5731
  readonly flags: "--company <slug>";
5732
- readonly description: "Optional non-forcing company hint (resolver default still wins)";
5732
+ readonly description: "Optional non-forcing company hint";
5733
+ }, {
5734
+ readonly flags: "--use-device-default";
5735
+ readonly description: "Opt in to historical repair using the device default (lower-confidence attribution)";
5733
5736
  }, {
5734
5737
  readonly flags: "--json";
5735
5738
  readonly description: "Print machine-readable JSON";
@@ -5869,6 +5872,9 @@ export declare const COMMAND_CATALOG: readonly [{
5869
5872
  readonly options: readonly [{
5870
5873
  readonly flags: "--allow-without-migration";
5871
5874
  readonly description: "Deprecated compatibility no-op; selected-company membership is always verified";
5875
+ }, {
5876
+ readonly flags: "--repair-held-with-default";
5877
+ readonly description: "Persist opt-in for lower-confidence historical held-session repair";
5872
5878
  }, {
5873
5879
  readonly flags: "--company <slug|uid>";
5874
5880
  readonly description: "Deprecated no-op; the selected company is verified directly";
@@ -7391,7 +7391,11 @@ export const COMMAND_CATALOG = [
7391
7391
  },
7392
7392
  {
7393
7393
  "flags": "--company <slug>",
7394
- "description": "Optional non-forcing company hint (resolver default still wins)"
7394
+ "description": "Optional non-forcing company hint"
7395
+ },
7396
+ {
7397
+ "flags": "--use-device-default",
7398
+ "description": "Opt in to historical repair using the device default (lower-confidence attribution)"
7395
7399
  },
7396
7400
  {
7397
7401
  "flags": "--json",
@@ -7573,6 +7577,10 @@ export const COMMAND_CATALOG = [
7573
7577
  "flags": "--allow-without-migration",
7574
7578
  "description": "Deprecated compatibility no-op; selected-company membership is always verified"
7575
7579
  },
7580
+ {
7581
+ "flags": "--repair-held-with-default",
7582
+ "description": "Persist opt-in for lower-confidence historical held-session repair"
7583
+ },
7576
7584
  {
7577
7585
  "flags": "--company <slug|uid>",
7578
7586
  "description": "Deprecated no-op; the selected company is verified directly"
@@ -4,6 +4,11 @@
4
4
  import chalk from 'chalk';
5
5
  import { browserLogin, loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
6
6
  import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
7
+ import * as readline from "node:readline/promises";
8
+ import { listActiveMembershipCompanies } from "../lib/mesh/api.js";
9
+ import { setDefaultCompany } from "../lib/work-context/config.js";
10
+ import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
11
+ import { workContextRoot } from "../lib/work-context/paths.js";
7
12
  function peekIdToken(idToken) {
8
13
  try {
9
14
  const payload = idToken.split('.')[1];
@@ -24,21 +29,59 @@ export function registerLoginCommand(program) {
24
29
  .description('Authenticate with HQ via Cognito')
25
30
  .option('--provider <provider>', 'OAuth provider to use: google, microsoft, or picker')
26
31
  .action(async (options) => {
32
+ let accessToken;
27
33
  try {
28
34
  const existing = loadCachedTokens();
29
35
  if (existing && !isExpiring(existing, 120)) {
30
36
  const who = peekIdToken(existing.idToken).email ?? 'cached session';
31
37
  console.log(chalk.green(`Already logged in as ${who}. Run \`hq logout\` to switch accounts.`));
32
- return;
38
+ accessToken = existing.accessToken;
39
+ }
40
+ else {
41
+ console.log('Opening browser for authentication...');
42
+ const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
43
+ const who = peekIdToken(tokens.idToken).email ?? 'HQ';
44
+ console.log(chalk.green(`Logged in as ${who}`));
45
+ accessToken = tokens.accessToken;
33
46
  }
34
- console.log('Opening browser for authentication...');
35
- const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
36
- const who = peekIdToken(tokens.idToken).email ?? 'HQ';
37
- console.log(chalk.green(`Logged in as ${who}`));
38
47
  }
39
48
  catch (error) {
40
49
  console.error(chalk.red('Login failed:'), error instanceof Error ? error.message : 'Unknown error');
41
- process.exit(1);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ try {
54
+ const root = workContextRoot();
55
+ const activation = await ensureDefaultCompany({
56
+ root,
57
+ listMemberships: () => listActiveMembershipCompanies(accessToken),
58
+ log: (message) => console.error(chalk.dim(message)),
59
+ });
60
+ if (activation.status === "needs_choice" && process.stdin.isTTY && process.stdout.isTTY) {
61
+ const choices = activation.memberships
62
+ .map((membership, index) => `${index + 1}. ${membership.slug}`)
63
+ .join("\n");
64
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
65
+ try {
66
+ const answer = await rl.question(`Choose this device's default company:\n${choices}\nSelection: `);
67
+ const selected = activation.memberships[Number.parseInt(answer, 10) - 1];
68
+ if (selected) {
69
+ await setDefaultCompany(selected.slug, {
70
+ root,
71
+ validateMembership: (candidate) => candidate === selected.slug ? { uid: selected.uid } : false,
72
+ });
73
+ }
74
+ }
75
+ finally {
76
+ rl.close();
77
+ }
78
+ }
79
+ else if (activation.status === "needs_choice") {
80
+ console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
81
+ }
82
+ }
83
+ catch (error) {
84
+ console.error(chalk.yellow('Default-company setup failed:'), error instanceof Error ? error.message : 'Unknown error');
42
85
  }
43
86
  });
44
87
  }
@@ -12,13 +12,15 @@ import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js
12
12
  import * as readline from "node:readline/promises";
13
13
  import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
14
14
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
15
- import { clearDefaultCompany, getDefaultCompany, readDeviceConfig, recordMigrationCapabilitySnapshot, setDefaultCompany, } from "../lib/work-context/config.js";
15
+ import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
16
+ import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
16
17
  import { DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
17
18
  import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
18
19
  import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
19
20
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
20
21
  import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
21
22
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
23
+ const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
22
24
  import { buildEmitEvidence } from "../lib/mesh/live/emit-evidence.js";
23
25
  import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
24
26
  import { emitEvents } from "../lib/mesh/live/emit.js";
@@ -153,25 +155,73 @@ async function runContextReconcile(opts) {
153
155
  let fetchCandidates;
154
156
  let validateMembership;
155
157
  let offline = Boolean(opts.offline);
156
- if (!offline) {
157
- try {
158
- const token = await requireToken();
159
- deliver = createWorkSessionDeliverer({ token });
160
- fetchCandidates = createCandidatesFetcher({ token });
161
- validateMembership = async (candidate) => {
162
- const membership = await resolveActiveMembershipCompany(token, candidate);
163
- if (!membership)
164
- return false;
165
- return { uid: membership.companyUid };
166
- };
158
+ let token;
159
+ try {
160
+ // Offline is outbox-only delivery. It still refreshes a device default's
161
+ // token-backed membership so a revoked default cannot be preflighted.
162
+ token = await requireToken();
163
+ const sessionToken = token;
164
+ validateMembership = async (candidate) => {
165
+ const membership = await new Promise((resolve, reject) => {
166
+ const timer = setTimeout(() => reject(new Error("membership validation timed out")), MEMBERSHIP_VALIDATION_TIMEOUT_MS);
167
+ void resolveActiveMembershipCompany(sessionToken, candidate).then((value) => {
168
+ clearTimeout(timer);
169
+ resolve(value);
170
+ }, (error) => {
171
+ clearTimeout(timer);
172
+ reject(error);
173
+ });
174
+ });
175
+ if (!membership || typeof membership.companyUid !== "string" || !membership.companyUid) {
176
+ return false;
177
+ }
178
+ return { uid: membership.companyUid };
179
+ };
180
+ if (!offline) {
181
+ deliver = createWorkSessionDeliverer({ token: sessionToken });
182
+ fetchCandidates = createCandidatesFetcher({ token: sessionToken });
167
183
  }
168
- catch {
184
+ }
185
+ catch {
186
+ if (!offline) {
169
187
  offline = true;
170
188
  if (!opts.machine) {
171
189
  console.error(chalk.yellow("No Cognito session; reconciling offline (outbox only)."));
172
190
  }
173
191
  }
174
192
  }
193
+ if (token) {
194
+ const activation = await ensureDefaultCompany({
195
+ root,
196
+ listMemberships: () => listActiveMembershipCompanies(token),
197
+ log: (message) => console.error(chalk.dim(message)),
198
+ });
199
+ if (activation.status === "needs_choice") {
200
+ const interactive = !opts.machine && Boolean(process.stdin.isTTY && process.stdout.isTTY) && !isMachineIdentity();
201
+ if (interactive) {
202
+ const choices = activation.memberships
203
+ .map((membership, index) => `${index + 1}. ${membership.slug}`)
204
+ .join("\n");
205
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
206
+ try {
207
+ const answer = await rl.question(`Choose this device's default company:\n${choices}\nSelection: `);
208
+ const selected = activation.memberships[Number.parseInt(answer, 10) - 1];
209
+ if (selected) {
210
+ await setDefaultCompany(selected.slug, {
211
+ root,
212
+ validateMembership: (candidate) => candidate === selected.slug ? { uid: selected.uid } : false,
213
+ });
214
+ }
215
+ }
216
+ finally {
217
+ rl.close();
218
+ }
219
+ }
220
+ else if (!opts.machine) {
221
+ console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
222
+ }
223
+ }
224
+ }
175
225
  const outcome = await reconcileObservation(obs, {
176
226
  root,
177
227
  env: process.env,
@@ -295,6 +345,7 @@ async function runContextBackfillHeld(opts) {
295
345
  const parsedLimit = opts.limit ? Number.parseInt(opts.limit, 10) : 0;
296
346
  const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
297
347
  const companyHint = opts.company?.trim() || undefined;
348
+ const useDeviceDefault = opts.useDeviceDefault === true || readDeviceConfig({ root }).repairHeldWithDefault === true;
298
349
  // Build the reconcile network seam exactly like `hq mesh context reconcile`.
299
350
  // Not needed in --dry-run (reconcile is never invoked then). Explicit types
300
351
  // because these are captured in the reconcile closure below (no control-flow
@@ -331,6 +382,7 @@ async function runContextBackfillHeld(opts) {
331
382
  // Mirror the reconcile closure's non-forcing hint in the dry-run predictor
332
383
  // so `--dry-run` and the real run report the same counts.
333
384
  remoteOwnerHint: companyHint || undefined,
385
+ useDeviceDefault,
334
386
  reconcile: (obs) => reconcileObservation(
335
387
  // Pass --company as a non-forcing hint (remoteOwnerSlug feeds
336
388
  // deterministic resolution BELOW the identity-file / device default,
@@ -342,6 +394,8 @@ async function runContextBackfillHeld(opts) {
342
394
  fetchCandidates,
343
395
  offline,
344
396
  validateMembership,
397
+ allowDeviceDefault: useDeviceDefault,
398
+ deviceDefaultConfidence: useDeviceDefault,
345
399
  }),
346
400
  });
347
401
  if (opts.json) {
@@ -364,16 +418,34 @@ async function runContextBackfillHeld(opts) {
364
418
  async function runContextDefaultGet(opts) {
365
419
  const root = workContextHomeRoot();
366
420
  const cfg = readDeviceConfig({ root });
367
- const current = getDefaultCompany({ root });
421
+ const current = cfg.defaultCompany?.enabled && cfg.defaultCompany.slug
422
+ ? cfg.defaultCompany
423
+ : null;
424
+ const memberships = cfg.defaultCompanyMemberships?.memberships ?? [];
425
+ const disabled = isDefaultCompanyDisabled({ root });
426
+ const source = disabled ? "disabled" : current?.source ?? (current ? "chosen" : "unset");
368
427
  if (opts.json) {
369
- console.log(JSON.stringify({ ok: true, config: cfg, defaultCompany: current }, null, 2));
428
+ console.log(JSON.stringify({
429
+ ok: true,
430
+ slug: current?.slug ?? null,
431
+ uid: current?.uid ?? null,
432
+ enabled: current?.enabled ?? false,
433
+ source,
434
+ needsChoice: !current && !disabled && memberships.length > 1,
435
+ memberships,
436
+ repairHeldWithDefault: cfg.repairHeldWithDefault === true,
437
+ }, null, 2));
438
+ return;
439
+ }
440
+ if (disabled) {
441
+ console.log("Default company disabled on this device.");
370
442
  return;
371
443
  }
372
444
  if (!current) {
373
445
  console.log("No device default company (default-company mode may be dark).");
374
446
  return;
375
447
  }
376
- console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled}`);
448
+ console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled} source=${source}`);
377
449
  }
378
450
  async function runContextDefaultSet(slug, opts) {
379
451
  const root = workContextHomeRoot();
@@ -434,11 +506,14 @@ async function runContextDefaultSet(slug, opts) {
434
506
  return { uid: membership.companyUid };
435
507
  },
436
508
  });
509
+ const configured = opts.repairHeldWithDefault
510
+ ? setRepairHeldWithDefault(true, { root })
511
+ : cfg;
437
512
  if (opts.json) {
438
- console.log(JSON.stringify({ ok: true, config: cfg, migrationWarning }, null, 2));
513
+ console.log(JSON.stringify({ ok: true, config: configured, migrationWarning }, null, 2));
439
514
  return;
440
515
  }
441
- const stored = cfg.defaultCompany;
516
+ const stored = configured.defaultCompany;
442
517
  console.log(`Default company set to ${stored?.slug ?? slug}${stored?.uid ? ` (${stored.uid})` : ""}`);
443
518
  if (migrationWarning)
444
519
  console.error(chalk.yellow(migrationWarning));
@@ -1035,7 +1110,8 @@ export function registerMeshCommand(program) {
1035
1110
  .option("--dry-run", "Report what would be reconciled; write nothing, no network")
1036
1111
  .option("--offline", "Reconcile outbox-only (no candidates/register network)")
1037
1112
  .option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
1038
- .option("--company <slug>", "Optional non-forcing company hint (resolver default still wins)")
1113
+ .option("--company <slug>", "Optional non-forcing company hint")
1114
+ .option("--use-device-default", "Opt in to historical repair using the device default (lower-confidence attribution)")
1039
1115
  .option("--json", "Print machine-readable JSON")
1040
1116
  .action((opts) => wrap(() => runContextBackfillHeld(opts))());
1041
1117
  context
@@ -1090,6 +1166,7 @@ export function registerMeshCommand(program) {
1090
1166
  .description("Set the device default company slug after verifying membership")
1091
1167
  .argument("<slug>", "Company slug")
1092
1168
  .option("--allow-without-migration", "Deprecated compatibility no-op; selected-company membership is always verified")
1169
+ .option("--repair-held-with-default", "Persist opt-in for lower-confidence historical held-session repair")
1093
1170
  .option("--company <slug|uid>", "Deprecated no-op; the selected company is verified directly")
1094
1171
  .option("--json", "Print machine-readable JSON")
1095
1172
  .action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts))());
@@ -6,7 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { readDeviceConfig, getDefaultCompany, } from "../../work-context/config.js";
9
+ import { readDeviceConfig, } from "../../work-context/config.js";
10
10
  import { outboxStats, listOutboxOperations } from "../../work-context/outbox.js";
11
11
  import { workContextRoot, workContextSessionsDir } from "../../work-context/paths.js";
12
12
  import { countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
@@ -96,7 +96,9 @@ function runChecks(deps) {
96
96
  // Default-company mode + migration capability (US-017B)
97
97
  try {
98
98
  const cfg = readDeviceConfig({ root });
99
- const def = getDefaultCompany({ root });
99
+ const def = cfg.defaultCompany?.enabled && cfg.defaultCompany.slug
100
+ ? cfg.defaultCompany
101
+ : null;
100
102
  const mode = def?.enabled
101
103
  ? `enabled slug=${def.slug}`
102
104
  : "disabled (dark / unset)";
@@ -111,6 +111,8 @@ export interface BackfillHeldDeps {
111
111
  * meta) in BOTH the dry-run predictor and the write path, so the two agree.
112
112
  */
113
113
  remoteOwnerHint?: string;
114
+ /** Historical device-default repair is opt-in; false keeps no-evidence sessions held. */
115
+ useDeviceDefault?: boolean;
114
116
  /** Env passed to the dry-run resolver (tests). Default process.env. */
115
117
  env?: NodeJS.ProcessEnv;
116
118
  /**
@@ -231,7 +231,7 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
231
231
  * the write run can still under-count; the gross all-or-nothing mismatch is
232
232
  * gone because both paths key off this same resolver + evidence.
233
233
  */
234
- function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
234
+ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault) {
235
235
  // This predictor is called only with a ref already rebuilt from trusted local
236
236
  // evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
237
237
  const remoteOwnerSlug = remoteOwnerHint?.trim();
@@ -241,6 +241,7 @@ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
241
241
  env,
242
242
  trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
243
243
  remoteOwnerSlug,
244
+ allowDeviceDefault: useDeviceDefault,
244
245
  });
245
246
  return resolution.status === "resolved";
246
247
  }
@@ -385,7 +386,7 @@ export async function backfillHeldSessions(deps) {
385
386
  const env = deps.env ?? process.env;
386
387
  const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
387
388
  const predictCompany = deps.predictCompany ??
388
- ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
389
+ ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true));
389
390
  const sessions = readHeldSessions(deps.workMeshRoot);
390
391
  // An acked register is authoritative local evidence. Older clients could
391
392
  // lose its state projection on a later hook write; recover that projection
@@ -434,7 +435,10 @@ export async function backfillHeldSessions(deps) {
434
435
  continue;
435
436
  }
436
437
  }
437
- if (!evidence.candidate?.slug) {
438
+ // Historical repair may use the explicitly opted-in device default only
439
+ // when there is no durable local company evidence. Never manufacture a
440
+ // slug from the held event itself.
441
+ if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
438
442
  result.unresolved += 1;
439
443
  deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
440
444
  continue;
@@ -442,7 +446,7 @@ export async function backfillHeldSessions(deps) {
442
446
  const evidenceRef = {
443
447
  sessionId,
444
448
  harness,
445
- companySlug: evidence.candidate.slug,
449
+ companySlug: evidence.candidate?.slug,
446
450
  // Held-event fields are transport input, not trusted context. Preserve
447
451
  // project/task only when the durable session state already owns them.
448
452
  project: state?.projectId,
@@ -6,6 +6,7 @@
6
6
  * authoritative in ProjectView because `hq mesh story` writes there.
7
7
  * Metadata-only snapshot — never prompts or tokens.
8
8
  */
9
+ import type { SessionStateFile } from "../../../work-context/state.js";
9
10
  export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
10
11
  export interface BoardStorySnapshot {
11
12
  id: string;
@@ -26,6 +27,8 @@ export interface BoardRefreshDeps {
26
27
  /** Per-session last refresh timestamps (ms). */
27
28
  lastRefresh?: Map<string, number>;
28
29
  intervalMs?: number;
30
+ /** Authorizes a device-default-derived session before its tenant read. */
31
+ authorizeDeviceDefaultTenant?: (sessionId: string, state: SessionStateFile) => Promise<boolean>;
29
32
  }
30
33
  export declare function formatBoardMarkdown(input: {
31
34
  sessionId: string;
@@ -92,6 +92,12 @@ export async function refreshBoundSessionBoards(deps) {
92
92
  skipped += 1;
93
93
  continue;
94
94
  }
95
+ if (state.companyConfidence === "device_default" &&
96
+ deps.authorizeDeviceDefaultTenant &&
97
+ !(await deps.authorizeDeviceDefaultTenant(sessionId, state))) {
98
+ skipped += 1;
99
+ continue;
100
+ }
95
101
  try {
96
102
  const board = await deps.readBoard(state.companyUid, state.projectId);
97
103
  if (!board) {
@@ -19,6 +19,7 @@ export interface DaemonDoctorReport {
19
19
  heldCount: number;
20
20
  /** Distinct held sessions with neither bound state nor local company evidence. */
21
21
  heldWithNoLocalEvidence: number;
22
+ attributedByDeviceDefault: number;
22
23
  deadLetterCount: number;
23
24
  /** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
24
25
  unattributedEvents: UnattributedEventCounts;
@@ -47,6 +48,8 @@ export interface DaemonDoctorReport {
47
48
  rejected?: number;
48
49
  error?: string;
49
50
  };
51
+ /** Work held or quarantined after default-derived membership was denied. */
52
+ deviceDefaultAuthorizationDenials: number;
50
53
  }
51
54
  export interface DaemonDoctorDeps {
52
55
  home?: string;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
+ import * as path from "node:path";
7
8
  import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
8
9
  import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
9
10
  import { readSessionState } from "../../../work-context/state.js";
@@ -52,6 +53,21 @@ function oldestJsonlAgeMs(filePath, nowMs) {
52
53
  return undefined;
53
54
  }
54
55
  }
56
+ function countDeviceDefaultAttribution(root) {
57
+ try {
58
+ return fs.readdirSync(path.join(root, "sessions")).filter((entry) => {
59
+ if (!entry.endsWith(".json"))
60
+ return false;
61
+ const state = readSessionState(entry.slice(0, -5), root);
62
+ return state?.companyConfidence === "device_default" &&
63
+ Boolean(state.companyUid) &&
64
+ state.contextStatus !== "needs_company";
65
+ }).length;
66
+ }
67
+ catch {
68
+ return 0;
69
+ }
70
+ }
55
71
  export function collectDaemonDoctor(deps = {}) {
56
72
  const home = deps.home ?? os.homedir();
57
73
  const env = deps.env ?? process.env;
@@ -88,6 +104,7 @@ export function collectDaemonDoctor(deps = {}) {
88
104
  !hasCurrentAcknowledgedRegistration(ref, state, outboxOperations, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots) &&
89
105
  !refWithLocalCompanyEvidence(ref, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots, state));
90
106
  }).length;
107
+ const attributedByDeviceDefault = countDeviceDefaultAttribution(ctxRoot);
91
108
  const deadLetterCount = countJsonlLines(deadPath);
92
109
  const unattributedEvents = countUnattributedEvents(meshRoot);
93
110
  const outbox = outboxStats(ctxRoot);
@@ -95,6 +112,7 @@ export function collectDaemonDoctor(deps = {}) {
95
112
  const companiesOnline = state?.companiesOnline ?? [];
96
113
  const emitMode = state?.emitMode ?? "legacy";
97
114
  const subscribed = mqttState === "connected";
115
+ const deviceDefaultAuthorizationDenials = state?.deviceDefaultAuthorizationDenials ?? 0;
98
116
  const oldestSpoolAgeMs = oldestJsonlAgeMs(spoolPath, now());
99
117
  const unhealthyReasons = [];
100
118
  const online = mqttState === "connected" && companiesOnline.length > 0;
@@ -140,6 +158,7 @@ export function collectDaemonDoctor(deps = {}) {
140
158
  spoolDepth,
141
159
  heldCount,
142
160
  heldWithNoLocalEvidence,
161
+ attributedByDeviceDefault,
143
162
  deadLetterCount,
144
163
  unattributedEvents,
145
164
  outboxDepth: outbox.depth,
@@ -164,6 +183,7 @@ export function collectDaemonDoctor(deps = {}) {
164
183
  error: emitState.lastError,
165
184
  }
166
185
  : undefined,
186
+ deviceDefaultAuthorizationDenials,
167
187
  };
168
188
  }
169
189
  export function formatDaemonDoctor(report) {
@@ -179,7 +199,7 @@ export function formatDaemonDoctor(report) {
179
199
  // receive-only (direct) mode.
180
200
  lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
181
201
  }
182
- lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
202
+ lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
183
203
  `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
184
204
  if (report.emitMode === "legacy") {
185
205
  lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
@@ -8,8 +8,11 @@
8
8
  * - MQTT presence (contract-3)
9
9
  * - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
10
10
  */
11
+ import { type DeliverFn } from "../../../work-context/outbox.js";
12
+ import { type SessionEventsPoster } from "../session-events-client.js";
11
13
  import { type FlushSummary } from "../flush.js";
12
14
  import { type BackfillHeldResult } from "../backfill-held.js";
15
+ import { type BoardReader } from "./board-refresh.js";
13
16
  import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
14
17
  import { type PidLockDeps } from "./pid-lock.js";
15
18
  import { PresenceClient, type MqttConnectFn } from "./presence.js";
@@ -19,6 +22,7 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
19
22
  export declare const FLUSH_INTERVAL_MS = 10000;
20
23
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
21
24
  export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
25
+ export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
22
26
  /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
23
27
  export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
24
28
  /**
@@ -79,6 +83,13 @@ export interface DaemonRunDeps {
79
83
  exit?: (code: number) => void;
80
84
  /** Token provider (tests). */
81
85
  getToken?: () => Promise<string>;
86
+ /** Low-level tenant I/O seams used by authorization regression tests. */
87
+ poster?: SessionEventsPoster;
88
+ deliver?: DeliverFn;
89
+ readBoard?: BoardReader;
90
+ resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
91
+ companyUid: string;
92
+ } | null>;
82
93
  log?: (dir: string, line: string) => void;
83
94
  /** Stop after this many ms (tests). */
84
95
  runForMs?: number;