@indigoai-us/hq-cli 5.121.1 → 5.122.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 (38) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/command-catalog.generated.d.ts +38 -4
  3. package/dist/command-catalog.generated.js +48 -4
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +26 -0
  7. package/dist/commands/agent-enroll.js +63 -6
  8. package/dist/commands/agent.js +15 -1
  9. package/dist/commands/cloud-demote.js +3 -2
  10. package/dist/commands/cloud-provision.d.ts +11 -0
  11. package/dist/commands/cloud-provision.js +25 -0
  12. package/dist/commands/cloud-retire.d.ts +51 -0
  13. package/dist/commands/cloud-retire.js +154 -0
  14. package/dist/commands/mesh.js +26 -26
  15. package/dist/lib/agent-kit/adopt-identity.d.ts +27 -0
  16. package/dist/lib/agent-kit/adopt-identity.js +51 -0
  17. package/dist/lib/agent-kit/paths.d.ts +20 -0
  18. package/dist/lib/agent-kit/paths.js +61 -1
  19. package/dist/lib/doctor/checks/work-context.js +1 -1
  20. package/dist/lib/mesh/client.d.ts +3 -2
  21. package/dist/lib/mesh/client.js +3 -2
  22. package/dist/lib/mesh/live/backfill-held.d.ts +3 -2
  23. package/dist/lib/mesh/live/backfill-held.js +3 -2
  24. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  25. package/dist/lib/mesh/live/daemon/doctor.js +12 -2
  26. package/dist/lib/mesh/live/daemon/run.d.ts +7 -0
  27. package/dist/lib/mesh/live/daemon/run.js +41 -0
  28. package/dist/lib/mesh/live/flush.js +44 -2
  29. package/dist/lib/mesh/live/spool.d.ts +13 -0
  30. package/dist/lib/mesh/live/spool.js +67 -0
  31. package/dist/lib/work-context/config.d.ts +1 -4
  32. package/dist/lib/work-context/config.js +1 -5
  33. package/dist/lib/work-context/outbox.d.ts +8 -0
  34. package/dist/lib/work-context/outbox.js +47 -0
  35. package/dist/lib/work-context/reconcile.js +12 -2
  36. package/dist/lib/work-context/state.d.ts +23 -0
  37. package/dist/lib/work-context/state.js +67 -0
  38. package/package.json +1 -1
@@ -18,6 +18,8 @@ import { replayOutbox } from "../../../work-context/outbox.js";
18
18
  import { workContextRoot } from "../../../work-context/paths.js";
19
19
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
20
20
  import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
21
+ import { backfillHeldSessions, } from "../backfill-held.js";
22
+ import { reconcileObservation } from "../../../work-context/reconcile.js";
21
23
  import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
22
24
  import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
23
25
  import { createContract3Fetcher, realTimerHost, } from "./credentials.js";
@@ -31,6 +33,8 @@ import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, Transcript
31
33
  import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
32
34
  export const SPOOL_DEBOUNCE_MS = 2_000;
33
35
  export const FLUSH_INTERVAL_MS = 10_000;
36
+ /** Keep startup recovery bounded: the next daemon start continues the backlog. */
37
+ export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
34
38
  /**
35
39
  * Only producer appends to spool.jsonl should wake the watcher. Every other
36
40
  * file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
@@ -121,6 +125,26 @@ export async function runMeshDaemon(deps = {}) {
121
125
  const deliver = createWorkSessionDeliverer({ token: t });
122
126
  return replayOutbox(ctxRoot, deliver);
123
127
  });
128
+ const backfillFn = deps.backfillHeld ??
129
+ (async () => {
130
+ const token = await getToken();
131
+ const deliver = createWorkSessionDeliverer({ token });
132
+ return backfillHeldSessions({
133
+ workMeshRoot: meshRoot,
134
+ workContextRoot: ctxRoot,
135
+ limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
136
+ env,
137
+ reconcile: (observation) => reconcileObservation(observation, {
138
+ root: ctxRoot,
139
+ env,
140
+ deliver,
141
+ validateMembership: async (slug) => {
142
+ const membership = await resolveActiveMembershipCompany(token, slug);
143
+ return membership ? { uid: membership.companyUid } : false;
144
+ },
145
+ }),
146
+ });
147
+ });
124
148
  const boardLast = new Map();
125
149
  const refreshFn = deps.refreshBoards ??
126
150
  (async () => {
@@ -253,6 +277,23 @@ export async function runMeshDaemon(deps = {}) {
253
277
  }
254
278
  flushInFlight = (async () => {
255
279
  try {
280
+ // A held session has already ended, so no later hook will naturally
281
+ // revisit its context. Repair a bounded batch before the first flush;
282
+ // recovered state is then retried immediately in this same cycle.
283
+ if (reason === "start") {
284
+ try {
285
+ const recovered = await backfillFn();
286
+ if (recovered.reconciled > 0 ||
287
+ recovered.unresolved > 0 ||
288
+ recovered.errors > 0) {
289
+ log(dir, `held backfill: considered=${recovered.considered} reconciled=${recovered.reconciled} unresolved=${recovered.unresolved} errors=${recovered.errors}`);
290
+ }
291
+ }
292
+ catch (err) {
293
+ const msg = err instanceof Error ? err.message : String(err);
294
+ log(dir, `held backfill error: ${msg.slice(0, 200)}`);
295
+ }
296
+ }
256
297
  // Record hook-emitted session ids from spool/held before claim/rename.
257
298
  const at = now().getTime();
258
299
  noteHookSessionsFromSpoolFile(workMeshSpoolPath(meshRoot), lastHookEventAt, at);
@@ -10,7 +10,10 @@
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";
14
+ import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
13
15
  import { readSessionState } from "../../work-context/state.js";
16
+ import { repairAckedRegisterStates } from "../../work-context/outbox.js";
14
17
  import { workContextRoot } from "../../work-context/paths.js";
15
18
  import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
16
19
  import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
@@ -60,6 +63,35 @@ function unwrapHeld(obj) {
60
63
  function heldEnvelope(event, heldReason, heldAt) {
61
64
  return JSON.stringify({ event, heldReason, heldAt });
62
65
  }
66
+ /**
67
+ * A seven-day retention rule must never beat local recovery. An event can
68
+ * still carry enough trusted/deterministic context (or have a configured
69
+ * device default) to reconcile its session on the next daemon startup.
70
+ */
71
+ function isLocallyReconcilableHeldEvent(event, state, workContextRoot) {
72
+ const sessionId = sessionIdOf(event);
73
+ if (!sessionId || state?.companyUid)
74
+ return Boolean(sessionId && state?.companyUid);
75
+ const text = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
76
+ const cwd = text(event.cwd);
77
+ const hqRoot = text(event.hqRoot);
78
+ const remoteOwnerSlug = cwd
79
+ ? deriveRemoteOwnerSlug({ cwd, hqRoot }) ?? undefined
80
+ : undefined;
81
+ const repoIdentityKey = cwd ? deriveRepoIdentityKey({ cwd }) : null;
82
+ return (resolveCompany({
83
+ root: workContextRoot,
84
+ sessionId,
85
+ existingState: state,
86
+ trusted: text(event.companySlug)
87
+ ? { companySlug: text(event.companySlug) }
88
+ : undefined,
89
+ cwd,
90
+ hqRoot,
91
+ remoteOwnerSlug,
92
+ repoIdentityKey,
93
+ }).status === "resolved");
94
+ }
63
95
  function deadLetterEnvelope(event, reason, responseCode, at) {
64
96
  return JSON.stringify({ event, reason, responseCode, at });
65
97
  }
@@ -157,10 +189,16 @@ export async function flushSessionEvents(deps) {
157
189
  summary.deadLettered += 1;
158
190
  continue;
159
191
  }
160
- // Expire held lines older than 7 days.
192
+ // Expire held lines older than 7 days only when local recovery cannot
193
+ // still resolve the session. The daemon backfill runs before startup
194
+ // flush, so retaining these lines gives that deterministic repair a
195
+ // chance rather than silently losing attributable work.
161
196
  if (heldAt) {
162
197
  const age = now().getTime() - Date.parse(heldAt);
163
- if (Number.isFinite(age) && age > HELD_TTL_MS) {
198
+ const state = readSessionState(sessionIdOf(event), deps.workContextRoot);
199
+ if (Number.isFinite(age) &&
200
+ age > HELD_TTL_MS &&
201
+ !isLocallyReconcilableHeldEvent(event, state, deps.workContextRoot)) {
164
202
  appendDeadLetterLine(deadLetterEnvelope(event, "HELD_EXPIRED", "held_ttl", now().toISOString()), deps.workMeshRoot);
165
203
  summary.deadLettered += 1;
166
204
  continue;
@@ -171,6 +209,10 @@ export async function flushSessionEvents(deps) {
171
209
  }
172
210
  }
173
211
  const bySession = groupBySession(collected);
212
+ // Older clients acknowledged register operations without copying the
213
+ // authoritative company into the session projection. Repair those files
214
+ // before classification so a valid receipt cannot be held as NEEDS_COMPANY.
215
+ repairAckedRegisterStates(deps.workContextRoot, new Set(bySession.keys()), now);
174
216
  const markDisposed = (event) => {
175
217
  pendingRestore.delete(event);
176
218
  };
@@ -49,4 +49,17 @@ export declare function recoverOrphanClaims(root: string): {
49
49
  export declare function removeClaimFile(claimPath: string | null): void;
50
50
  export declare function deadLetterNonEmpty(root?: string): boolean;
51
51
  export declare function countJsonlLines(filePath: string): number;
52
+ /** Local queues that still lack a usable company attribution. */
53
+ export interface UnattributedEventCounts {
54
+ spool: number;
55
+ held: number;
56
+ deadLetter: number;
57
+ total: number;
58
+ }
59
+ /**
60
+ * Count the actionable, unfiled events across all local delivery queues.
61
+ * This deliberately does not inspect session state: the signal must stay
62
+ * visible even when state is absent or corrupt.
63
+ */
64
+ export declare function countUnattributedEvents(root?: string): UnattributedEventCounts;
52
65
  //# sourceMappingURL=spool.d.ts.map
@@ -195,4 +195,71 @@ export function countJsonlLines(filePath) {
195
195
  }
196
196
  return n;
197
197
  }
198
+ function record(value) {
199
+ return value && typeof value === "object" && !Array.isArray(value)
200
+ ? value
201
+ : null;
202
+ }
203
+ /**
204
+ * A queue line can be a raw event, a held envelope, or a dead-letter
205
+ * envelope. Count the line when it is explicitly marked NEEDS_COMPANY/NONE,
206
+ * or when its underlying event has no company identity at all.
207
+ */
208
+ function isUnattributedLine(line) {
209
+ try {
210
+ const outer = record(JSON.parse(line));
211
+ if (!outer)
212
+ return false;
213
+ const event = record(outer.event) ?? outer;
214
+ const markers = [
215
+ outer.heldReason,
216
+ outer.reason,
217
+ outer.contextStatus,
218
+ event.heldReason,
219
+ event.reason,
220
+ event.contextStatus,
221
+ event.company,
222
+ event.companyUid,
223
+ event.companySlug,
224
+ ];
225
+ if (markers.some((value) => typeof value === "string" &&
226
+ ["NEEDS_COMPANY", "NONE"].includes(value.trim().toUpperCase()))) {
227
+ return true;
228
+ }
229
+ return ![
230
+ event.companyUid,
231
+ event.companySlug,
232
+ event.company,
233
+ ].some((value) => typeof value === "string" && value.trim().length > 0);
234
+ }
235
+ catch {
236
+ // Malformed lines are reported separately by the dead-letter count.
237
+ return false;
238
+ }
239
+ }
240
+ function countUnattributedJsonlLines(filePath) {
241
+ if (!fs.existsSync(filePath))
242
+ return 0;
243
+ try {
244
+ return fs
245
+ .readFileSync(filePath, "utf8")
246
+ .split("\n")
247
+ .filter((line) => line.trim() && isUnattributedLine(line)).length;
248
+ }
249
+ catch {
250
+ return 0;
251
+ }
252
+ }
253
+ /**
254
+ * Count the actionable, unfiled events across all local delivery queues.
255
+ * This deliberately does not inspect session state: the signal must stay
256
+ * visible even when state is absent or corrupt.
257
+ */
258
+ export function countUnattributedEvents(root) {
259
+ const meshRoot = root ?? workMeshRoot();
260
+ const spool = countUnattributedJsonlLines(workMeshSpoolPath(meshRoot));
261
+ const held = countUnattributedJsonlLines(workMeshHeldPath(meshRoot));
262
+ const deadLetter = countUnattributedJsonlLines(workMeshDeadLetterPath(meshRoot));
263
+ return { spool, held, deadLetter, total: spool + held + deadLetter };
264
+ }
198
265
  //# sourceMappingURL=spool.js.map
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Device-local default company preference (~/.hq/work-context/config.json).
3
3
  * Preference only — never authority. activeCompany is never treated as default.
4
- * Default-company mode ships dark until migrationCapability (US-017A).
5
4
  */
6
5
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
7
6
  export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
@@ -61,9 +60,7 @@ export interface DeviceConfigDeps {
61
60
  }
62
61
  export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
63
62
  export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
64
- export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps & {
65
- allowWithoutMigration?: boolean;
66
- }): Promise<WorkContextDeviceConfig>;
63
+ export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps): Promise<WorkContextDeviceConfig>;
67
64
  export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
68
65
  /**
69
66
  * Read the persisted company mapping for a repo identity key (gap 4).
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * Device-local default company preference (~/.hq/work-context/config.json).
3
3
  * Preference only — never authority. activeCompany is never treated as default.
4
- * Default-company mode ships dark until migrationCapability (US-017A).
5
4
  */
6
5
  import * as fs from "node:fs";
7
6
  import * as path from "node:path";
8
7
  import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
9
- import { DefaultCompanyLockedError, DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
8
+ import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
10
9
  import { workContextConfigPath, workContextRoot } from "./paths.js";
11
10
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
12
11
  export const DEVICE_CONFIG_SCHEMA_VERSION = 1;
@@ -116,9 +115,6 @@ export async function setDefaultCompany(slug, deps) {
116
115
  if (!trimmed || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) {
117
116
  throw new WorkContextError("InvalidCompanySlug", `Invalid company slug: ${slug}`);
118
117
  }
119
- if (!deps.migrationCapability && !deps.allowWithoutMigration) {
120
- throw new DefaultCompanyLockedError();
121
- }
122
118
  if (!deps.validateMembership) {
123
119
  throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${trimmed}" (no membership validator)`);
124
120
  }
@@ -76,6 +76,14 @@ export declare function enqueueOutbox(input: OutboxEnqueueInput, root: string):
76
76
  export declare function readOutboxOperation(operationId: string, root: string): OutboxOperation | null;
77
77
  export declare function updateOutboxOperation(op: OutboxOperation, root: string): void;
78
78
  export declare function markOutboxAcked(operationId: string, root: string, receiptId: string, now?: () => Date): OutboxOperation | null;
79
+ /**
80
+ * Write server-acknowledged register scope into the session projection.
81
+ * This closes the outbox → emitter handoff for both direct reconciliation and
82
+ * daemon replay. Non-register or incomplete legacy operations are ignored.
83
+ */
84
+ export declare function writeAckedRegisterState(op: OutboxOperation, root: string, receiptId: string, now?: () => Date): void;
85
+ /** Repair older unresolved session projections from acknowledged registrations. */
86
+ export declare function repairAckedRegisterStates(root: string, sessionIds?: ReadonlySet<string>, now?: () => Date): number;
79
87
  export declare function markOutboxQueued(operationId: string, root: string, errorCode: string, now?: () => Date, random?: () => number): OutboxOperation | null;
80
88
  export declare function markOutboxQuarantined(operationId: string, root: string, errorCode: string, now?: () => Date): OutboxOperation | null;
81
89
  /**
@@ -8,6 +8,7 @@ import * as path from "node:path";
8
8
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
9
9
  import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
10
10
  import { NotTrackingError } from "./errors.js";
11
+ import { readSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
11
12
  import { isSafeWorkContextSegment, workContextOutboxDir, workContextOutboxPath, } from "./paths.js";
12
13
  /** Fields allowed on a durable outbox operation (privacy allowlist). */
13
14
  export const OUTBOX_ALLOWLIST = [
@@ -258,6 +259,51 @@ export function markOutboxAcked(operationId, root, receiptId, now = () => new Da
258
259
  updateOutboxOperation(op, root);
259
260
  return op;
260
261
  }
262
+ /**
263
+ * Write server-acknowledged register scope into the session projection.
264
+ * This closes the outbox → emitter handoff for both direct reconciliation and
265
+ * daemon replay. Non-register or incomplete legacy operations are ignored.
266
+ */
267
+ export function writeAckedRegisterState(op, root, receiptId, now = () => new Date()) {
268
+ if (op.kind !== "register" || !op.companyUid)
269
+ return;
270
+ writeAcknowledgedRegisterBinding({
271
+ kind: "register",
272
+ sessionId: op.sessionId,
273
+ companyUid: op.companyUid,
274
+ companySlug: op.companySlug,
275
+ projectId: op.projectId,
276
+ taskId: op.taskId,
277
+ receiptId,
278
+ }, root, now);
279
+ }
280
+ /** Repair older unresolved session projections from acknowledged registrations. */
281
+ export function repairAckedRegisterStates(root, sessionIds, now = () => new Date()) {
282
+ let repaired = 0;
283
+ for (const op of listOutboxOperations(root)) {
284
+ if (op.delivery !== "acked" ||
285
+ !op.receiptId ||
286
+ op.kind !== "register" ||
287
+ !op.companyUid ||
288
+ (sessionIds && !sessionIds.has(op.sessionId))) {
289
+ continue;
290
+ }
291
+ const before = readSessionState(op.sessionId, root);
292
+ const after = writeAcknowledgedRegisterBinding({
293
+ kind: "register",
294
+ sessionId: op.sessionId,
295
+ companyUid: op.companyUid,
296
+ companySlug: op.companySlug,
297
+ projectId: op.projectId,
298
+ taskId: op.taskId,
299
+ receiptId: op.receiptId,
300
+ }, root, now);
301
+ if (after && (before?.contextStatus === "unresolved" || !before?.companyUid)) {
302
+ repaired += 1;
303
+ }
304
+ }
305
+ return repaired;
306
+ }
261
307
  export function markOutboxQueued(operationId, root, errorCode, now = () => new Date(), random = Math.random) {
262
308
  const op = readOutboxOperation(operationId, root);
263
309
  if (!op)
@@ -461,6 +507,7 @@ export async function replayOutbox(root, deliver, opts = {}) {
461
507
  const result = await deliver(op);
462
508
  if (result.ok) {
463
509
  markOutboxAcked(op.operationId, root, result.receiptId, now);
510
+ writeAckedRegisterState(op, root, result.receiptId, now);
464
511
  delivered += 1;
465
512
  }
466
513
  else if (result.retryable) {
@@ -10,7 +10,7 @@ import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOrigi
10
10
  import { decisionFromCandidates, } from "./organize.js";
11
11
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
12
12
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
13
- import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, } from "./outbox.js";
13
+ import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
14
14
  import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
15
15
  function resultOf(parts) {
16
16
  const out = {
@@ -482,6 +482,15 @@ export async function reconcileObservation(obs, deps) {
482
482
  registerClassification === "unresolved") {
483
483
  registerClassification = "needs_project";
484
484
  }
485
+ // Do not let a weak later observation erase a server-acknowledged company
486
+ // binding. Keep the result unresolved so the normal candidate threshold can
487
+ // still advance the session, while preserving the durable emitter projection.
488
+ const stateClassification = registerClassification === "unresolved" &&
489
+ prior?.contextStatus === "needs_project" &&
490
+ prior.companyUid === resolvedCompany.uid &&
491
+ prior.bindingEpisodeId
492
+ ? "needs_project"
493
+ : registerClassification;
485
494
  let outboxOp;
486
495
  try {
487
496
  outboxOp = enqueueOutbox({
@@ -524,7 +533,7 @@ export async function reconcileObservation(obs, deps) {
524
533
  sessionId,
525
534
  companyUid: resolvedCompany.uid,
526
535
  companySlug: resolvedCompany.slug,
527
- contextStatus: registerClassification,
536
+ contextStatus: stateClassification,
528
537
  projectId,
529
538
  taskId,
530
539
  updatedAt: nowIso,
@@ -578,6 +587,7 @@ export async function reconcileObservation(obs, deps) {
578
587
  const delivered = await deps.deliver(outboxOp);
579
588
  if (delivered.ok) {
580
589
  markOutboxAcked(outboxOp.operationId, deps.root, delivered.receiptId, nowFn);
590
+ writeAckedRegisterState(outboxOp, deps.root, delivered.receiptId, nowFn);
581
591
  // Persist binding episode on success for bound registrations.
582
592
  if (registerClassification === "bound") {
583
593
  writeSessionState(mergeLocalSessionFields({
@@ -91,6 +91,20 @@ export interface SessionStateFile {
91
91
  transcriptMarker?: "from transcript";
92
92
  updatedAt: string;
93
93
  }
94
+ /**
95
+ * Metadata from a register operation that the control plane has acknowledged.
96
+ * Kept structural here so the state projection does not need a runtime
97
+ * dependency on the outbox implementation.
98
+ */
99
+ export interface AcknowledgedRegisterBinding {
100
+ kind: "register";
101
+ sessionId: string;
102
+ companyUid?: string;
103
+ companySlug?: string;
104
+ projectId?: string;
105
+ taskId?: string;
106
+ receiptId: string;
107
+ }
94
108
  /** Allowlisted keys that may appear in the durable state projection. */
95
109
  export declare const SESSION_STATE_ALLOWLIST: Set<string>;
96
110
  export declare function readSessionState(sessionId: string, root: string): SessionStateFile | null;
@@ -102,6 +116,15 @@ export declare function projectSessionState(state: SessionStateFile): SessionSta
102
116
  * and reconcile updates do not clobber each other.
103
117
  */
104
118
  export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null): SessionStateFile;
119
+ /**
120
+ * Upgrade the local projection after the server acknowledges a register.
121
+ *
122
+ * A receipt makes the operation's company authoritative, but it must never
123
+ * erase an explicit untracked choice, a conflicting existing company, or a
124
+ * more specific binding for the same company. This is also used to repair
125
+ * state files created by older clients before this write-back existed.
126
+ */
127
+ export declare function writeAcknowledgedRegisterBinding(binding: AcknowledgedRegisterBinding, root: string, now?: () => Date): SessionStateFile | null;
105
128
  /** True when a durable state file was written by the hook / reconcile path. */
106
129
  export declare function isHookWrittenSessionState(state: SessionStateFile | null): boolean;
107
130
  /** Authoritative prior scope usable for company precedence. */
@@ -136,6 +136,73 @@ export function mergeLocalSessionFields(next, prior) {
136
136
  }
137
137
  return out;
138
138
  }
139
+ function acknowledgedStatus(binding) {
140
+ if (!binding.companyUid)
141
+ return null;
142
+ if (binding.projectId && binding.taskId)
143
+ return "bound";
144
+ if (binding.projectId)
145
+ return "needs_task";
146
+ return "needs_project";
147
+ }
148
+ function bindingStrength(state) {
149
+ if (state.contextStatus === "bound" && state.projectId && state.taskId)
150
+ return 3;
151
+ if (state.contextStatus === "needs_task" && state.projectId)
152
+ return 2;
153
+ if (state.contextStatus === "needs_project")
154
+ return 1;
155
+ return 0;
156
+ }
157
+ /**
158
+ * Upgrade the local projection after the server acknowledges a register.
159
+ *
160
+ * A receipt makes the operation's company authoritative, but it must never
161
+ * erase an explicit untracked choice, a conflicting existing company, or a
162
+ * more specific binding for the same company. This is also used to repair
163
+ * state files created by older clients before this write-back existed.
164
+ */
165
+ export function writeAcknowledgedRegisterBinding(binding, root, now = () => new Date()) {
166
+ const acknowledged = acknowledgedStatus(binding);
167
+ if (!acknowledged || !isSafeWorkContextSegment(binding.sessionId))
168
+ return null;
169
+ const prior = readSessionState(binding.sessionId, root);
170
+ // Reconcile always writes the local projection before network delivery. Do
171
+ // not manufacture session files when replay sees an orphaned legacy outbox
172
+ // record with no corresponding tracked session.
173
+ if (!prior)
174
+ return null;
175
+ if (prior.contextStatus === "untracked" ||
176
+ (prior.companyUid && prior.companyUid !== binding.companyUid)) {
177
+ return prior;
178
+ }
179
+ const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
180
+ const priorStrength = bindingStrength(prior);
181
+ const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
182
+ const next = mergeLocalSessionFields({
183
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
184
+ sessionId: binding.sessionId,
185
+ companyUid: binding.companyUid,
186
+ companySlug: binding.companySlug ?? prior.companySlug,
187
+ contextStatus: preservePriorBinding ? prior.contextStatus : acknowledged,
188
+ projectId: preservePriorBinding ? prior.projectId : binding.projectId,
189
+ taskId: preservePriorBinding ? prior.taskId : binding.taskId,
190
+ bindingEpisodeId: preservePriorBinding
191
+ ? prior.bindingEpisodeId ?? binding.receiptId
192
+ : binding.receiptId,
193
+ updatedAt: now().toISOString(),
194
+ }, prior);
195
+ if (prior.companyUid === next.companyUid &&
196
+ prior.companySlug === next.companySlug &&
197
+ prior.contextStatus === next.contextStatus &&
198
+ prior.projectId === next.projectId &&
199
+ prior.taskId === next.taskId &&
200
+ prior.bindingEpisodeId === next.bindingEpisodeId) {
201
+ return prior;
202
+ }
203
+ writeSessionState(next, root);
204
+ return next;
205
+ }
139
206
  /** True when a durable state file was written by the hook / reconcile path. */
140
207
  export function isHookWrittenSessionState(state) {
141
208
  if (!state)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.121.1",
3
+ "version": "5.122.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {