@indigoai-us/hq-cli 5.126.0 → 5.127.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 (76) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/lanes/accounts.d.ts +67 -7
  17. package/dist/lib/lanes/accounts.js +233 -43
  18. package/dist/lib/lanes/compaction.d.ts +96 -0
  19. package/dist/lib/lanes/compaction.js +197 -0
  20. package/dist/lib/lanes/config.d.ts +15 -0
  21. package/dist/lib/lanes/config.js +65 -8
  22. package/dist/lib/lanes/delivery.d.ts +151 -0
  23. package/dist/lib/lanes/delivery.js +354 -0
  24. package/dist/lib/lanes/dropbox.d.ts +96 -0
  25. package/dist/lib/lanes/dropbox.js +408 -0
  26. package/dist/lib/lanes/effort.d.ts +50 -0
  27. package/dist/lib/lanes/effort.js +109 -0
  28. package/dist/lib/lanes/errors.d.ts +2 -2
  29. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  30. package/dist/lib/lanes/hooks-install.js +326 -0
  31. package/dist/lib/lanes/hq-root.d.ts +19 -0
  32. package/dist/lib/lanes/hq-root.js +28 -0
  33. package/dist/lib/lanes/index.d.ts +35 -5
  34. package/dist/lib/lanes/index.js +19 -4
  35. package/dist/lib/lanes/liveness.d.ts +7 -1
  36. package/dist/lib/lanes/liveness.js +64 -5
  37. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  38. package/dist/lib/lanes/machine-identity.js +92 -0
  39. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  40. package/dist/lib/lanes/mesh-events.js +275 -0
  41. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  42. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  43. package/dist/lib/lanes/questions.d.ts +75 -0
  44. package/dist/lib/lanes/questions.js +243 -0
  45. package/dist/lib/lanes/quota.d.ts +95 -0
  46. package/dist/lib/lanes/quota.js +320 -0
  47. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  48. package/dist/lib/lanes/reconcile-install.js +221 -0
  49. package/dist/lib/lanes/reconcile.d.ts +102 -0
  50. package/dist/lib/lanes/reconcile.js +985 -0
  51. package/dist/lib/lanes/registry.d.ts +18 -5
  52. package/dist/lib/lanes/registry.js +293 -19
  53. package/dist/lib/lanes/spawn.d.ts +274 -0
  54. package/dist/lib/lanes/spawn.js +1363 -0
  55. package/dist/lib/lanes/store.d.ts +7 -3
  56. package/dist/lib/lanes/store.js +150 -32
  57. package/dist/lib/lanes/types.d.ts +346 -4
  58. package/dist/lib/lanes/types.js +138 -5
  59. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  60. package/dist/lib/lanes/watcher-main.js +22 -0
  61. package/dist/lib/lanes/watcher.d.ts +216 -0
  62. package/dist/lib/lanes/watcher.js +859 -0
  63. package/dist/lib/mesh/api.d.ts +71 -7
  64. package/dist/lib/mesh/api.js +167 -28
  65. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  66. package/dist/lib/mesh/live/backfill-held.js +66 -21
  67. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  68. package/dist/lib/mesh/live/daemon/run.js +111 -54
  69. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  71. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  72. package/dist/lib/scan-packages/index.d.ts +2 -0
  73. package/dist/lib/scan-packages/index.js +1 -1
  74. package/dist/lib/work-context/state.d.ts +12 -0
  75. package/dist/lib/work-context/state.js +131 -3
  76. package/package.json +3 -1
@@ -23,10 +23,40 @@ export interface MeshThread {
23
23
  lastActivityAt?: string;
24
24
  createdAt?: string;
25
25
  }
26
+ export interface MeshProjectStoryInput {
27
+ id: string;
28
+ title: string;
29
+ description?: string;
30
+ acceptanceCriteria?: string[];
31
+ status?: string;
32
+ passes?: boolean;
33
+ priority?: number;
34
+ }
35
+ export interface MeshProjectRepo {
36
+ path: string;
37
+ branch: string;
38
+ }
39
+ export interface MeshProjectView {
40
+ companyUid: string;
41
+ projectId: string;
42
+ name: string;
43
+ description: string;
44
+ stories: MeshProjectStoryInput[];
45
+ repos: MeshProjectRepo[];
46
+ version?: number;
47
+ }
48
+ export interface MeshProjectRegistration {
49
+ companyUid: string;
50
+ projectId: string;
51
+ threadId: string;
52
+ channelId: string;
53
+ channelCreated: boolean;
54
+ }
26
55
  export declare function meshJson(token: string, path: string, init?: {
27
56
  method?: string;
28
57
  body?: Record<string, unknown>;
29
58
  query?: Record<string, string>;
59
+ signal?: AbortSignal;
30
60
  }): Promise<unknown>;
31
61
  export declare function resolveMeshCompany(token: string, company?: string): Promise<MeshCompany>;
32
62
  /**
@@ -47,7 +77,14 @@ export declare function listActiveMembershipCompanies(token: string): Promise<Me
47
77
  * auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
48
78
  */
49
79
  export declare function resolveActiveMembershipCompany(token: string, slug: string): Promise<MeshCompany | null>;
50
- export declare function requireToken(): Promise<string>;
80
+ export interface RequireTokenOptions {
81
+ /**
82
+ * False from detached or unattended callers (the lane watcher). A person
83
+ * cache that cannot refresh then throws instead of opening a browser.
84
+ */
85
+ interactive?: boolean;
86
+ }
87
+ export declare function requireToken(opts?: RequireTokenOptions): Promise<string>;
51
88
  export declare function listActiveThreads(token: string, companyUid: string, projectId?: string): Promise<MeshThread[]>;
52
89
  export declare function clamp(value: string, max: number): string;
53
90
  export declare function eventPayload(kind: string, opts: {
@@ -62,15 +99,43 @@ export declare function ensureProjectThread(token: string, company: MeshCompany,
62
99
  threadId: string;
63
100
  created: boolean;
64
101
  }>;
65
- export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>): Promise<{
102
+ export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<{
66
103
  eventId?: string;
67
104
  createdAt?: string;
68
105
  }>;
106
+ export interface StoryPatch {
107
+ status?: string;
108
+ assigneeUid?: string;
109
+ clearAssignee?: boolean;
110
+ }
111
+ export interface StoryPatchResult {
112
+ projectId: string;
113
+ status: string;
114
+ version?: number;
115
+ assigneeUid: string | null;
116
+ }
117
+ /**
118
+ * PATCH one Board story. The server derives the actor from the request
119
+ * credential. This body never includes actorUid.
120
+ */
121
+ export declare function patchStory(token: string, companyUid: string, projectId: string, storyId: string, patch: StoryPatch): Promise<StoryPatchResult>;
69
122
  export declare function patchStoryStatus(token: string, companyUid: string, projectId: string, storyId: string, status: string): Promise<{
70
123
  status: string;
71
124
  version?: number;
72
125
  projectId: string;
73
126
  }>;
127
+ /** Read the full durable Board view before preparing a replacement PUT. */
128
+ export declare function getProjectView(token: string, companyUid: string, projectId: string): Promise<MeshProjectView>;
129
+ /** Replace a project's durable Board view and keep the local project cache current. */
130
+ export declare function putProjectView(token: string, companyUid: string, projectId: string, input: {
131
+ name?: string;
132
+ description?: string;
133
+ stories?: MeshProjectStoryInput[];
134
+ repos?: MeshProjectRepo[];
135
+ version?: number;
136
+ }): Promise<MeshProjectView>;
137
+ /** Register the project genesis thread and deterministic project channel. */
138
+ export declare function registerMeshProject(token: string, companyUid: string, projectId: string): Promise<MeshProjectRegistration>;
74
139
  export declare function pairDmUidsFromInbox(inbox: unknown): string[];
75
140
  export interface WarmCacheResult {
76
141
  directory: boolean;
@@ -94,13 +159,12 @@ export declare function meshCachePrincipalUid(tokens: {
94
159
  accessToken?: string;
95
160
  } | null | undefined, fallbackToken?: string): string;
96
161
  /**
97
- * Resolve the cache principal. JWT first; if the human access/id tokens have
98
- * no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
99
- * doctor). Only the uid is kept — credentials are discarded.
162
+ * Resolve the authenticated request principal. The request token is the source
163
+ * of truth; cached credentials may contribute an entity uid only when they
164
+ * identify the same caller. Only the uid is kept — credentials are discarded.
100
165
  */
101
166
  export declare function resolveMeshPrincipalUid(token: string, tokens?: {
102
167
  idToken?: string;
103
168
  accessToken?: string;
104
- } | null): Promise<string>;
105
- export declare function callerLabelFromToken(token: string): string;
169
+ } | null): Promise<string | undefined>;
106
170
  //# sourceMappingURL=api.d.ts.map
@@ -30,6 +30,7 @@ export async function meshJson(token, path, init = {}) {
30
30
  method: init.method,
31
31
  body: init.body,
32
32
  query: init.query,
33
+ signal: init.signal,
33
34
  });
34
35
  const text = await res.text();
35
36
  let data = {};
@@ -48,7 +49,11 @@ export async function meshJson(token, path, init = {}) {
48
49
  // 400 whose body text matched no pattern was retried forever.
49
50
  const detail = err.error || err.message || res.statusText || "request failed";
50
51
  const code = typeof err.code === "string" && err.code ? `${err.code}: ` : "";
51
- throw new Error(`${res.status} ${code}${detail}`);
52
+ const context = [
53
+ typeof err.stage === "string" && err.stage ? `stage=${err.stage}` : "",
54
+ typeof err.recovery === "string" && err.recovery ? `recovery=${err.recovery}` : "",
55
+ ].filter(Boolean);
56
+ throw new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
52
57
  }
53
58
  return data;
54
59
  }
@@ -107,7 +112,11 @@ export async function resolveActiveMembershipCompany(token, slug) {
107
112
  const trimmed = slug.trim();
108
113
  if (!trimmed)
109
114
  return null;
110
- const rows = await listActiveMembershipCompanies(token);
115
+ const payload = asRecord(await meshJson(token, "/membership/me"));
116
+ if (!payload || !Array.isArray(payload.memberships)) {
117
+ throw new Error("Malformed membership response: expected memberships array");
118
+ }
119
+ const rows = activeMembershipCompanies(payload.memberships);
111
120
  const needle = trimmed.toLowerCase();
112
121
  const matches = rows.filter((c) => c.companySlug.toLowerCase() === needle ||
113
122
  c.companyUid.toLowerCase() === needle);
@@ -118,7 +127,29 @@ export async function resolveActiveMembershipCompany(token, slug) {
118
127
  // Ambiguous slug across memberships — refuse rather than guess.
119
128
  return null;
120
129
  }
121
- export async function requireToken() {
130
+ function activeMembershipCompanies(rows) {
131
+ const out = [];
132
+ const seen = new Set();
133
+ for (const row of rows) {
134
+ const rec = asRecord(row);
135
+ if (!rec)
136
+ continue;
137
+ if (String(rec.status || "").toLowerCase() !== "active")
138
+ continue;
139
+ const companyUid = typeof rec.companyUid === "string" ? rec.companyUid : "";
140
+ if (!companyUid || seen.has(companyUid))
141
+ continue;
142
+ const companySlug = typeof rec.companySlug === "string"
143
+ ? rec.companySlug
144
+ : typeof rec.companyFolderSlug === "string"
145
+ ? rec.companyFolderSlug
146
+ : companyUid;
147
+ seen.add(companyUid);
148
+ out.push({ companyUid, companySlug: companySlug || companyUid });
149
+ }
150
+ return out;
151
+ }
152
+ export async function requireToken(opts = {}) {
122
153
  // Agent/machine boxes must authenticate the way the mesh daemon does:
123
154
  // an agent's identity claims (custom:entityType=agent, custom:entityUid)
124
155
  // ride the ID token ONLY; a person-cache ACCESS token on the box carries
@@ -129,10 +160,11 @@ export async function requireToken() {
129
160
  // hard policy indigo-agent-caller-resolution-id-token, agent claims ride
130
161
  // the ID token only; the server is correct to require it. Humans keep the
131
162
  // person access token (unchanged).
163
+ const interactive = opts.interactive ?? true;
132
164
  if (isMachineIdentity()) {
133
- return ensureCognitoToken({ tokenSource: "machine" });
165
+ return ensureCognitoToken({ tokenSource: "machine", interactive });
134
166
  }
135
- return ensureCognitoToken();
167
+ return ensureCognitoToken({ interactive });
136
168
  }
137
169
  function asRecord(value) {
138
170
  return value && typeof value === "object" && !Array.isArray(value)
@@ -291,20 +323,32 @@ export async function ensureProjectThread(token, company, projectId, opts) {
291
323
  throw new Error("Work mesh did not return a threadId");
292
324
  return { threadId, created: true };
293
325
  }
294
- export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload) {
326
+ export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload, signal) {
295
327
  const data = asRecord(await meshJson(token, `/v1/work-mesh/threads/${encodeURIComponent(threadId)}/events`, {
296
328
  method: "POST",
297
329
  body: { companyUid, eventKind, payload },
330
+ signal,
298
331
  }));
299
332
  return {
300
333
  eventId: typeof data?.eventId === "string" ? data.eventId : undefined,
301
334
  createdAt: typeof data?.createdAt === "string" ? data.createdAt : undefined,
302
335
  };
303
336
  }
304
- export async function patchStoryStatus(token, companyUid, projectId, storyId, status) {
337
+ /**
338
+ * PATCH one Board story. The server derives the actor from the request
339
+ * credential. This body never includes actorUid.
340
+ */
341
+ export async function patchStory(token, companyUid, projectId, storyId, patch) {
342
+ const body = { companyUid };
343
+ if (patch.status)
344
+ body.status = patch.status;
345
+ if (patch.assigneeUid)
346
+ body.assigneeUid = patch.assigneeUid;
347
+ if (patch.clearAssignee)
348
+ body.clearAssignee = true;
305
349
  const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
306
350
  method: "PATCH",
307
- body: { companyUid, status },
351
+ body,
308
352
  }));
309
353
  if (view && typeof view.projectId === "string" && typeof view.companyUid === "string") {
310
354
  writeMeshCacheFile(["projects", view.companyUid, `${view.projectId}.json`], view);
@@ -313,10 +357,88 @@ export async function patchStoryStatus(token, companyUid, projectId, storyId, st
313
357
  const patched = stories
314
358
  .map(asRecord)
315
359
  .find((row) => row && row.id === storyId);
360
+ const assigneeUid = patch.clearAssignee
361
+ ? typeof patched?.assigneeUid === "string"
362
+ ? patched.assigneeUid
363
+ : null
364
+ : typeof patched?.assigneeUid === "string"
365
+ ? patched.assigneeUid
366
+ : patch.assigneeUid ?? null;
316
367
  return {
317
368
  projectId: String(view?.projectId || projectId),
318
- status: typeof patched?.status === "string" ? patched.status : status,
369
+ status: typeof patched?.status === "string" ? patched.status : patch.status || "",
319
370
  version: typeof view?.version === "number" ? view.version : undefined,
371
+ assigneeUid,
372
+ };
373
+ }
374
+ export async function patchStoryStatus(token, companyUid, projectId, storyId, status) {
375
+ const patched = await patchStory(token, companyUid, projectId, storyId, { status });
376
+ return {
377
+ projectId: patched.projectId,
378
+ status: patched.status,
379
+ version: patched.version,
380
+ };
381
+ }
382
+ function projectViewFromResponse(view, companyUid, projectId) {
383
+ const returnedProjectId = String(view.projectId || projectId).trim();
384
+ const returnedCompanyUid = String(view.companyUid || companyUid).trim();
385
+ const stories = Array.isArray(view.stories)
386
+ ? view.stories.filter((story) => asRecord(story))
387
+ : [];
388
+ const repos = Array.isArray(view.repos)
389
+ ? view.repos.flatMap((repo) => {
390
+ const row = asRecord(repo);
391
+ const path = typeof row?.path === "string" ? row.path.trim() : "";
392
+ if (!path)
393
+ return [];
394
+ return [{ path, branch: typeof row?.branch === "string" ? row.branch.trim() : "" }];
395
+ })
396
+ : [];
397
+ return {
398
+ companyUid: returnedCompanyUid,
399
+ projectId: returnedProjectId,
400
+ name: String(view.name || returnedProjectId),
401
+ description: typeof view.description === "string" ? view.description : "",
402
+ stories,
403
+ repos,
404
+ version: typeof view.version === "number" ? view.version : undefined,
405
+ };
406
+ }
407
+ /** Read the full durable Board view before preparing a replacement PUT. */
408
+ export async function getProjectView(token, companyUid, projectId) {
409
+ const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}`, {
410
+ query: { companyUid },
411
+ }));
412
+ if (!view)
413
+ throw new Error("Work mesh returned an invalid project view");
414
+ return projectViewFromResponse(view, companyUid, projectId);
415
+ }
416
+ /** Replace a project's durable Board view and keep the local project cache current. */
417
+ export async function putProjectView(token, companyUid, projectId, input) {
418
+ const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}`, {
419
+ method: "PUT",
420
+ body: { companyUid, ...input },
421
+ }));
422
+ if (!view)
423
+ throw new Error("Work mesh returned an invalid project view");
424
+ const result = projectViewFromResponse(view, companyUid, projectId);
425
+ writeMeshCacheFile(["projects", result.companyUid, `${result.projectId}.json`], view);
426
+ return result;
427
+ }
428
+ /** Register the project genesis thread and deterministic project channel. */
429
+ export async function registerMeshProject(token, companyUid, projectId) {
430
+ const result = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/register`, { method: "POST", body: { companyUid } }));
431
+ const threadId = String(result?.threadId || "").trim();
432
+ const channelId = String(result?.channelId || "").trim();
433
+ if (!threadId || !channelId) {
434
+ throw new Error("Work mesh registration did not return a threadId and channelId");
435
+ }
436
+ return {
437
+ companyUid: String(result?.companyUid || companyUid).trim(),
438
+ projectId: String(result?.projectId || projectId).trim(),
439
+ threadId,
440
+ channelId,
441
+ channelCreated: result?.channelCreated === true,
320
442
  };
321
443
  }
322
444
  export function pairDmUidsFromInbox(inbox) {
@@ -423,15 +545,43 @@ export function meshCachePrincipalUid(tokens, fallbackToken) {
423
545
  }
424
546
  return "session";
425
547
  }
548
+ function tokensIdentifySamePrincipal(token, candidate) {
549
+ if (token === candidate)
550
+ return true;
551
+ const tokenClaims = peekIdToken(token);
552
+ const candidateClaims = peekIdToken(candidate);
553
+ const tokenEntityUid = entityUidFromJwt(token);
554
+ const candidateEntityUid = entityUidFromJwt(candidate);
555
+ if (tokenEntityUid && candidateEntityUid && tokenEntityUid !== candidateEntityUid) {
556
+ return false;
557
+ }
558
+ for (const claim of ["sub", "cognito:username", "username", "email"]) {
559
+ const tokenValue = tokenClaims[claim];
560
+ const candidateValue = candidateClaims[claim];
561
+ if (typeof tokenValue === "string" &&
562
+ tokenValue.length > 0 &&
563
+ tokenValue === candidateValue) {
564
+ return true;
565
+ }
566
+ }
567
+ return false;
568
+ }
426
569
  /**
427
- * Resolve the cache principal. JWT first; if the human access/id tokens have
428
- * no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
429
- * doctor). Only the uid is kept — credentials are discarded.
570
+ * Resolve the authenticated request principal. The request token is the source
571
+ * of truth; cached credentials may contribute an entity uid only when they
572
+ * identify the same caller. Only the uid is kept — credentials are discarded.
430
573
  */
431
574
  export async function resolveMeshPrincipalUid(token, tokens) {
432
- const fromJwt = meshCachePrincipalUid(tokens, token);
433
- if (fromJwt !== "session")
434
- return fromJwt;
575
+ const fromRequestToken = entityUidFromJwt(token);
576
+ if (fromRequestToken)
577
+ return fromRequestToken;
578
+ for (const candidate of [tokens?.idToken, tokens?.accessToken]) {
579
+ if (!candidate || !tokensIdentifySamePrincipal(token, candidate))
580
+ continue;
581
+ const uid = entityUidFromJwt(candidate);
582
+ if (uid)
583
+ return uid;
584
+ }
435
585
  try {
436
586
  const data = await meshJson(token, "/v1/realtime/credentials", {
437
587
  method: "POST",
@@ -442,19 +592,8 @@ export async function resolveMeshPrincipalUid(token, tokens) {
442
592
  return uid;
443
593
  }
444
594
  catch {
445
- // best-effort; session.json is worse than failing doctor
595
+ // Best-effort; callers must fail rather than claim work as "session".
446
596
  }
447
- return "session";
448
- }
449
- export function callerLabelFromToken(token) {
450
- const claims = peekIdToken(token);
451
- const entityUid = claims["custom:entityUid"];
452
- if (typeof entityUid === "string" && entityUid)
453
- return entityUid;
454
- if (typeof claims.email === "string" && claims.email)
455
- return claims.email;
456
- if (typeof claims.sub === "string" && claims.sub)
457
- return claims.sub;
458
- return "hq-cli";
597
+ return undefined;
459
598
  }
460
599
  //# sourceMappingURL=api.js.map
@@ -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 { MembershipValidationResult } from "../../work-context/config.js";
25
26
  import { type OutboxOperation } from "../../work-context/outbox.js";
26
27
  /**
27
28
  * One distinct session discovered in held.jsonl, carrying the company evidence
@@ -75,8 +76,20 @@ export interface BackfillHeldResult {
75
76
  unresolved: number;
76
77
  /** Unresolved because otherwise-trusted local evidence disagreed. */
77
78
  conflictingEvidence: number;
79
+ /**
80
+ * Unresolved because no local company evidence existed (not even a slug).
81
+ * These sessions cannot be recovered without --use-device-default or new
82
+ * local activity; they are never silently folded into the plain unresolved
83
+ * count.
84
+ */
85
+ noEvidence: number;
78
86
  /** Sessions that errored during reconcile. */
79
87
  errors: number;
88
+ /** Why each session with otherwise-valid local evidence stayed unresolved. */
89
+ unresolvedReasons: Array<{
90
+ sessionId: string;
91
+ reason: string;
92
+ }>;
80
93
  dryRun: boolean;
81
94
  }
82
95
  export interface BackfillHeldDeps {
@@ -88,7 +101,7 @@ export interface BackfillHeldDeps {
88
101
  hqRoot?: string;
89
102
  /** Durable device allowlist of verified local HQ roots. */
90
103
  hqRoots?: string[];
91
- /** Report only; write nothing, call no network, invoke no reconcile. */
104
+ /** Report only; write nothing or deliver events. Membership validation may run. */
92
105
  dryRun?: boolean;
93
106
  /** Cap distinct sessions processed. 0 (default) = no cap. */
94
107
  limit?: number;
@@ -116,13 +129,11 @@ export interface BackfillHeldDeps {
116
129
  /** Env passed to the dry-run resolver (tests). Default process.env. */
117
130
  env?: NodeJS.ProcessEnv;
118
131
  /**
119
- * Read-only company predictor for --dry-run. MUST mirror what the write
120
- * path's reconcile would resolve, so dry-run and write report the same
121
- * counts (the earlier bug: dry-run counted every session as would-reconcile
122
- * while the write path, given no evidence, resolved none). Default runs the
123
- * shared `resolveCompany` over the same reconstructed evidence.
132
+ * Membership validation used by both the dry-run preview and the live
133
+ * reconciler. A false response means the locally resolved slug is no longer
134
+ * an active membership and cannot be recovered.
124
135
  */
125
- predictCompany?: (ref: HeldSessionRef) => boolean;
136
+ validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
126
137
  /** Optional progress logger. */
127
138
  log?: (message: string) => void;
128
139
  /** Read/write seam for the bounded-startup rotation cursor. */
@@ -24,7 +24,7 @@ import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
25
  import { readSessionState } from "../../work-context/state.js";
26
26
  import { listOutboxOperations, writeAckedRegisterState, } from "../../work-context/outbox.js";
27
- import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
27
+ import { WORK_CONTEXT_CONTRACT_VERSION, } from "../../work-context/contract.js";
28
28
  import { resolveCompany, sameCompany } from "../../work-context/company.js";
29
29
  import { readMetaCompanySlug } from "../../work-context/company.js";
30
30
  import { deriveRemoteOwnerSlug, } from "../../work-context/repo-remote.js";
@@ -222,16 +222,11 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
222
222
  }
223
223
  return obs;
224
224
  }
225
- /**
226
- * Default dry-run predictor: run the shared `resolveCompany` over the same
227
- * evidence the write path forwards. Returns true when a company WOULD resolve
228
- * (so dry-run's `reconciled` count matches the write run's). Purely local:
229
- * reads config / meta / git-remote, never the network. A slug-only resolution
230
- * that later fails online membership validation is the one narrow case where
231
- * the write run can still under-count; the gross all-or-nothing mismatch is
232
- * gone because both paths key off this same resolver + evidence.
233
- */
234
- function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault) {
225
+ /** The sole success predicate for both the preview and a live reconciliation. */
226
+ function isSuccessfulBackfillRecovery(kind) {
227
+ return kind === "bound" || kind === "needs_project" || kind === "needs_task" || kind === "queued";
228
+ }
229
+ async function previewCompanyRecovery(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault, validateMembership) {
235
230
  // This predictor is called only with a ref already rebuilt from trusted local
236
231
  // evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
237
232
  const remoteOwnerSlug = remoteOwnerHint?.trim();
@@ -243,16 +238,61 @@ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDe
243
238
  remoteOwnerSlug,
244
239
  allowDeviceDefault: useDeviceDefault,
245
240
  });
246
- return resolution.status === "resolved";
241
+ if (resolution.status !== "resolved") {
242
+ return { kind: resolution.status, reason: resolution.status };
243
+ }
244
+ const resolved = resolution.company;
245
+ if (!resolved.slug)
246
+ return { kind: "queued" };
247
+ if (!validateMembership) {
248
+ // Device defaults are weak, cached evidence. Reconcile refuses to use one
249
+ // for tenant I/O until membership has been revalidated, so the dry-run
250
+ // preview must fail closed in precisely the same way.
251
+ if (resolved.source === "device_default") {
252
+ return {
253
+ kind: "needs_company",
254
+ reason: "needs_company: device-default membership validator unavailable",
255
+ };
256
+ }
257
+ return { kind: "queued" };
258
+ }
259
+ try {
260
+ const membership = await validateMembership(resolved.slug);
261
+ if (membership === false) {
262
+ return {
263
+ kind: "needs_company",
264
+ reason: `membership denied for slug=${resolved.slug}`,
265
+ };
266
+ }
267
+ }
268
+ catch (err) {
269
+ // reconcile continues on a failed refresh for durable local evidence, but
270
+ // must stop a device-default recovery because that evidence is weak.
271
+ if (resolved.source === "device_default") {
272
+ return {
273
+ kind: "needs_company",
274
+ reason: `device-default membership refresh unavailable: ${err instanceof Error ? err.message : String(err)}`,
275
+ };
276
+ }
277
+ }
278
+ return { kind: "queued" };
247
279
  }
248
280
  /**
249
281
  * A backfill is only useful when the daemon can observe a durable company
250
282
  * binding on its next held retry. Do not use the reconcile result alone here:
251
283
  * a completed reconcile may legitimately return needs_company after a failed
252
284
  * membership lookup, and that leaves the held events exactly where they were.
285
+ *
286
+ * A persisted companySlug without a companyUid is still a durable binding:
287
+ * the daemon resolves the uid via membership validation on its next held retry.
288
+ * Requiring companyUid causes dry-run and live counts to diverge when offline
289
+ * reconcile writes only a slug (resolveCompany returns trusted_explicit with
290
+ * no uid when no manifest lookup occurs), making live report reconciled=0 for
291
+ * sessions the dry-run correctly predicted as recoverable.
253
292
  */
254
293
  function hasDurableCompanyRecovery(sessionId, workContextRoot, readState) {
255
- return Boolean(readState(sessionId, workContextRoot)?.companyUid);
294
+ const state = readState(sessionId, workContextRoot);
295
+ return Boolean(state?.companyUid || state?.companySlug);
256
296
  }
257
297
  function latestAcknowledgedRegistration(sessionId, operations) {
258
298
  for (let index = operations.length - 1; index >= 0; index -= 1) {
@@ -385,8 +425,6 @@ export async function backfillHeldSessions(deps) {
385
425
  const readState = deps.readState ?? readSessionState;
386
426
  const env = deps.env ?? process.env;
387
427
  const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
388
- const predictCompany = deps.predictCompany ??
389
- ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true));
390
428
  const sessions = readHeldSessions(deps.workMeshRoot);
391
429
  // An acked register is authoritative local evidence. Older clients could
392
430
  // lose its state projection on a later hook write; recover that projection
@@ -400,7 +438,9 @@ export async function backfillHeldSessions(deps) {
400
438
  reconciled: 0,
401
439
  unresolved: 0,
402
440
  conflictingEvidence: 0,
441
+ noEvidence: 0,
403
442
  errors: 0,
443
+ unresolvedReasons: [],
404
444
  dryRun,
405
445
  };
406
446
  for (const ref of considered) {
@@ -440,6 +480,7 @@ export async function backfillHeldSessions(deps) {
440
480
  // slug from the held event itself.
441
481
  if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
442
482
  result.unresolved += 1;
483
+ result.noEvidence += 1;
443
484
  deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
444
485
  continue;
445
486
  }
@@ -453,22 +494,24 @@ export async function backfillHeldSessions(deps) {
453
494
  task: state?.taskId,
454
495
  };
455
496
  if (dryRun) {
456
- // Predict with the SAME evidence + resolver the write path uses, so the
457
- // dry-run counts match a real run (no blanket would-reconcile).
458
- if (predictCompany(evidenceRef)) {
497
+ const preview = await previewCompanyRecovery(evidenceRef, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true, deps.validateMembership);
498
+ if (isSuccessfulBackfillRecovery(preview.kind)) {
459
499
  result.reconciled += 1;
460
500
  deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
461
501
  }
462
502
  else {
463
503
  result.unresolved += 1;
464
- deps.log?.(`would stay unresolved ${sessionId} (no company evidence)`);
504
+ const reason = preview.reason ?? "no company evidence";
505
+ result.unresolvedReasons.push({ sessionId, reason });
506
+ deps.log?.(`would stay unresolved ${sessionId} (${reason})`);
465
507
  }
466
508
  continue;
467
509
  }
468
510
  const obs = observationFromHeldRef(evidenceRef, contractVersion, newOperationId());
469
511
  try {
470
512
  const outcome = await deps.reconcile(obs);
471
- if (hasDurableCompanyRecovery(sessionId, deps.workContextRoot, readState)) {
513
+ if (isSuccessfulBackfillRecovery(outcome.result.kind) &&
514
+ hasDurableCompanyRecovery(sessionId, deps.workContextRoot, readState)) {
472
515
  result.reconciled += 1;
473
516
  deps.log?.(`reconciled ${sessionId} → durable company binding`);
474
517
  }
@@ -478,7 +521,9 @@ export async function backfillHeldSessions(deps) {
478
521
  }
479
522
  else {
480
523
  result.unresolved += 1;
481
- deps.log?.(`unresolved ${sessionId} (${outcome.result.kind}: no durable company binding)`);
524
+ const reason = outcome.diagnostics?.at(-1) ?? `${outcome.result.kind}: no durable company binding`;
525
+ result.unresolvedReasons.push({ sessionId, reason });
526
+ deps.log?.(`unresolved ${sessionId} (${reason})`);
482
527
  }
483
528
  }
484
529
  catch (err) {
@@ -90,6 +90,8 @@ export interface DaemonRunDeps {
90
90
  resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
91
91
  companyUid: string;
92
92
  } | null>;
93
+ /** Test seam immediately before a device-default revocation's guarded commit. */
94
+ beforeDeviceDefaultRevocationCommit?: () => void;
93
95
  log?: (dir: string, line: string) => void;
94
96
  /** Stop after this many ms (tests). */
95
97
  runForMs?: number;