@indigoai-us/hq-cli 5.268.0 → 5.270.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 (37) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/command-catalog.generated.d.ts +77 -4
  3. package/dist/command-catalog.generated.js +98 -4
  4. package/dist/commands/files.js +6 -3
  5. package/dist/commands/lanes-hold.d.ts +2 -0
  6. package/dist/commands/lanes-hold.js +122 -310
  7. package/dist/commands/lanes-message.js +6 -4
  8. package/dist/commands/lanes-review.d.ts +10 -0
  9. package/dist/commands/lanes-review.js +56 -0
  10. package/dist/commands/lanes-shared.d.ts +1 -0
  11. package/dist/commands/lanes.d.ts +2 -0
  12. package/dist/commands/lanes.js +29 -4
  13. package/dist/lib/billing/plan-limit-upgrade-url.d.ts +1 -4
  14. package/dist/lib/billing/plan-limit-upgrade-url.js +7 -18
  15. package/dist/lib/doctor/checks/sync-runtime.d.ts +1 -1
  16. package/dist/lib/doctor/checks/sync-runtime.js +69 -19
  17. package/dist/lib/lanes/blocker-freshness.js +2 -1
  18. package/dist/lib/lanes/keep-alive.js +49 -20
  19. package/dist/lib/lanes/kr-freshness.js +3 -2
  20. package/dist/lib/lanes/lane-company.d.ts +3 -2
  21. package/dist/lib/lanes/lane-company.js +21 -4
  22. package/dist/lib/lanes/manager-status-gate.js +5 -3
  23. package/dist/lib/lanes/merge-command.d.ts +14 -0
  24. package/dist/lib/lanes/merge-command.js +363 -0
  25. package/dist/lib/lanes/reviews.d.ts +38 -0
  26. package/dist/lib/lanes/reviews.js +395 -0
  27. package/dist/lib/lanes/watcher.js +4 -2
  28. package/dist/lib/plan-limit-nag.js +2 -4
  29. package/dist/utils/feedback-logs.d.ts +14 -9
  30. package/dist/utils/feedback-logs.js +43 -14
  31. package/dist/utils/local-files-overview.d.ts +4 -2
  32. package/dist/utils/local-files-overview.js +47 -6
  33. package/dist/utils/sync-log-sources.d.ts +15 -0
  34. package/dist/utils/sync-log-sources.js +36 -0
  35. package/dist/utils/version-gate.d.ts +1 -0
  36. package/dist/utils/version-gate.js +6 -1
  37. package/package.json +1 -1
@@ -0,0 +1,395 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { assertNoSecretText } from "./text-scan.js";
5
+ import { LanesError } from "./errors.js";
6
+ export const LANE_REVIEWS_REL = path.join("workspace", "lanes", "reviews");
7
+ export const LANE_REVIEW_SCHEMA_VERSION = "hq-lanes-review.v1";
8
+ export const LANE_REVIEW_HISTORY_CAP = 50;
9
+ const REVIEW_LOCK_WAIT_MS = 5_000;
10
+ const REVIEW_LOCK_STALE_MS = 5 * 60_000;
11
+ function fail(error, message, details = {}) {
12
+ throw new LanesError(error, message, details);
13
+ }
14
+ export function canonicalLaneReviewRepo(raw) {
15
+ const value = raw.trim();
16
+ const parts = value.split("/");
17
+ if (parts.length !== 2) {
18
+ return fail("review_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
19
+ }
20
+ const [owner, repository] = parts;
21
+ if (!owner || !repository ||
22
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(owner) ||
23
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
24
+ owner === "." || owner === ".." || repository === "." || repository === "..") {
25
+ return fail("review_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
26
+ }
27
+ return `${owner.toLowerCase()}/${repository.toLowerCase()}`;
28
+ }
29
+ export function parseLaneReviewPr(raw) {
30
+ const text = String(raw).trim();
31
+ if (!/^\d+$/.test(text)) {
32
+ return fail("review_pr_invalid", "Pull request number must be a positive integer", { field: "pr" });
33
+ }
34
+ const pr = Number(text);
35
+ if (!Number.isSafeInteger(pr) || pr <= 0) {
36
+ return fail("review_pr_invalid", "Pull request number must be a positive integer", { field: "pr" });
37
+ }
38
+ return pr;
39
+ }
40
+ function parseHeadSha(value, field = "head") {
41
+ const head = value.trim();
42
+ if (!/^[a-fA-F0-9]{40}$/.test(head)) {
43
+ return fail("review_head_invalid", "Head must be a 40-character hexadecimal commit SHA", { field });
44
+ }
45
+ return head.toLowerCase();
46
+ }
47
+ function reviewPath(hqRoot, repo, pr) {
48
+ const [owner, name] = canonicalLaneReviewRepo(repo).split("/");
49
+ return path.join(hqRoot, LANE_REVIEWS_REL, `${owner}__${name}`, `${parseLaneReviewPr(pr)}.json`);
50
+ }
51
+ export function laneReviewPath(hqRoot, repo, pr) {
52
+ return reviewPath(hqRoot, repo, pr);
53
+ }
54
+ function isObject(value) {
55
+ return value !== null && typeof value === "object" && !Array.isArray(value);
56
+ }
57
+ function isTimestamp(value) {
58
+ if (typeof value !== "string")
59
+ return false;
60
+ const date = Date.parse(value);
61
+ return Number.isFinite(date) && new Date(date).toISOString() === value;
62
+ }
63
+ function isNullableText(value) {
64
+ return value === null || (typeof value === "string" && value.length > 0);
65
+ }
66
+ function storedTextIsSafe(field, value, file) {
67
+ try {
68
+ assertNoSecretText(field, value);
69
+ }
70
+ catch {
71
+ fail("review_record_invalid", "Review record contains unsafe text", { path: file, field });
72
+ }
73
+ }
74
+ function parseHistoryEntry(value, file) {
75
+ if (!isObject(value) || typeof value.repo !== "string" ||
76
+ !Number.isSafeInteger(value.pr) || value.pr <= 0 ||
77
+ typeof value.head_sha !== "string" || !/^[a-f0-9]{40}$/.test(value.head_sha) ||
78
+ !isNullableText(value.reviewer_lane) || !isNullableText(value.reviewer_session) ||
79
+ !isNullableText(value.worker_lane) || !isNullableText(value.note) ||
80
+ !isTimestamp(value.recorded_at)) {
81
+ return fail("review_record_invalid", "Review history entry has invalid fields", { path: file });
82
+ }
83
+ let repo;
84
+ try {
85
+ repo = canonicalLaneReviewRepo(value.repo);
86
+ }
87
+ catch {
88
+ return fail("review_record_invalid", "Review history entry has an invalid repository", { path: file });
89
+ }
90
+ if (repo !== value.repo) {
91
+ return fail("review_record_invalid", "Review history repository is not canonical", { path: file });
92
+ }
93
+ for (const field of ["reviewer_lane", "reviewer_session", "worker_lane", "note"]) {
94
+ const text = value[field];
95
+ if (typeof text === "string")
96
+ storedTextIsSafe(`review.${field}`, text, file);
97
+ }
98
+ return value;
99
+ }
100
+ function parseReview(value, file) {
101
+ if (!isObject(value) || value.schema_version !== LANE_REVIEW_SCHEMA_VERSION || !Array.isArray(value.history)) {
102
+ return fail("review_record_invalid", "Review record has invalid fields", { path: file });
103
+ }
104
+ const current = parseHistoryEntry(value, file);
105
+ if (value.history.length > LANE_REVIEW_HISTORY_CAP) {
106
+ return fail("review_record_invalid", "Review record history exceeds its cap", { path: file });
107
+ }
108
+ const history = value.history.map((entry) => parseHistoryEntry(entry, file));
109
+ return { ...current, schema_version: LANE_REVIEW_SCHEMA_VERSION, history };
110
+ }
111
+ export function readLaneReview(hqRoot, repo, pr) {
112
+ const canonicalRepo = canonicalLaneReviewRepo(repo);
113
+ const canonicalPr = parseLaneReviewPr(pr);
114
+ const file = reviewPath(hqRoot, canonicalRepo, canonicalPr);
115
+ let contents;
116
+ try {
117
+ contents = fs.readFileSync(file, "utf8");
118
+ }
119
+ catch (error) {
120
+ if (error.code === "ENOENT")
121
+ return null;
122
+ return fail("review_record_unreadable", "Review record could not be read", { path: file });
123
+ }
124
+ let value;
125
+ try {
126
+ value = JSON.parse(contents);
127
+ }
128
+ catch {
129
+ return fail("review_record_invalid", "Review record is not valid JSON", { path: file });
130
+ }
131
+ const review = parseReview(value, file);
132
+ if (review.repo !== canonicalRepo || review.pr !== canonicalPr || reviewPath(hqRoot, review.repo, review.pr) !== file) {
133
+ return fail("review_record_invalid", "Review record identity does not match its path", { path: file });
134
+ }
135
+ return review;
136
+ }
137
+ export function listLaneReviews(hqRoot, reviewerLane) {
138
+ const directory = path.join(hqRoot, LANE_REVIEWS_REL);
139
+ let repoDirectories;
140
+ try {
141
+ repoDirectories = fs.readdirSync(directory).sort();
142
+ }
143
+ catch (error) {
144
+ if (error.code === "ENOENT")
145
+ return { reviews: [], skipped: 0 };
146
+ return { reviews: [], skipped: 1 };
147
+ }
148
+ const reviews = [];
149
+ let skipped = 0;
150
+ for (const repoDirectory of repoDirectories) {
151
+ const match = /^([a-z0-9-]+)__([a-z0-9._-]+)$/.exec(repoDirectory);
152
+ if (!match || repoDirectory.startsWith(".")) {
153
+ skipped += 1;
154
+ continue;
155
+ }
156
+ const repoPath = path.join(directory, repoDirectory);
157
+ try {
158
+ const stat = fs.lstatSync(repoPath);
159
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
160
+ skipped += 1;
161
+ continue;
162
+ }
163
+ }
164
+ catch {
165
+ skipped += 1;
166
+ continue;
167
+ }
168
+ const repo = `${match[1]}/${match[2]}`;
169
+ let names;
170
+ try {
171
+ names = fs.readdirSync(repoPath).sort();
172
+ }
173
+ catch {
174
+ skipped += 1;
175
+ continue;
176
+ }
177
+ for (const name of names) {
178
+ if (/^\d+\.json\.lock$/.test(name) || /^\d+\.json\.\d+\.[a-f0-9]{12}\.tmp$/.test(name))
179
+ continue;
180
+ if (name.startsWith(".") || !/^\d+\.json$/.test(name)) {
181
+ skipped += 1;
182
+ continue;
183
+ }
184
+ const file = path.join(repoPath, name);
185
+ try {
186
+ const stat = fs.lstatSync(file);
187
+ if (!stat.isFile() || stat.isSymbolicLink()) {
188
+ skipped += 1;
189
+ continue;
190
+ }
191
+ }
192
+ catch {
193
+ skipped += 1;
194
+ continue;
195
+ }
196
+ let pr;
197
+ try {
198
+ pr = parseLaneReviewPr(name.slice(0, -5));
199
+ }
200
+ catch {
201
+ skipped += 1;
202
+ continue;
203
+ }
204
+ let review;
205
+ try {
206
+ review = readLaneReview(hqRoot, repo, pr);
207
+ }
208
+ catch {
209
+ skipped += 1;
210
+ continue;
211
+ }
212
+ if (!review) {
213
+ skipped += 1;
214
+ continue;
215
+ }
216
+ if (reviewerLane === undefined || review.reviewer_lane === reviewerLane)
217
+ reviews.push(review);
218
+ }
219
+ }
220
+ return {
221
+ reviews: reviews.sort((left, right) => right.recorded_at.localeCompare(left.recorded_at) ||
222
+ left.repo.localeCompare(right.repo) || left.pr - right.pr),
223
+ skipped,
224
+ };
225
+ }
226
+ export async function recordLaneReview(hqRoot, input) {
227
+ const repo = canonicalLaneReviewRepo(input.repo);
228
+ const pr = parseLaneReviewPr(input.pr);
229
+ const head_sha = parseHeadSha(input.headSha);
230
+ const reviewer_lane = input.reviewerLane?.trim() || null;
231
+ const reviewer_session = input.reviewerSession?.trim() || null;
232
+ const worker_lane = input.workerLane?.trim() || null;
233
+ const note = input.note?.trim() || null;
234
+ if (worker_lane && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(worker_lane)) {
235
+ return fail("review_worker_invalid", "Worker lane id has an invalid format", { field: "worker" });
236
+ }
237
+ if (reviewer_lane)
238
+ assertNoSecretText("reviewer_lane", reviewer_lane);
239
+ if (reviewer_session)
240
+ assertNoSecretText("reviewer_session", reviewer_session);
241
+ if (note)
242
+ assertNoSecretText("note", note);
243
+ if (worker_lane)
244
+ assertNoSecretText("worker", worker_lane);
245
+ const file = reviewPath(hqRoot, repo, pr);
246
+ return withReviewRecordLock(file, async () => {
247
+ const previous = readLaneReview(hqRoot, repo, pr);
248
+ const entry = {
249
+ repo,
250
+ pr,
251
+ head_sha,
252
+ reviewer_lane,
253
+ reviewer_session,
254
+ worker_lane,
255
+ note,
256
+ recorded_at: input.recordedAt.toISOString(),
257
+ };
258
+ const review = {
259
+ ...entry,
260
+ schema_version: LANE_REVIEW_SCHEMA_VERSION,
261
+ history: [...(previous?.history ?? []), ...(previous ? [currentHistoryEntry(previous)] : [])]
262
+ .slice(-LANE_REVIEW_HISTORY_CAP),
263
+ };
264
+ writeAtomic(file, review);
265
+ return review;
266
+ });
267
+ }
268
+ function reviewLockOwnerAlive(pid) {
269
+ try {
270
+ process.kill(pid, 0);
271
+ return true;
272
+ }
273
+ catch (error) {
274
+ return error.code !== "ESRCH";
275
+ }
276
+ }
277
+ function removeStaleReviewLock(lockPath) {
278
+ let first;
279
+ try {
280
+ first = fs.lstatSync(lockPath);
281
+ }
282
+ catch (error) {
283
+ if (error.code === "ENOENT")
284
+ return true;
285
+ throw error;
286
+ }
287
+ if (Date.now() - first.mtimeMs < REVIEW_LOCK_STALE_MS)
288
+ return false;
289
+ let pid;
290
+ try {
291
+ pid = Number(fs.readFileSync(lockPath, "utf8").split("\n", 1)[0]);
292
+ }
293
+ catch (error) {
294
+ if (error.code === "ENOENT")
295
+ return true;
296
+ throw error;
297
+ }
298
+ if (!Number.isSafeInteger(pid) || pid <= 0 || reviewLockOwnerAlive(pid))
299
+ return false;
300
+ let second;
301
+ try {
302
+ second = fs.lstatSync(lockPath);
303
+ }
304
+ catch (error) {
305
+ if (error.code === "ENOENT")
306
+ return true;
307
+ throw error;
308
+ }
309
+ if (second.dev !== first.dev || second.ino !== first.ino || second.mtimeMs !== first.mtimeMs)
310
+ return false;
311
+ try {
312
+ fs.unlinkSync(lockPath);
313
+ return true;
314
+ }
315
+ catch (error) {
316
+ if (error.code === "ENOENT")
317
+ return true;
318
+ throw error;
319
+ }
320
+ }
321
+ async function withReviewRecordLock(file, action) {
322
+ const lockPath = `${file}.lock`;
323
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
324
+ const started = Date.now();
325
+ let fd;
326
+ while (fd === undefined) {
327
+ try {
328
+ fd = fs.openSync(lockPath, "wx", 0o600);
329
+ }
330
+ catch (error) {
331
+ if (error.code !== "EEXIST")
332
+ throw error;
333
+ if (Date.now() - started >= REVIEW_LOCK_WAIT_MS) {
334
+ return fail("review_record_write_busy", "This review record is being updated; retry the command", { path: file });
335
+ }
336
+ if (!removeStaleReviewLock(lockPath))
337
+ await new Promise((resolve) => setTimeout(resolve, 10));
338
+ }
339
+ }
340
+ let ownedStat;
341
+ try {
342
+ ownedStat = fs.fstatSync(fd);
343
+ fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
344
+ return await action();
345
+ }
346
+ finally {
347
+ try {
348
+ fs.closeSync(fd);
349
+ }
350
+ catch (error) {
351
+ process.stderr.write(`hq lanes review: lock close failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
352
+ }
353
+ if (ownedStat) {
354
+ try {
355
+ const current = fs.lstatSync(lockPath);
356
+ if (current.dev === ownedStat.dev && current.ino === ownedStat.ino)
357
+ fs.unlinkSync(lockPath);
358
+ }
359
+ catch (error) {
360
+ if (error.code !== "ENOENT") {
361
+ process.stderr.write(`hq lanes review: lock cleanup failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ function currentHistoryEntry(review) {
368
+ const { repo, pr, head_sha, reviewer_lane, reviewer_session, worker_lane, note, recorded_at } = review;
369
+ return { repo, pr, head_sha, reviewer_lane, reviewer_session, worker_lane, note, recorded_at };
370
+ }
371
+ function writeAtomic(file, review) {
372
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
373
+ try {
374
+ fs.writeFileSync(temporary, `${JSON.stringify(review, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
375
+ fs.renameSync(temporary, file);
376
+ fs.chmodSync(file, 0o600);
377
+ }
378
+ catch (error) {
379
+ try {
380
+ fs.rmSync(temporary, { force: true });
381
+ }
382
+ catch (cleanupError) {
383
+ throw new LanesError("review_record_write_failed", "Review record could not be written and temporary cleanup failed", {
384
+ path: file,
385
+ error: error instanceof Error ? error.name : "UnknownError",
386
+ cleanup_error: cleanupError instanceof Error ? cleanupError.name : "UnknownError",
387
+ });
388
+ }
389
+ throw new LanesError("review_record_write_failed", "Review record could not be written", {
390
+ path: file,
391
+ error: error instanceof Error ? error.name : "UnknownError",
392
+ });
393
+ }
394
+ }
395
+ //# sourceMappingURL=reviews.js.map
@@ -48,6 +48,7 @@ import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission
48
48
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
49
49
  import { envelopeQuestionId } from "./questions.js";
50
50
  import { readLanesRegistryFlag } from "./registry-flag.js";
51
+ import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
51
52
  import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "./blocker-freshness.js";
52
53
  import { krFreshnessFingerprint, resolveKrFreshnessForLane, } from "./kr-freshness.js";
53
54
  import { MANAGER_STATUS_GATE_FLAG, managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, managerStatusGateEnabledForLane, } from "./manager-status-gate.js";
@@ -647,11 +648,12 @@ async function settleManagerStatusGate(hqRoot, lane, manifestTurnId, at, deps, l
647
648
  const readFlag = deps.readManagerStatusGateFlag ?? readLanesRegistryFlag;
648
649
  let enabled = false;
649
650
  try {
650
- const company = lane.company ?? lane.mesh_company ?? undefined;
651
+ const resolution = resolveLaneCompany(hqRoot, lane);
652
+ const company = resolution.company ?? undefined;
651
653
  enabled = await readFlag(MANAGER_STATUS_GATE_FLAG, {
652
654
  company,
653
655
  ...(company ? {
654
- companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
656
+ companyUidSource: laneCompanyUidSource(resolution.source),
655
657
  } : {}),
656
658
  });
657
659
  }
@@ -37,7 +37,7 @@ import * as fs from "node:fs";
37
37
  import * as os from "node:os";
38
38
  import * as path from "node:path";
39
39
  import { resolveProcessFlagCompanyContext } from "./flag-registry.js";
40
- import { isPlanLimitUpgradeFallbackEnabled, planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
40
+ import { planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
41
41
  export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
42
42
  /**
43
43
  * Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
@@ -230,9 +230,7 @@ export function recordPlanLimitStatus(body, opts = {}) {
230
230
  : null;
231
231
  const topLevelUpgradeUrl = parseUpgradeUrl(bodyRecord?.upgradeUrl);
232
232
  const nestedUpgradeUrl = parseNestedUpgradeUrl(body);
233
- const companySlug = isPlanLimitUpgradeFallbackEnabled()
234
- ? resolveProcessFlagCompanyContext().companySlug
235
- : undefined;
233
+ const companySlug = resolveProcessFlagCompanyContext().companySlug;
236
234
  resolvedUpgradeUrl = selectPlanLimitUpgradeUrl(companySlug, {
237
235
  topLevelUrl: topLevelUpgradeUrl,
238
236
  nestedUrl: nestedUpgradeUrl,
@@ -13,8 +13,9 @@
13
13
  * (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
14
14
  * the active file is nearly empty and the history lives in `.1`.
15
15
  * `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
16
- * Any future `*.log`/`*.jsonl` dropped into `~/.hq/logs/` is picked up
17
- * automatically.
16
+ * The daemon runner's `daemon/logs/sync.log` and its three rotations are
17
+ * eligible too. When both sync families exist, only the newer family is
18
+ * attached.
18
19
  *
19
20
  * - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
20
21
  * and client health currently stand: the in-flight sync phase and conflict
@@ -36,8 +37,9 @@
36
37
  *
37
38
  * 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
38
39
  * `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
39
- * directly inside `~/.hq/logs/` plus the two known root-level boot logs
40
- * are ever eligible. A recursive walk over `~/.hq` is a credential leak
40
+ * directly inside `~/.hq/logs/`, the exact daemon sync-log generations
41
+ * under the configured daemon root, and the two known root-level boot
42
+ * logs are eligible. A recursive walk over `~/.hq` is a credential leak
41
43
  * and must never be introduced here.
42
44
  *
43
45
  * 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
@@ -235,12 +237,13 @@ export declare function feedbackLogsEnabled(env?: NodeJS.ProcessEnv): boolean;
235
237
  * Enumerate eligible log files under `hqDir`.
236
238
  *
237
239
  * Eligibility is the allowlist described in the module header: `*.log` files
238
- * directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
239
- * the root. Every candidate must be a REGULAR file by `lstat` — directories
240
- * and, critically, symlinks are rejected. Returned newest-modified first so
241
- * the most relevant log is offered the budget before older ones.
240
+ * directly inside `hqDir/logs/`, the exact daemon sync-log generations, plus
241
+ * {@link FEEDBACK_ROOT_LOG_FILENAMES} at the root. Every candidate must be a
242
+ * REGULAR file by `lstat` — directories and, critically, symlinks are rejected.
243
+ * Returned newest-modified first so the most relevant log is offered budget
244
+ * before older ones.
242
245
  */
243
- export declare function discoverLogFiles(hqDir: string): LogCandidate[];
246
+ export declare function discoverLogFiles(hqDir: string, env?: NodeJS.ProcessEnv): LogCandidate[];
244
247
  /**
245
248
  * Read the last `maxBytes` of a file.
246
249
  *
@@ -314,6 +317,8 @@ export interface CollectFeedbackLogsOptions {
314
317
  hqDir?: string;
315
318
  /** Override the home directory used to derive `~/.hq` (tests). */
316
319
  homeDir?: string;
320
+ /** Override process environment for configured log paths (tests). */
321
+ env?: NodeJS.ProcessEnv;
317
322
  }
318
323
  /**
319
324
  * Collect redacted `~/.hq` evidence within `budgetBytes`.
@@ -13,8 +13,9 @@
13
13
  * (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
14
14
  * the active file is nearly empty and the history lives in `.1`.
15
15
  * `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
16
- * Any future `*.log`/`*.jsonl` dropped into `~/.hq/logs/` is picked up
17
- * automatically.
16
+ * The daemon runner's `daemon/logs/sync.log` and its three rotations are
17
+ * eligible too. When both sync families exist, only the newer family is
18
+ * attached.
18
19
  *
19
20
  * - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
20
21
  * and client health currently stand: the in-flight sync phase and conflict
@@ -36,8 +37,9 @@
36
37
  *
37
38
  * 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
38
39
  * `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
39
- * directly inside `~/.hq/logs/` plus the two known root-level boot logs
40
- * are ever eligible. A recursive walk over `~/.hq` is a credential leak
40
+ * directly inside `~/.hq/logs/`, the exact daemon sync-log generations
41
+ * under the configured daemon root, and the two known root-level boot
42
+ * logs are eligible. A recursive walk over `~/.hq` is a credential leak
41
43
  * and must never be introduced here.
42
44
  *
43
45
  * 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
@@ -58,6 +60,7 @@
58
60
  import * as fs from "node:fs";
59
61
  import * as os from "node:os";
60
62
  import * as path from "node:path";
63
+ import { currentSyncLogFamily, SYNC_LOG_RELATIVE_PATHS, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogFamilyForRelativePath, syncLogGenerationPath, } from "./sync-log-sources.js";
61
64
  /**
62
65
  * Ceiling on the total redacted log text attached to one submission.
63
66
  *
@@ -279,15 +282,15 @@ function lstatOrNull(absPath) {
279
282
  * Enumerate eligible log files under `hqDir`.
280
283
  *
281
284
  * Eligibility is the allowlist described in the module header: `*.log` files
282
- * directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
283
- * the root. Every candidate must be a REGULAR file by `lstat` — directories
284
- * and, critically, symlinks are rejected. Returned newest-modified first so
285
- * the most relevant log is offered the budget before older ones.
285
+ * directly inside `hqDir/logs/`, the exact daemon sync-log generations, plus
286
+ * {@link FEEDBACK_ROOT_LOG_FILENAMES} at the root. Every candidate must be a
287
+ * REGULAR file by `lstat` — directories and, critically, symlinks are rejected.
288
+ * Returned newest-modified first so the most relevant log is offered budget
289
+ * before older ones.
286
290
  */
287
- export function discoverLogFiles(hqDir) {
291
+ export function discoverLogFiles(hqDir, env = process.env) {
288
292
  const candidates = [];
289
- const consider = (relName) => {
290
- const absPath = path.join(hqDir, relName);
293
+ const consider = (relName, absPath = path.join(hqDir, relName)) => {
291
294
  const st = lstatOrNull(absPath);
292
295
  // isFile() is false for both directories and symlinks under lstat. A
293
296
  // symlink here would be the credential-exfiltration path (see header).
@@ -313,11 +316,37 @@ export function discoverLogFiles(hqDir) {
313
316
  continue;
314
317
  consider(`${FEEDBACK_LOGS_DIRNAME}/${entry}`);
315
318
  }
319
+ const daemonLogPath = syncLogBasePaths(hqDir, env).daemon;
320
+ for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
321
+ const relName = syncLogGenerationPath(SYNC_LOG_RELATIVE_PATHS.daemon, generation);
322
+ const absPath = syncLogGenerationPath(daemonLogPath, generation);
323
+ consider(relName, absPath);
324
+ }
316
325
  for (const filename of FEEDBACK_ROOT_LOG_FILENAMES) {
317
326
  consider(filename);
318
327
  }
319
- candidates.sort((a, b) => b.modifiedMs - a.modifiedMs);
320
- return candidates;
328
+ let desktopModifiedMs = null;
329
+ let daemonModifiedMs = null;
330
+ for (const candidate of candidates) {
331
+ const family = syncLogFamilyForRelativePath(candidate.name);
332
+ if (family === "desktop") {
333
+ desktopModifiedMs = desktopModifiedMs === null
334
+ ? candidate.modifiedMs
335
+ : Math.max(desktopModifiedMs, candidate.modifiedMs);
336
+ }
337
+ else if (family === "daemon") {
338
+ daemonModifiedMs = daemonModifiedMs === null
339
+ ? candidate.modifiedMs
340
+ : Math.max(daemonModifiedMs, candidate.modifiedMs);
341
+ }
342
+ }
343
+ const selectedFamily = currentSyncLogFamily(desktopModifiedMs, daemonModifiedMs);
344
+ const selected = candidates.filter((candidate) => {
345
+ const family = syncLogFamilyForRelativePath(candidate.name);
346
+ return family === null || family === selectedFamily;
347
+ });
348
+ selected.sort((a, b) => b.modifiedMs - a.modifiedMs);
349
+ return selected;
321
350
  }
322
351
  /**
323
352
  * Read the last `maxBytes` of a file.
@@ -546,7 +575,7 @@ export function collectFeedbackLogs(opts = {}) {
546
575
  let logCandidates;
547
576
  let stateCandidates;
548
577
  try {
549
- logCandidates = discoverLogFiles(hqDir);
578
+ logCandidates = discoverLogFiles(hqDir, opts.env);
550
579
  // Order matters: root status documents first, then the lock/claim files
551
580
  // (highest signal for a hang), then scheduled-job status. Under a tight
552
581
  // budget the earlier ones win, and that is the intended priority.
@@ -11,10 +11,11 @@
11
11
  * cross the wire; there is no string field on it for anything sensitive to
12
12
  * hide inside.
13
13
  *
14
- * Sources (metadata only, via `fs.stat` — full content is read only for the
14
+ * Sources (metadata only, via `fs.lstat` — full content is read only for the
15
15
  * bounded error-line count below, and even then only a capped tail window,
16
16
  * and even then only an integer count is ever returned):
17
- * - the hq-sync desktop app's log file, `~/.hq/logs/hq-sync.log`
17
+ * - the newest available desktop or daemon sync-log family and its rotations
18
+ * under `~/.hq/logs/` or `~/.hq/daemon/logs/`
18
19
  * - the CLI's own per-company sync journals, enumerated via `listJournals()`
19
20
  * (never a hand-reconstructed path — see client-health.ts's note on the
20
21
  * single-path-reconstruction regression this mirrors).
@@ -42,6 +43,7 @@ export declare function countRecentErrorLines(filePath: string, maxBytes?: numbe
42
43
  export interface CollectLocalFilesOverviewDeps {
43
44
  now?: () => Date;
44
45
  homeDir?: () => string;
46
+ env?: NodeJS.ProcessEnv;
45
47
  journals?: () => readonly {
46
48
  path: string;
47
49
  }[];