@indigoai-us/hq-cli 5.345.134 → 5.345.136

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 (100) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
  3. package/dist/command-catalog.generated.d.ts +62 -6
  4. package/dist/command-catalog.generated.js +79 -7
  5. package/dist/command-registration-plan.d.ts +6 -0
  6. package/dist/command-registration-plan.js +1 -0
  7. package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +3 -1
  8. package/dist/commands/core-bucket-c-handoff-knowledge-native.js +8 -4
  9. package/dist/commands/core-checkpoint-native.d.ts +10 -0
  10. package/dist/commands/core-checkpoint-native.js +60 -15
  11. package/dist/commands/core-handoff.d.ts +2 -0
  12. package/dist/commands/core-handoff.js +3 -1
  13. package/dist/commands/core-pipeline-workflow-runner-native.js +7 -5
  14. package/dist/commands/daemon.js +3 -1
  15. package/dist/commands/fingerprint.d.ts +10 -0
  16. package/dist/commands/fingerprint.js +191 -0
  17. package/dist/commands/integrations-core.d.ts +22 -0
  18. package/dist/commands/integrations-core.js +25 -0
  19. package/dist/commands/integrations-manage.d.ts +24 -1
  20. package/dist/commands/integrations-manage.js +92 -17
  21. package/dist/commands/integrations.d.ts +11 -0
  22. package/dist/commands/integrations.js +100 -19
  23. package/dist/commands/lanes-loop.d.ts +9 -0
  24. package/dist/commands/lanes-loop.js +9 -1
  25. package/dist/commands/lanes.js +78 -55
  26. package/dist/commands/meetings.d.ts +2 -0
  27. package/dist/commands/meetings.js +57 -14
  28. package/dist/commands/mesh.d.ts +19 -1
  29. package/dist/commands/mesh.js +158 -70
  30. package/dist/commands/reindex.js +80 -5
  31. package/dist/commands/storage-offload.d.ts +93 -28
  32. package/dist/commands/storage-offload.js +290 -34
  33. package/dist/commands/storage.js +60 -7
  34. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
  35. package/dist/hq-cloud-sync-runner/package.json +1 -1
  36. package/dist/index.js +4 -0
  37. package/dist/lib/capacity/route.js +24 -0
  38. package/dist/lib/core-utils/session-company.d.ts +11 -0
  39. package/dist/lib/core-utils/session-company.js +34 -0
  40. package/dist/lib/core-utils/worktree-resume.js +44 -4
  41. package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
  42. package/dist/lib/core-utils/worktree-session-owner.js +157 -0
  43. package/dist/lib/daemon/catalog.d.ts +2 -0
  44. package/dist/lib/daemon/catalog.js +3 -3
  45. package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
  46. package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
  47. package/dist/lib/daemon/server.js +10 -1
  48. package/dist/lib/daemon/supervisor.js +6 -0
  49. package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
  50. package/dist/lib/daemon/sync-runner-bundle.js +4 -12
  51. package/dist/lib/doctor/checks/claude-wiring.js +2 -1
  52. package/dist/lib/doctor/fix/apply.d.ts +20 -0
  53. package/dist/lib/doctor/fix/apply.js +63 -3
  54. package/dist/lib/doctor/fix/remediation.d.ts +1 -1
  55. package/dist/lib/doctor/fix/remediation.js +16 -0
  56. package/dist/lib/doctor/shared.d.ts +11 -1
  57. package/dist/lib/doctor/shared.js +16 -2
  58. package/dist/lib/fingerprint/fixtures.d.ts +19 -0
  59. package/dist/lib/fingerprint/fixtures.js +61 -0
  60. package/dist/lib/fingerprint/format.d.ts +50 -0
  61. package/dist/lib/fingerprint/format.js +105 -0
  62. package/dist/lib/fingerprint/offline.d.ts +16 -0
  63. package/dist/lib/fingerprint/offline.js +44 -0
  64. package/dist/lib/fingerprint/registry-client.d.ts +74 -0
  65. package/dist/lib/fingerprint/registry-client.js +141 -0
  66. package/dist/lib/fingerprint/resolve-local.d.ts +14 -0
  67. package/dist/lib/fingerprint/resolve-local.js +67 -0
  68. package/dist/lib/fingerprint/self-core.d.ts +139 -0
  69. package/dist/lib/fingerprint/self-core.js +371 -0
  70. package/dist/lib/fingerprint/self.d.ts +44 -0
  71. package/dist/lib/fingerprint/self.js +191 -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/lanes/monitor.js +14 -2
  75. package/dist/lib/lanes/spawn.d.ts +19 -1
  76. package/dist/lib/lanes/spawn.js +50 -6
  77. package/dist/lib/lanes/story-ledger.d.ts +1 -0
  78. package/dist/lib/lanes/story-ledger.js +3 -1
  79. package/dist/lib/lanes/types.d.ts +12 -0
  80. package/dist/lib/lanes/types.js +12 -1
  81. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  82. package/dist/lib/mesh/live/daemon/install.js +22 -0
  83. package/dist/lib/mesh/live/flush.js +106 -3
  84. package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
  85. package/dist/lib/mesh/live/session-events-client.js +15 -0
  86. package/dist/lib/work-context/state.d.ts +10 -0
  87. package/dist/lib/work-context/state.js +5 -0
  88. package/dist/utils/cli-network-lifecycle.d.ts +2 -0
  89. package/dist/utils/cli-network-lifecycle.js +5 -1
  90. package/dist/utils/cognito-session.d.ts +2 -21
  91. package/dist/utils/cognito-session.js +3 -84
  92. package/dist/utils/fsync-directory.d.ts +17 -0
  93. package/dist/utils/fsync-directory.js +27 -0
  94. package/dist/utils/person-token-cache.d.ts +32 -0
  95. package/dist/utils/person-token-cache.js +95 -0
  96. package/dist/utils/vault-api.d.ts +21 -1
  97. package/dist/utils/vault-api.js +54 -5
  98. package/dist/utils/windows-hide-default.d.ts +42 -0
  99. package/dist/utils/windows-hide-default.js +98 -0
  100. package/package.json +2 -2
@@ -174,6 +174,42 @@ async function requireAssignableSelf(token, verb) {
174
174
  }
175
175
  return resolved;
176
176
  }
177
+ /** 400 PROJECT_VIEW_INVALID "Unknown story ..." from a story PATCH. */
178
+ export function isUnknownBoardStory(error) {
179
+ if (!error || typeof error !== "object")
180
+ return false;
181
+ const candidate = error;
182
+ return (httpStatus(error) === 400 &&
183
+ (candidate.code === "PROJECT_VIEW_INVALID" ||
184
+ (typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_INVALID"))) &&
185
+ typeof candidate.message === "string" &&
186
+ candidate.message.includes("Unknown story"));
187
+ }
188
+ /** The local PRD for this project when it lists `storyId`; null otherwise. */
189
+ export function localPrdWithStory(company, projectId, storyId, hqRoot = process.env.HQ_ROOT?.trim() || findHqRoot() || undefined) {
190
+ const prdPath = findLocalPrd(hqRoot, company.companySlug, projectId);
191
+ if (!prdPath)
192
+ return null;
193
+ let source;
194
+ try {
195
+ source = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ return source.stories.some((story) => story.id === storyId) ? { prdPath, source } : null;
201
+ }
202
+ function unknownStoryHint(error, company, projectId, storyId, prdPath) {
203
+ const base = error instanceof Error ? error.message : String(error);
204
+ const command = `hq mesh project ensure ${projectId} --company ${company.companySlug ?? company.companyUid} --stories-file ${prdPath ?? "<prd.json>"}`;
205
+ const hint = prdPath
206
+ ? `${storyId} is in ${prdPath} but not on the Board. Register it with: ${command}`
207
+ : `${storyId} is not on the ${projectId} Board. Add it to the project PRD, then run: ${command}`;
208
+ return Object.assign(new Error(`${base}\n${hint}`), {
209
+ status: httpStatus(error),
210
+ code: error?.code,
211
+ });
212
+ }
177
213
  async function runStory(opts) {
178
214
  const storyId = opts.story?.trim();
179
215
  const status = opts.status?.trim();
@@ -203,14 +239,46 @@ async function runStory(opts) {
203
239
  }
204
240
  assigneeUid = assignee;
205
241
  }
242
+ const patch = {
243
+ ...(status ? { status } : {}),
244
+ ...(assigneeUid ? { assigneeUid } : {}),
245
+ ...(clearAssignee ? { clearAssignee: true } : {}),
246
+ ...(opts.force ? { force: true } : {}),
247
+ };
206
248
  let patched;
249
+ let registeredFrom;
207
250
  try {
208
- patched = await patchStory(token, company.companyUid, projectId, storyId, {
209
- ...(status ? { status } : {}),
210
- ...(assigneeUid ? { assigneeUid } : {}),
211
- ...(clearAssignee ? { clearAssignee: true } : {}),
212
- ...(opts.force ? { force: true } : {}),
213
- });
251
+ try {
252
+ patched = await patchStory(token, company.companyUid, projectId, storyId, patch);
253
+ }
254
+ catch (err) {
255
+ if (!isUnknownBoardStory(err))
256
+ throw err;
257
+ // The story is in the local PRD but not on the Board yet: register the
258
+ // PRD (the same merge `hq mesh project ensure` does), then retry once.
259
+ const recovery = localPrdWithStory(company, projectId, storyId);
260
+ if (!recovery)
261
+ throw unknownStoryHint(err, company, projectId, storyId);
262
+ if (opts.ensure === false) {
263
+ throw unknownStoryHint(err, company, projectId, storyId, recovery.prdPath);
264
+ }
265
+ await ensureProjectFromSource({
266
+ token,
267
+ company,
268
+ projectId,
269
+ source: recovery.source,
270
+ current: await getProjectView(token, company.companyUid, projectId).catch((viewErr) => {
271
+ if (httpStatus(viewErr) === 404)
272
+ return undefined;
273
+ throw viewErr;
274
+ }),
275
+ });
276
+ registeredFrom = recovery.prdPath;
277
+ if (!opts.json) {
278
+ console.error(`Work mesh: ${storyId} was not on the ${projectId} Board; registered the project from ${recovery.prdPath}.`);
279
+ }
280
+ patched = await patchStory(token, company.companyUid, projectId, storyId, patch);
281
+ }
214
282
  }
215
283
  catch (err) {
216
284
  const conflict = storyConflictFromError(err);
@@ -219,7 +287,14 @@ async function runStory(opts) {
219
287
  throw err;
220
288
  }
221
289
  if (opts.json) {
222
- console.log(JSON.stringify({ ok: true, action: "story", company, ...patched, storyId }, null, 2));
290
+ console.log(JSON.stringify({
291
+ ok: true,
292
+ action: "story",
293
+ company,
294
+ ...patched,
295
+ storyId,
296
+ ...(registeredFrom ? { registeredFrom } : {}),
297
+ }, null, 2));
223
298
  return;
224
299
  }
225
300
  const assignment = assignee === "none"
@@ -590,67 +665,13 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
590
665
  ? `Work mesh: added ${personUid} to project ${projectId}`
591
666
  : `Work mesh: ${personUid} is already a member of project ${projectId}`);
592
667
  }
593
- async function runProjectEnsure(projectId, opts) {
594
- if (!opts.storiesFile?.trim())
595
- fail("`--stories-file <prd.json>` is required.");
596
- let source;
597
- try {
598
- source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true });
599
- }
600
- catch (err) {
601
- if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
602
- throw err;
603
- failProjectStoryStates(err.rejected, true);
604
- return;
605
- }
606
- const { token, company } = await withCompany(opts);
607
- let current;
608
- try {
609
- current = await getProjectView(token, company.companyUid, projectId);
610
- }
611
- catch (err) {
612
- if (httpStatus(err) !== 404)
613
- throw err;
614
- }
615
- if (opts.dryRun) {
616
- if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
617
- fail("Work mesh project ensure dry-run: Board company or project id did not match the request.");
618
- }
619
- const stories = current ? mergeProjectStories(current.stories, source.stories) : source.stories;
620
- const sourceStatuses = source.stories.map((story) => ({
621
- id: story.id,
622
- title: story.title,
623
- status: boardStoryStatus(story),
624
- }));
625
- const boardStatuses = stories.map((story) => ({
626
- id: story.id,
627
- title: story.title,
628
- status: boardStoryStatus(story),
629
- }));
630
- if (opts.json) {
631
- console.log(JSON.stringify({
632
- ok: source.rejectedStories.length === 0,
633
- action: "project.ensure.dry-run",
634
- projectId,
635
- companyUid: company.companyUid,
636
- sourceStatuses,
637
- stories: boardStatuses,
638
- ...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
639
- }, null, 2));
640
- }
641
- else {
642
- console.log(`Work mesh: project ${projectId} dry run (${sourceStatuses.length} source stories)`);
643
- console.log("Source statuses:");
644
- for (const story of sourceStatuses)
645
- console.log(`${story.id}\t${story.status}`);
646
- console.log("Merged Board statuses:");
647
- for (const story of boardStatuses)
648
- console.log(`${story.id}\t${story.status}`);
649
- }
650
- if (source.rejectedStories.length)
651
- process.exitCode = 1;
652
- return;
653
- }
668
+ /**
669
+ * Create or merge the project Board view from a parsed PRD, register the
670
+ * project, and verify every PRD story and repo is on the Board. Shared by
671
+ * `hq mesh project ensure` and the `hq mesh story` unknown-story recovery.
672
+ */
673
+ async function ensureProjectFromSource(input) {
674
+ const { token, company, projectId, source, current } = input;
654
675
  // Genesis thread, then the view, then register. Register returns 403
655
676
  // PROJECT_NOT_IN_COMPANY until both exist. A missing view uses that chain
656
677
  // from ensureProjectBoard; an existing view is merged, then registered.
@@ -726,12 +747,78 @@ async function runProjectEnsure(projectId, opts) {
726
747
  if (verified.version === undefined) {
727
748
  fail("Work mesh project ensure: verified view has no version.");
728
749
  }
729
- const payload = {
750
+ return {
730
751
  projectId: verified.projectId,
731
752
  threadId: registration.threadId,
732
753
  channelId: registration.channelId,
733
754
  version: verified.version,
734
755
  stories: verified.stories,
756
+ };
757
+ }
758
+ async function runProjectEnsure(projectId, opts) {
759
+ if (!opts.storiesFile?.trim())
760
+ fail("`--stories-file <prd.json>` is required.");
761
+ let source;
762
+ try {
763
+ source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true });
764
+ }
765
+ catch (err) {
766
+ if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
767
+ throw err;
768
+ failProjectStoryStates(err.rejected, true);
769
+ return;
770
+ }
771
+ const { token, company } = await withCompany(opts);
772
+ let current;
773
+ try {
774
+ current = await getProjectView(token, company.companyUid, projectId);
775
+ }
776
+ catch (err) {
777
+ if (httpStatus(err) !== 404)
778
+ throw err;
779
+ }
780
+ if (opts.dryRun) {
781
+ if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
782
+ fail("Work mesh project ensure dry-run: Board company or project id did not match the request.");
783
+ }
784
+ const stories = current ? mergeProjectStories(current.stories, source.stories) : source.stories;
785
+ const sourceStatuses = source.stories.map((story) => ({
786
+ id: story.id,
787
+ title: story.title,
788
+ status: boardStoryStatus(story),
789
+ }));
790
+ const boardStatuses = stories.map((story) => ({
791
+ id: story.id,
792
+ title: story.title,
793
+ status: boardStoryStatus(story),
794
+ }));
795
+ if (opts.json) {
796
+ console.log(JSON.stringify({
797
+ ok: source.rejectedStories.length === 0,
798
+ action: "project.ensure.dry-run",
799
+ projectId,
800
+ companyUid: company.companyUid,
801
+ sourceStatuses,
802
+ stories: boardStatuses,
803
+ ...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
804
+ }, null, 2));
805
+ }
806
+ else {
807
+ console.log(`Work mesh: project ${projectId} dry run (${sourceStatuses.length} source stories)`);
808
+ console.log("Source statuses:");
809
+ for (const story of sourceStatuses)
810
+ console.log(`${story.id}\t${story.status}`);
811
+ console.log("Merged Board statuses:");
812
+ for (const story of boardStatuses)
813
+ console.log(`${story.id}\t${story.status}`);
814
+ }
815
+ if (source.rejectedStories.length)
816
+ process.exitCode = 1;
817
+ return;
818
+ }
819
+ const ensured = await ensureProjectFromSource({ token, company, projectId, source, current });
820
+ const payload = {
821
+ ...ensured,
735
822
  ...(source.rejectedStories.length ? { ok: false, errors: projectStateErrors(source.rejectedStories) } : {}),
736
823
  };
737
824
  noteCallingSessionBound(payload.projectId, company);
@@ -2520,7 +2607,8 @@ export function registerMeshCommand(program, deps = {}) {
2520
2607
  .requiredOption("--story <id>", "Story id, e.g. US-001")
2521
2608
  .option("--status <status>", "queued|in_progress|review|done")
2522
2609
  .option("--assignee <uid|me|none>", "Assign a person or agent uid, yourself, or clear")
2523
- .option("--force", "Override another holder's assignment")).action((opts) => wrap(() => runStory(opts))());
2610
+ .option("--force", "Override another holder's assignment")
2611
+ .option("--no-ensure", "Do not register the project from its local prd.json when the story is not on the Board")).action((opts) => wrap(() => runStory(opts))());
2524
2612
  addSharedFlags(mesh
2525
2613
  .command("claim")
2526
2614
  .description("Assign a story to yourself and move it to in_progress")
@@ -33,6 +33,7 @@ import { reindex, rescue } from '@indigoai-us/hq-cloud';
33
33
  import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
34
34
  import { findHqRoot } from '../utils/manifest.js';
35
35
  import { guardLargeFiles } from '../utils/large-file-guard.js';
36
+ import { liveProcessCwds, recentTranscriptCwds, sessionOwnerReason, } from '../lib/core-utils/worktree-session-owner.js';
36
37
  const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
37
38
  const BYPASS_MODE = 'bypassPermissions';
38
39
  const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
@@ -296,6 +297,34 @@ function hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline = Number.P
296
297
  });
297
298
  return latestContentChange(dirtyPaths) >= cutoff;
298
299
  }
300
+ /**
301
+ * Why removing this worktree could lose work, or undefined when it is clean
302
+ * and every commit on HEAD is on its upstream and on a remote. A Git failure
303
+ * (including a deadline hit) is a reason too, so the worktree is retained.
304
+ */
305
+ function unpreservedWorkReason(worktree, deadline) {
306
+ const status = runGit(worktree, ['status', '--porcelain', '--untracked-files=all', '--ignored=no'], deadline);
307
+ if (status?.status !== 0)
308
+ return 'git-error';
309
+ if (status.stdout.trim() !== '')
310
+ return 'dirty';
311
+ const upstream = runGit(worktree, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], deadline);
312
+ if (upstream === undefined)
313
+ return 'git-error';
314
+ if (upstream.status !== 0 || !upstream.stdout.trim())
315
+ return 'no-upstream';
316
+ const ahead = runGit(worktree, ['rev-list', '--count', '@{u}..HEAD'], deadline);
317
+ if (ahead?.status !== 0)
318
+ return 'git-error';
319
+ if (ahead.stdout.trim() !== '0')
320
+ return 'unpushed';
321
+ const offRemote = runGit(worktree, ['rev-list', '--count', 'HEAD', '--not', '--remotes'], deadline);
322
+ if (offRemote?.status !== 0)
323
+ return 'git-error';
324
+ if (offRemote.stdout.trim() !== '0')
325
+ return 'unpushed';
326
+ return undefined;
327
+ }
299
328
  /**
300
329
  * Remove inactive worktrees created by `hq worktree` without ever deleting
301
330
  * directories directly. This runs on the hook path, so it uses a shallow
@@ -305,6 +334,11 @@ export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Numb
305
334
  if (!isHqRoot(hqRoot))
306
335
  return;
307
336
  const cutoff = now - WORKTREE_STALE_AFTER_MS;
337
+ const kept = (worktree, reason) => {
338
+ console.log(`reindex: kept stale worktree ${worktree} (${reason})`);
339
+ };
340
+ // Pass 1: cheap local checks. Only clean, pushed, idle worktrees go on.
341
+ const eligible = [];
308
342
  for (const worktree of managedWorktreeCandidates(hqRoot)) {
309
343
  if (Date.now() >= deadline)
310
344
  break;
@@ -313,18 +347,59 @@ export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Numb
313
347
  const gitDir = linkedWorktreeGitDir(worktree, deadline);
314
348
  if (!gitDir || hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline))
315
349
  continue;
350
+ const unsafe = unpreservedWorkReason(worktree, deadline);
351
+ if (unsafe) {
352
+ kept(worktree, unsafe);
353
+ continue;
354
+ }
355
+ eligible.push(worktree);
356
+ }
357
+ if (eligible.length === 0)
358
+ return;
359
+ // Pass 2: session ownership. A session working in the worktree may have no
360
+ // process parked there between tool calls, so recent transcripts count too.
361
+ // A probe that cannot finish inside the budget retains everything.
362
+ const processCwds = liveProcessCwds(Math.max(1, Math.min(WORKTREE_GIT_TIMEOUT_MS, deadline - Date.now())));
363
+ const transcriptCwds = processCwds === undefined
364
+ ? undefined
365
+ : recentTranscriptCwds({ nowMs: now, deadline });
366
+ if (processCwds === undefined || transcriptCwds === undefined) {
367
+ for (const worktree of eligible)
368
+ kept(worktree, 'session-check-incomplete');
369
+ return;
370
+ }
371
+ for (const worktree of eligible) {
372
+ if (Date.now() >= deadline)
373
+ break;
374
+ let real = worktree;
375
+ try {
376
+ real = fs.realpathSync(worktree);
377
+ }
378
+ catch { /* compare the literal spelling */ }
379
+ const owner = sessionOwnerReason(processCwds, transcriptCwds, worktree, real);
380
+ if (owner) {
381
+ kept(worktree, owner);
382
+ continue;
383
+ }
316
384
  // Narrow the destructive TOCTOU window: re-resolve the linked-worktree
317
- // identity and re-measure activity immediately before asking Git to remove
318
- // it. Git remains the only component that deletes the directory.
385
+ // identity and re-measure activity and preservation immediately before
386
+ // asking Git to remove it. Git remains the only component that deletes the
387
+ // directory.
319
388
  const confirmedGitDir = linkedWorktreeGitDir(worktree, deadline);
320
389
  if (!confirmedGitDir || hasRecentWorktreeActivity(worktree, confirmedGitDir, cutoff, deadline))
321
390
  continue;
391
+ const confirmedUnsafe = unpreservedWorkReason(worktree, deadline);
392
+ if (confirmedUnsafe) {
393
+ kept(worktree, confirmedUnsafe);
394
+ continue;
395
+ }
322
396
  // Recursive removal is not transactional. Only start it inside the sweep
323
397
  // budget, then let Git finish so a timeout cannot leave a registered,
324
- // partially deleted worktree behind.
325
- const removal = runGit(worktree, ['worktree', 'remove', '--force', worktree], deadline, true);
398
+ // partially deleted worktree behind. No --force: Git itself refuses a
399
+ // worktree that gained changes since the checks above.
400
+ const removal = runGit(worktree, ['worktree', 'remove', worktree], deadline, true);
326
401
  if (removal?.status === 0) {
327
- console.log(`reindex: removed stale worktree ${worktree} (branch preserved; stale uncommitted files are not recoverable)`);
402
+ console.log(`reindex: removed stale worktree ${worktree} (clean and pushed; branch kept)`);
328
403
  continue;
329
404
  }
330
405
  const reason = removal?.stderr.trim() || removal?.stdout.trim() || 'Git could not remove it';
@@ -8,13 +8,18 @@
8
8
  * uploads each blob, verifies size + sha256 + git blob id, then hands only
9
9
  * verified blobs to core `hq-storage.sh strip-large`, which rewrites history
10
10
  * to the pointer text. Working-tree files are never touched.
11
- * - Current files (idle big files in the working tree). NOT offloaded yet:
12
- * the sync delete planner lives in @indigoai-us/hq-cloud (`computePullPlan`
13
- * and the push delete planner in dist/cli/sync.js), so replacing `x.mp4`
14
- * with `x.mp4.hqcloud` would still propagate as a remote delete of `x.mp4`.
15
- * `--current` reports `{ available: false }` until hq-cloud learns about
16
- * placeholders (CURRENT_OFFLOAD_ENABLED). `offloadCurrentFile` is built
17
- * and tested but not called.
11
+ * - Current files (`--current [paths...]`, idle big files in the working
12
+ * tree). Each file goes to the vault that owns its path. A file under
13
+ * `companies/<slug>/` is never uploaded: when HQ sync already stored this
14
+ * exact version in that company's vault (journal hash + size, and the vault
15
+ * object still has the journal's ETag), the file is replaced by a
16
+ * placeholder pointing at that object. Otherwise it is skipped with "run
17
+ * hq sync first", so sync stays the only writer of company vault keys. Any
18
+ * other file points at its personal-vault sync object the same way, or is
19
+ * uploaded to the personal vault's `.hq-offload/blobs/`. Current-file
20
+ * offload runs only when the installed HQ sync holds every `.hqcloud`
21
+ * sibling (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING) and skips
22
+ * `.hq-offload/`; otherwise it reports "update HQ sync".
18
23
  *
19
24
  * Fetch also reads placeholders that HQ sync rewrote after a teammate changed
20
25
  * the file (`vault: company`, the company vault key, `sha256: unknown`,
@@ -24,9 +29,10 @@
24
29
  * which writes the caller's own personal-vault copy to a file outside HQ so
25
30
  * it can never sync back.
26
31
  *
27
- * Tenant isolation: bytes only ever go to the caller's personal vault. The
28
- * personal store refuses a company vault bucket, and nothing here lists or
29
- * opens company memberships.
32
+ * Tenant isolation: uploads only ever go to the caller's personal vault, and
33
+ * the personal store refuses a company vault bucket. A company vault is opened
34
+ * read-only (HEAD and ETag-pinned GET), and only for a company the caller is
35
+ * an active member of.
30
36
  */
31
37
  import { GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
32
38
  import type { ScriptRunner } from "./storage.js";
@@ -37,14 +43,10 @@ export declare const POINTER_HEADER = "HQ-CLOUD-FILE v1";
37
43
  export declare const POINTER_FOOTER = "This file is stored in HQ cloud storage. Open it in HQ, or run the command above to download it.";
38
44
  export declare const DEFAULT_MIN_BYTES: number;
39
45
  export declare const DEFAULT_IDLE_DAYS = 14;
40
- export declare const CURRENT_BLOCKED_REASON = "Offloading current files is not available yet: HQ sync would treat the replaced file as deleted.";
41
- /**
42
- * Gate for `--current`. Stays false until the owner enables current-file
43
- * offload on top of hq-cloud's cloud-only placeholders (PR #919, 6.18.61).
44
- */
45
- export declare const CURRENT_OFFLOAD_ENABLED = false;
46
46
  export declare const ORPHANED_MESSAGE = "This file was deleted by a teammate. It's no longer in the cloud for your team.";
47
47
  export declare const UPDATE_SYNC = "update HQ sync";
48
+ /** Reason on company files HQ sync has not stored in the company vault yet. */
49
+ export declare const RUN_SYNC_FIRST = "run hq sync first";
48
50
  /** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
49
51
  export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
50
52
  /**
@@ -56,7 +58,7 @@ export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
56
58
  * @indigoai-us/hq-cloud 6.18.61 (PR #919) lists ".hq-offload" in
57
59
  * PERSONAL_VAULT_EXCLUDED_TOP_LEVEL; storage.ts passes that export here. The
58
60
  * check stays at runtime so an older bundled HQ sync still reports
59
- * "update HQ sync". CURRENT_OFFLOAD_ENABLED is a separate owner decision.
61
+ * "update HQ sync". Current-file offload also needs syncHoldsPlaceholders.
60
62
  */
61
63
  export declare function syncExcludesOffload(excludedTopLevel: readonly string[] | undefined): boolean;
62
64
  export declare function offloadKey(gitBlobSha: string): string;
@@ -118,11 +120,16 @@ export interface OffloadS3Client {
118
120
  export declare function s3BlobStore(bucket: string, s3: OffloadS3Client): BlobStore;
119
121
  /** Read-only access to vault objects at their sync keys, pinned to an ETag. */
120
122
  export interface VaultObjectStore {
123
+ /** Size and unquoted ETag of `key`, or null when it is not there. */
124
+ head(key: string): Promise<{
125
+ size: number;
126
+ etag: string;
127
+ } | null>;
121
128
  /** Download `key` to `dest` only if its ETag is `etag`; returns the served ETag. */
122
129
  download(key: string, dest: string, etag: string): Promise<string>;
123
130
  }
124
131
  export interface VaultObjectS3Client {
125
- send(cmd: GetObjectCommand): Promise<unknown>;
132
+ send(cmd: GetObjectCommand | HeadObjectCommand): Promise<unknown>;
126
133
  }
127
134
  export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client): VaultObjectStore;
128
135
  export interface OffloadDeps {
@@ -152,7 +159,40 @@ export interface OffloadDeps {
152
159
  vault: "company";
153
160
  slug: string;
154
161
  }) => Promise<VaultObjectStore>;
162
+ /**
163
+ * Whether the installed HQ sync treats every `x.hqcloud` sibling as a hold
164
+ * on `x` in push and pull (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING).
165
+ * Current-file offload stays off unless this returns true.
166
+ */
167
+ syncHoldsPlaceholders?: () => boolean;
168
+ /** What HQ sync last recorded for `target` (its journal entry), or null. */
169
+ syncRecord?: (target: OffloadTarget) => Promise<SyncRecord | null>;
170
+ /** File operations for current-file offload (tests inject to see the order). */
171
+ placeholderFs?: PlaceholderFs;
155
172
  }
173
+ /** The vault that owns a working-tree path, and the object key there. */
174
+ export type OffloadTarget = {
175
+ vault: "personal";
176
+ key: string;
177
+ } | {
178
+ vault: "company";
179
+ slug: string;
180
+ key: string;
181
+ };
182
+ /** A sync journal entry: the version HQ sync last pushed or pulled. */
183
+ export interface SyncRecord {
184
+ /** Hex sha256 of the synced bytes. */
185
+ hash?: string;
186
+ size: number;
187
+ remoteEtag?: string;
188
+ }
189
+ /**
190
+ * `companies/<slug>/<rest>` belongs to that company's vault at key `<rest>`.
191
+ * Every other path belongs to the personal vault at its HQ-root-relative path.
192
+ */
193
+ export declare function resolveOffloadTarget(rel: string): OffloadTarget;
194
+ /** Whether current-file offload can run with the installed HQ sync. */
195
+ export declare function currentSyncReady(deps: Pick<OffloadDeps, "syncExcludesOffload" | "syncHoldsPlaceholders">): boolean;
156
196
  export declare function storageScriptPath(hqRoot: string): string;
157
197
  /** Whether the installed core script has a `case` arm for `sub`. */
158
198
  export declare function scriptSupports(hqRoot: string, sub: "large" | "strip-large"): boolean;
@@ -215,17 +255,34 @@ export interface PlaceholderFs {
215
255
  }
216
256
  export declare const durablePlaceholderFs: PlaceholderFs;
217
257
  /**
218
- * Offload one current file: upload and verify, write `x.hqcloud` (fsynced),
219
- * and only then remove `x`. With the placeholder written first, a sync
220
- * watcher never sees `x` missing without its placeholder, which it would
221
- * push as a delete.
222
- *
223
- * Not called while CURRENT_OFFLOAD_ENABLED is false.
258
+ * Offload one personal-scope current file by upload: upload and verify,
259
+ * write `x.hqcloud` (fsynced), and only then remove `x`. With the placeholder
260
+ * written first, a sync watcher never sees `x` missing without its
261
+ * placeholder. Company files never take this path (see pointCurrentFileAtVault).
224
262
  */
225
263
  export declare function offloadCurrentFile(rel: string, store: BlobStore, deps: Pick<OffloadDeps, "hqRoot" | "now">, fsOps?: PlaceholderFs): Promise<{
226
264
  path: string;
227
265
  bytes: number;
228
266
  }>;
267
+ /** One current file in a `--current` plan (dry run and real run). */
268
+ export interface CurrentFilePlan {
269
+ path: string;
270
+ bytes: number;
271
+ idle_days: number;
272
+ vault: "personal" | "company";
273
+ company?: string;
274
+ /** HQ sync already stored this exact version in the owning vault. */
275
+ synced: boolean;
276
+ action: "point-at-vault" | "upload" | "skip";
277
+ reason?: string;
278
+ }
279
+ export interface CurrentOffloaded {
280
+ path: string;
281
+ bytes: number;
282
+ vault: "personal" | "company";
283
+ company?: string;
284
+ action: "point-at-vault" | "upload";
285
+ }
229
286
  export interface OffloadOptions {
230
287
  minBytes: number;
231
288
  idleDays: number;
@@ -233,12 +290,16 @@ export interface OffloadOptions {
233
290
  current: boolean;
234
291
  dryRun: boolean;
235
292
  yes: boolean;
293
+ /** With `current`: offload files at or under these HQ paths instead of the large-file list. */
294
+ paths?: string[];
236
295
  }
237
296
  export interface OffloadResult {
238
297
  history: {
239
298
  uploaded: number;
240
299
  bytes: number;
241
300
  freed_bytes: number;
301
+ /** True when this run did not ask for history. */
302
+ skipped?: boolean;
242
303
  available?: boolean;
243
304
  reason?: string;
244
305
  candidates?: number;
@@ -273,13 +334,17 @@ export interface OffloadResult {
273
334
  } | null;
274
335
  };
275
336
  current: {
276
- offloaded: {
277
- path: string;
278
- bytes: number;
279
- }[];
337
+ offloaded: CurrentOffloaded[];
280
338
  freed_bytes: number;
281
339
  available?: boolean;
282
340
  reason?: string;
341
+ /** Every candidate with its target vault, sync state, and action. */
342
+ files?: CurrentFilePlan[];
343
+ /** True when this run did not ask for current files. */
344
+ skipped?: boolean;
345
+ /** Company files skipped because HQ sync has not stored this version yet. */
346
+ unsynced?: string[];
347
+ message?: string;
283
348
  };
284
349
  errors: string[];
285
350
  dry_run: boolean;