@indigoai-us/hq-cli 5.169.0 → 5.170.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.170.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
10
+
5
11
  ## [5.169.0] — 2026-09-24
6
12
 
7
13
  ### Fixed
@@ -10,10 +10,12 @@
10
10
  */
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
- import { resolveCompany } from "../../work-context/company.js";
13
+ import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
14
+ import { companyBindingChanged, reboundSessionProjection, } from "../../work-context/company-rebind.js";
14
15
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
15
16
  import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
16
- import { readSessionState, updateSessionState } from "../../work-context/state.js";
17
+ import { readSessionState, updateSessionState, writeSessionState } from "../../work-context/state.js";
18
+ import { generateUlid } from "./ulid.js";
17
19
  import { repairAckedRegisterStates } from "../../work-context/outbox.js";
18
20
  import { workContextRoot } from "../../work-context/paths.js";
19
21
  import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
@@ -252,6 +254,23 @@ export async function flushSessionEvents(deps) {
252
254
  if (deps.signal?.aborted)
253
255
  break sessionLoop;
254
256
  let state = readSessionState(sessionId, deps.workContextRoot);
257
+ state = applySessionMetaCompanyRebind(sessionId, items, state, deps, now);
258
+ const leaveResult = await postPresenceLeaveIfNeeded({
259
+ sessionId,
260
+ items,
261
+ state,
262
+ deps,
263
+ now,
264
+ maxAttempts,
265
+ sleep,
266
+ random,
267
+ summary,
268
+ markDisposed,
269
+ });
270
+ state = leaveResult.state;
271
+ if (leaveResult.abort)
272
+ continue;
273
+ const remainingItems = leaveResult.items;
255
274
  if (state?.companyConfidence === "device_default" &&
256
275
  deps.authorizeDeviceDefaultTenant) {
257
276
  const allowed = await deps.authorizeDeviceDefaultTenant(state);
@@ -259,7 +278,7 @@ export async function flushSessionEvents(deps) {
259
278
  state = readSessionState(sessionId, deps.workContextRoot) ?? state;
260
279
  if (!allowed) {
261
280
  const heldAt = now().toISOString();
262
- for (const item of items) {
281
+ for (const item of remainingItems) {
263
282
  appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
264
283
  summary.held += 1;
265
284
  markDisposed(item.event);
@@ -269,33 +288,34 @@ export async function flushSessionEvents(deps) {
269
288
  }
270
289
  // After the company gate (which may restore a cleared company), try to
271
290
  // bind a needs_project session to an existing Board before classifying.
272
- state = rebindUnboundSession(sessionId, items, state, deps, nowMs);
291
+ state = rebindUnboundSession(sessionId, remainingItems, state, deps, nowMs);
273
292
  const decision = classifySession(state);
274
293
  if (decision.action === "drop") {
275
294
  appendDroppedReceipt({
276
295
  v: 1,
277
296
  sessionId,
278
- dropped: items.length,
297
+ dropped: remainingItems.length,
279
298
  reason: "untracked",
280
299
  at: now().toISOString(),
281
300
  }, deps.workMeshRoot);
282
- summary.dropped += items.length;
283
- for (const item of items)
301
+ summary.dropped += remainingItems.length;
302
+ for (const item of remainingItems)
284
303
  markDisposed(item.event);
285
304
  continue;
286
305
  }
287
306
  if (decision.action === "hold") {
288
307
  const heldAt = now().toISOString();
289
- for (const item of items) {
308
+ for (const item of remainingItems) {
290
309
  appendHeldLine(heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt), deps.workMeshRoot);
291
310
  summary.held += 1;
292
311
  markDisposed(item.event);
293
312
  }
294
313
  continue;
295
314
  }
296
- // Post a bounded portion of this session's backlog. Untouched events
297
- // remain in pendingRestore and are put back on the spool below.
298
- const flushItems = items.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
315
+ // Post a bounded portion of this session's remaining backlog (after any
316
+ // presence-leave). Untouched events stay in pendingRestore and are put
317
+ // back on the spool below.
318
+ const flushItems = remainingItems.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
299
319
  const stripped = flushItems.map((item) => stripLocalOnlyFields(item.event));
300
320
  for (let i = 0; i < stripped.length; i += SESSION_EVENTS_BATCH_MAX) {
301
321
  const chunk = stripped.slice(i, i + SESSION_EVENTS_BATCH_MAX);
@@ -547,6 +567,135 @@ function eventText(event, key) {
547
567
  const value = event[key];
548
568
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
549
569
  }
570
+ const SESSION_EVENT_HARNESSES = new Set([
571
+ "claude-code",
572
+ "claude-desktop",
573
+ "codex",
574
+ "grok",
575
+ "hq-sessions",
576
+ "agent-box",
577
+ "agents-v2",
578
+ ]);
579
+ function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
580
+ if (!state)
581
+ return state;
582
+ const hqRoot = items.map((item) => eventText(item.event, "hqRoot")).find(Boolean);
583
+ if (!hqRoot)
584
+ return state;
585
+ const meta = readMetaCompanySlug(sessionId, hqRoot);
586
+ if (!meta)
587
+ return state;
588
+ if (!companyBindingChanged(state, { slug: meta }))
589
+ return state;
590
+ const next = reboundSessionProjection({
591
+ prior: state,
592
+ sessionId,
593
+ nextSlug: meta,
594
+ nowIso: now().toISOString(),
595
+ });
596
+ writeSessionState(next, deps.workContextRoot);
597
+ return readSessionState(sessionId, deps.workContextRoot) ?? next;
598
+ }
599
+ function leaveContextStatus(leave) {
600
+ if (leave.projectId && leave.taskId)
601
+ return "bound";
602
+ if (leave.projectId)
603
+ return "needs_task";
604
+ return "needs_project";
605
+ }
606
+ function synthesizeSessionEnd(sessionId, sample, seq, at) {
607
+ const harnessRaw = eventText(sample, "harness");
608
+ const harness = harnessRaw && SESSION_EVENT_HARNESSES.has(harnessRaw)
609
+ ? harnessRaw
610
+ : "claude-code";
611
+ return stripLocalOnlyFields({
612
+ v: 1,
613
+ eventId: generateUlid(),
614
+ kind: "session_end",
615
+ sessionId,
616
+ harness,
617
+ adapterVersion: eventText(sample, "adapterVersion") || "1.0.0",
618
+ at,
619
+ seq,
620
+ });
621
+ }
622
+ async function postPresenceLeaveIfNeeded(input) {
623
+ const leave = input.state?.presenceLeave;
624
+ if (!leave?.companyUid) {
625
+ return { state: input.state, items: input.items, abort: false };
626
+ }
627
+ const oldSlug = leave.companySlug?.trim().toLowerCase();
628
+ const leaveItems = oldSlug
629
+ ? input.items.filter((item) => {
630
+ const slug = eventText(item.event, "companySlug")?.toLowerCase();
631
+ return slug === oldSlug;
632
+ })
633
+ : [];
634
+ const remaining = input.items.filter((item) => !leaveItems.includes(item));
635
+ const sample = leaveItems[0]?.event ?? input.items[0]?.event ?? {};
636
+ const maxSeq = input.items.reduce((max, item) => {
637
+ const seq = item.event.seq;
638
+ return typeof seq === "number" && Number.isFinite(seq) ? Math.max(max, seq) : max;
639
+ }, 0);
640
+ const sessionEnd = synthesizeSessionEnd(input.sessionId, sample, maxSeq + 1, input.now().toISOString());
641
+ const events = [
642
+ ...leaveItems.map((item) => stripLocalOnlyFields(item.event)),
643
+ sessionEnd,
644
+ ];
645
+ const body = {
646
+ contractVersion: 1,
647
+ companyUid: leave.companyUid,
648
+ sessionId: input.sessionId,
649
+ contextStatus: leaveContextStatus(leave),
650
+ events,
651
+ };
652
+ if (leave.bindingEpisodeId)
653
+ body.bindingEpisodeId = leave.bindingEpisodeId;
654
+ if (leave.projectId)
655
+ body.projectId = leave.projectId;
656
+ if (leave.taskId)
657
+ body.taskId = leave.taskId;
658
+ input.summary.batches += 1;
659
+ const result = await postBatchWithRetries(input.deps.poster, body, {
660
+ maxAttempts: input.maxAttempts,
661
+ sleep: input.sleep,
662
+ random: input.random,
663
+ signal: input.deps.signal,
664
+ });
665
+ if (!result.ok) {
666
+ const at = input.now().toISOString();
667
+ if (!result.retryable) {
668
+ for (const item of input.items) {
669
+ appendDeadLetterLine(deadLetterEnvelope(item.event, `HTTP_${result.status}`, result.status, at), input.deps.workMeshRoot);
670
+ input.summary.deadLettered += 1;
671
+ input.markDisposed(item.event);
672
+ }
673
+ return { state: input.state, items: [], abort: true };
674
+ }
675
+ for (const item of input.items) {
676
+ appendSpoolLine(JSON.stringify(canonicalizeForRestore(item.event)), input.deps.workMeshRoot);
677
+ input.summary.restored += 1;
678
+ input.markDisposed(item.event);
679
+ }
680
+ return { state: input.state, items: [], abort: true };
681
+ }
682
+ accountBatchSuccess({
683
+ accepted: events.length,
684
+ duplicates: 0,
685
+ rejected: [],
686
+ }, [...leaveItems, { event: sessionEnd }], input.deps.workMeshRoot, input.now, input.summary, input.markDisposed);
687
+ const cleared = updateSessionState(input.sessionId, input.deps.workContextRoot, (current) => {
688
+ if (!current)
689
+ return current;
690
+ const { presenceLeave: _leave, ...rest } = current;
691
+ return { ...rest, updatedAt: input.now().toISOString() };
692
+ });
693
+ return {
694
+ state: cleared ?? input.state,
695
+ items: remaining,
696
+ abort: false,
697
+ };
698
+ }
550
699
  /**
551
700
  * Before a needs_project (or unresolved-with-company) batch is classified,
552
701
  * run the prompt-submit bind-best refinement. A bind updates the session
@@ -0,0 +1,23 @@
1
+ /**
2
+ * US-047: when the HQ session company changes, drop the old Work Mesh project
3
+ * and remember a presence leave for the previous company.
4
+ */
5
+ import type { SessionStateFile } from "./state.js";
6
+ export declare const NON_CLOUD_COMPANY_SLUG = "personal";
7
+ export declare function isNonCloudCompanySlug(slug?: string | null): boolean;
8
+ export declare function companyBindingChanged(prior: SessionStateFile | null | undefined, next: {
9
+ slug?: string;
10
+ uid?: string;
11
+ }): boolean;
12
+ export declare function presenceLeaveFromState(prior: SessionStateFile): NonNullable<SessionStateFile["presenceLeave"]> | undefined;
13
+ export declare function reboundSessionProjection(input: {
14
+ prior: SessionStateFile;
15
+ sessionId: string;
16
+ nextSlug?: string;
17
+ nextUid?: string;
18
+ nowIso: string;
19
+ startedAt?: string;
20
+ toolWrites?: number;
21
+ hasTurn?: boolean;
22
+ }): SessionStateFile;
23
+ //# sourceMappingURL=company-rebind.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * US-047: when the HQ session company changes, drop the old Work Mesh project
3
+ * and remember a presence leave for the previous company.
4
+ */
5
+ import { sameCompany } from "./company.js";
6
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
7
+ export const NON_CLOUD_COMPANY_SLUG = "personal";
8
+ export function isNonCloudCompanySlug(slug) {
9
+ return slug?.trim().toLowerCase() === NON_CLOUD_COMPANY_SLUG;
10
+ }
11
+ export function companyBindingChanged(prior, next) {
12
+ if (!prior)
13
+ return false;
14
+ if (!prior.companySlug && !prior.companyUid)
15
+ return false;
16
+ if (!next.slug && !next.uid)
17
+ return false;
18
+ return !sameCompany({ slug: prior.companySlug, uid: prior.companyUid }, next);
19
+ }
20
+ export function presenceLeaveFromState(prior) {
21
+ const uid = prior.companyUid?.trim();
22
+ if (!uid)
23
+ return undefined;
24
+ return {
25
+ companyUid: uid,
26
+ ...(prior.companySlug ? { companySlug: prior.companySlug } : {}),
27
+ ...(prior.projectId ? { projectId: prior.projectId } : {}),
28
+ ...(prior.taskId ? { taskId: prior.taskId } : {}),
29
+ ...(prior.bindingEpisodeId ? { bindingEpisodeId: prior.bindingEpisodeId } : {}),
30
+ };
31
+ }
32
+ export function reboundSessionProjection(input) {
33
+ const nonCloud = isNonCloudCompanySlug(input.nextSlug) || !input.nextUid;
34
+ const leave = presenceLeaveFromState(input.prior);
35
+ const out = {
36
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
37
+ sessionId: input.sessionId,
38
+ contextStatus: nonCloud ? "needs_company" : "needs_project",
39
+ updatedAt: input.nowIso,
40
+ };
41
+ if (input.nextSlug)
42
+ out.companySlug = input.nextSlug;
43
+ if (!nonCloud && input.nextUid)
44
+ out.companyUid = input.nextUid;
45
+ const startedAt = input.startedAt ?? input.prior.startedAt;
46
+ if (startedAt)
47
+ out.startedAt = startedAt;
48
+ if (typeof input.toolWrites === "number")
49
+ out.toolWrites = input.toolWrites;
50
+ else if (typeof input.prior.toolWrites === "number") {
51
+ out.toolWrites = input.prior.toolWrites;
52
+ }
53
+ if (input.hasTurn || input.prior.hasTurn)
54
+ out.hasTurn = true;
55
+ if (leave)
56
+ out.presenceLeave = leave;
57
+ return out;
58
+ }
59
+ //# sourceMappingURL=company-rebind.js.map
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Order:
5
5
  * 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
6
- * 2. existing authoritative session scope
7
- * 3. session meta.yaml company_slug (/startwork)
6
+ * 2. existing authoritative session scope — unless session meta.yaml
7
+ * names a different company (hq-session.sh rebind, US-047)
8
+ * 3. session meta.yaml company_slug (/startwork / hq-session.sh)
8
9
  * 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
9
10
  * trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
10
11
  * above device default)
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Order:
5
5
  * 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
6
- * 2. existing authoritative session scope
7
- * 3. session meta.yaml company_slug (/startwork)
6
+ * 2. existing authoritative session scope — unless session meta.yaml
7
+ * names a different company (hq-session.sh rebind, US-047)
8
+ * 3. session meta.yaml company_slug (/startwork / hq-session.sh)
8
9
  * 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
9
10
  * trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
10
11
  * above device default)
@@ -254,12 +255,23 @@ export function resolveCompany(input) {
254
255
  ? input.existingState
255
256
  : readSessionState(input.sessionId, input.root);
256
257
  const prior = authoritativeCompanyFromState(state);
258
+ // 3. session meta.yaml company_slug — read before existing_scope so an
259
+ // operator rebind via hq-session.sh can replace a stale Work Mesh company.
260
+ const metaSlug = input.metaCompanySlug !== undefined
261
+ ? normalizeSlug(input.metaCompanySlug)
262
+ : readMetaCompanySlug(input.sessionId, input.hqRoot);
257
263
  if (prior) {
258
264
  const scoped = {
259
265
  slug: prior.slug,
260
266
  uid: prior.uid,
261
267
  source: "existing_scope",
262
268
  };
269
+ if (metaSlug && !sameCompany(scoped, { slug: metaSlug })) {
270
+ return {
271
+ status: "resolved",
272
+ company: { slug: metaSlug, source: "session_meta" },
273
+ };
274
+ }
263
275
  // Deterministic evidence from another company never silently replaces a
264
276
  // scoped session, even before it has a project binding. Preserve the
265
277
  // original scope and surface a conflict before tenant I/O.
@@ -288,10 +300,6 @@ export function resolveCompany(input) {
288
300
  }
289
301
  return { status: "resolved", company: scoped };
290
302
  }
291
- // 3. session meta.yaml company_slug
292
- const metaSlug = input.metaCompanySlug !== undefined
293
- ? normalizeSlug(input.metaCompanySlug)
294
- : readMetaCompanySlug(input.sessionId, input.hqRoot);
295
303
  if (metaSlug) {
296
304
  return {
297
305
  status: "resolved",
@@ -12,6 +12,7 @@ export * from "./config.js";
12
12
  export * from "./state.js";
13
13
  export * from "./outbox.js";
14
14
  export * from "./company.js";
15
+ export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
15
16
  export * from "./project.js";
16
17
  export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
17
18
  export type { CandidatesResponse, FetchCandidatesFn, OrganizeCandidate, OrganizeListResult, OrganizeReceipt, OrganizeSubmitDecision, OrganizeSubmitResult, PostOrganizeFn, } from "./organize.js";
@@ -12,6 +12,7 @@ export * from "./config.js";
12
12
  export * from "./state.js";
13
13
  export * from "./outbox.js";
14
14
  export * from "./company.js";
15
+ export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
15
16
  export * from "./project.js";
16
17
  export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
17
18
  export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
@@ -5,6 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import { InvalidSessionIdentityError, normalizeSessionIdentity, } from "../mesh/live/session-identity.js";
7
7
  import { companyCorrectionPath, companySlugFromCwd, resolveCompany, } from "./company.js";
8
+ import { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
8
9
  import { invalidateDefaultCompanyMemberships, } from "./config.js";
9
10
  import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
10
11
  import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
@@ -354,6 +355,43 @@ export async function reconcileObservation(obs, deps) {
354
355
  // A device default is weak, cached evidence: validate it authoritatively on
355
356
  // every tenant-I/O path so a membership revocation takes effect immediately.
356
357
  let resolvedCompany = company.company;
358
+ if (isNonCloudCompanySlug(resolvedCompany.slug)) {
359
+ const projection = prior
360
+ ? reboundSessionProjection({
361
+ prior,
362
+ sessionId,
363
+ nextSlug: resolvedCompany.slug,
364
+ nowIso,
365
+ startedAt,
366
+ toolWrites,
367
+ hasTurn,
368
+ })
369
+ : {
370
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
371
+ sessionId,
372
+ contextStatus: "needs_company",
373
+ companySlug: resolvedCompany.slug,
374
+ updatedAt: nowIso,
375
+ startedAt,
376
+ ...(typeof toolWrites === "number" ? { toolWrites } : {}),
377
+ ...(hasTurn ? { hasTurn: true } : {}),
378
+ };
379
+ const statePath = writeSessionState(mergeLocalSessionFields({ ...projection, ...transcriptFields }, prior, { preserveBindingEpisode: false, preserveCompanyBinding: false }), deps.root);
380
+ diagnostics.push(`company rebind to ${resolvedCompany.slug}; cleared project; held (non-cloud)`);
381
+ return {
382
+ result: resultOf({
383
+ kind: "needs_company",
384
+ classification: "needs_company",
385
+ delivery: "clean",
386
+ sessionId,
387
+ clientOperationId: obs.clientOperationId,
388
+ companySlug: resolvedCompany.slug,
389
+ }),
390
+ exitCode: EXIT_OK,
391
+ diagnostics: [...diagnostics, "needs_company: non-cloud session company; no tenant I/O"],
392
+ statePath,
393
+ };
394
+ }
357
395
  const deviceDefaultRequiresValidation = resolvedCompany.source === "device_default";
358
396
  if (deviceDefaultRequiresValidation && !deps.validateMembership) {
359
397
  return {
@@ -385,6 +423,7 @@ export async function reconcileObservation(obs, deps) {
385
423
  invalidateDefaultCompanyMemberships({ root: deps.root, now: nowFn });
386
424
  }
387
425
  diagnostics.push(`membership denied for slug=${resolvedCompany.slug}; treating as needs_company`);
426
+ const deniedLeave = prior ? presenceLeaveFromState(prior) : undefined;
388
427
  const statePath = writeSessionState(mergeLocalSessionFields({
389
428
  contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
390
429
  sessionId,
@@ -395,7 +434,10 @@ export async function reconcileObservation(obs, deps) {
395
434
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
396
435
  ...(hasTurn ? { hasTurn: true } : {}),
397
436
  ...transcriptFields,
398
- }, prior), deps.root);
437
+ ...(deniedLeave ? { presenceLeave: deniedLeave } : {}),
438
+ }, prior, companyBindingChanged(prior, resolvedCompany)
439
+ ? { preserveBindingEpisode: false, preserveCompanyBinding: false }
440
+ : {}), deps.root);
399
441
  return {
400
442
  result: resultOf({
401
443
  kind: "needs_company",
@@ -431,13 +473,23 @@ export async function reconcileObservation(obs, deps) {
431
473
  }
432
474
  }
433
475
  // Company resolved — resolve project/task (US-007B order).
476
+ const companyRebind = companyBindingChanged(prior, resolvedCompany);
477
+ const projectPrior = companyRebind && prior
478
+ ? {
479
+ ...prior,
480
+ projectId: undefined,
481
+ taskId: undefined,
482
+ bindingEpisodeId: undefined,
483
+ contextStatus: "needs_project",
484
+ }
485
+ : prior;
434
486
  let projectResolved = resolveProjectTask({
435
487
  sessionId,
436
488
  env,
437
489
  trusted,
438
490
  cwd,
439
491
  hqRoot,
440
- existingState: prior,
492
+ existingState: projectPrior,
441
493
  });
442
494
  // Deterministic cwd projects belong to the cwd company. A higher-precedence
443
495
  // identity UID must not adopt a project from an unrelated/unverified slug.
@@ -537,7 +589,7 @@ export async function reconcileObservation(obs, deps) {
537
589
  }
538
590
  }
539
591
  const projectHint = !projectId
540
- ? (prior?.projectHint || projectResolved?.projectId)
592
+ ? (companyRebind ? projectResolved?.projectId : (prior?.projectHint || projectResolved?.projectId))
541
593
  : undefined;
542
594
  const bindResultFields = {
543
595
  ...(projectBindReason ? { projectBindReason } : {}),
@@ -634,6 +686,10 @@ export async function reconcileObservation(obs, deps) {
634
686
  // Do not let a weak later observation erase a server-acknowledged company
635
687
  // binding. Keep the result unresolved so the normal candidate threshold can
636
688
  // still advance the session, while preserving the durable emitter projection.
689
+ if (companyRebind && !projectId && resolvedCompany.uid) {
690
+ registerClassification = "needs_project";
691
+ diagnostics.push(`company rebind to ${resolvedCompany.slug ?? resolvedCompany.uid}; cleared project`);
692
+ }
637
693
  const stateClassification = registerClassification === "unresolved" &&
638
694
  prior?.contextStatus === "needs_project" &&
639
695
  prior.companyUid === resolvedCompany.uid &&
@@ -707,9 +763,12 @@ export async function reconcileObservation(obs, deps) {
707
763
  };
708
764
  }
709
765
  let outboxOp;
710
- const companyConfidence = company.status === "resolved" && company.company.source === "device_default"
766
+ const companyConfidence = !companyRebind &&
767
+ company.status === "resolved" &&
768
+ company.company.source === "device_default"
711
769
  ? "device_default"
712
770
  : undefined;
771
+ const presenceLeave = companyRebind && prior ? presenceLeaveFromState(prior) : undefined;
713
772
  try {
714
773
  outboxOp = enqueueOutbox({
715
774
  clientOperationId: obs.clientOperationId,
@@ -762,9 +821,12 @@ export async function reconcileObservation(obs, deps) {
762
821
  ...(hasTurn ? { hasTurn: true } : {}),
763
822
  ...(decision ? { decision } : {}),
764
823
  ...(companyConfidence ? { companyConfidence } : {}),
824
+ ...(presenceLeave ? { presenceLeave } : {}),
765
825
  ...bindStateFields,
766
826
  ...transcriptFields,
767
- }, prior);
827
+ }, prior, companyRebind
828
+ ? { preserveBindingEpisode: false, preserveCompanyBinding: false }
829
+ : {});
768
830
  statePath = writeSessionState(stateWrite, deps.root);
769
831
  }
770
832
  catch (err) {
@@ -871,6 +933,7 @@ export async function reconcileObservation(obs, deps) {
871
933
  sessionId,
872
934
  clientOperationId: obs.clientOperationId,
873
935
  companyUid: resolvedCompany.uid,
936
+ companySlug: resolvedCompany.slug,
874
937
  projectId,
875
938
  taskId,
876
939
  bindingEpisodeId: delivered.receiptId,
@@ -106,6 +106,17 @@ export interface SessionStateFile {
106
106
  projectGate?: "choose" | "missing_board";
107
107
  /** Slug the session was trying to bind. Re-checked on the next prompt. */
108
108
  projectHint?: string;
109
+ /**
110
+ * LOCAL-ONLY. Pending session_end against a company this session just left
111
+ * (US-047). Flush posts it to that companyUid, then clears the field.
112
+ */
113
+ presenceLeave?: {
114
+ companyUid: string;
115
+ companySlug?: string;
116
+ projectId?: string;
117
+ taskId?: string;
118
+ bindingEpisodeId?: string;
119
+ };
109
120
  updatedAt: string;
110
121
  }
111
122
  /**
@@ -33,6 +33,7 @@ export const SESSION_STATE_ALLOWLIST = new Set([
33
33
  "projectCandidates",
34
34
  "projectGate",
35
35
  "projectHint",
36
+ "presenceLeave",
36
37
  "updatedAt",
37
38
  ]);
38
39
  const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
@@ -240,6 +241,8 @@ export function projectSessionState(state) {
240
241
  out.projectGate = state.projectGate;
241
242
  if (state.projectHint && !state.projectId)
242
243
  out.projectHint = state.projectHint;
244
+ if (state.presenceLeave?.companyUid)
245
+ out.presenceLeave = state.presenceLeave;
243
246
  return out;
244
247
  }
245
248
  /**
@@ -298,9 +301,15 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
298
301
  if (!out.companyConfidence && prior.companyConfidence && sameCompanyBinding) {
299
302
  out.companyConfidence = prior.companyConfidence;
300
303
  }
301
- if (!out.projectId && !out.projectHint && prior.projectHint) {
304
+ if (!out.projectId &&
305
+ !out.projectHint &&
306
+ prior.projectHint &&
307
+ sameCompanyBinding) {
302
308
  out.projectHint = prior.projectHint;
303
309
  }
310
+ if (!out.presenceLeave && prior.presenceLeave) {
311
+ out.presenceLeave = prior.presenceLeave;
312
+ }
304
313
  if (out.projectId)
305
314
  out.projectHint = undefined;
306
315
  const sameBinding = prior.companyUid === out.companyUid &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.169.0",
3
+ "version": "5.170.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {