@indigoai-us/hq-cli 5.121.2 → 5.122.1

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.
@@ -13,7 +13,16 @@
13
13
  * `HQ_AGENT_DIR` relocates the whole tree (tests, containers). The creds file
14
14
  * additionally honours `HQ_MACHINE_CREDS_FILE`, the override hq-cloud reads,
15
15
  * so a kit pointed at a custom creds path and hq-cloud's mint agree.
16
+ *
17
+ * LOCAL BOTS. A bot that runs on its owner's own computer cannot use the
18
+ * default tree: that machine already has the owner's HQ session, and an agent
19
+ * identity must not share a host account with a person's login. Such a bot
20
+ * enrolls into a SIBLING directory instead — `~/.hq-agent/<name>/` with the
21
+ * same layout — and every `hq agent …` command finds it here, so the bot does
22
+ * not have to carry environment variables around to be itself. Two or more
23
+ * local agents are ambiguous on purpose: pick one with `HQ_AGENT_DIR`.
16
24
  */
25
+ import * as fs from "node:fs";
17
26
  import * as os from "node:os";
18
27
  import * as path from "node:path";
19
28
  export const AGENT_DIR_ENV = "HQ_AGENT_DIR";
@@ -23,11 +32,62 @@ export const MACHINE_CREDS_NAME = "machine-creds.json";
23
32
  export const KIT_CONFIG_NAME = "kit.json";
24
33
  export const LAST_HEARTBEAT_NAME = "last-heartbeat.json";
25
34
  export const KIT_COMPONENTS = ["sync", "inbox"];
35
+ /** Default name for a bot enrolled alongside its owner's own session. */
36
+ export const DEFAULT_LOCAL_AGENT_NAME = "local";
37
+ /** `~/.hq-agent` — the root, whether or not it holds an identity itself. */
38
+ export function agentRootDir(home = os.homedir()) {
39
+ return path.join(home, ".hq-agent");
40
+ }
41
+ /** `~/.hq-agent/<name>` — an isolated home for one local bot. */
42
+ export function localAgentDir(name, home = os.homedir()) {
43
+ return path.join(agentRootDir(home), name);
44
+ }
45
+ /**
46
+ * Local agent homes under `~/.hq-agent`, by name, oldest name order. A
47
+ * directory counts only once it holds a creds file, so a half-written tree is
48
+ * never mistaken for an identity.
49
+ */
50
+ export function listLocalAgentDirs(home = os.homedir()) {
51
+ const root = agentRootDir(home);
52
+ let entries;
53
+ try {
54
+ entries = fs.readdirSync(root, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ return entries
60
+ .filter((e) => e.isDirectory())
61
+ .map((e) => path.join(root, e.name))
62
+ .filter((dir) => {
63
+ try {
64
+ return fs.statSync(path.join(dir, MACHINE_CREDS_NAME)).isFile();
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ })
70
+ .sort();
71
+ }
26
72
  export function agentDir(home = os.homedir(), env = process.env) {
27
73
  const override = env[AGENT_DIR_ENV]?.trim();
28
74
  if (override)
29
75
  return override;
30
- return path.join(home, ".hq-agent");
76
+ const root = agentRootDir(home);
77
+ // An identity in the root wins: that is the dedicated-host layout, and a
78
+ // host that has one is not running a local bot beside a person.
79
+ try {
80
+ if (fs.statSync(path.join(root, MACHINE_CREDS_NAME)).isFile())
81
+ return root;
82
+ }
83
+ catch {
84
+ /* no identity in the root — fall through to the local homes */
85
+ }
86
+ const locals = listLocalAgentDirs(home);
87
+ // Exactly one is unambiguous. Two or more cannot be guessed, so keep the
88
+ // root and let the caller fail with a message naming HQ_AGENT_DIR rather
89
+ // than silently acting as whichever agent sorted first.
90
+ return locals.length === 1 ? locals[0] : root;
31
91
  }
32
92
  export function agentKitPaths(home = os.homedir(), env = process.env) {
33
93
  const dir = agentDir(home, env);
@@ -178,7 +178,7 @@ function formatMigrationCapabilityCheck(cap, root) {
178
178
  status: "WARN",
179
179
  checkId: "work-context.migration-capability",
180
180
  target,
181
- message: `Migration capability: offline at ${cap.checkedAt}; default-company stays locked.`,
181
+ message: `Migration capability: offline at ${cap.checkedAt}; selected-company membership is still verified when setting a default.`,
182
182
  };
183
183
  }
184
184
  const falseCount = cap.companies.filter((c) => !c.migration).length;
@@ -73,8 +73,9 @@ export interface MigrationCapabilityProbe {
73
73
  companies: MigrationCapabilityCompanyResult[];
74
74
  }
75
75
  /**
76
- * Unlock rule for default-company mode (US-017B): migration must be true for
77
- * EVERY company the caller belongs to. Offline / empty memberships locked.
76
+ * Diagnostic probe for migration capability across active memberships. The
77
+ * result is recorded and warned on, but does not gate a verified member from
78
+ * choosing a device-local default company.
78
79
  */
79
80
  export declare function probeMigrationCapabilityForMemberships(token: string, opts?: {
80
81
  listCompanies?: (token: string) => Promise<MeshCompany[]>;
@@ -119,8 +119,9 @@ export async function fetchMigrationCapability(token, companyUid) {
119
119
  }
120
120
  }
121
121
  /**
122
- * Unlock rule for default-company mode (US-017B): migration must be true for
123
- * EVERY company the caller belongs to. Offline / empty memberships locked.
122
+ * Diagnostic probe for migration capability across active memberships. The
123
+ * result is recorded and warned on, but does not gate a verified member from
124
+ * choosing a device-local default company.
124
125
  */
125
126
  export async function probeMigrationCapabilityForMemberships(token, opts) {
126
127
  const now = (opts?.now ?? (() => new Date()))().toISOString();
@@ -13,8 +13,9 @@
13
13
  * company (via the identity-file default resolver shipped in 5.108.22). The
14
14
  * daemon's next held retry then re-attributes and posts those events naturally.
15
15
  *
16
- * Constraints (owner directive):
17
- * - Explicit, opt-in only. Never automatic; never wired into a hook or daemon.
16
+ * Constraints:
17
+ * - The daemon runs this in bounded startup batches; the CLI command remains
18
+ * available for an explicit full/manual recovery.
18
19
  * - Idempotent: sessions that already carry a companyUid are skipped.
19
20
  * - Never deletes held events; the daemon posts them on the next retry.
20
21
  * - Purely local to the box it runs on; no fleet fan-out.
@@ -13,8 +13,9 @@
13
13
  * company (via the identity-file default resolver shipped in 5.108.22). The
14
14
  * daemon's next held retry then re-attributes and posts those events naturally.
15
15
  *
16
- * Constraints (owner directive):
17
- * - Explicit, opt-in only. Never automatic; never wired into a hook or daemon.
16
+ * Constraints:
17
+ * - The daemon runs this in bounded startup batches; the CLI command remains
18
+ * available for an explicit full/manual recovery.
18
19
  * - Idempotent: sessions that already carry a companyUid are skipped.
19
20
  * - Never deletes held events; the daemon posts them on the next retry.
20
21
  * - Purely local to the box it runs on; no fleet fan-out.
@@ -3,6 +3,7 @@
3
3
  * outbox/last-flush, and unhealthy when unacked spool age > 60s while online.
4
4
  */
5
5
  import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
6
+ import { type UnattributedEventCounts } from "../spool.js";
6
7
  import { type DaemonStateFile } from "./state.js";
7
8
  export declare const UNHEALTHY_SPOOL_AGE_MS = 60000;
8
9
  export interface DaemonDoctorReport {
@@ -17,6 +18,8 @@ export interface DaemonDoctorReport {
17
18
  spoolDepth: number;
18
19
  heldCount: number;
19
20
  deadLetterCount: number;
21
+ /** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
22
+ unattributedEvents: UnattributedEventCounts;
20
23
  outboxDepth: number;
21
24
  lastFlushAt?: string;
22
25
  lastFlushResult?: DaemonStateFile["lastFlushResult"];
@@ -7,7 +7,7 @@ import * as os from "node:os";
7
7
  import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
8
8
  import { outboxStats, } from "../../../work-context/outbox.js";
9
9
  import { workContextRoot } from "../../../work-context/paths.js";
10
- import { countJsonlLines, } from "../spool.js";
10
+ import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
11
11
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
12
12
  import { daemonDir, daemonPidPath } from "./paths.js";
13
13
  import { readEmitState } from "../emit.js";
@@ -78,6 +78,7 @@ export function collectDaemonDoctor(deps = {}) {
78
78
  const spoolDepth = countJsonlLines(spoolPath);
79
79
  const heldCount = countJsonlLines(heldPath);
80
80
  const deadLetterCount = countJsonlLines(deadPath);
81
+ const unattributedEvents = countUnattributedEvents(meshRoot);
81
82
  const outbox = outboxStats(ctxRoot);
82
83
  const mqttState = state?.mqttState ?? (running ? "unknown" : "closed");
83
84
  const companiesOnline = state?.companiesOnline ?? [];
@@ -96,6 +97,13 @@ export function collectDaemonDoctor(deps = {}) {
96
97
  if (deadLetterCount > 0) {
97
98
  unhealthyReasons.push(`dead-letter count=${deadLetterCount}`);
98
99
  }
100
+ if (emitMode === "legacy" && heldCount > 0) {
101
+ unhealthyReasons.push(`held events awaiting company attribution=${heldCount}`);
102
+ }
103
+ if (unattributedEvents.total > 0) {
104
+ unhealthyReasons.push(`unattributed events=${unattributedEvents.total} ` +
105
+ `(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
106
+ }
99
107
  const presenceRefusal = state?.presenceRefusal ?? null;
100
108
  if (presenceRefusal) {
101
109
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
@@ -114,6 +122,7 @@ export function collectDaemonDoctor(deps = {}) {
114
122
  spoolDepth,
115
123
  heldCount,
116
124
  deadLetterCount,
125
+ unattributedEvents,
117
126
  outboxDepth: outbox.depth,
118
127
  lastFlushAt: state?.lastFlushAt,
119
128
  lastFlushResult: state?.lastFlushResult,
@@ -150,7 +159,8 @@ export function formatDaemonDoctor(report) {
150
159
  // receive-only (direct) mode.
151
160
  lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `outbox depth: ${report.outboxDepth}`);
152
161
  }
153
- lines.push(`dead-letter: ${report.deadLetterCount}`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
162
+ lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
163
+ `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
154
164
  if (report.emitMode === "legacy") {
155
165
  lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
156
166
  ? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
@@ -9,6 +9,7 @@
9
9
  * - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
10
10
  */
11
11
  import { type FlushSummary } from "../flush.js";
12
+ import { type BackfillHeldResult } from "../backfill-held.js";
12
13
  import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
13
14
  import { type PidLockDeps } from "./pid-lock.js";
14
15
  import { PresenceClient, type MqttConnectFn } from "./presence.js";
@@ -16,6 +17,8 @@ import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
16
17
  import { type MeshEmitMode } from "./mode.js";
17
18
  export declare const SPOOL_DEBOUNCE_MS = 2000;
18
19
  export declare const FLUSH_INTERVAL_MS = 10000;
20
+ /** Keep startup recovery bounded: the next daemon start continues the backlog. */
21
+ export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
19
22
  /**
20
23
  * Only producer appends to spool.jsonl should wake the watcher. Every other
21
24
  * file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
@@ -44,6 +47,10 @@ export interface DaemonRunDeps {
44
47
  quarantined: number;
45
48
  skipped?: number;
46
49
  }>;
50
+ /** Injected held-session recovery (tests). Runs once before the startup flush. */
51
+ backfillHeld?: () => Promise<BackfillHeldResult>;
52
+ /** Maximum distinct held sessions reconciled during one daemon startup. */
53
+ heldBackfillBatchSize?: number;
47
54
  /** Injected board refresh (tests). */
48
55
  refreshBoards?: () => Promise<{
49
56
  refreshed: number;
@@ -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({