@indigoai-us/hq-cli 5.345.135 → 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 (63) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
  3. package/dist/command-catalog.generated.d.ts +17 -6
  4. package/dist/command-catalog.generated.js +23 -7
  5. package/dist/commands/core-checkpoint-native.d.ts +10 -0
  6. package/dist/commands/core-checkpoint-native.js +60 -15
  7. package/dist/commands/daemon.js +3 -1
  8. package/dist/commands/integrations-core.d.ts +22 -0
  9. package/dist/commands/integrations-core.js +25 -0
  10. package/dist/commands/integrations-manage.d.ts +24 -1
  11. package/dist/commands/integrations-manage.js +92 -17
  12. package/dist/commands/integrations.d.ts +11 -0
  13. package/dist/commands/integrations.js +100 -19
  14. package/dist/commands/lanes-loop.d.ts +9 -0
  15. package/dist/commands/lanes-loop.js +9 -1
  16. package/dist/commands/lanes.js +69 -54
  17. package/dist/commands/meetings.d.ts +2 -0
  18. package/dist/commands/meetings.js +57 -14
  19. package/dist/commands/mesh.d.ts +19 -1
  20. package/dist/commands/mesh.js +158 -70
  21. package/dist/commands/reindex.js +80 -5
  22. package/dist/commands/storage-offload.d.ts +93 -28
  23. package/dist/commands/storage-offload.js +290 -34
  24. package/dist/commands/storage.js +60 -7
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/lib/capacity/route.js +24 -0
  29. package/dist/lib/core-utils/session-company.d.ts +11 -0
  30. package/dist/lib/core-utils/session-company.js +34 -0
  31. package/dist/lib/core-utils/worktree-resume.js +44 -4
  32. package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
  33. package/dist/lib/core-utils/worktree-session-owner.js +157 -0
  34. package/dist/lib/daemon/catalog.d.ts +2 -0
  35. package/dist/lib/daemon/catalog.js +3 -3
  36. package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
  37. package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
  38. package/dist/lib/daemon/server.js +10 -1
  39. package/dist/lib/daemon/supervisor.js +6 -0
  40. package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
  41. package/dist/lib/daemon/sync-runner-bundle.js +4 -12
  42. package/dist/lib/doctor/checks/claude-wiring.js +2 -1
  43. package/dist/lib/doctor/fix/apply.d.ts +20 -0
  44. package/dist/lib/doctor/fix/apply.js +63 -3
  45. package/dist/lib/doctor/fix/remediation.d.ts +1 -1
  46. package/dist/lib/doctor/fix/remediation.js +16 -0
  47. package/dist/lib/doctor/shared.d.ts +11 -1
  48. package/dist/lib/doctor/shared.js +16 -2
  49. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  50. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  51. package/dist/lib/lanes/monitor.js +14 -2
  52. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  53. package/dist/lib/mesh/live/daemon/install.js +22 -0
  54. package/dist/lib/mesh/live/flush.js +106 -3
  55. package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
  56. package/dist/lib/mesh/live/session-events-client.js +15 -0
  57. package/dist/lib/work-context/state.d.ts +10 -0
  58. package/dist/lib/work-context/state.js +5 -0
  59. package/dist/utils/fsync-directory.d.ts +17 -0
  60. package/dist/utils/fsync-directory.js +27 -0
  61. package/dist/utils/windows-hide-default.d.ts +42 -0
  62. package/dist/utils/windows-hide-default.js +98 -0
  63. package/package.json +2 -2
@@ -251,26 +251,69 @@ function renderSignals(signals) {
251
251
  }
252
252
  console.log();
253
253
  }
254
+ /** The server caps one `/v1/meetings` page at this many rows. */
255
+ const MEETINGS_PAGE_SIZE = "100";
256
+ /** Upper bound on list pages one short-id lookup reads (per cursor chain). */
257
+ export const SHORT_ID_MAX_PAGES = 50;
258
+ /**
259
+ * Resolve the 8-character id `hq meetings list` prints to a full meeting id.
260
+ *
261
+ * `/v1/meetings` is paged: the server caps a page at 100 rows (it ignores a
262
+ * larger `limit`) and may end a page early, even empty, with
263
+ * `truncated: true` when it runs out of time. Reading only the first page
264
+ * missed any meeting past it, so follow `nextToken` (and `nativeNextToken`
265
+ * for HQ Meet calls) until the prefix is found or the list ends. A full
266
+ * 8-character prefix stops at the first page with a match; a shorter prefix
267
+ * reads every page so ambiguity is still reported.
268
+ */
254
269
  async function resolveShortId(token, prefix, query) {
255
270
  if (prefix.includes("-") && prefix.length > 8)
256
271
  return prefix;
257
- const res = await vaultApiFetch({
258
- token,
259
- path: "/v1/meetings",
260
- query: { ...query, limit: "500" },
261
- });
262
- if (!res.ok) {
263
- throw new Error(`Could not resolve meeting ID "${prefix}" — failed to list meetings (${res.status}). Pass the full meeting id.`);
272
+ const stopOnFirstMatch = prefix.length >= 8;
273
+ const matches = new Map();
274
+ let capped = false;
275
+ const fetchPage = async (extra) => {
276
+ const res = await vaultApiFetch({
277
+ token,
278
+ path: "/v1/meetings",
279
+ query: { ...query, limit: MEETINGS_PAGE_SIZE, ...extra },
280
+ });
281
+ if (!res.ok) {
282
+ throw new Error(`Could not resolve meeting ID "${prefix}" — failed to list meetings (${res.status}). Pass the full meeting id.`);
283
+ }
284
+ const data = (await res.json());
285
+ for (const m of data.meetings ?? []) {
286
+ if (typeof m.meetingId === "string" && m.meetingId.startsWith(prefix))
287
+ matches.set(m.meetingId, m);
288
+ }
289
+ return data;
290
+ };
291
+ const first = await fetchPage({});
292
+ let nextToken = first.nextToken;
293
+ for (let page = 1; nextToken && !(stopOnFirstMatch && matches.size > 0); page += 1) {
294
+ if (page >= SHORT_ID_MAX_PAGES) {
295
+ capped = true;
296
+ break;
297
+ }
298
+ nextToken = (await fetchPage({ nextToken })).nextToken;
299
+ }
300
+ let nativeCursor = first.nativeNextToken;
301
+ for (let page = 1; nativeCursor && !(stopOnFirstMatch && matches.size > 0); page += 1) {
302
+ if (page >= SHORT_ID_MAX_PAGES) {
303
+ capped = true;
304
+ break;
305
+ }
306
+ nativeCursor = (await fetchPage({ nativeCursor })).nativeNextToken;
264
307
  }
265
- const data = (await res.json());
266
- const matches = data.meetings.filter((m) => m.meetingId.startsWith(prefix));
267
- if (matches.length === 1)
268
- return matches[0].meetingId;
269
- if (matches.length > 1) {
270
- console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
308
+ if (matches.size === 1)
309
+ return [...matches.keys()][0];
310
+ if (matches.size > 1) {
311
+ console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.size} meetings. Use a longer prefix.`));
271
312
  process.exit(1);
272
313
  }
273
- console.error(chalk.red(`No meeting matches ID "${prefix}". It may be older than the meetings shown by \`hq meetings list\`, still processing, or attributed to a different company. Pass the full meeting id, add --company <slug>, or widen the list with \`hq meetings list --limit <n>\`.`));
314
+ console.error(chalk.red(capped
315
+ ? `No meeting matches ID "${prefix}" in the newest ${SHORT_ID_MAX_PAGES * Number(MEETINGS_PAGE_SIZE)} meetings. Pass the full meeting id (shown by \`hq meetings list --json\`).`
316
+ : `No meeting matches ID "${prefix}". It may still be processing or attributed to a different company. Pass the full meeting id, or add --company <slug>.`));
274
317
  process.exit(1);
275
318
  }
276
319
  async function handleApiError(res, json = false) {
@@ -6,10 +6,27 @@
6
6
  * Distinct from `hq doctor` (hook guardrails).
7
7
  */
8
8
  import { Command } from "commander";
9
- import { type MeshCompany, type MeshThread } from "../lib/mesh/api.js";
9
+ import { type MeshCompany, type MeshProjectRepo, type MeshProjectStoryInput, type MeshThread } from "../lib/mesh/api.js";
10
+ import { UnknownProjectStoryStateError } from "../lib/mesh/story-status.js";
10
11
  import { type DaemonInstallPlatform, type InstallPaths } from "../lib/mesh/live/daemon/install.js";
11
12
  import { type RunCommand } from "../lib/service-manager/launchd.js";
12
13
  export declare function formatCheckLines(threads: MeshThread[], company: MeshCompany, projectId?: string): string[];
14
+ /** 400 PROJECT_VIEW_INVALID "Unknown story ..." from a story PATCH. */
15
+ export declare function isUnknownBoardStory(error: unknown): boolean;
16
+ /** The local PRD for this project when it lists `storyId`; null otherwise. */
17
+ export declare function localPrdWithStory(company: {
18
+ companySlug?: string;
19
+ }, projectId: string, storyId: string, hqRoot?: string | undefined): {
20
+ prdPath: string;
21
+ source: ProjectPlanSource;
22
+ } | null;
23
+ type ProjectPlanSource = {
24
+ name?: string;
25
+ description?: string;
26
+ stories: MeshProjectStoryInput[];
27
+ repos?: MeshProjectRepo[];
28
+ rejectedStories: UnknownProjectStoryStateError[];
29
+ };
13
30
  /** Test seam for the usage-attribution question asked by `default set`. */
14
31
  export interface DefaultCompanyPromptDeps {
15
32
  /** True when stdin and stdout are a terminal (a person can answer). */
@@ -61,4 +78,5 @@ export declare function runDaemonStatus(opts: {
61
78
  run?: RunCommand;
62
79
  uid?: number;
63
80
  }): Promise<void>;
81
+ export {};
64
82
  //# sourceMappingURL=mesh.d.ts.map
@@ -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';