@indigoai-us/hq-cli 5.123.0 → 5.123.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.123.1] — 2026-09-19
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
+
5
26
  ## [5.123.0] — 2026-09-19
6
27
 
7
28
  ### Fixed
@@ -6,6 +6,7 @@
6
6
  * Distinct from `hq doctor` (hook guardrails).
7
7
  */
8
8
  import * as os from "node:os";
9
+ import { findHqRoot } from "../utils/manifest.js";
9
10
  import chalk from "chalk";
10
11
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
11
12
  import * as readline from "node:readline/promises";
@@ -324,6 +325,7 @@ async function runContextBackfillHeld(opts) {
324
325
  const result = await backfillHeldSessions({
325
326
  workMeshRoot: meshRoot,
326
327
  workContextRoot: root,
328
+ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot(),
327
329
  dryRun,
328
330
  limit,
329
331
  // Mirror the reconcile closure's non-forcing hint in the dry-run predictor
@@ -1181,7 +1183,10 @@ async function runDaemonRun() {
1181
1183
  async function runDaemonInstall(opts) {
1182
1184
  const result = installDaemonService({
1183
1185
  platform: detectPlatform(),
1184
- paths: buildInstallPaths({ systemUser: opts.systemUser }),
1186
+ paths: buildInstallPaths({
1187
+ systemUser: opts.systemUser,
1188
+ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot(),
1189
+ }),
1185
1190
  systemUser: opts.systemUser,
1186
1191
  });
1187
1192
  if (opts.json) {
@@ -1242,7 +1247,7 @@ async function runDaemonStatus(opts) {
1242
1247
  }
1243
1248
  }
1244
1249
  async function runDaemonDoctorCmd(opts) {
1245
- const report = collectDaemonDoctor();
1250
+ const report = collectDaemonDoctor({ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot() });
1246
1251
  if (opts.json) {
1247
1252
  console.log(JSON.stringify({ ok: !report.unhealthy, ...report }, null, 2));
1248
1253
  return;
@@ -5,6 +5,7 @@
5
5
  import * as path from 'path';
6
6
  import { findHqRoot, readManifest, readLock, writeLock, getModulesDir } from '../utils/manifest.js';
7
7
  import { fetchRepo, pullRepo, getCurrentCommit, isRepo } from '../utils/git.js';
8
+ import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
8
9
  export function registerUpdateCommand(program) {
9
10
  program
10
11
  .command('update [module-name]')
@@ -49,6 +50,11 @@ export function registerUpdateCommand(program) {
49
50
  }
50
51
  }
51
52
  writeLock(hqRoot, lock);
53
+ // An installed daemon is a separate Node process. Refresh it
54
+ // best-effort after a successful update without making the update fail.
55
+ const restart = restartDaemonServiceIfInstalled();
56
+ if (restart.message)
57
+ console.log(restart.message);
52
58
  console.log('\nLock file updated.');
53
59
  }
54
60
  catch (error) {
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import type { ReconcileObservation, ReconcileOutcome } from "../../work-context/reconcile.js";
24
24
  import { type SessionStateFile } from "../../work-context/state.js";
25
+ import { type OutboxOperation } from "../../work-context/outbox.js";
25
26
  /**
26
27
  * One distinct session discovered in held.jsonl, carrying the company evidence
27
28
  * its held events recorded at enqueue time (cwd / hqRoot / companySlug /
@@ -38,12 +39,25 @@ export interface HeldSessionRef {
38
39
  cwd?: string;
39
40
  /** HQ root, so the resolver can read workspace/sessions/<sid>/meta.yaml. */
40
41
  hqRoot?: string;
42
+ /** Every HQ root seen for this session, ordered by first observation. */
43
+ hqRoots?: string[];
41
44
  /** Company the session was bound to at emit time (enqueue --company-slug). */
42
45
  companySlug?: string;
43
46
  /** Project id carried on the event, if any. */
44
47
  project?: string;
45
48
  /** Task id carried on the event, if any. */
46
49
  task?: string;
50
+ /**
51
+ * Per-event local evidence retained while coalescing a held session. Event
52
+ * timestamps are instance boundaries: a session id may be reused, so a root
53
+ * observed before the current state.startedAt must not attribute it.
54
+ */
55
+ eventEvidence?: Array<{
56
+ hqRoot?: string;
57
+ cwd?: string;
58
+ harness?: string;
59
+ at?: string;
60
+ }>;
47
61
  }
48
62
  export interface BackfillHeldResult {
49
63
  /** Distinct sessions found in held.jsonl (before --limit). */
@@ -52,10 +66,15 @@ export interface BackfillHeldResult {
52
66
  considered: number;
53
67
  /** Skipped because state already has a companyUid. */
54
68
  alreadyAttributed: number;
55
- /** Reconcile invoked and a company was resolved (would-reconcile in dry-run). */
69
+ /**
70
+ * A recovered company binding was persisted locally (would-recover in
71
+ * dry-run). A reconcile attempt that ends in needs_company is unresolved.
72
+ */
56
73
  reconciled: number;
57
74
  /** Reconcile ran but resolver found no company. */
58
75
  unresolved: number;
76
+ /** Unresolved because otherwise-trusted local evidence disagreed. */
77
+ conflictingEvidence: number;
59
78
  /** Sessions that errored during reconcile. */
60
79
  errors: number;
61
80
  dryRun: boolean;
@@ -65,6 +84,10 @@ export interface BackfillHeldDeps {
65
84
  workMeshRoot: string;
66
85
  /** Work-context root that holds sessions/<sid>.json. */
67
86
  workContextRoot: string;
87
+ /** HQ root configured for this daemon/CLI invocation (or device allowlist). */
88
+ hqRoot?: string;
89
+ /** Durable device allowlist of verified local HQ roots. */
90
+ hqRoots?: string[];
68
91
  /** Report only; write nothing, call no network, invoke no reconcile. */
69
92
  dryRun?: boolean;
70
93
  /** Cap distinct sessions processed. 0 (default) = no cap. */
@@ -100,17 +123,29 @@ export interface BackfillHeldDeps {
100
123
  predictCompany?: (ref: HeldSessionRef) => boolean;
101
124
  /** Optional progress logger. */
102
125
  log?: (message: string) => void;
126
+ /** Read/write seam for the bounded-startup rotation cursor. */
127
+ readCursor?: () => BackfillCursor | null;
128
+ writeCursor?: (cursor: BackfillCursor) => void;
129
+ }
130
+ interface BackfillCursor {
131
+ sessionId?: string;
132
+ /** Zero-based index of the last considered session in its file generation. */
133
+ ordinal?: number;
103
134
  }
135
+ /** Read-only doctor helper: report only independently verified held evidence. */
136
+ export declare function refWithLocalCompanyEvidence(ref: HeldSessionRef, canonicalHqRoot?: string, canonicalHqRoots?: readonly string[], state?: SessionStateFile | null): HeldSessionRef | null;
104
137
  /**
105
- * Build the reconcile observation for one held session, forwarding the company
106
- * evidence its events recorded (cwd / hqRoot / bound companySlug / project /
107
- * task). The event's own companySlug is trusted explicit — it is the company
108
- * the session was bound to when the events were emitted on this box.
138
+ * Build the reconcile observation for a company that was independently proven
139
+ * from durable local evidence. Held-event fields are deliberately not forwarded
140
+ * as trusted explicit context.
109
141
  */
110
142
  export declare function observationFromHeldRef(ref: HeldSessionRef, contractVersion: number, clientOperationId: string): ReconcileObservation;
143
+ /** Shared doctor/backfill gate for a receipt that is safe to treat as current evidence. */
144
+ export declare function hasCurrentAcknowledgedRegistration(ref: HeldSessionRef, state: SessionStateFile | null, operations: readonly OutboxOperation[], hqRoot?: string, hqRoots?: readonly string[]): boolean;
111
145
  /**
112
- * Read held.jsonl and return distinct sessions (first occurrence wins),
113
- * carrying the harness from whichever held event we saw first for that session.
146
+ * Read held.jsonl and return distinct sessions. Later events can add local
147
+ * evidence absent from the first event (the original first-occurrence-only
148
+ * implementation missed exactly that shape after a session became bound).
114
149
  * Lines that fail to parse or carry no sessionId are ignored (the daemon owns
115
150
  * their disposition; this backfill never mutates or deletes held lines).
116
151
  */
@@ -120,4 +155,5 @@ export declare function readHeldSessions(workMeshRoot: string): HeldSessionRef[]
120
155
  * re-running skips sessions that already carry a companyUid.
121
156
  */
122
157
  export declare function backfillHeldSessions(deps: BackfillHeldDeps): Promise<BackfillHeldResult>;
158
+ export {};
123
159
  //# sourceMappingURL=backfill-held.d.ts.map
@@ -21,16 +21,189 @@
21
21
  * - Purely local to the box it runs on; no fleet fan-out.
22
22
  */
23
23
  import * as fs from "node:fs";
24
+ import * as path from "node:path";
24
25
  import { readSessionState } from "../../work-context/state.js";
26
+ import { listOutboxOperations, writeAckedRegisterState, } from "../../work-context/outbox.js";
25
27
  import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
26
- import { resolveCompany } from "../../work-context/company.js";
27
- import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
28
- import { workMeshHeldPath } from "./paths.js";
28
+ import { resolveCompany, sameCompany } from "../../work-context/company.js";
29
+ import { readMetaCompanySlug } from "../../work-context/company.js";
30
+ import { deriveRemoteOwnerSlug, } from "../../work-context/repo-remote.js";
31
+ import { workMeshHeldBackfillCursorPath, workMeshHeldPath } from "./paths.js";
32
+ function readBackfillCursor(workMeshRoot) {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(workMeshHeldBackfillCursorPath(workMeshRoot), "utf8"));
35
+ const sessionId = typeof parsed.sessionId === "string" && parsed.sessionId.trim()
36
+ ? parsed.sessionId.trim()
37
+ : undefined;
38
+ const ordinal = typeof parsed.ordinal === "number" &&
39
+ Number.isInteger(parsed.ordinal) && parsed.ordinal >= 0
40
+ ? parsed.ordinal
41
+ : undefined;
42
+ return sessionId || ordinal !== undefined ? { sessionId, ordinal } : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ function writeBackfillCursor(workMeshRoot, cursor) {
49
+ try {
50
+ fs.writeFileSync(workMeshHeldBackfillCursorPath(workMeshRoot), `${JSON.stringify(cursor)}\n`, { mode: 0o600 });
51
+ }
52
+ catch {
53
+ // A missing cursor only repeats a bounded batch; it must never stop flush.
54
+ }
55
+ }
56
+ function rotateSessions(sessions, limit, cursor) {
57
+ if (!limit || limit >= sessions.length)
58
+ return sessions;
59
+ const previous = cursor?.sessionId
60
+ ? sessions.findIndex((ref) => ref.sessionId === cursor.sessionId)
61
+ : -1;
62
+ const start = previous >= 0
63
+ ? (previous + 1) % sessions.length
64
+ : cursor?.ordinal !== undefined
65
+ ? cursor.ordinal % sessions.length
66
+ : 0;
67
+ const rotated = [...sessions.slice(start), ...sessions.slice(0, start)];
68
+ return rotated.slice(0, limit);
69
+ }
70
+ function verifiedHqRoot(hqRoot) {
71
+ try {
72
+ const root = fs.realpathSync(hqRoot);
73
+ const manifest = path.join(root, "companies", "manifest.yaml");
74
+ return fs.statSync(manifest).isFile() ? root : null;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function verifiedMetaCompanySlug(sessionId, hqRoot) {
81
+ try {
82
+ const root = verifiedHqRoot(hqRoot);
83
+ if (!root)
84
+ return undefined;
85
+ const meta = path.join(root, "workspace", "sessions", sessionId, "meta.yaml");
86
+ if (!fs.statSync(meta).isFile())
87
+ return undefined;
88
+ // Do not follow a meta symlink outside the claimed HQ root.
89
+ const realMeta = fs.realpathSync(meta);
90
+ if (path.relative(root, realMeta).startsWith(".."))
91
+ return undefined;
92
+ return readMetaCompanySlug(sessionId, root);
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ function candidatesAgree(a, b) {
99
+ return sameCompany(a, b) ||
100
+ (!a.uid && !b.uid && Boolean(a.slug && b.slug) &&
101
+ a.slug.toLowerCase() === b.slug.toLowerCase());
102
+ }
103
+ function consistentCandidate(candidates) {
104
+ let chosen = null;
105
+ for (const candidate of candidates) {
106
+ if (!candidate.slug && !candidate.uid)
107
+ continue;
108
+ if (!chosen) {
109
+ chosen = { ...candidate };
110
+ continue;
111
+ }
112
+ if (!candidatesAgree(chosen, candidate))
113
+ return { candidate: null, conflict: true };
114
+ chosen = {
115
+ slug: chosen.slug ?? candidate.slug,
116
+ uid: chosen.uid ?? candidate.uid,
117
+ hqRoot: chosen.hqRoot ?? candidate.hqRoot,
118
+ };
119
+ }
120
+ return { candidate: chosen, conflict: false };
121
+ }
122
+ function localCompanyCandidates(ref, state, canonicalHqRoot, canonicalHqRoots = []) {
123
+ const candidates = [];
124
+ // A slug without a UID can be the failed/stale projection this repair is
125
+ // investigating. Only a persisted UID is a durable registration signal.
126
+ if (state?.companyUid) {
127
+ candidates.push({ slug: state.companySlug, uid: state.companyUid });
128
+ }
129
+ const roots = [...new Set([canonicalHqRoot, ...canonicalHqRoots]
130
+ .filter((candidate) => Boolean(candidate)))]
131
+ .map(verifiedHqRoot).filter((root) => Boolean(root));
132
+ const eventEvidence = currentInstanceEventEvidence(ref, state);
133
+ const eventRoots = eventEvidence.map((evidence) => evidence.hqRoot).filter((candidate) => Boolean(candidate));
134
+ for (const root of roots)
135
+ if (eventRoots.some((candidate) => {
136
+ try {
137
+ return fs.realpathSync(candidate) === root;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ })) {
143
+ const slug = verifiedMetaCompanySlug(ref.sessionId, root);
144
+ if (slug)
145
+ candidates.push({ slug, hqRoot: root });
146
+ // The event cwd itself is not company evidence. The repository's origin is,
147
+ // but only when it is mapped by this verified HQ root's manifest.
148
+ for (const evidence of eventEvidence) {
149
+ if (!evidence.cwd || !evidence.hqRoot)
150
+ continue;
151
+ try {
152
+ if (fs.realpathSync(evidence.hqRoot) !== root)
153
+ continue;
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ const remoteSlug = deriveRemoteOwnerSlug({ cwd: evidence.cwd, hqRoot: root });
159
+ if (remoteSlug)
160
+ candidates.push({ slug: remoteSlug, hqRoot: root });
161
+ }
162
+ }
163
+ return candidates;
164
+ }
29
165
  /**
30
- * Build the reconcile observation for one held session, forwarding the company
31
- * evidence its events recorded (cwd / hqRoot / bound companySlug / project /
32
- * task). The event's own companySlug is trusted explicit — it is the company
33
- * the session was bound to when the events were emitted on this box.
166
+ * Keep only event evidence that belongs to the current session instance. A
167
+ * current state is a durable instance marker; metadata from a held event that
168
+ * predates it could belong to a prior use of the same session id.
169
+ */
170
+ function currentInstanceEventEvidence(ref, state) {
171
+ const evidence = ref.eventEvidence ?? [
172
+ {
173
+ hqRoot: ref.hqRoot,
174
+ cwd: ref.cwd,
175
+ harness: ref.harness,
176
+ },
177
+ ...(ref.hqRoots ?? []).filter((hqRoot) => hqRoot !== ref.hqRoot).map((hqRoot) => ({ hqRoot })),
178
+ ];
179
+ if (!state?.startedAt)
180
+ return evidence;
181
+ const startedAt = Date.parse(state.startedAt);
182
+ if (!Number.isFinite(startedAt))
183
+ return [];
184
+ return evidence.filter((entry) => {
185
+ if (!entry.hqRoot)
186
+ return false;
187
+ const eventAt = entry.at ? Date.parse(entry.at) : Number.NaN;
188
+ return Number.isFinite(eventAt) && eventAt >= startedAt;
189
+ });
190
+ }
191
+ function hasOnlyStaleEventEvidence(ref, state) {
192
+ return Boolean(state?.startedAt &&
193
+ ref.eventEvidence?.some((entry) => entry.hqRoot) &&
194
+ !currentInstanceEventEvidence(ref, state).some((entry) => entry.hqRoot));
195
+ }
196
+ /** Read-only doctor helper: report only independently verified held evidence. */
197
+ export function refWithLocalCompanyEvidence(ref, canonicalHqRoot, canonicalHqRoots = [], state = null) {
198
+ const evidence = consistentCandidate(localCompanyCandidates(ref, state, canonicalHqRoot, canonicalHqRoots));
199
+ return !evidence.conflict && evidence.candidate?.slug
200
+ ? { ...ref, companySlug: evidence.candidate.slug }
201
+ : null;
202
+ }
203
+ /**
204
+ * Build the reconcile observation for a company that was independently proven
205
+ * from durable local evidence. Held-event fields are deliberately not forwarded
206
+ * as trusted explicit context.
34
207
  */
35
208
  export function observationFromHeldRef(ref, contractVersion, clientOperationId) {
36
209
  const obs = {
@@ -40,12 +213,8 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
40
213
  : { sessionId: ref.sessionId },
41
214
  clientOperationId,
42
215
  };
43
- if (ref.cwd)
44
- obs.cwd = ref.cwd;
45
- if (ref.hqRoot)
46
- obs.hqRoot = ref.hqRoot;
47
216
  if (ref.companySlug || ref.project || ref.task) {
48
- obs.explicit = {
217
+ obs.trustedContext = {
49
218
  ...(ref.companySlug ? { companySlug: ref.companySlug } : {}),
50
219
  ...(ref.project ? { projectId: ref.project } : {}),
51
220
  ...(ref.task ? { taskId: ref.task } : {}),
@@ -63,28 +232,74 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
63
232
  * gone because both paths key off this same resolver + evidence.
64
233
  */
65
234
  function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
66
- const remoteOwnerSlug = remoteOwnerHint?.trim() ||
67
- (ref.cwd
68
- ? deriveRemoteOwnerSlug({ cwd: ref.cwd, hqRoot: ref.hqRoot }) ?? undefined
69
- : undefined);
70
- const repoIdentityKey = ref.cwd
71
- ? deriveRepoIdentityKey({ cwd: ref.cwd })
72
- : null;
235
+ // This predictor is called only with a ref already rebuilt from trusted local
236
+ // evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
237
+ const remoteOwnerSlug = remoteOwnerHint?.trim();
73
238
  const resolution = resolveCompany({
74
239
  root: workContextRoot,
75
240
  sessionId: ref.sessionId,
76
241
  env,
77
242
  trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
78
- cwd: ref.cwd,
79
- hqRoot: ref.hqRoot,
80
243
  remoteOwnerSlug,
81
- repoIdentityKey,
82
244
  });
83
245
  return resolution.status === "resolved";
84
246
  }
85
247
  /**
86
- * Read held.jsonl and return distinct sessions (first occurrence wins),
87
- * carrying the harness from whichever held event we saw first for that session.
248
+ * A backfill is only useful when the daemon can observe a durable company
249
+ * binding on its next held retry. Do not use the reconcile result alone here:
250
+ * a completed reconcile may legitimately return needs_company after a failed
251
+ * membership lookup, and that leaves the held events exactly where they were.
252
+ */
253
+ function hasDurableCompanyRecovery(sessionId, workContextRoot, readState) {
254
+ return Boolean(readState(sessionId, workContextRoot)?.companyUid);
255
+ }
256
+ function latestAcknowledgedRegistration(sessionId, operations) {
257
+ for (let index = operations.length - 1; index >= 0; index -= 1) {
258
+ const operation = operations[index];
259
+ if (operation.kind === "register" &&
260
+ operation.delivery === "acked" &&
261
+ operation.sessionId === sessionId &&
262
+ operation.companyUid &&
263
+ operation.receiptId) {
264
+ return operation;
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+ function acknowledgedRegistrationMatches(operation, ref, evidence, state) {
270
+ if (!operation.companyUid || !operation.receiptId)
271
+ return false;
272
+ if (!state?.startedAt)
273
+ return false;
274
+ const startedAt = Date.parse(state.startedAt);
275
+ const registeredAt = Date.parse(operation.createdAt);
276
+ if (!Number.isFinite(startedAt) || !Number.isFinite(registeredAt) || registeredAt < startedAt) {
277
+ return false;
278
+ }
279
+ if (evidence &&
280
+ !candidatesAgree(evidence, {
281
+ slug: operation.companySlug,
282
+ uid: operation.companyUid,
283
+ }))
284
+ return false;
285
+ if (ref.harness && operation.harness && ref.harness !== operation.harness)
286
+ return false;
287
+ if (state?.bindingEpisodeId && state.bindingEpisodeId !== operation.receiptId)
288
+ return false;
289
+ return true;
290
+ }
291
+ /** Shared doctor/backfill gate for a receipt that is safe to treat as current evidence. */
292
+ export function hasCurrentAcknowledgedRegistration(ref, state, operations, hqRoot, hqRoots = []) {
293
+ const evidence = consistentCandidate(localCompanyCandidates(ref, state, hqRoot, hqRoots));
294
+ if (evidence.conflict)
295
+ return false;
296
+ const operation = latestAcknowledgedRegistration(ref.sessionId, operations);
297
+ return Boolean(operation && acknowledgedRegistrationMatches(operation, ref, evidence.candidate, state));
298
+ }
299
+ /**
300
+ * Read held.jsonl and return distinct sessions. Later events can add local
301
+ * evidence absent from the first event (the original first-occurrence-only
302
+ * implementation missed exactly that shape after a session became bound).
88
303
  * Lines that fail to parse or carry no sessionId are ignored (the daemon owns
89
304
  * their disposition; this backfill never mutates or deletes held lines).
90
305
  */
@@ -113,37 +328,47 @@ export function readHeldSessions(workMeshRoot) {
113
328
  continue;
114
329
  const obj = parsed;
115
330
  // Peel the held envelope { event, heldReason, heldAt } if present.
116
- const event = obj.event &&
331
+ const envelope = obj.event &&
117
332
  typeof obj.event === "object" &&
118
333
  !Array.isArray(obj.event) &&
119
334
  typeof obj.heldReason === "string"
120
- ? obj.event
121
- : obj;
335
+ ? obj
336
+ : undefined;
337
+ const event = envelope ? envelope.event : obj;
122
338
  const sessionId = typeof event.sessionId === "string" && event.sessionId.trim()
123
339
  ? event.sessionId.trim()
124
340
  : null;
125
- if (!sessionId || seen.has(sessionId))
341
+ if (!sessionId)
126
342
  continue;
127
343
  const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
128
- const ref = { sessionId };
344
+ const ref = seen.get(sessionId) ?? { sessionId };
129
345
  const harness = str(event.harness);
130
- if (harness)
346
+ if (harness && !ref.harness)
131
347
  ref.harness = harness;
132
348
  const cwd = str(event.cwd);
133
- if (cwd)
349
+ if (cwd && !ref.cwd)
134
350
  ref.cwd = cwd;
135
351
  const hqRoot = str(event.hqRoot);
136
- if (hqRoot)
137
- ref.hqRoot = hqRoot;
352
+ if (hqRoot) {
353
+ if (!ref.hqRoot)
354
+ ref.hqRoot = hqRoot;
355
+ if (!ref.hqRoots?.includes(hqRoot)) {
356
+ ref.hqRoots = [...(ref.hqRoots ?? []), hqRoot];
357
+ }
358
+ }
138
359
  const companySlug = str(event.companySlug);
139
- if (companySlug)
360
+ if (companySlug && !ref.companySlug)
140
361
  ref.companySlug = companySlug;
141
362
  const project = str(event.project);
142
- if (project)
363
+ if (project && !ref.project)
143
364
  ref.project = project;
144
365
  const task = str(event.task);
145
- if (task)
366
+ if (task && !ref.task)
146
367
  ref.task = task;
368
+ const at = str(event.at) ?? (envelope ? str(envelope.heldAt) : undefined);
369
+ if (hqRoot || cwd) {
370
+ ref.eventEvidence = [...(ref.eventEvidence ?? []), { hqRoot, cwd, harness, at }];
371
+ }
147
372
  seen.set(sessionId, ref);
148
373
  }
149
374
  return [...seen.values()];
@@ -162,13 +387,18 @@ export async function backfillHeldSessions(deps) {
162
387
  const predictCompany = deps.predictCompany ??
163
388
  ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
164
389
  const sessions = readHeldSessions(deps.workMeshRoot);
165
- const considered = limit > 0 ? sessions.slice(0, limit) : sessions;
390
+ // An acked register is authoritative local evidence. Older clients could
391
+ // lose its state projection on a later hook write; recover that projection
392
+ // directly instead of re-registering the same session.
393
+ const outboxOperations = listOutboxOperations(deps.workContextRoot);
394
+ const considered = rotateSessions(sessions, limit, (deps.readCursor ?? (() => readBackfillCursor(deps.workMeshRoot)))());
166
395
  const result = {
167
396
  scanned: sessions.length,
168
397
  considered: considered.length,
169
398
  alreadyAttributed: 0,
170
399
  reconciled: 0,
171
400
  unresolved: 0,
401
+ conflictingEvidence: 0,
172
402
  errors: 0,
173
403
  dryRun,
174
404
  };
@@ -179,10 +409,49 @@ export async function backfillHeldSessions(deps) {
179
409
  result.alreadyAttributed += 1;
180
410
  continue;
181
411
  }
412
+ const evidence = consistentCandidate(localCompanyCandidates(ref, state, deps.hqRoot, deps.hqRoots));
413
+ if (evidence.conflict) {
414
+ result.unresolved += 1;
415
+ result.conflictingEvidence += 1;
416
+ deps.log?.(`unresolved ${sessionId} (CONFLICTING_EVIDENCE)`);
417
+ continue;
418
+ }
419
+ const acknowledged = latestAcknowledgedRegistration(sessionId, outboxOperations);
420
+ const acknowledgedMatches = acknowledged && acknowledgedRegistrationMatches(acknowledged, ref, evidence.candidate, state);
421
+ if (acknowledged && !acknowledgedMatches) {
422
+ deps.log?.(`ignored stale acknowledged register for ${sessionId}`);
423
+ }
424
+ if (acknowledgedMatches && dryRun) {
425
+ result.reconciled += 1;
426
+ deps.log?.(`would reconcile ${sessionId} from acknowledged register`);
427
+ continue;
428
+ }
429
+ if (acknowledgedMatches) {
430
+ writeAckedRegisterState(acknowledged, deps.workContextRoot, acknowledged.receiptId);
431
+ if (readState(sessionId, deps.workContextRoot)?.companyUid) {
432
+ result.reconciled += 1;
433
+ deps.log?.(`reconciled ${sessionId} from acknowledged register`);
434
+ continue;
435
+ }
436
+ }
437
+ if (!evidence.candidate?.slug) {
438
+ result.unresolved += 1;
439
+ deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
440
+ continue;
441
+ }
442
+ const evidenceRef = {
443
+ sessionId,
444
+ harness,
445
+ companySlug: evidence.candidate.slug,
446
+ // Held-event fields are transport input, not trusted context. Preserve
447
+ // project/task only when the durable session state already owns them.
448
+ project: state?.projectId,
449
+ task: state?.taskId,
450
+ };
182
451
  if (dryRun) {
183
452
  // Predict with the SAME evidence + resolver the write path uses, so the
184
453
  // dry-run counts match a real run (no blanket would-reconcile).
185
- if (predictCompany(ref)) {
454
+ if (predictCompany(evidenceRef)) {
186
455
  result.reconciled += 1;
187
456
  deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
188
457
  }
@@ -192,16 +461,20 @@ export async function backfillHeldSessions(deps) {
192
461
  }
193
462
  continue;
194
463
  }
195
- const obs = observationFromHeldRef(ref, contractVersion, newOperationId());
464
+ const obs = observationFromHeldRef(evidenceRef, contractVersion, newOperationId());
196
465
  try {
197
466
  const outcome = await deps.reconcile(obs);
198
- if (outcome.result.companyUid) {
467
+ if (hasDurableCompanyRecovery(sessionId, deps.workContextRoot, readState)) {
199
468
  result.reconciled += 1;
200
- deps.log?.(`reconciled ${sessionId} → ${outcome.result.companyUid}`);
469
+ deps.log?.(`reconciled ${sessionId} → durable company binding`);
470
+ }
471
+ else if (outcome.result.errorCode === "NOT_TRACKING") {
472
+ result.errors += 1;
473
+ deps.log?.(`error ${sessionId}: NOT_TRACKING (state was not durable)`);
201
474
  }
202
475
  else {
203
476
  result.unresolved += 1;
204
- deps.log?.(`unresolved ${sessionId} (no company)`);
477
+ deps.log?.(`unresolved ${sessionId} (${outcome.result.kind}: no durable company binding)`);
205
478
  }
206
479
  }
207
480
  catch (err) {
@@ -209,6 +482,14 @@ export async function backfillHeldSessions(deps) {
209
482
  deps.log?.(`error ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
210
483
  }
211
484
  }
485
+ if (!dryRun && limit > 0 && considered.length > 0) {
486
+ const last = considered[considered.length - 1];
487
+ const ordinal = sessions.findIndex((ref) => ref.sessionId === last.sessionId);
488
+ (deps.writeCursor ?? ((cursor) => writeBackfillCursor(deps.workMeshRoot, cursor)))({
489
+ sessionId: last.sessionId,
490
+ ...(ordinal >= 0 ? { ordinal } : {}),
491
+ });
492
+ }
212
493
  return result;
213
494
  }
214
495
  //# sourceMappingURL=backfill-held.js.map
@@ -0,0 +1,9 @@
1
+ export interface DaemonConfig {
2
+ trustedHqRoots: string[];
3
+ }
4
+ export declare function daemonConfigPath(daemonDirectory: string): string;
5
+ export declare function verifyHqRoot(candidate: string | undefined): string | null;
6
+ export declare function readDaemonConfig(daemonDirectory: string): DaemonConfig;
7
+ /** Record a verified local HQ checkout without trusting a caller-supplied path at runtime. */
8
+ export declare function registerTrustedHqRoot(daemonDirectory: string, candidate: string | undefined): DaemonConfig;
9
+ //# sourceMappingURL=config.d.ts.map