@indigoai-us/hq-cli 5.345.146 → 5.345.148

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 (138) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +14 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +326 -4
  5. package/dist/command-catalog.generated.js +403 -4
  6. package/dist/commands/agent-inbox.js +2 -1
  7. package/dist/commands/auth.js +4 -2
  8. package/dist/commands/bot.js +1 -1
  9. package/dist/commands/cloud.js +31 -1
  10. package/dist/commands/core-refresh-vault-access-native.d.ts +17 -0
  11. package/dist/commands/core-refresh-vault-access-native.js +214 -19
  12. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  13. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  14. package/dist/commands/core.js +6 -0
  15. package/dist/commands/daemon.d.ts +7 -0
  16. package/dist/commands/daemon.js +37 -3
  17. package/dist/commands/dm.js +9 -2
  18. package/dist/commands/doctor.d.ts +11 -1
  19. package/dist/commands/doctor.js +56 -1
  20. package/dist/commands/files.js +10 -6
  21. package/dist/commands/install-global.d.ts +7 -0
  22. package/dist/commands/install-global.js +129 -35
  23. package/dist/commands/lanes-shared.d.ts +3 -1
  24. package/dist/commands/lanes.d.ts +4 -4
  25. package/dist/commands/lanes.js +380 -25
  26. package/dist/commands/login.js +4 -3
  27. package/dist/commands/pack-install.d.ts +2 -2
  28. package/dist/commands/pack-install.js +133 -35
  29. package/dist/commands/sync-manifest.d.ts +2 -9
  30. package/dist/commands/sync-manifest.js +4 -15
  31. package/dist/hook-engine-path.d.ts +4 -0
  32. package/dist/hook-engine-path.js +36 -0
  33. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2784 -2360
  34. package/dist/hq-cloud-sync-runner/package.json +1 -1
  35. package/dist/index.js +6 -0
  36. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  37. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  38. package/dist/lib/agent-kit/inbox-state.js +11 -2
  39. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  40. package/dist/lib/agent-kit/run/inbox.js +36 -7
  41. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  42. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  43. package/dist/lib/bot/daemon.js +3 -0
  44. package/dist/lib/bot/runtime/index.js +3 -1
  45. package/dist/lib/company-skill-wrapper.js +6 -4
  46. package/dist/lib/daemon/install.d.ts +47 -1
  47. package/dist/lib/daemon/install.js +181 -15
  48. package/dist/lib/daemon/lock.d.ts +32 -0
  49. package/dist/lib/daemon/lock.js +55 -2
  50. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  51. package/dist/lib/doctor/checks/anywhere.d.ts +3 -0
  52. package/dist/lib/doctor/checks/anywhere.js +23 -8
  53. package/dist/lib/doctor/checks/cli-path.d.ts +11 -0
  54. package/dist/lib/doctor/checks/cli-path.js +45 -0
  55. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  56. package/dist/lib/doctor/checks/hook-engine.js +16 -0
  57. package/dist/lib/doctor/checks/release-links.d.ts +23 -0
  58. package/dist/lib/doctor/checks/release-links.js +152 -0
  59. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  60. package/dist/lib/doctor/checks/sync-health.js +38 -2
  61. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  62. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  63. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  64. package/dist/lib/doctor/fix/apply.js +176 -10
  65. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  66. package/dist/lib/doctor/fix/json-output.js +6 -0
  67. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  68. package/dist/lib/doctor/fix/remediation.js +23 -1
  69. package/dist/lib/doctor/registry.js +13 -0
  70. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  71. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  72. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  73. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  74. package/dist/lib/flag-registry.js +9 -1
  75. package/dist/lib/flag-snapshot-file.js +8 -3
  76. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  77. package/dist/lib/hook-engine/resolve.js +134 -0
  78. package/dist/lib/lanes/admission-host.d.ts +1 -0
  79. package/dist/lib/lanes/admission-host.js +8 -1
  80. package/dist/lib/lanes/admission.d.ts +4 -0
  81. package/dist/lib/lanes/admission.js +6 -2
  82. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  83. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  84. package/dist/lib/lanes/index.d.ts +2 -2
  85. package/dist/lib/lanes/index.js +1 -1
  86. package/dist/lib/lanes/keep-alive.js +4 -1
  87. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  88. package/dist/lib/lanes/local-host-identity.js +314 -0
  89. package/dist/lib/lanes/reaper.d.ts +1 -1
  90. package/dist/lib/lanes/reaper.js +7 -0
  91. package/dist/lib/lanes/reconcile.d.ts +4 -0
  92. package/dist/lib/lanes/reconcile.js +16 -8
  93. package/dist/lib/lanes/spawn.d.ts +20 -0
  94. package/dist/lib/lanes/spawn.js +48 -2
  95. package/dist/lib/lanes/status-fields.js +6 -1
  96. package/dist/lib/lanes/store.js +8 -1
  97. package/dist/lib/lanes/throttle.d.ts +49 -6
  98. package/dist/lib/lanes/throttle.js +154 -17
  99. package/dist/lib/lanes/types.d.ts +22 -1
  100. package/dist/lib/lanes/types.js +27 -3
  101. package/dist/lib/lanes/watcher.js +27 -2
  102. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  103. package/dist/lib/lanes/worker-continuity.js +18 -11
  104. package/dist/lib/manifest-disabled.d.ts +13 -0
  105. package/dist/lib/manifest-disabled.js +19 -0
  106. package/dist/lib/service-manager/launchd.d.ts +1 -0
  107. package/dist/lib/service-manager/launchd.js +1 -1
  108. package/dist/lib/windows/user-path.d.ts +48 -0
  109. package/dist/lib/windows/user-path.js +114 -0
  110. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  111. package/dist/lib/work-context/auto-bind.js +22 -3
  112. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  113. package/dist/lib/work-context/lane-bind.js +101 -0
  114. package/dist/lib/work-context/organize.js +1 -0
  115. package/dist/lib/work-context/prd-sync.js +7 -0
  116. package/dist/lib/work-context/project.d.ts +36 -10
  117. package/dist/lib/work-context/project.js +130 -41
  118. package/dist/lib/work-context/reconcile.d.ts +6 -0
  119. package/dist/lib/work-context/reconcile.js +25 -5
  120. package/dist/lib/work-context/state.d.ts +6 -0
  121. package/dist/lib/work-context/state.js +29 -1
  122. package/dist/lib/workers-registry/index.d.ts +6 -0
  123. package/dist/lib/workers-registry/index.js +8 -0
  124. package/dist/utils/copy-tree.d.ts +61 -0
  125. package/dist/utils/copy-tree.js +249 -0
  126. package/dist/utils/environmental-error.js +7 -3
  127. package/dist/utils/headless.d.ts +2 -0
  128. package/dist/utils/headless.js +8 -0
  129. package/dist/utils/login-browser.d.ts +4 -0
  130. package/dist/utils/login-browser.js +122 -0
  131. package/dist/utils/npm-invocation.d.ts +27 -0
  132. package/dist/utils/npm-invocation.js +54 -0
  133. package/dist/utils/safe-link.d.ts +50 -0
  134. package/dist/utils/safe-link.js +300 -0
  135. package/dist/utils/team-upgrade.js +1 -7
  136. package/package.json +17 -5
  137. package/scripts/hook-engine-manifest.mjs +22 -0
  138. package/scripts/install-hook-engine.mjs +10 -0
@@ -56,7 +56,11 @@ export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
56
56
  export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
57
57
  /** A newly unlocked safe repair can be picked up for at most this many passes. */
58
58
  export const MAX_FIX_PASSES = 3;
59
- /** Two 90s pulls leave 120s for startup and check overhead under the hook's 300s ceiling. */
59
+ /**
60
+ * Two 90s network repairs leave 120s for startup and check overhead under the
61
+ * hook's 300s ceiling. Journal pulls and manifest uploads share this budget;
62
+ * pulls go first and uploads take any slot left.
63
+ */
60
64
  export const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
61
65
  function rotateRefreshSyncPlans(plans, afterSlug) {
62
66
  const ordered = [...plans].sort((a, b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
@@ -85,6 +89,7 @@ export async function applyFixes(options) {
85
89
  restoreCommand: null,
86
90
  backups: [],
87
91
  applied: [],
92
+ unresolved: [],
88
93
  ...over,
89
94
  });
90
95
  // 1. Dirty-tree refusal (AC8). Never write over uncommitted security-layer
@@ -99,7 +104,9 @@ export async function applyFixes(options) {
99
104
  // Journal refreshes never write hook files. Allow them by class, not
100
105
  // only by empty relpath, so a personal-vault pull and a company pull
101
106
  // still run when SessionStart `--fix --yes` cannot pass `--force`.
102
- const allowed = plans.filter((plan) => plan.fixClass === "refresh-sync" || plan.relpath.length === 0);
107
+ const allowed = plans.filter((plan) => plan.fixClass === "refresh-sync" ||
108
+ plan.fixClass === "refresh-manifest" ||
109
+ plan.relpath.length === 0);
103
110
  if (allowed.length === plans.length)
104
111
  return plans;
105
112
  if (allowed.length === 0)
@@ -109,12 +116,14 @@ export async function applyFixes(options) {
109
116
  `re-run with --force to repair hook files.\n`);
110
117
  return allowed;
111
118
  };
112
- const refuseDirtyTree = () => {
119
+ const refuseDirtyTree = (results) => {
113
120
  writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
114
121
  `${HOOK_CONFIG_DIRS.join(", ")}:\n` +
115
122
  dirty.map((line) => ` ${line}`).join("\n") +
116
123
  `\nCommit or stash them, or re-run with --force.\n`);
117
- return empty({ exitCode: 1, refused: "dirty-tree" });
124
+ const unresolved = unresolvedSyncChecks(results, []);
125
+ writeUnresolvedSyncChecks(unresolved, write);
126
+ return empty({ exitCode: 1, refused: "dirty-tree", unresolved });
118
127
  };
119
128
  // Resolve the customer-visible manual-only output gate once per fix run.
120
129
  // Fail open to the pre-change output if the identity is absent or the
@@ -129,6 +138,7 @@ export async function applyFixes(options) {
129
138
  }
130
139
  const collect = options.runChecks ?? defaultRunChecks;
131
140
  const refreshSync = options.refreshSync ?? defaultRefreshSync;
141
+ const refreshManifest = options.refreshManifest ?? defaultRefreshManifest;
132
142
  const rewriteBotNodePath = options.rewriteBotNodePath ?? defaultRewriteBotNodePath;
133
143
  const repairBotHqBinary = options.repairBotHqBinary ?? defaultRepairBotHqBinary;
134
144
  // 2. Collect findings, then plan each newly unlocked allowlisted repair.
@@ -137,6 +147,7 @@ export async function applyFixes(options) {
137
147
  let findings = await collect(hqRoot);
138
148
  let lastAttemptedRefreshSyncSlug = readRefreshSyncProgress(hqRoot)?.lastAttemptedSlug ?? null;
139
149
  let refreshSyncAppliedThisRun = 0;
150
+ let refreshManifestAppliedThisRun = 0;
140
151
  const appliedKeys = new Set();
141
152
  const planned = [];
142
153
  const backups = [];
@@ -155,7 +166,7 @@ export async function applyFixes(options) {
155
166
  const rem = deriveRemediation(result);
156
167
  if (!rem?.autoFixable)
157
168
  continue;
158
- const plan = planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath, repairBotHqBinary);
169
+ const plan = planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath, repairBotHqBinary, refreshManifest);
159
170
  if (!plan)
160
171
  continue;
161
172
  const key = JSON.stringify([plan.checkId, plan.target]);
@@ -171,16 +182,25 @@ export async function applyFixes(options) {
171
182
  seenRefreshTargets.add(plan.target);
172
183
  return true;
173
184
  }).slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun));
185
+ // Manifest uploads take whatever network budget the pulls left.
186
+ const manifestPlans = plans
187
+ .filter((plan) => plan.fixClass === "refresh-manifest")
188
+ .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
189
+ .slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN -
190
+ refreshSyncAppliedThisRun -
191
+ refreshManifestAppliedThisRun -
192
+ refreshPlans.length));
174
193
  return [
175
- ...plans.filter((plan) => plan.fixClass !== "refresh-sync"),
194
+ ...plans.filter((plan) => plan.fixClass !== "refresh-sync" && plan.fixClass !== "refresh-manifest"),
176
195
  ...refreshPlans,
196
+ ...manifestPlans,
177
197
  ];
178
198
  };
179
199
  for (let pass = 1; pass <= MAX_FIX_PASSES; pass += 1) {
180
200
  const plannedThisPass = refuseDirtyHookWrites(buildPlans(findings));
181
201
  if (plannedThisPass === "refuse") {
182
202
  if (planned.length === 0)
183
- return refuseDirtyTree();
203
+ return refuseDirtyTree(findings);
184
204
  write(`Pass ${pass}/${MAX_FIX_PASSES}: remaining repairs write hook files; ` +
185
205
  `stopped because the tree is dirty.\n`);
186
206
  refused = "dirty-tree";
@@ -202,7 +222,9 @@ export async function applyFixes(options) {
202
222
  write("Aborted — no changes written.\n");
203
223
  if (showManualOnlyRemediations)
204
224
  writeManualOnlyRemediations(findings, write);
205
- return empty({ refused, fixableCount });
225
+ const unresolved = unresolvedSyncChecks(findings, []);
226
+ writeUnresolvedSyncChecks(unresolved, write);
227
+ return empty({ refused, fixableCount, unresolved });
206
228
  }
207
229
  write(`Pass ${pass} declined — no further changes written.\n`);
208
230
  break;
@@ -233,6 +255,8 @@ export async function applyFixes(options) {
233
255
  lastAttemptedRefreshSyncSlug = plan.target;
234
256
  refreshSyncAppliedThisRun += 1;
235
257
  }
258
+ if (plan.fixClass === "refresh-manifest")
259
+ refreshManifestAppliedThisRun += 1;
236
260
  appliedKeys.add(JSON.stringify([plan.checkId, plan.target]));
237
261
  planned.push({ plan, pass });
238
262
  await plan.apply();
@@ -244,7 +268,9 @@ export async function applyFixes(options) {
244
268
  write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
245
269
  if (showManualOnlyRemediations)
246
270
  writeManualOnlyRemediations(findings, write);
247
- return empty({ refused, fixableCount });
271
+ const unresolved = unresolvedSyncChecks(findings, []);
272
+ writeUnresolvedSyncChecks(unresolved, write);
273
+ return empty({ refused, fixableCount, unresolved });
248
274
  }
249
275
  // Show each repair's final post-fix status, including work from earlier passes.
250
276
  const applied = planned.map(({ plan, pass }) => {
@@ -283,6 +309,8 @@ export async function applyFixes(options) {
283
309
  }
284
310
  if (showManualOnlyRemediations)
285
311
  writeManualOnlyRemediations(findings, write);
312
+ const unresolved = unresolvedSyncChecks(findings, planned.map(({ plan }) => plan));
313
+ writeUnresolvedSyncChecks(unresolved, write);
286
314
  return {
287
315
  exitCode: 0,
288
316
  wrote: true,
@@ -292,11 +320,81 @@ export async function applyFixes(options) {
292
320
  restoreCommand,
293
321
  backups,
294
322
  applied,
323
+ unresolved,
295
324
  };
296
325
  }
326
+ /**
327
+ * The sync checks still FAIL or WARN in `findings`, each with why `--fix` left
328
+ * it and what to do next. Reported on every run, independent of the
329
+ * manual-only flag: the SessionStart health hook files a bug for any sync row
330
+ * left after `--fix`, so the user must see the same rows and a next step.
331
+ */
332
+ export function unresolvedSyncChecks(findings, attempted) {
333
+ return findings.flatMap((result) => {
334
+ if (!result.checkId.startsWith("sync."))
335
+ return [];
336
+ const remediation = deriveRemediation(result);
337
+ if (!remediation)
338
+ return [];
339
+ const tried = attempted.find((plan) => plan.checkId === result.checkId);
340
+ const command = remediation.command.trim();
341
+ if (tried) {
342
+ const failure = tried.failureReason?.();
343
+ const outcome = failure
344
+ ? `--fix tried to ${tried.summary}, but ${failure}, so the check still shows ${result.status}.`
345
+ : `--fix tried to ${tried.summary}, but the check still shows ${result.status}.`;
346
+ return [
347
+ {
348
+ checkId: result.checkId,
349
+ status: result.status,
350
+ reason: "repair-did-not-clear",
351
+ nextStep: command
352
+ ? `${outcome} Run \`${command}\` yourself and read its error.`
353
+ : `${outcome} Run \`hq doctor --only sync\` for details.`,
354
+ },
355
+ ];
356
+ }
357
+ if (remediation.autoFixable) {
358
+ return [
359
+ {
360
+ checkId: result.checkId,
361
+ status: result.status,
362
+ reason: "not-attempted",
363
+ nextStep: `--fix did not get to this one in this run. Run \`hq doctor --fix\` again, or run \`${command}\` now.`,
364
+ },
365
+ ];
366
+ }
367
+ // The action is the check's remediation (or a classifier override). When
368
+ // it is only the finding message, give a generic step: messages can hold
369
+ // paths, and the JSON surface carries none.
370
+ const nextStep = remediation.action && remediation.action !== result.message
371
+ ? remediation.action
372
+ : "Run `hq doctor --only sync` to see the details, then fix what it describes.";
373
+ return [
374
+ {
375
+ checkId: result.checkId,
376
+ status: result.status,
377
+ reason: "manual-only",
378
+ nextStep: `--fix cannot repair this automatically. ${nextStep}`,
379
+ },
380
+ ];
381
+ });
382
+ }
383
+ /** Print the sync checks `--fix` left unresolved, or nothing when there are none. */
384
+ function writeUnresolvedSyncChecks(unresolved, write) {
385
+ if (unresolved.length === 0)
386
+ return;
387
+ write("\nSync checks still not resolved after --fix:\n");
388
+ for (const entry of unresolved) {
389
+ write(` ${entry.status} ${entry.checkId}\n Next step: ${entry.nextStep}\n`);
390
+ }
391
+ }
297
392
  /** Print the available user action for findings that `--fix` deliberately skips. */
298
393
  function writeManualOnlyRemediations(findings, write) {
299
394
  const manual = findings.flatMap((result) => {
395
+ // Sync rows are always listed by writeUnresolvedSyncChecks.
396
+ if (result.checkId.startsWith("sync."))
397
+ return [];
300
398
  const remediation = deriveRemediation(result);
301
399
  return remediation && !remediation.autoFixable
302
400
  ? [{ result, remediation }]
@@ -403,6 +501,49 @@ export function defaultRefreshSync(target) {
403
501
  message: `pulled '${target.slug}'`,
404
502
  });
405
503
  }
504
+ /**
505
+ * Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
506
+ * against the tree `--fix` is repairing. Exit 0 also covers throttled,
507
+ * disabled, and skipped passes, so the post-fix re-check decides whether the
508
+ * row cleared. A timeout or non-zero exit is reported, not thrown.
509
+ */
510
+ export function defaultRefreshManifest(target) {
511
+ const hqBin = process.argv[1];
512
+ const args = ["sync", "manifest", "--scope", target.scope, "--hq-root", target.hqRoot];
513
+ const result = spawnSync(hqBin ? process.execPath : "hq", hqBin ? [hqBin, ...args] : args, {
514
+ encoding: "utf8",
515
+ timeout: REFRESH_SYNC_TIMEOUT_MS,
516
+ cwd: target.hqRoot,
517
+ });
518
+ return Promise.resolve(manifestUploadResult(target.scope, result));
519
+ }
520
+ /**
521
+ * Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
522
+ * A spawn killed by the timeout has a null status, so it is reported as a
523
+ * timeout rather than as an exit code.
524
+ */
525
+ export function manifestUploadResult(scope, result) {
526
+ const errorCode = result.error?.code;
527
+ if (errorCode === "ETIMEDOUT" || (result.status === null && result.signal)) {
528
+ const reason = `the upload timed out after ${REFRESH_SYNC_TIMEOUT_MS / 1000}s`;
529
+ return { ok: false, reason, message: `manifest upload for '${scope}' timed out` };
530
+ }
531
+ if (result.error) {
532
+ return {
533
+ ok: false,
534
+ reason: `the upload could not start${errorCode ? ` (${errorCode})` : ""}`,
535
+ message: `could not upload the manifest for '${scope}': ${result.error.message}`,
536
+ };
537
+ }
538
+ if (result.status !== 0) {
539
+ return {
540
+ ok: false,
541
+ reason: `the upload exited with code ${result.status}`,
542
+ message: `manifest upload for '${scope}' exited ${result.status}`,
543
+ };
544
+ }
545
+ return { ok: true, message: `uploaded the manifest for '${scope}'` };
546
+ }
406
547
  // --- Dirty-tree probe ----------------------------------------------------------
407
548
  /**
408
549
  * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
@@ -422,7 +563,7 @@ export function uncommittedHookConfigChanges(hqRoot) {
422
563
  }
423
564
  // --- Fix planning --------------------------------------------------------------
424
565
  /** Build the concrete plan for one auto-fixable finding, or null if unplannable. */
425
- function planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath = defaultRewriteBotNodePath, repairBotHqBinary = defaultRepairBotHqBinary) {
566
+ function planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath = defaultRewriteBotNodePath, repairBotHqBinary = defaultRepairBotHqBinary, refreshManifest = defaultRefreshManifest) {
426
567
  switch (rem.fixClass) {
427
568
  case "bot-node-path":
428
569
  return planBotNodePath(result, rem, rewriteBotNodePath);
@@ -436,6 +577,8 @@ function planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath = defaultR
436
577
  return planRemoveStrayGateEntries(hqRoot, result, rem);
437
578
  case "refresh-sync":
438
579
  return planRefreshSync(hqRoot, result, rem, refreshSync);
580
+ case "refresh-manifest":
581
+ return planRefreshManifest(hqRoot, result, rem, refreshManifest);
439
582
  default:
440
583
  return null;
441
584
  }
@@ -555,6 +698,29 @@ function planRefreshSync(hqRoot, result, rem, refreshSync) {
555
698
  postStatus: (results) => results.find((entry) => entry.checkId === result.checkId)?.status ?? null,
556
699
  };
557
700
  }
701
+ /** Re-upload one stale sync manifest scope. Writes nothing under the HQ tree. */
702
+ function planRefreshManifest(hqRoot, result, rem, refreshManifest) {
703
+ const scope = rem.fixTarget;
704
+ if (!scope)
705
+ return null;
706
+ let failureReason;
707
+ return {
708
+ checkId: result.checkId,
709
+ fixClass: "refresh-manifest",
710
+ target: scope,
711
+ relpath: "",
712
+ preview: ` sync manifest (${scope === "personal" ? "personal vault" : `company ${scope}`})\n` +
713
+ ` ~ hq sync manifest --scope ${scope}\n` +
714
+ ` upload a fresh file manifest for the sync audit (no local files change)`,
715
+ summary: `upload a fresh sync manifest for ${scope}`,
716
+ apply: async () => {
717
+ const outcome = await refreshManifest({ scope, hqRoot });
718
+ failureReason = outcome.ok ? undefined : outcome.reason;
719
+ },
720
+ postStatus: (results) => results.find((entry) => entry.checkId === result.checkId)?.status ?? null,
721
+ failureReason: () => failureReason,
722
+ };
723
+ }
558
724
  /** Restore the execute bit on a hook script. */
559
725
  function planExecutableBit(hqRoot, result, rem) {
560
726
  const abs = rem.fixTarget;
@@ -1,4 +1,4 @@
1
- import type { AppliedFix, AppliedFixBackup, ApplyFixesResult } from "./apply.js";
1
+ import type { AppliedFix, AppliedFixBackup, ApplyFixesResult, UnresolvedSyncCheck } from "./apply.js";
2
2
  export interface DoctorFixJsonResult {
3
3
  schemaVersion: number;
4
4
  exitCode: number;
@@ -15,6 +15,8 @@ export interface DoctorFixJsonResult {
15
15
  outcome: NonNullable<AppliedFix["refreshSyncOutcome"]>;
16
16
  postStatus: AppliedFix["postStatus"];
17
17
  }[];
18
+ /** Sync checks still FAIL or WARN after the run, with the next step. */
19
+ unresolved: Pick<UnresolvedSyncCheck, "checkId" | "status" | "reason" | "nextStep">[];
18
20
  }
19
21
  /** Build the intentionally narrow JSON surface for `hq doctor --fix --json`. */
20
22
  export declare function buildDoctorFixJson(input: {
@@ -22,6 +24,7 @@ export declare function buildDoctorFixJson(input: {
22
24
  refused: ApplyFixesResult["refused"] | "outside-hq-tree";
23
25
  applied: readonly AppliedFix[];
24
26
  backups?: readonly AppliedFixBackup[];
27
+ unresolved?: readonly UnresolvedSyncCheck[];
25
28
  }): DoctorFixJsonResult;
26
29
  export declare function renderDoctorFixJson(input: Parameters<typeof buildDoctorFixJson>[0]): string;
27
30
  //# sourceMappingURL=json-output.d.ts.map
@@ -25,6 +25,12 @@ export function buildDoctorFixJson(input) {
25
25
  },
26
26
  ]
27
27
  : []),
28
+ unresolved: (input.unresolved ?? []).map(({ checkId, status, reason, nextStep }) => ({
29
+ checkId,
30
+ status,
31
+ reason,
32
+ nextStep,
33
+ })),
28
34
  };
29
35
  }
30
36
  export function renderDoctorFixJson(input) {
@@ -29,6 +29,12 @@
29
29
  * keep`, no hook-file rewrite. Leftover shards
30
30
  * without a local tree stay unfixed (the check reports
31
31
  * NA).
32
+ * - `refresh-manifest` — re-run `hq sync manifest --scope <slug|personal>`
33
+ * for a manifest scope whose last upload is past the
34
+ * stale threshold. Upload-only metadata (file paths
35
+ * and hashes); it never writes the HQ tree. A company
36
+ * scope whose slug did not resolve locally stays
37
+ * manual-only.
32
38
  *
33
39
  * EVERYTHING else — content drift between platform copies, a missing script, a
34
40
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -40,7 +46,7 @@
40
46
  */
41
47
  import type { CheckResult } from "../types.js";
42
48
  /** The allowlisted safe repair classes `--fix` is permitted to apply. */
43
- export type FixClass = "executable-bit" | "gate-profile" | "remove-stray-gate-entries" | "refresh-sync" | "bot-node-path" | "bot-hq-binary";
49
+ export type FixClass = "executable-bit" | "gate-profile" | "remove-stray-gate-entries" | "refresh-sync" | "refresh-manifest" | "bot-node-path" | "bot-hq-binary";
44
50
  /**
45
51
  * A finding's structured remediation. `autoFixable`, `action`, and `command`
46
52
  * are the `--json` surface (AC1); `fixTarget`/`fixClass` are the internal handle
@@ -29,6 +29,12 @@
29
29
  * keep`, no hook-file rewrite. Leftover shards
30
30
  * without a local tree stay unfixed (the check reports
31
31
  * NA).
32
+ * - `refresh-manifest` — re-run `hq sync manifest --scope <slug|personal>`
33
+ * for a manifest scope whose last upload is past the
34
+ * stale threshold. Upload-only metadata (file paths
35
+ * and hashes); it never writes the HQ tree. A company
36
+ * scope whose slug did not resolve locally stays
37
+ * manual-only.
32
38
  *
33
39
  * EVERYTHING else — content drift between platform copies, a missing script, a
34
40
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -38,7 +44,7 @@
38
44
  * content-drift finding is manual-only, because that boundary is load-bearing:
39
45
  * deciding which of two diverged copies is correct needs human judgement.
40
46
  */
41
- import { isCompanyJournalSlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
47
+ import { isCompanyJournalSlug, isSafeCompanySlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
42
48
  /** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
43
49
  const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
44
50
  /**
@@ -171,6 +177,22 @@ export function deriveRemediation(result) {
171
177
  command: `hq sync pull --company ${journalSlug} --on-conflict keep`,
172
178
  };
173
179
  }
180
+ // refresh-manifest — a stale manifest upload. The check sets `target` to the
181
+ // `--scope` value only when it resolved one it can name safely.
182
+ if (checkId.startsWith("sync.manifest.") &&
183
+ result.status === "WARN" &&
184
+ result.reasonCode === "stale-threshold" &&
185
+ target &&
186
+ (target === "personal" || isSafeCompanySlug(target))) {
187
+ const scopeLabel = target === "personal" ? "the personal vault" : `company '${target}'`;
188
+ return {
189
+ autoFixable: true,
190
+ fixClass: "refresh-manifest",
191
+ fixTarget: target,
192
+ action: `Upload a fresh sync manifest for ${scopeLabel}.`,
193
+ command: `hq sync manifest --scope ${target}`,
194
+ };
195
+ }
174
196
  // Manual-only: content drift, missing scripts/counterparts, unquoted
175
197
  // expansions, stale allowed-divergence entries, invalid settings, etc. `--fix`
176
198
  // never touches these.
@@ -14,6 +14,7 @@
14
14
  * later stories add per-item results to this family without touching the engine.
15
15
  */
16
16
  import * as fs from "node:fs";
17
+ import { hookEngineFamily } from './checks/hook-engine.js';
17
18
  import * as path from "node:path";
18
19
  import { checkCodexWiring } from "./checks/codex-wiring.js";
19
20
  import { checkGrokWiring } from "./checks/grok-wiring.js";
@@ -26,6 +27,9 @@ import { botsFamily } from "./checks/bots.js";
26
27
  import { anywhereFamily } from "./checks/anywhere.js";
27
28
  import { scaffoldCliFloorFamily } from "./checks/scaffold-cli-floor.js";
28
29
  import { strandedLanesFamily } from "./checks/stranded-lanes.js";
30
+ import { releaseLinksFamily } from "./checks/release-links.js";
31
+ import { cliPathFamily } from "./checks/cli-path.js";
32
+ import { tempHomesFamily } from "./checks/temp-homes.js";
29
33
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
30
34
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
31
35
  /**
@@ -239,6 +243,7 @@ function hasCommandHook(settings, event) {
239
243
  export function createDefaultRegistry() {
240
244
  const registry = new DoctorRegistry();
241
245
  registry.register(hooksFamily);
246
+ registry.register(hookEngineFamily);
242
247
  registry.register(scaffoldCliFloorFamily);
243
248
  // Fixture coverage (US-007): reports UNTESTED for every registered hook with
244
249
  // no fixture and prints the tested/total coverage line. A separate family so
@@ -270,6 +275,14 @@ export function createDefaultRegistry() {
270
275
  // Failed keep-alive lead lanes cannot recover themselves; make them visible
271
276
  // in the standard local doctor report with the senior recovery command.
272
277
  registry.register(strandedLanesFamily);
278
+ // Release symlinks left pointing at a deleted update staging folder.
279
+ registry.register(releaseLinksFamily);
280
+ // Windows: the npm hq.cmd shim folder must be on PATH for a new terminal to
281
+ // find hq. Registry read only; prints the fix, never edits PATH.
282
+ registry.register(cliPathFamily);
283
+ // Idle tmp.* throwaway homes holding a Rust toolchain download. Dry-run
284
+ // scan only; `hq doctor --sweep-temp-homes` is the delete path.
285
+ registry.register(tempHomesFamily);
273
286
  return registry;
274
287
  }
275
288
  //# sourceMappingURL=registry.js.map
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Sweeper for abandoned throwaway `$HOME` folders.
3
+ *
4
+ * Commands run as `HOME=$(mktemp -d) <cmd>` leave a `tmp.XXXXXXXX` folder in
5
+ * the temp dir. When the command touched Rust or pnpm tooling, that folder
6
+ * holds a full `.rustup` / `.cargo` download (1-2 GB) and nothing deletes it.
7
+ * On 2026-10-18 about 45 GB refilled within hours of a cleanup this way.
8
+ * `core/scripts/with-temp-home.sh` makes `hq-temp-home.XXXXXXXX` folders and
9
+ * removes them on exit, but a wrapper killed with SIGKILL leaves its folder.
10
+ *
11
+ * The sweep is deliberately narrow. A folder is removed only when ALL hold:
12
+ *
13
+ * - it is a direct child of a temp root (TMPDIR, os.tmpdir(), /tmp,
14
+ * /private/tmp, /var/folders/{a}/{b}/T) and its name starts with `tmp.` or
15
+ * `hq-temp-home.`;
16
+ * - `lstat` says it is a real directory (never a symlink) owned by this user;
17
+ * - a `tmp.*` folder holds a `.rustup` or `.cargo` directory (again by
18
+ * `lstat`). An `hq-temp-home.*` folder needs no marker: the name shows the
19
+ * wrapper made it;
20
+ * - nothing inside it, the folder itself included, was modified in the last
21
+ * 90 minutes;
22
+ * - no live process uses it: the wrapper's `.hq-temp-home.lock` does not name
23
+ * a live pid, and no process of this user has its cwd or an open file
24
+ * there (on Linux, or its `HOME` there either).
25
+ *
26
+ * The walk uses `lstat` throughout and never descends into a symlink, so a
27
+ * link inside a temp home that points elsewhere is neither measured nor
28
+ * followed. `fs.rmSync` unlinks symlinks rather than following them. A folder
29
+ * that cannot be fully read is kept, because an incomplete scan cannot prove
30
+ * the folder is idle. When the in-use probe fails, every candidate is kept.
31
+ *
32
+ * `shallow` mode is for plain `hq doctor`, which must stay fast. It checks
33
+ * only the folder and its direct children for recent changes, skips the size
34
+ * walk and the process probe, and reports no byte counts.
35
+ */
36
+ import * as fs from "node:fs";
37
+ /** Folders younger than this (by newest mtime anywhere inside) are kept. */
38
+ export declare const TEMP_HOME_MIN_IDLE_MS: number;
39
+ /** Names that mark a `tmp.*` temp home as holding a toolchain download. */
40
+ export declare const TEMP_HOME_TOOLCHAIN_MARKERS: readonly [".rustup", ".cargo"];
41
+ /** Folders made by `HOME=$(mktemp -d)`; swept only with a toolchain marker. */
42
+ export declare const TEMP_HOME_MKTEMP_PREFIX = "tmp.";
43
+ /** Folders made by `core/scripts/with-temp-home.sh`. */
44
+ export declare const TEMP_HOME_WRAPPER_PREFIX = "hq-temp-home.";
45
+ /** Lock file the wrapper writes into its temp home; holds the wrapper pid. */
46
+ export declare const TEMP_HOME_LOCK_FILE = ".hq-temp-home.lock";
47
+ export type TempHomeAction = "removed" | "would-remove" | "kept" | "failed";
48
+ export type TempHomeKeptReason = "symlink" | "not-directory" | "not-owned" | "no-toolchain-marker" | "recent" | "in-use" | "in-use-unknown" | "unreadable" | "changed-before-delete";
49
+ export interface TempHomeEntry {
50
+ path: string;
51
+ action: TempHomeAction;
52
+ /** On-disk bytes. Present when the folder was fully measured. */
53
+ bytes?: number;
54
+ /** Why the folder was kept. Present only for `kept`. */
55
+ reason?: TempHomeKeptReason;
56
+ /** Error message. Present for `failed`, and for some `kept` entries. */
57
+ error?: string;
58
+ }
59
+ export interface TempHomeSweepResult {
60
+ dryRun: boolean;
61
+ /** True when the sweep ran in shallow mode (no sizes, no process probe). */
62
+ shallow: boolean;
63
+ /** Real paths of the temp roots that were scanned, deduplicated. */
64
+ roots: string[];
65
+ /** Every matching child that was examined, in scan order. */
66
+ entries: TempHomeEntry[];
67
+ /** Bytes removed, or bytes that would be removed in a dry run. 0 when shallow. */
68
+ bytesFreed: number;
69
+ /** `bytesFreed` in GB (10^9 bytes), rounded to two decimals. */
70
+ gbFreed: number;
71
+ }
72
+ /** File system calls the sweeper uses; a seam for tests. */
73
+ export interface TempHomeSweepFs {
74
+ lstatSync(target: string): fs.Stats;
75
+ readdirSync(target: string): string[];
76
+ realpathSync(target: string): string;
77
+ readFileSync(target: string, encoding: "utf8"): string;
78
+ rmSync(target: string, options: {
79
+ recursive: true;
80
+ force: false;
81
+ }): void;
82
+ }
83
+ /**
84
+ * Returns the subset of `homes` that a live process uses: its cwd or an open
85
+ * file is inside the home (and on Linux, its `HOME` is the home). Throws when
86
+ * it cannot tell; the sweep then keeps every candidate.
87
+ */
88
+ export type TempHomeInUseProbe = (homes: readonly string[]) => Set<string>;
89
+ export interface TempHomeSweepOptions {
90
+ /** Temp roots to scan. Defaults to {@link defaultTempRoots}. */
91
+ roots?: readonly string[];
92
+ /** Report what would be removed without deleting anything. */
93
+ dryRun?: boolean;
94
+ /**
95
+ * Cheap scan for plain `hq doctor`: no size walk, no process probe, only
96
+ * the folder and its direct children checked for recent changes. Implies
97
+ * `dryRun`.
98
+ */
99
+ shallow?: boolean;
100
+ /** Current time in ms. Defaults to `Date.now()`. */
101
+ nowMs?: number;
102
+ /** Minimum idle time in ms. Defaults to {@link TEMP_HOME_MIN_IDLE_MS}. */
103
+ minIdleMs?: number;
104
+ /** Owner uid to require. Defaults to `process.getuid()` when it exists. */
105
+ uid?: number;
106
+ fs?: TempHomeSweepFs;
107
+ /** Live-process probe. Defaults to {@link defaultInUseProbe}. */
108
+ inUse?: TempHomeInUseProbe;
109
+ /** Whether a pid is alive. Defaults to `process.kill(pid, 0)`. */
110
+ isPidAlive?: (pid: number) => boolean;
111
+ }
112
+ /**
113
+ * The temp roots a throwaway home can land in: `$TMPDIR`, `os.tmpdir()`,
114
+ * `/tmp`, `/private/tmp`, and every per-user macOS temp dir
115
+ * `/var/folders/{a}/{b}/T`. Missing roots are dropped later by the sweep.
116
+ */
117
+ export declare function defaultTempRoots(env?: NodeJS.ProcessEnv, fsImpl?: Pick<TempHomeSweepFs, "readdirSync">): string[];
118
+ /** `process.kill(pid, 0)`: alive unless the pid does not exist (ESRCH). */
119
+ export declare function defaultIsPidAlive(pid: number): boolean;
120
+ /**
121
+ * True when `value` (a cwd or open-file path, or `HOME`) is `home` or a path
122
+ * inside it. Matched by folder name as a path segment, because the same temp
123
+ * dir is reachable as `/var/folders/...` and `/private/var/folders/...`; the
124
+ * random `mktemp` suffix makes the name unique. Over-matching only keeps more.
125
+ */
126
+ export declare function pathUsesHome(value: string, home: string): boolean;
127
+ /**
128
+ * Default in-use probe. Only processes of `uid` are checked; other users
129
+ * cannot own these folders' contents in a way the sweep would delete.
130
+ *
131
+ * - Linux: `/proc/{pid}/cwd`, every `/proc/{pid}/fd/*` link, and `HOME` from
132
+ * `/proc/{pid}/environ`. Environment values are matched in memory and never
133
+ * logged.
134
+ * - macOS: `lsof -u uid` for every file each process has open, its cwd and
135
+ * its executable included. macOS does not show other processes'
136
+ * environments (`ps -E` leaves them out), so `HOME` itself is not checked
137
+ * there. A process that has HOME set to a temp home but holds nothing open
138
+ * inside it is not seen; the wrapper lock and the 90-minute idle check
139
+ * cover that case.
140
+ */
141
+ export declare function defaultInUseProbe(uid: number | undefined): TempHomeInUseProbe;
142
+ /**
143
+ * Find and (unless `dryRun`) remove idle, unused temp homes. See the module
144
+ * comment for the exact rules.
145
+ */
146
+ export declare function sweepTempHomes(options?: TempHomeSweepOptions): TempHomeSweepResult;
147
+ /** Bytes to GB (10^9 bytes), rounded to two decimals. */
148
+ export declare function toGb(bytes: number): number;
149
+ //# sourceMappingURL=temp-home-sweeper.d.ts.map