@indigoai-us/hq-cli 5.125.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 (108) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
  3. package/assets/bot-workers/setup/skills/your-business.md +270 -0
  4. package/assets/bot-workers/setup/worker.yaml +189 -58
  5. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  6. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  7. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  8. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  9. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  10. package/dist/command-catalog.generated.d.ts +643 -6
  11. package/dist/command-catalog.generated.js +836 -6
  12. package/dist/command-registration-plan.d.ts +7 -0
  13. package/dist/command-registration-plan.js +11 -1
  14. package/dist/commands/agents.d.ts +12 -0
  15. package/dist/commands/agents.js +46 -2
  16. package/dist/commands/bot.d.ts +10 -0
  17. package/dist/commands/bot.js +6 -0
  18. package/dist/commands/lanes.d.ts +114 -0
  19. package/dist/commands/lanes.js +2969 -0
  20. package/dist/commands/mesh.js +287 -24
  21. package/dist/commands/onboard-create-identity.d.ts +26 -0
  22. package/dist/commands/onboard-create-identity.js +23 -0
  23. package/dist/commands/onboard.js +13 -4
  24. package/dist/lib/bot/progress.d.ts +6 -0
  25. package/dist/lib/bot/progress.js +16 -0
  26. package/dist/lib/bot/runtime/grok.d.ts +30 -7
  27. package/dist/lib/bot/runtime/grok.js +86 -8
  28. package/dist/lib/bot/runtime/index.js +15 -8
  29. package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
  30. package/dist/lib/bot/runtime/messages-stream.js +49 -3
  31. package/dist/lib/lanes/accounts.d.ts +103 -0
  32. package/dist/lib/lanes/accounts.js +318 -0
  33. package/dist/lib/lanes/compaction.d.ts +96 -0
  34. package/dist/lib/lanes/compaction.js +197 -0
  35. package/dist/lib/lanes/config.d.ts +71 -0
  36. package/dist/lib/lanes/config.js +408 -0
  37. package/dist/lib/lanes/delivery.d.ts +151 -0
  38. package/dist/lib/lanes/delivery.js +354 -0
  39. package/dist/lib/lanes/dropbox.d.ts +96 -0
  40. package/dist/lib/lanes/dropbox.js +408 -0
  41. package/dist/lib/lanes/effort.d.ts +50 -0
  42. package/dist/lib/lanes/effort.js +109 -0
  43. package/dist/lib/lanes/errors.d.ts +39 -0
  44. package/dist/lib/lanes/errors.js +72 -0
  45. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  46. package/dist/lib/lanes/hooks-install.js +326 -0
  47. package/dist/lib/lanes/hq-root.d.ts +19 -0
  48. package/dist/lib/lanes/hq-root.js +28 -0
  49. package/dist/lib/lanes/index.d.ts +49 -0
  50. package/dist/lib/lanes/index.js +27 -0
  51. package/dist/lib/lanes/liveness.d.ts +95 -0
  52. package/dist/lib/lanes/liveness.js +332 -0
  53. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  54. package/dist/lib/lanes/machine-identity.js +92 -0
  55. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  56. package/dist/lib/lanes/mesh-events.js +275 -0
  57. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  58. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  59. package/dist/lib/lanes/questions.d.ts +75 -0
  60. package/dist/lib/lanes/questions.js +243 -0
  61. package/dist/lib/lanes/quota.d.ts +95 -0
  62. package/dist/lib/lanes/quota.js +320 -0
  63. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  64. package/dist/lib/lanes/reconcile-install.js +221 -0
  65. package/dist/lib/lanes/reconcile.d.ts +102 -0
  66. package/dist/lib/lanes/reconcile.js +985 -0
  67. package/dist/lib/lanes/registry.d.ts +116 -0
  68. package/dist/lib/lanes/registry.js +521 -0
  69. package/dist/lib/lanes/spawn.d.ts +274 -0
  70. package/dist/lib/lanes/spawn.js +1363 -0
  71. package/dist/lib/lanes/store.d.ts +68 -0
  72. package/dist/lib/lanes/store.js +745 -0
  73. package/dist/lib/lanes/throttle.d.ts +89 -0
  74. package/dist/lib/lanes/throttle.js +297 -0
  75. package/dist/lib/lanes/types.d.ts +535 -0
  76. package/dist/lib/lanes/types.js +350 -0
  77. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  78. package/dist/lib/lanes/watcher-main.js +22 -0
  79. package/dist/lib/lanes/watcher.d.ts +216 -0
  80. package/dist/lib/lanes/watcher.js +859 -0
  81. package/dist/lib/mesh/api.d.ts +71 -7
  82. package/dist/lib/mesh/api.js +167 -28
  83. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  84. package/dist/lib/mesh/live/backfill-held.js +66 -21
  85. package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
  86. package/dist/lib/mesh/live/daemon/config.js +75 -0
  87. package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
  88. package/dist/lib/mesh/live/daemon/index.js +3 -3
  89. package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
  90. package/dist/lib/mesh/live/daemon/install.js +444 -2
  91. package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
  92. package/dist/lib/mesh/live/daemon/paths.js +2 -0
  93. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  94. package/dist/lib/mesh/live/daemon/run.js +111 -54
  95. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  96. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  97. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  98. package/dist/lib/scan-packages/index.d.ts +2 -0
  99. package/dist/lib/scan-packages/index.js +18 -13
  100. package/dist/lib/work-context/config.d.ts +20 -0
  101. package/dist/lib/work-context/config.js +26 -5
  102. package/dist/lib/work-context/reconcile.js +28 -7
  103. package/dist/lib/work-context/state.d.ts +12 -0
  104. package/dist/lib/work-context/state.js +131 -3
  105. package/dist/lib/work-context/windows-acl.d.ts +123 -0
  106. package/dist/lib/work-context/windows-acl.js +425 -0
  107. package/dist/utils/pack-contributions.js +5 -4
  108. package/package.json +3 -1
@@ -19,7 +19,7 @@ import { replayOutbox } from "../../../work-context/outbox.js";
19
19
  import { workContextRoot } from "../../../work-context/paths.js";
20
20
  import { ensureDefaultCompany } from "../../../work-context/default-company.js";
21
21
  import { invalidateDefaultCompanyMemberships, readDeviceConfig, } from "../../../work-context/config.js";
22
- import { readSessionState, writeSessionState, } from "../../../work-context/state.js";
22
+ import { readSessionState, mergeLocalSessionFields, updateSessionState, } from "../../../work-context/state.js";
23
23
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
24
24
  import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
25
25
  import { backfillHeldSessions, } from "../backfill-held.js";
@@ -58,6 +58,30 @@ export function shouldFlushOnSpoolDirEvent(filename) {
58
58
  return true;
59
59
  return String(filename) === "spool.jsonl";
60
60
  }
61
+ function sameCompanyValue(a, b) {
62
+ return Boolean(a && b && a.toLowerCase() === b.toLowerCase());
63
+ }
64
+ function stillMatchesDeniedCompany(current, input, slug) {
65
+ const deniedUid = input.companyUid ?? input.state?.companyUid;
66
+ const deniedSlug = input.companySlug ?? input.state?.companySlug ?? slug;
67
+ if (deniedUid && current.companyUid && deniedUid !== current.companyUid)
68
+ return false;
69
+ if (deniedSlug && current.companySlug && !sameCompanyValue(deniedSlug, current.companySlug))
70
+ return false;
71
+ return Boolean(current.companyUid || current.companySlug);
72
+ }
73
+ function stillMatchesDeniedDeviceDefaultBinding(current, original, input, slug) {
74
+ return stillMatchesDeniedCompany(current, input, slug) &&
75
+ current.companyUid === original.companyUid &&
76
+ current.companySlug === original.companySlug &&
77
+ current.contextStatus === original.contextStatus &&
78
+ current.projectId === original.projectId &&
79
+ current.taskId === original.taskId &&
80
+ current.bindingEpisodeId === original.bindingEpisodeId &&
81
+ current.source === original.source &&
82
+ current.version === original.version &&
83
+ current.updatedAt === original.updatedAt;
84
+ }
61
85
  function alive(pid) {
62
86
  try {
63
87
  process.kill(pid, 0);
@@ -123,33 +147,54 @@ export async function runMeshDaemon(deps = {}) {
123
147
  ? readSessionState(input.sessionId, ctxRoot)
124
148
  : null);
125
149
  const slug = input.companySlug ?? state?.companySlug;
126
- const membership = await (async () => {
150
+ let authorization;
151
+ if (!slug) {
152
+ authorization = { status: "denied" };
153
+ }
154
+ else {
155
+ const controller = new AbortController();
156
+ const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
127
157
  try {
128
- if (!slug)
129
- return null;
130
- const controller = new AbortController();
131
- const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
132
- try {
133
- return await Promise.race([
134
- resolveMembership(await getToken(), slug, controller.signal),
135
- new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(null), { once: true })),
136
- ]);
137
- }
138
- finally {
139
- clearTimeout(timeout);
140
- }
158
+ authorization = await Promise.race([
159
+ (async () => {
160
+ const membership = await resolveMembership(await getToken(), slug, controller.signal);
161
+ return membership
162
+ ? { status: "authorized", uid: membership.companyUid }
163
+ : { status: "denied" };
164
+ })(),
165
+ new Promise((resolve) => {
166
+ controller.signal.addEventListener("abort", () => resolve({
167
+ status: "unavailable",
168
+ error: `membership lookup timed out after ${DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS}ms`,
169
+ }), { once: true });
170
+ }),
171
+ ]);
141
172
  }
142
- catch {
143
- return null;
173
+ catch (err) {
174
+ authorization = {
175
+ status: "unavailable",
176
+ error: err instanceof Error ? err.message : String(err),
177
+ };
144
178
  }
145
- })();
146
- const uid = membership?.companyUid;
179
+ finally {
180
+ clearTimeout(timeout);
181
+ }
182
+ }
183
+ if (authorization.status === "unavailable") {
184
+ const prior = readDaemonState(dir)?.deviceDefaultMembershipLookupFailures ?? 0;
185
+ patchDaemonState(dir, {
186
+ lastErrorCode: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED",
187
+ deviceDefaultMembershipLookupFailures: prior + 1,
188
+ }, now);
189
+ log(dir, `device-default membership lookup unavailable${slug ? ` company=${slug}` : ""}: ${authorization.error.slice(0, 160)}`);
190
+ return authorization;
191
+ }
147
192
  if (!input.deviceDefault)
148
- return uid ? { uid } : false;
149
- if (uid &&
150
- (!input.companyUid || input.companyUid === uid) &&
151
- (!state?.companyUid || state.companyUid === uid)) {
152
- return { uid };
193
+ return authorization;
194
+ if (authorization.status === "authorized" &&
195
+ (!input.companyUid || input.companyUid === authorization.uid) &&
196
+ (!state?.companyUid || state.companyUid === authorization.uid)) {
197
+ return authorization;
153
198
  }
154
199
  try {
155
200
  invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
@@ -158,20 +203,39 @@ export async function runMeshDaemon(deps = {}) {
158
203
  log(dir, `default membership cache invalidation failed: ${String(err).slice(0, 160)}`);
159
204
  }
160
205
  if (state) {
161
- writeSessionState({
162
- ...state,
163
- companyUid: undefined,
164
- companySlug: undefined,
165
- contextStatus: "needs_company",
166
- updatedAt: now().toISOString(),
167
- }, ctxRoot);
206
+ // Commit only if the device-default binding that started this lookup is
207
+ // still current. The shared session-state lock also makes a hook writer
208
+ // that arrives at this boundary commit after us rather than get lost.
209
+ deps.beforeDeviceDefaultRevocationCommit?.();
210
+ const revoked = updateSessionState(state.sessionId, ctxRoot, (current) => {
211
+ if (!current || !stillMatchesDeniedDeviceDefaultBinding(current, state, input, slug)) {
212
+ return null;
213
+ }
214
+ return mergeLocalSessionFields({
215
+ ...current,
216
+ companyUid: undefined,
217
+ companySlug: undefined,
218
+ contextStatus: "needs_company",
219
+ updatedAt: now().toISOString(),
220
+ }, current, { preserveCompanyBinding: false });
221
+ });
222
+ if (!revoked) {
223
+ log(dir, `device-default membership denial left newer session binding intact session=${state.sessionId}`);
224
+ }
168
225
  }
169
226
  const prior = readDaemonState(dir)?.deviceDefaultAuthorizationDenials ?? 0;
170
227
  patchDaemonState(dir, { deviceDefaultAuthorizationDenials: prior + 1 }, now);
171
228
  log(dir, `device-default membership denied${slug ? ` company=${slug}` : ""}; work held`);
172
- return false;
229
+ return { status: "denied" };
173
230
  },
174
231
  };
232
+ const validateDaemonMembership = async (slug, deviceDefault) => {
233
+ const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault });
234
+ if (authorization.status === "unavailable") {
235
+ throw new Error(`device-default membership lookup unavailable: ${authorization.error}`);
236
+ }
237
+ return authorization.status === "authorized" ? { uid: authorization.uid } : false;
238
+ };
175
239
  /** In-memory held re-attempt throttle (survives watch-triggered flushes). */
176
240
  let lastHeldRetryAtMs = 0;
177
241
  const flushFn = deps.flush ??
@@ -191,12 +255,12 @@ export async function runMeshDaemon(deps = {}) {
191
255
  lastHeldRetryAtMs = atMs;
192
256
  },
193
257
  log: (message) => log(dir, message),
194
- authorizeDeviceDefaultTenant: async (state) => Boolean(await tenantGate.authorize({
258
+ authorizeDeviceDefaultTenant: async (state) => (await tenantGate.authorize({
195
259
  state,
196
260
  companySlug: state.companySlug,
197
261
  companyUid: state.companyUid,
198
262
  deviceDefault: true,
199
- })),
263
+ })).status === "authorized",
200
264
  });
201
265
  });
202
266
  const replayFn = deps.replayOutbox ??
@@ -215,13 +279,16 @@ export async function runMeshDaemon(deps = {}) {
215
279
  // only authority that may release them.
216
280
  !op.companyConfidence;
217
281
  if (requiresMembershipCheck) {
218
- const allowed = await tenantGate.authorize({
282
+ const authorization = await tenantGate.authorize({
219
283
  state: state ?? undefined,
220
284
  companySlug: op.companySlug ?? state?.companySlug,
221
285
  companyUid: op.companyUid ?? state?.companyUid,
222
286
  deviceDefault: true,
223
287
  });
224
- if (!allowed) {
288
+ if (authorization.status === "unavailable") {
289
+ return { ok: false, retryable: true, code: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED" };
290
+ }
291
+ if (authorization.status !== "authorized") {
225
292
  return { ok: false, retryable: false, code: "DEVICE_DEFAULT_MEMBERSHIP_DENIED" };
226
293
  }
227
294
  }
@@ -260,13 +327,11 @@ export async function runMeshDaemon(deps = {}) {
260
327
  root: ctxRoot,
261
328
  env,
262
329
  deliver,
263
- validateMembership: async (slug) => tenantGate.authorize({
264
- companySlug: slug,
265
- deviceDefault: useDeviceDefault,
266
- }),
330
+ validateMembership: (slug) => validateDaemonMembership(slug, useDeviceDefault),
267
331
  allowDeviceDefault: useDeviceDefault,
268
332
  deviceDefaultConfidence: useDeviceDefault,
269
333
  }),
334
+ validateMembership: (slug) => validateDaemonMembership(slug, useDeviceDefault),
270
335
  });
271
336
  });
272
337
  const boardLast = new Map();
@@ -282,12 +347,12 @@ export async function runMeshDaemon(deps = {}) {
282
347
  lastRefresh: boardLast,
283
348
  intervalMs: BOARD_REFRESH_INTERVAL_MS,
284
349
  now: () => timers.now(),
285
- authorizeDeviceDefaultTenant: async (_sessionId, state) => Boolean(await tenantGate.authorize({
350
+ authorizeDeviceDefaultTenant: async (_sessionId, state) => (await tenantGate.authorize({
286
351
  state,
287
352
  companySlug: state.companySlug,
288
353
  companyUid: state.companyUid,
289
354
  deviceDefault: true,
290
- })),
355
+ })).status === "authorized",
291
356
  });
292
357
  });
293
358
  let presence = null;
@@ -387,18 +452,10 @@ export async function runMeshDaemon(deps = {}) {
387
452
  fs: deps.transcriptFs,
388
453
  now: () => now().getTime(),
389
454
  lastHookEventAt,
390
- validateMembership: async (slug) => {
391
- try {
392
- const token = await getToken();
393
- const membership = await resolveActiveMembershipCompany(token, slug);
394
- if (!membership)
395
- return false;
396
- return { uid: membership.companyUid };
397
- }
398
- catch {
399
- return false;
400
- }
401
- },
455
+ // Transcript watch has no direct state writer: reconcile owns its write
456
+ // and merges local fields. Surface transient lookup failures as throws so
457
+ // reconcile defers without turning them into an authoritative denial.
458
+ validateMembership: (slug) => validateDaemonMembership(slug, true),
402
459
  deliver: async (op) => {
403
460
  try {
404
461
  const token = await getToken();
@@ -29,6 +29,8 @@ export interface DaemonStateFile {
29
29
  emitMode?: "legacy" | "direct";
30
30
  /** Authoritative membership denials for device-default-derived tenant I/O. */
31
31
  deviceDefaultAuthorizationDenials?: number;
32
+ /** Transient device-default membership refresh failures; the next cycle retries. */
33
+ deviceDefaultMembershipLookupFailures?: number;
32
34
  updatedAt: string;
33
35
  }
34
36
  export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
@@ -49,6 +49,9 @@ export declare const TRANSCRIPT_WATCH_REVALIDATE_MS: number;
49
49
  export declare const TRANSCRIPT_TURN_QUIET_MS = 5000;
50
50
  export declare const TRANSCRIPT_SESSION_END_MS: number;
51
51
  export declare const TRANSCRIPT_HOOK_COVER_MS = 60000;
52
+ /** Retry unavailable transcript-start reconciliation without hot-looping. */
53
+ export declare const TRANSCRIPT_START_RETRY_BASE_MS = 30000;
54
+ export declare const TRANSCRIPT_START_RETRY_MAX_MS = 120000;
52
55
  /** Ignore cold transcripts whose mtime is older than this on first sight. */
53
56
  export declare const TRANSCRIPT_FRESH_MS: number;
54
57
  export declare const TRANSCRIPT_MARKER: "from transcript";
@@ -54,6 +54,9 @@ export const TRANSCRIPT_WATCH_REVALIDATE_MS = 5 * 60_000;
54
54
  export const TRANSCRIPT_TURN_QUIET_MS = 5_000;
55
55
  export const TRANSCRIPT_SESSION_END_MS = 10 * 60_000;
56
56
  export const TRANSCRIPT_HOOK_COVER_MS = 60_000;
57
+ /** Retry unavailable transcript-start reconciliation without hot-looping. */
58
+ export const TRANSCRIPT_START_RETRY_BASE_MS = 30_000;
59
+ export const TRANSCRIPT_START_RETRY_MAX_MS = 120_000;
57
60
  /** Ignore cold transcripts whose mtime is older than this on first sight. */
58
61
  export const TRANSCRIPT_FRESH_MS = TRANSCRIPT_SESSION_END_MS;
59
62
  export const TRANSCRIPT_MARKER = "from transcript";
@@ -652,6 +655,8 @@ export class TranscriptWatcher {
652
655
  ended: false,
653
656
  pendingTurn: false,
654
657
  seq: 0,
658
+ startRetryAttempts: 0,
659
+ nextStartRetryAtMs: 0,
655
660
  };
656
661
  this.tracked.set(d.path, track);
657
662
  const started = await this.emitStart(track, nowMs);
@@ -661,6 +666,14 @@ export class TranscriptWatcher {
661
666
  }
662
667
  if (track.ended)
663
668
  continue;
669
+ if (!track.started) {
670
+ if (nowMs < track.nextStartRetryAtMs)
671
+ continue;
672
+ const started = await this.emitStart(track, nowMs);
673
+ if (started)
674
+ result.started += 1;
675
+ continue;
676
+ }
664
677
  if (d.mtimeMs > track.lastMtimeMs) {
665
678
  track.lastMtimeMs = d.mtimeMs;
666
679
  track.lastChangeAtMs = nowMs;
@@ -737,14 +750,22 @@ export class TranscriptWatcher {
737
750
  hqRoot: this.deps.hqRoot,
738
751
  source: "transcript",
739
752
  };
740
- await this.reconcileFn(observation, {
741
- root: this.deps.workContextRoot,
742
- env: this.deps.env,
743
- now: () => new Date(nowMs),
744
- validateMembership: this.deps.validateMembership,
745
- deliver: this.deps.deliver,
746
- offline: !this.deps.deliver,
747
- });
753
+ try {
754
+ await this.reconcileFn(observation, {
755
+ root: this.deps.workContextRoot,
756
+ env: this.deps.env,
757
+ now: () => new Date(nowMs),
758
+ validateMembership: this.deps.validateMembership,
759
+ deliver: this.deps.deliver,
760
+ offline: !this.deps.deliver,
761
+ });
762
+ }
763
+ catch {
764
+ track.startRetryAttempts += 1;
765
+ const delayMs = Math.min(TRANSCRIPT_START_RETRY_BASE_MS * 2 ** (track.startRetryAttempts - 1), TRANSCRIPT_START_RETRY_MAX_MS);
766
+ track.nextStartRetryAtMs = nowMs + delayMs;
767
+ return false;
768
+ }
748
769
  // Re-check: a hook may have raced us during reconcile.
749
770
  const after = this.readState(track.sessionId, this.deps.workContextRoot);
750
771
  if (isHookWrittenSessionState(after)) {
@@ -753,6 +774,8 @@ export class TranscriptWatcher {
753
774
  }
754
775
  this.emitKind(track, "session_start", nowMs);
755
776
  track.started = true;
777
+ track.startRetryAttempts = 0;
778
+ track.nextStartRetryAtMs = 0;
756
779
  return true;
757
780
  }
758
781
  emitKind(track, kind, nowMs) {
@@ -1,3 +1,4 @@
1
+ import type { PackContributeKey } from '../../types.js';
1
2
  type Logger = (message: string) => void;
2
3
  export interface ScanPackagesOptions {
3
4
  quiet?: boolean;
@@ -7,6 +8,7 @@ export interface ScanPackagesOptions {
7
8
  export interface ScanPackagesResult {
8
9
  status: 0 | 1;
9
10
  }
11
+ export declare function readContributes(manifest: string): Partial<Record<PackContributeKey, string[]>>;
10
12
  /** Wire installed pack contributions into their table-declared host locations. */
11
13
  export declare function scanPackages(hqRoot: string, options?: ScanPackagesOptions): ScanPackagesResult;
12
14
  export {};
@@ -18,7 +18,7 @@ catch {
18
18
  return false;
19
19
  } }
20
20
  function normalized(relative) { return relative.replaceAll(path.sep, '/'); }
21
- function readContributes(manifest) {
21
+ export function readContributes(manifest) {
22
22
  // This deliberately mirrors the staging script's shallow awk grammar rather
23
23
  // than accepting YAML forms the legacy ABI never recognized (notably a
24
24
  // non-empty inline list). Mapping remains single-sourced in
@@ -163,34 +163,39 @@ function ensureSymlink(link, info, warn) {
163
163
  warn(`payload missing: ${link.src} (declared but not shipped)`);
164
164
  return;
165
165
  }
166
+ const relativeSrc = path.relative(path.dirname(link.dst), link.src);
166
167
  fs.mkdirSync(path.dirname(link.dst), { recursive: true });
167
168
  try {
168
169
  const stat = fs.lstatSync(link.dst);
169
170
  if (stat.isSymbolicLink()) {
170
171
  const existing = fs.readlinkSync(link.dst);
171
- if (existing === link.src)
172
+ // Correct form is relative to the host directory so a tree can move
173
+ // between machines. An absolute match (this host, pre-relative installer)
174
+ // is ours and converges. A dangling leftover from another HQ root is
175
+ // not host content and is rewritten the same way.
176
+ if (existing === relativeSrc)
172
177
  return;
173
- // "Host content wins" protects content. A symlink that does not resolve
174
- // is not content — and it is the shape every pack link takes when a tree
175
- // moves between machines, because these links are absolute and the old
176
- // HQ root does not exist on the new host. Skipping it would make that
177
- // state permanent: stale on this run, and on every run after it.
178
- //
178
+ if (existing === link.src) {
179
+ fs.rmSync(link.dst, { force: true });
180
+ fs.symlinkSync(relativeSrc, link.dst);
181
+ info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing})`);
182
+ return;
183
+ }
179
184
  // Only a lookup that specifically says "not there" earns a relink.
180
185
  // fs.existsSync also answers false for EACCES, which would read content
181
186
  // this user merely cannot traverse as absent and delete the link to it.
182
187
  const lookup = targetLookup(link.dst);
183
188
  if (lookup === 'absent') {
184
189
  fs.rmSync(link.dst, { force: true });
185
- fs.symlinkSync(link.src, link.dst);
186
- info(`relinked ${link.dst} -> ${link.src} (was ${existing}, which does not exist here)`);
190
+ fs.symlinkSync(relativeSrc, link.dst);
191
+ info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing}, which does not exist here)`);
187
192
  return;
188
193
  }
189
194
  if (lookup !== 'present') {
190
195
  warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
191
196
  return;
192
197
  }
193
- warn(`collision: ${link.dst} already points at ${existing} (wanted ${link.src}) — skipping`);
198
+ warn(`collision: ${link.dst} already points at ${existing} (wanted ${relativeSrc}) — skipping`);
194
199
  return;
195
200
  }
196
201
  warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
@@ -200,8 +205,8 @@ function ensureSymlink(link, info, warn) {
200
205
  if (error.code !== 'ENOENT')
201
206
  throw error;
202
207
  }
203
- fs.symlinkSync(link.src, link.dst);
204
- info(`linked ${link.dst} -> ${link.src}`);
208
+ fs.symlinkSync(relativeSrc, link.dst);
209
+ info(`linked ${link.dst} -> ${relativeSrc}`);
205
210
  }
206
211
  /**
207
212
  * The link text, when `entry` is a symlink whose target is definitively not
@@ -2,6 +2,8 @@
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
4
  */
5
+ import * as fs from "node:fs";
6
+ import { type WindowsAclCheckOptions } from "./windows-acl.js";
5
7
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
6
8
  export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
7
9
  export interface DeviceDefaultCompany {
@@ -81,6 +83,24 @@ export declare function acquireDeviceConfigLock(root: string, options?: {
81
83
  timeoutMs?: number;
82
84
  staleMs?: number;
83
85
  }): () => void;
86
+ /** Injection points for assertSafeConfigPath (tests only). */
87
+ export interface SafeConfigPathIo {
88
+ platform?: NodeJS.Platform;
89
+ existsSync?: (p: string) => boolean;
90
+ lstatSync?: (p: string) => Pick<fs.Stats, "mode" | "isSymbolicLink">;
91
+ windowsAcl?: WindowsAclCheckOptions;
92
+ }
93
+ /**
94
+ * Refuse a config path that is a symlink (every platform) or writable by
95
+ * anyone other than the owner.
96
+ *
97
+ * - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
98
+ * - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
99
+ * file, so the mode test would reject every config (US-018). We read the
100
+ * real DACL instead (see windows-acl.ts). Unreadable DACL → documented
101
+ * reduced-guarantee path: one diagnostic line, no throw.
102
+ */
103
+ export declare function assertSafeConfigPath(configPath: string, io?: SafeConfigPathIo): void;
84
104
  export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
85
105
  export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
86
106
  /** A clear is a durable opt-out, not an invitation for automatic reactivation. */
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
8
8
  import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
9
9
  import { workContextConfigPath, workContextRoot } from "./paths.js";
10
+ import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
10
11
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
11
12
  export const DEVICE_CONFIG_SCHEMA_VERSION = 1;
12
13
  const CONFIG_LOCK_TIMEOUT_MS = 5_000;
@@ -147,20 +148,40 @@ function sanitizeRepoCompanyMap(raw) {
147
148
  }
148
149
  return Object.keys(out).length > 0 ? out : null;
149
150
  }
150
- function assertSafeConfigPath(configPath) {
151
+ /**
152
+ * Refuse a config path that is a symlink (every platform) or writable by
153
+ * anyone other than the owner.
154
+ *
155
+ * - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
156
+ * - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
157
+ * file, so the mode test would reject every config (US-018). We read the
158
+ * real DACL instead (see windows-acl.ts). Unreadable DACL → documented
159
+ * reduced-guarantee path: one diagnostic line, no throw.
160
+ */
161
+ export function assertSafeConfigPath(configPath, io = {}) {
162
+ const platform = io.platform ?? process.platform;
163
+ const existsSync = io.existsSync ?? fs.existsSync;
164
+ const lstatSync = io.lstatSync ?? fs.lstatSync;
151
165
  const dir = path.dirname(configPath);
152
166
  try {
153
- if (fs.existsSync(dir)) {
154
- const lst = fs.lstatSync(dir);
167
+ if (existsSync(dir)) {
168
+ const lst = lstatSync(dir);
155
169
  if (lst.isSymbolicLink()) {
156
170
  throw new UnsafeConfigPathError(`Config directory is a symlink: ${dir}`);
157
171
  }
172
+ if (platform === "win32") {
173
+ assertWindowsOwnerOnlyAcl(dir, "directory", io.windowsAcl);
174
+ }
158
175
  }
159
- if (fs.existsSync(configPath)) {
160
- const lst = fs.lstatSync(configPath);
176
+ if (existsSync(configPath)) {
177
+ const lst = lstatSync(configPath);
161
178
  if (lst.isSymbolicLink()) {
162
179
  throw new UnsafeConfigPathError(`Config file is a symlink: ${configPath}`);
163
180
  }
181
+ if (platform === "win32") {
182
+ assertWindowsOwnerOnlyAcl(configPath, "file", io.windowsAcl);
183
+ return;
184
+ }
164
185
  const mode = lst.mode & 0o777;
165
186
  // Warn-level only for world-writable; refuse group/other write bits on read for safety.
166
187
  if (mode & 0o022) {
@@ -701,17 +701,37 @@ export async function reconcileObservation(obs, deps) {
701
701
  if (delivered.ok) {
702
702
  markOutboxAcked(outboxOp.operationId, deps.root, delivered.receiptId, nowFn);
703
703
  writeAckedRegisterState(outboxOp, deps.root, delivered.receiptId, nowFn);
704
- // Persist binding episode on success for bound registrations.
705
- if (registerClassification === "bound") {
704
+ // Company attribution write for EVERY acked register that resolved a
705
+ // company — not only bound sessions. A company-only ack (needs_project /
706
+ // needs_task / unresolved) must still project companyUid/companySlug and
707
+ // the true computed classification into durable session state; otherwise
708
+ // the emitter and mesh presence lose the tenant the server already
709
+ // acknowledged. projectId / taskId are written only when genuinely
710
+ // resolved, and a fresh bindingEpisodeId only when the session is bound —
711
+ // never fabricated on a partial path. (writeAckedRegisterState above may
712
+ // have already recorded a receipt-derived episode for a resolved partial
713
+ // binding; the default merge preserves that when the binding still matches.)
714
+ if (resolvedCompany.uid) {
715
+ const bound = registerClassification === "bound";
716
+ // A server ack of the company makes it authoritative: the durable status
717
+ // is at least needs_project (company known, no project), needs_task
718
+ // (project, no task), or bound (project + task) — mirroring
719
+ // acknowledgedStatus() in state.ts. Never downgrade an acked company to a
720
+ // pre-threshold "unresolved" projection; that is what dropped the tenant.
721
+ const ackedClassification = projectId && taskId
722
+ ? "bound"
723
+ : projectId
724
+ ? "needs_task"
725
+ : "needs_project";
706
726
  writeSessionState(mergeLocalSessionFields({
707
727
  contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
708
728
  sessionId,
709
729
  companyUid: resolvedCompany.uid,
710
730
  companySlug: resolvedCompany.slug,
711
- contextStatus: "bound",
712
- projectId,
713
- taskId,
714
- bindingEpisodeId: delivered.receiptId,
731
+ contextStatus: bound ? "bound" : ackedClassification,
732
+ ...(projectId ? { projectId } : {}),
733
+ ...(taskId ? { taskId } : {}),
734
+ ...(bound ? { bindingEpisodeId: delivered.receiptId } : {}),
715
735
  updatedAt: nowFn().toISOString(),
716
736
  startedAt,
717
737
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
@@ -719,7 +739,8 @@ export async function reconcileObservation(obs, deps) {
719
739
  ...transcriptFields,
720
740
  },
721
741
  // Merge against the just-written projection rather than the stale
722
- // snapshot from before reconciliation (retains device confidence).
742
+ // pre-reconcile snapshot, so device confidence and any receipt
743
+ // attribution survive.
723
744
  readSessionState(sessionId, deps.root) ?? stateWrite), deps.root);
724
745
  }
725
746
  const kind = registerClassification === "bound"
@@ -109,8 +109,19 @@ export interface AcknowledgedRegisterBinding {
109
109
  }
110
110
  /** Allowlisted keys that may appear in the durable state projection. */
111
111
  export declare const SESSION_STATE_ALLOWLIST: Set<string>;
112
+ /**
113
+ * Serialize a session-state read/modify/write with every state writer. Atomic
114
+ * rename protects readers from torn files, but only this shared lock protects
115
+ * a writer that has to verify the current binding before changing it.
116
+ */
117
+ export declare function withSessionStateLock<T>(sessionId: string, root: string, fn: () => T): T;
112
118
  export declare function readSessionState(sessionId: string, root: string): SessionStateFile | null;
113
119
  export declare function writeSessionState(state: SessionStateFile, root: string): string;
120
+ /**
121
+ * Commit a state change only after inspecting the latest value under the same
122
+ * lock used by ordinary session writers. Returning null declines the write.
123
+ */
124
+ export declare function updateSessionState(sessionId: string, root: string, update: (current: SessionStateFile | null) => SessionStateFile | null): SessionStateFile | null;
114
125
  /** Strip any non-allowlisted fields before durable write. */
115
126
  export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
116
127
  /**
@@ -119,6 +130,7 @@ export declare function projectSessionState(state: SessionStateFile): SessionSta
119
130
  */
120
131
  export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null, options?: {
121
132
  preserveBindingEpisode?: boolean;
133
+ preserveCompanyBinding?: boolean;
122
134
  }): SessionStateFile;
123
135
  /**
124
136
  * Upgrade the local projection after the server acknowledges a register.