@indigoai-us/hq-cli 5.303.0 → 5.305.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 (42) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/command-catalog.generated.d.ts +1 -1
  3. package/dist/command-catalog.generated.js +3 -1
  4. package/dist/command-registration-plan.js +8 -0
  5. package/dist/commands/lanes.d.ts +2 -0
  6. package/dist/commands/lanes.js +34 -6
  7. package/dist/commands/mcp-status.js +1 -0
  8. package/dist/commands/members.js +0 -1
  9. package/dist/commands/people.d.ts +3 -0
  10. package/dist/commands/people.js +27 -3
  11. package/dist/index.js +6 -1
  12. package/dist/lazy-commands.js +5 -2
  13. package/dist/lib/aws-sdk-node-warning-output.d.ts +12 -0
  14. package/dist/lib/aws-sdk-node-warning-output.js +22 -0
  15. package/dist/lib/company-errors-sdk-warning-flag.d.ts +5 -0
  16. package/dist/lib/company-errors-sdk-warning-flag.js +8 -0
  17. package/dist/lib/doctor/checks/work-context.js +2 -2
  18. package/dist/lib/doctor/deep/executor.d.ts +4 -10
  19. package/dist/lib/doctor/deep/executor.js +1 -1
  20. package/dist/lib/doctor/deep/parity.d.ts +2 -29
  21. package/dist/lib/doctor/deep/parity.js +7 -7
  22. package/dist/lib/doctor/deep/sandbox.d.ts +7 -25
  23. package/dist/lib/doctor/deep/sandbox.js +4 -4
  24. package/dist/lib/lanes/keep-alive.js +13 -2
  25. package/dist/lib/mesh/live/daemon/doctor.js +2 -2
  26. package/dist/lib/mesh/live/flush.d.ts +2 -2
  27. package/dist/lib/mesh/live/flush.js +49 -29
  28. package/dist/lib/mesh/live/spool.d.ts +6 -0
  29. package/dist/lib/mesh/live/spool.js +203 -4
  30. package/dist/lib/work-context/bind-best.js +13 -1
  31. package/dist/main.js +16 -3
  32. package/dist/utils/self-update.d.ts +4 -0
  33. package/dist/utils/self-update.js +46 -9
  34. package/dist/utils/version-check.d.ts +7 -0
  35. package/dist/utils/version-check.js +30 -1
  36. package/dist/utils/version-gate.d.ts +28 -10
  37. package/dist/utils/version-gate.js +159 -29
  38. package/dist/utils/version-report.d.ts +2 -1
  39. package/dist/utils/version-report.js +8 -1
  40. package/package.json +1 -1
  41. package/dist/lib/work-context/index.d.ts +0 -26
  42. package/dist/lib/work-context/index.js +0 -22
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { LanesError } from "./errors.js";
4
4
  import { dropboxInboxHasMessages } from "./dropbox.js";
5
5
  import { appendCappedHistory, listQuestionIds, readQuestion, retryUpdateLane } from "./store.js";
6
- import { isIsoDateTime } from "./types.js";
6
+ import { isTerminalLaneState, isIsoDateTime, } from "./types.js";
7
7
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
8
8
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
9
9
  export const LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG = "lanes.keep-alive-next-check-at";
@@ -254,7 +254,10 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
254
254
  return lanes.find((candidate) => {
255
255
  if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
256
256
  return false;
257
- if (candidate.state === "cancelled" || candidate.state === "killed" || candidate.state === "failed")
257
+ if (candidate.state === "cancelled" ||
258
+ candidate.state === "killed" ||
259
+ candidate.state === "failed" ||
260
+ (lane.state === "running" && isTerminalLaneState(candidate.state)))
258
261
  return false;
259
262
  const candidateCompany = companyOf(candidate) ?? null;
260
263
  const sameCompany = laneCompany === null
@@ -270,8 +273,16 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
270
273
  const sameLaneSenior = (lane.senior.kind === "lane" || lane.senior.kind === "session") &&
271
274
  candidate.senior.kind === lane.senior.kind &&
272
275
  candidate.senior.id === lane.senior.id;
276
+ const differentKnownSenior = lane.senior.kind === "lane" &&
277
+ candidate.senior.kind === "lane" &&
278
+ typeof lane.senior.id === "string" &&
279
+ lane.senior.id.trim().length > 0 &&
280
+ typeof candidate.senior.id === "string" &&
281
+ candidate.senior.id.trim().length > 0 &&
282
+ candidate.senior.id !== lane.senior.id;
273
283
  const sameProfile = lane.worker_profile !== undefined &&
274
284
  candidate.worker_profile === lane.worker_profile &&
285
+ !(lane.state === "running" && differentKnownSenior) &&
275
286
  !(lineageAware && sameLaneSenior);
276
287
  const projectLead = lane.project_id !== undefined &&
277
288
  candidate.project_id === lane.project_id &&
@@ -11,7 +11,7 @@ import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
12
12
  import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
13
13
  import { formatDroppedPresenceCompany } from "./credentials.js";
14
- import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
14
+ import { countDistinctHeldEvents, countJsonlLines, countUnattributedEvents, } from "../spool.js";
15
15
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
16
16
  import { daemonDir, daemonPidPath } from "./paths.js";
17
17
  import { readEmitState } from "../emit.js";
@@ -178,7 +178,7 @@ export function collectDaemonDoctor(deps = {}) {
178
178
  const heldPath = workMeshHeldPath(meshRoot);
179
179
  const deadPath = workMeshDeadLetterPath(meshRoot);
180
180
  const spoolDepth = countJsonlLines(spoolPath);
181
- const heldCount = countJsonlLines(heldPath);
181
+ const heldCount = countDistinctHeldEvents(heldPath);
182
182
  const trustedRoots = readDaemonConfig(dir).trustedHqRoots;
183
183
  const outboxOperations = listOutboxOperations(ctxRoot);
184
184
  const heldWithNoLocalEvidence = readHeldSessions(meshRoot).filter((ref) => {
@@ -14,7 +14,7 @@ import { type SessionEventsPoster } from "./session-events-client.js";
14
14
  export declare const HELD_TTL_MS: number;
15
15
  /** Re-attempt held events at most this often (spool still flushes every cycle). */
16
16
  export declare const HELD_RETRY_INTERVAL_MS: number;
17
- /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
17
+ /** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
18
18
  export declare const HELD_MAX_LINES = 20000;
19
19
  /**
20
20
  * Re-check an unbound session against the project catalog at most this often.
@@ -46,7 +46,7 @@ export interface FlushDeps {
46
46
  lastHeldRetryAtMs?: number;
47
47
  /** Invoked when this flush claims held, so the daemon can advance its timestamp. */
48
48
  onHeldClaimed?: (atMs: number) => void;
49
- /** Max lines retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
49
+ /** Max distinct eventIds retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
50
50
  heldMaxLines?: number;
51
51
  /** Optional logger (daemon wires this); used once per overflow trim. */
52
52
  log?: (message: string) => void;
@@ -22,7 +22,7 @@ import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.
22
22
  import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
23
23
  import { enrichSessionEndEvent } from "./report-summary.js";
24
24
  import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
25
- import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLine, appendSpoolLine, claimHeld, claimSpool, countJsonlLines, ensureSpoolFile, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
25
+ import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
26
26
  import { workMeshHeldPath, workMeshRoot } from "./paths.js";
27
27
  /** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
28
28
  const SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH = 25;
@@ -31,7 +31,7 @@ const MAX_RETRY_AFTER_MS = 5 * 60 * 1000;
31
31
  export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
32
32
  /** Re-attempt held events at most this often (spool still flushes every cycle). */
33
33
  export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
34
- /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
34
+ /** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
35
35
  export const HELD_MAX_LINES = 20_000;
36
36
  /**
37
37
  * Re-check an unbound session against the project catalog at most this often.
@@ -279,11 +279,9 @@ export async function flushSessionEvents(deps) {
279
279
  state = readSessionState(sessionId, deps.workContextRoot) ?? state;
280
280
  if (!allowed) {
281
281
  const heldAt = now().toISOString();
282
- for (const item of remainingItems) {
283
- appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
284
- summary.held += 1;
282
+ summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt)), deps.workMeshRoot);
283
+ for (const item of remainingItems)
285
284
  markDisposed(item.event);
286
- }
287
285
  continue;
288
286
  }
289
287
  }
@@ -306,11 +304,9 @@ export async function flushSessionEvents(deps) {
306
304
  }
307
305
  if (decision.action === "hold") {
308
306
  const heldAt = now().toISOString();
309
- for (const item of remainingItems) {
310
- appendHeldLine(heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt), deps.workMeshRoot);
311
- summary.held += 1;
307
+ summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt)), deps.workMeshRoot);
308
+ for (const item of remainingItems)
312
309
  markDisposed(item.event);
313
- }
314
310
  continue;
315
311
  }
316
312
  // Post a bounded portion of this session's remaining backlog (after any
@@ -368,11 +364,9 @@ export async function flushSessionEvents(deps) {
368
364
  deps.log?.(`work-mesh: session ${sessionId} project missing (HTTP 404); downgraded to needs_project and held events`);
369
365
  }
370
366
  const heldAt = now().toISOString();
371
- for (const item of originalChunk) {
372
- appendHeldLine(heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt), deps.workMeshRoot);
373
- summary.held += 1;
367
+ summary.held += appendHeldLinesIfAbsent(originalChunk.map((item) => heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt)), deps.workMeshRoot);
368
+ for (const item of originalChunk)
374
369
  markDisposed(item.event);
375
- }
376
370
  continue;
377
371
  }
378
372
  if (!result.retryable) {
@@ -429,15 +423,17 @@ export async function flushSessionEvents(deps) {
429
423
  * flusher (`hq mesh flush` beside the daemon) may have appended to a fresh
430
424
  * held.jsonl after our claim renamed the old one away.
431
425
  * - The overflow claim file is removed only after every dead letter and every
432
- * retained line is on disk. If anything throws, the claim stays behind and
433
- * recoverOrphanClaims re-reads it on the next flush (duplicates are possible,
434
- * loss is not).
426
+ * retained line is on disk. Retained rows are idempotent by eventId, so a
427
+ * retry after an interrupted append is safe; the claim stays until recovery
428
+ * can finish, preserving the append-before-remove ordering.
435
429
  */
436
430
  function trimHeldOverflow(meshRoot, maxLines, now, summary) {
437
431
  if (maxLines <= 0)
438
432
  return 0;
439
433
  const heldPath = workMeshHeldPath(meshRoot);
440
- if (countJsonlLines(heldPath) <= maxLines)
434
+ const lineCount = countJsonlLines(heldPath);
435
+ const distinctCount = countDistinctHeldEvents(heldPath);
436
+ if (lineCount <= maxLines && lineCount === distinctCount)
441
437
  return 0;
442
438
  const claim = claimHeld(meshRoot, `${now().getTime()}.overflow`);
443
439
  if (!claim.claimPath)
@@ -447,15 +443,26 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
447
443
  return 0;
448
444
  }
449
445
  try {
450
- if (claim.lines.length <= maxLines) {
451
- appendHeldLines(meshRoot, claim.lines);
446
+ const alreadyHeldIds = readHeldEventIds(heldPath);
447
+ const seenClaimIds = new Set();
448
+ const uniqueLines = claim.lines.filter((line) => {
449
+ const eventId = heldEventIdOfLine(line);
450
+ if (!eventId)
451
+ return true;
452
+ if (alreadyHeldIds.has(eventId) || seenClaimIds.has(eventId))
453
+ return false;
454
+ seenClaimIds.add(eventId);
455
+ return true;
456
+ });
457
+ if (uniqueLines.length <= maxLines) {
458
+ appendHeldLinesOnce(meshRoot, uniqueLines);
452
459
  removeClaimFile(claim.claimPath);
453
460
  return 0;
454
461
  }
455
- const overflow = claim.lines.length - maxLines;
462
+ const overflow = uniqueLines.length - maxLines;
456
463
  // Oldest by heldAt: retries regroup held by session, so file order is not
457
464
  // chronological. Unparseable lines sort first (dead-lettered first).
458
- const ordered = claim.lines
465
+ const ordered = uniqueLines
459
466
  .map((line, idx) => ({ line, idx, atMs: heldAtMs(line) }))
460
467
  .sort((a, b) => a.atMs - b.atMs || a.idx - b.idx);
461
468
  const toDead = ordered.slice(0, overflow);
@@ -475,7 +482,7 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
475
482
  }
476
483
  summary.deadLettered += 1;
477
484
  }
478
- appendHeldLines(meshRoot, toKeep);
485
+ appendHeldLinesOnce(meshRoot, toKeep);
479
486
  summary.heldOverflow = overflow;
480
487
  removeClaimFile(claim.claimPath);
481
488
  return overflow;
@@ -496,12 +503,25 @@ function heldAtMs(line) {
496
503
  const ms = Date.parse(heldAt);
497
504
  return Number.isFinite(ms) ? ms : 0;
498
505
  }
499
- function appendHeldLines(meshRoot, lines) {
500
- if (lines.length === 0)
501
- return;
502
- const heldPath = workMeshHeldPath(meshRoot);
503
- ensureSpoolFile(heldPath);
504
- fs.appendFileSync(heldPath, `${lines.join("\n")}\n`, { mode: 0o600 });
506
+ function appendHeldLinesOnce(meshRoot, lines) {
507
+ appendHeldLinesIfAbsent(lines, meshRoot);
508
+ }
509
+ function heldEventIdOfLine(line) {
510
+ const parsed = parseLine(line);
511
+ return parsed ? eventIdOf(unwrapHeld(parsed).event) : null;
512
+ }
513
+ function readHeldEventIds(filePath) {
514
+ if (!fs.existsSync(filePath))
515
+ return new Set();
516
+ const ids = new Set();
517
+ for (const line of fs.readFileSync(filePath, "utf8").split("\n")) {
518
+ if (!line.trim())
519
+ continue;
520
+ const id = heldEventIdOfLine(line);
521
+ if (id)
522
+ ids.add(id);
523
+ }
524
+ return ids;
505
525
  }
506
526
  /**
507
527
  * Account a 2xx batch: dead-letter rejected eventIds with their codes;
@@ -19,6 +19,10 @@ export declare function ensureSpoolFile(filePath: string): void;
19
19
  export declare function appendJsonlLine(filePath: string, line: string): void;
20
20
  export declare function appendSpoolLine(line: string, root?: string): string;
21
21
  export declare function appendHeldLine(line: string, root?: string): string;
22
+ /** Append a held envelope only when its eventId is not already held. */
23
+ export declare function appendHeldLineIfAbsent(line: string, root?: string): boolean;
24
+ /** Append a deduplicated held batch under one file lock and one write. */
25
+ export declare function appendHeldLinesIfAbsent(lines: string[], root?: string): number;
22
26
  export declare function appendDeadLetterLine(line: string, root?: string): string;
23
27
  export declare function appendDroppedReceipt(receipt: Record<string, unknown>, root?: string): string;
24
28
  export interface ClaimResult {
@@ -49,6 +53,8 @@ export declare function recoverOrphanClaims(root: string): {
49
53
  export declare function removeClaimFile(claimPath: string | null): void;
50
54
  export declare function deadLetterNonEmpty(root?: string): boolean;
51
55
  export declare function countJsonlLines(filePath: string): number;
56
+ /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
57
+ export declare function countDistinctHeldEvents(filePath: string): number;
52
58
  /** Local queues that still lack a usable company attribution. */
53
59
  export interface UnattributedEventCounts {
54
60
  spool: number;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
+ import { randomBytes } from "node:crypto";
8
9
  import { ensureOwnerDir } from "../../work-context/atomic.js";
9
10
  import { workMeshClaimPath, workMeshDeadLetterPath, workMeshDroppedReceiptPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "./paths.js";
10
11
  /** Linux PIPE_BUF is typically 4096; keep a conservative ceiling for one line. */
@@ -65,12 +66,160 @@ export function appendSpoolLine(line, root) {
65
66
  }
66
67
  export function appendHeldLine(line, root) {
67
68
  const held = workMeshHeldPath(root ?? workMeshRoot());
68
- appendJsonlLine(held, line);
69
+ appendHeldLineIfAbsent(line, root);
69
70
  return held;
70
71
  }
72
+ /** Append a held envelope only when its eventId is not already held. */
73
+ export function appendHeldLineIfAbsent(line, root) {
74
+ return appendHeldLinesIfAbsent([line], root) === 1;
75
+ }
76
+ /** Append a deduplicated held batch under one file lock and one write. */
77
+ export function appendHeldLinesIfAbsent(lines, root) {
78
+ const heldPath = workMeshHeldPath(root ?? workMeshRoot());
79
+ ensureSpoolFile(heldPath);
80
+ return withJsonlLock(heldPath, () => {
81
+ const seen = readEventIds(heldPath);
82
+ const append = [];
83
+ for (const line of lines) {
84
+ const eventId = heldEventId(line);
85
+ if (eventId) {
86
+ if (seen.has(eventId))
87
+ continue;
88
+ seen.add(eventId);
89
+ }
90
+ append.push(line);
91
+ }
92
+ appendJsonlLines(heldPath, append);
93
+ return append.length;
94
+ });
95
+ }
96
+ function heldEventId(line) {
97
+ try {
98
+ const parsed = JSON.parse(line);
99
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
100
+ return null;
101
+ const record = parsed;
102
+ const event = record.event && typeof record.event === "object" && !Array.isArray(record.event)
103
+ ? record.event
104
+ : record;
105
+ return typeof event.eventId === "string" && event.eventId.trim() ? event.eventId.trim() : null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function readEventIds(filePath) {
112
+ const ids = new Set();
113
+ let raw;
114
+ try {
115
+ raw = fs.readFileSync(filePath, "utf8");
116
+ }
117
+ catch (error) {
118
+ if (error.code === "ENOENT")
119
+ return ids;
120
+ throw error;
121
+ }
122
+ for (const line of raw.split("\n")) {
123
+ if (!line.trim())
124
+ continue;
125
+ const eventId = heldEventId(line);
126
+ if (eventId)
127
+ ids.add(eventId);
128
+ }
129
+ return ids;
130
+ }
131
+ function appendJsonlLines(filePath, lines) {
132
+ if (lines.length === 0)
133
+ return;
134
+ ensureSpoolFile(filePath);
135
+ fs.appendFileSync(filePath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
136
+ chmodOwnerFile(filePath);
137
+ }
138
+ const JSONL_LOCK_STALE_MS = 5 * 60_000;
139
+ const JSONL_LOCK_TIMEOUT_MS = 5_000;
140
+ /** Serialize check-and-append with claims; stale locks expire after five minutes. */
141
+ function withJsonlLock(filePath, fn) {
142
+ const lockPath = `${filePath}.lock`;
143
+ const token = randomBytes(12).toString("hex");
144
+ const deadline = Date.now() + JSONL_LOCK_TIMEOUT_MS;
145
+ const waitCell = new Int32Array(new SharedArrayBuffer(4));
146
+ while (true) {
147
+ try {
148
+ const fd = fs.openSync(lockPath, "wx", 0o600);
149
+ try {
150
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() }));
151
+ }
152
+ finally {
153
+ fs.closeSync(fd);
154
+ }
155
+ break;
156
+ }
157
+ catch (error) {
158
+ if (error.code !== "EEXIST")
159
+ throw error;
160
+ let stale = false;
161
+ try {
162
+ const before = fs.statSync(lockPath);
163
+ stale = Date.now() - before.mtimeMs >= JSONL_LOCK_STALE_MS;
164
+ if (stale) {
165
+ const after = fs.statSync(lockPath);
166
+ if (before.dev === after.dev && before.ino === after.ino && after.mtimeMs === before.mtimeMs) {
167
+ fs.unlinkSync(lockPath);
168
+ }
169
+ else {
170
+ stale = false;
171
+ }
172
+ }
173
+ }
174
+ catch (statError) {
175
+ if (statError.code !== "ENOENT")
176
+ throw statError;
177
+ }
178
+ if (stale)
179
+ continue;
180
+ if (Date.now() >= deadline)
181
+ throw new SpoolWriteError(`Timed out waiting for ${path.basename(filePath)} lock`);
182
+ Atomics.wait(waitCell, 0, 0, 10);
183
+ }
184
+ }
185
+ let result;
186
+ let operationError;
187
+ let operationFailed = false;
188
+ try {
189
+ result = fn();
190
+ }
191
+ catch (error) {
192
+ operationError = error;
193
+ operationFailed = true;
194
+ }
195
+ let cleanupError;
196
+ try {
197
+ const record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
198
+ if (record.token === token)
199
+ fs.unlinkSync(lockPath);
200
+ }
201
+ catch (error) {
202
+ if (error.code !== "ENOENT")
203
+ cleanupError = error;
204
+ }
205
+ if (operationFailed && cleanupError !== undefined) {
206
+ throw new AggregateError([operationError, cleanupError], "JSONL write and lock cleanup failed");
207
+ }
208
+ if (operationFailed)
209
+ throw operationError;
210
+ if (cleanupError !== undefined)
211
+ throw cleanupError;
212
+ return result;
213
+ }
71
214
  export function appendDeadLetterLine(line, root) {
72
215
  const dl = workMeshDeadLetterPath(root ?? workMeshRoot());
73
- appendJsonlLine(dl, line);
216
+ withJsonlLock(dl, () => {
217
+ ensureSpoolFile(dl);
218
+ const ids = readEventIds(dl);
219
+ const eventId = heldEventId(line);
220
+ if (!eventId || !ids.has(eventId))
221
+ appendJsonlLine(dl, line);
222
+ });
74
223
  return dl;
75
224
  }
76
225
  export function appendDroppedReceipt(receipt, root) {
@@ -108,7 +257,8 @@ export function claimSpool(root, ts = `${Date.now()}`) {
108
257
  }
109
258
  export function claimHeld(root, ts = `${Date.now()}`) {
110
259
  ensureOwnerDir(root);
111
- return claimJsonlByRename(workMeshHeldPath(root), workMeshClaimPath("held", ts, root));
260
+ const heldPath = workMeshHeldPath(root);
261
+ return withJsonlLock(heldPath, () => claimJsonlByRename(heldPath, workMeshClaimPath("held", ts, root)));
112
262
  }
113
263
  /** Match leftover claim files: spool.<ts>.claimed / held.<ts>.claimed (and held.<ts>.held.claimed). */
114
264
  const ORPHAN_CLAIM_RE = /^(spool|held)\..+\.claimed$/;
@@ -195,6 +345,31 @@ export function countJsonlLines(filePath) {
195
345
  }
196
346
  return n;
197
347
  }
348
+ /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
349
+ export function countDistinctHeldEvents(filePath) {
350
+ let raw;
351
+ try {
352
+ raw = fs.readFileSync(filePath, "utf8");
353
+ }
354
+ catch {
355
+ // A concurrent claim can rename held.jsonl between existence and read checks.
356
+ return 0;
357
+ }
358
+ const seen = new Set();
359
+ let count = 0;
360
+ for (const line of raw.split("\n")) {
361
+ if (!line.trim())
362
+ continue;
363
+ const eventId = heldEventId(line);
364
+ if (eventId) {
365
+ if (seen.has(eventId))
366
+ continue;
367
+ seen.add(eventId);
368
+ }
369
+ count += 1;
370
+ }
371
+ return count;
372
+ }
198
373
  function record(value) {
199
374
  return value && typeof value === "object" && !Array.isArray(value)
200
375
  ? value
@@ -258,8 +433,32 @@ function countUnattributedJsonlLines(filePath) {
258
433
  export function countUnattributedEvents(root) {
259
434
  const meshRoot = root ?? workMeshRoot();
260
435
  const spool = countUnattributedJsonlLines(workMeshSpoolPath(meshRoot));
261
- const held = countUnattributedJsonlLines(workMeshHeldPath(meshRoot));
436
+ const held = countUnattributedHeldEvents(workMeshHeldPath(meshRoot));
262
437
  const deadLetter = countUnattributedJsonlLines(workMeshDeadLetterPath(meshRoot));
263
438
  return { spool, held, deadLetter, total: spool + held + deadLetter };
264
439
  }
440
+ function countUnattributedHeldEvents(filePath) {
441
+ let raw;
442
+ try {
443
+ raw = fs.readFileSync(filePath, "utf8");
444
+ }
445
+ catch {
446
+ // A concurrent held claim may rename this file between the read and open.
447
+ return 0;
448
+ }
449
+ const seen = new Set();
450
+ let count = 0;
451
+ for (const line of raw.split("\n")) {
452
+ if (!line.trim() || !isUnattributedLine(line))
453
+ continue;
454
+ const eventId = heldEventId(line);
455
+ if (eventId) {
456
+ if (seen.has(eventId))
457
+ continue;
458
+ seen.add(eventId);
459
+ }
460
+ count += 1;
461
+ }
462
+ return count;
463
+ }
265
464
  //# sourceMappingURL=spool.js.map
@@ -57,6 +57,17 @@ function readJson(filePath) {
57
57
  return null;
58
58
  }
59
59
  }
60
+ function isInactiveProject(hqRoot, project) {
61
+ if (!hqRoot || !project.prdPath)
62
+ return false;
63
+ const state = readJson(path.join(hqRoot, "workspace", "orchestrator", project.projectId, "state.json"));
64
+ const status = typeof state?.status === "string" ? state.status.trim().toLowerCase() : "";
65
+ if (status !== "archived" && status !== "retired")
66
+ return false;
67
+ if (typeof state?.prd_path !== "string" || !state.prd_path.trim())
68
+ return false;
69
+ return path.resolve(hqRoot, state.prd_path) === path.resolve(project.prdPath);
70
+ }
60
71
  function reposFromMetadata(metadata) {
61
72
  if (!metadata || typeof metadata !== "object")
62
73
  return [];
@@ -225,7 +236,8 @@ export function refineProjectBinding(input) {
225
236
  const hint = input.hint?.trim();
226
237
  if (!hint)
227
238
  return { action: "keep" };
228
- const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now());
239
+ const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now())
240
+ .filter((project) => !isInactiveProject(input.hqRoot?.trim(), project));
229
241
  const exact = catalog.find((row) => row.projectId === hint);
230
242
  if (exact?.hasBoard) {
231
243
  return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
package/dist/main.js CHANGED
@@ -38,7 +38,7 @@ import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
38
38
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
39
39
  import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
40
40
  import { upgradeToTeam } from "./utils/team-upgrade.js";
41
- import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
41
+ import { printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
42
42
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
43
43
  import { autoUpdateAndReexec } from "./utils/self-update.js";
44
44
  import { CLI_VERSION } from "./cli-version.js";
@@ -161,7 +161,8 @@ const program = new Command();
161
161
  program
162
162
  .name("hq")
163
163
  .description("HQ management CLI — modules, packages, and cloud sync")
164
- .version(CLI_VERSION);
164
+ .version(CLI_VERSION)
165
+ .option("--verbose", "Show verbose diagnostics, including SDK runtime warnings");
165
166
  program.hook("preAction", async () => {
166
167
  // A host that pinned HQ_AGENT_DIR has declared that it IS that agent, so
167
168
  // every command — `hq dm`, `hq secrets`, not only `hq agent …` — must
@@ -206,6 +207,7 @@ export async function runCli() {
206
207
  // already did the work, so this process only has to carry its exit status
207
208
  // out (after the finally block's telemetry, hence not process.exit here).
208
209
  let reexecStatus = null;
210
+ let versionNudgeHandled = false;
209
211
  try {
210
212
  runGlobalNetworkLifecycle(process.argv, () => Sentry.addBreadcrumb({
211
213
  category: "command",
@@ -248,6 +250,15 @@ export async function runCli() {
248
250
  });
249
251
  if (versionAction === "reexec")
250
252
  return;
253
+ // Commander exits immediately for its built-in --version action, before a
254
+ // nudge can be appended. Handle the global version-only form here so the
255
+ // installed semver stays the first stdout line and optional guidance follows.
256
+ if (process.argv.length === 3 &&
257
+ ["--version", "-V"].includes(process.argv[2] ?? "")) {
258
+ versionNudgeHandled = true;
259
+ await printVersionAndNudge();
260
+ return;
261
+ }
251
262
  // Register every contributor for the selected root only. Root help and
252
263
  // unknown-command recovery use generated metadata, so they keep the full
253
264
  // Commander surface without evaluating every command implementation.
@@ -298,7 +309,9 @@ export async function runCli() {
298
309
  const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
299
310
  const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
300
311
  await settleWithin([
301
- refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
312
+ ...(versionNudgeHandled
313
+ ? []
314
+ : [refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs })]),
302
315
  Sentry.flush(releaseHealthTimeoutMs),
303
316
  ...firstSyncOutcomeFlushes,
304
317
  ...inviteTeammateExposureFlushes,
@@ -137,6 +137,10 @@ export interface SelfUpdateDeps {
137
137
  currentVersion?: string;
138
138
  fetchLatest?: () => Promise<string | null>;
139
139
  resolveInstall?: () => RunningInstall;
140
+ /** Permission preflight for an npm global prefix (tests inject a read-only result). */
141
+ canWriteNpmInstall?: (install: RunningInstall) => boolean;
142
+ /** Home directory used for the durable per-install permission notice marker. */
143
+ homeDir?: string;
140
144
  /** Same-filesystem install rename, injectable for rollback tests. */
141
145
  renamePath?: typeof fs.renameSync;
142
146
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;