@indigoai-us/hq-cli 5.345.152 → 5.345.154

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 (82) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +4 -0
  3. package/dist/bin/hq-auth-refresh.js +9 -1
  4. package/dist/command-catalog.generated.d.ts +185 -0
  5. package/dist/command-catalog.generated.js +227 -0
  6. package/dist/command-registration-plan.d.ts +6 -0
  7. package/dist/command-registration-plan.js +1 -0
  8. package/dist/commands/agent-enroll.d.ts +12 -0
  9. package/dist/commands/agent-enroll.js +37 -4
  10. package/dist/commands/cloud.d.ts +17 -0
  11. package/dist/commands/cloud.js +48 -1
  12. package/dist/commands/core-policy.js +4 -4
  13. package/dist/commands/core-session-project-native.js +19 -4
  14. package/dist/commands/decide.d.ts +13 -0
  15. package/dist/commands/decide.js +74 -0
  16. package/dist/commands/integrations-connect.js +24 -1
  17. package/dist/commands/integrations-core.d.ts +14 -0
  18. package/dist/commands/integrations-core.js +19 -1
  19. package/dist/commands/integrations-import.js +22 -1
  20. package/dist/commands/integrations.js +43 -2
  21. package/dist/commands/lanes-message.js +4 -11
  22. package/dist/commands/lanes-questions.js +3 -0
  23. package/dist/commands/lanes-resume.js +4 -11
  24. package/dist/commands/lanes-stories.js +3 -0
  25. package/dist/commands/lanes.js +7 -1
  26. package/dist/commands/run.js +14 -5
  27. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  28. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  29. package/dist/lib/agent-kit/services.d.ts +3 -1
  30. package/dist/lib/agent-kit/services.js +5 -1
  31. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  32. package/dist/lib/daemon/ops/policy.js +37 -2
  33. package/dist/lib/decisions/client.d.ts +67 -0
  34. package/dist/lib/decisions/client.js +120 -0
  35. package/dist/lib/decisions/expiry.d.ts +2 -0
  36. package/dist/lib/decisions/expiry.js +12 -0
  37. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  38. package/dist/lib/decisions/fixtures/us001.js +21 -0
  39. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  40. package/dist/lib/decisions/import-lanes.js +152 -0
  41. package/dist/lib/decisions/principal.d.ts +11 -0
  42. package/dist/lib/decisions/principal.js +52 -0
  43. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  44. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  45. package/dist/lib/doctor/checks/work-context.js +7 -2
  46. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  47. package/dist/lib/doctor/fix/apply.js +19 -15
  48. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  49. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  50. package/dist/lib/doctor/fix/remediation.js +4 -2
  51. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  52. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  53. package/dist/lib/doctor/types.d.ts +5 -0
  54. package/dist/lib/install/provider-wrappers.js +5 -1
  55. package/dist/lib/lanes/autopilot.js +4 -1
  56. package/dist/lib/lanes/imported-question.d.ts +5 -0
  57. package/dist/lib/lanes/imported-question.js +17 -0
  58. package/dist/lib/lanes/questions.d.ts +0 -5
  59. package/dist/lib/lanes/questions.js +2 -0
  60. package/dist/lib/lanes/reconcile.js +21 -10
  61. package/dist/lib/lanes/store.d.ts +0 -8
  62. package/dist/lib/lanes/store.js +2 -1
  63. package/dist/lib/lanes/stories.d.ts +15 -0
  64. package/dist/lib/lanes/stories.js +25 -0
  65. package/dist/lib/mesh/client.d.ts +8 -0
  66. package/dist/lib/mesh/client.js +44 -3
  67. package/dist/lib/mesh/denial-codes.js +1 -1
  68. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  69. package/dist/lib/mesh/live/daemon/run.js +30 -1
  70. package/dist/lib/mesh/live/flush.d.ts +7 -0
  71. package/dist/lib/mesh/live/flush.js +53 -10
  72. package/dist/lib/registry/registry.js +6 -0
  73. package/dist/lib/scan-packages/index.js +7 -3
  74. package/dist/lib/sync/read-only-local-only.d.ts +45 -0
  75. package/dist/lib/sync/read-only-local-only.js +122 -0
  76. package/dist/lib/work-context/organize.d.ts +2 -0
  77. package/dist/lib/work-context/organize.js +22 -4
  78. package/dist/lib/workers-registry/index.d.ts +12 -1
  79. package/dist/lib/workers-registry/index.js +39 -1
  80. package/dist/utils/cognito-session.d.ts +4 -1
  81. package/dist/utils/cognito-session.js +11 -1
  82. package/package.json +7 -7
@@ -262,6 +262,7 @@ export async function flushSessionEvents(inputDeps) {
262
262
  pendingRestore.delete(event);
263
263
  };
264
264
  const loggedProject404 = new Set();
265
+ const heldUidCache = new Map();
265
266
  sessionLoop: for (const [sessionId, items] of bySession) {
266
267
  if (deps.signal?.aborted)
267
268
  break sessionLoop;
@@ -283,7 +284,8 @@ export async function flushSessionEvents(inputDeps) {
283
284
  if (leaveResult.abort)
284
285
  continue;
285
286
  const remainingItems = leaveResult.items;
286
- state = bindHeldCompanyIfResolved(sessionId, remainingItems, state, deps, now);
287
+ const heldBinding = bindHeldCompanyIfResolved(sessionId, remainingItems, state, deps, now, heldUidCache);
288
+ state = heldBinding instanceof Promise ? await heldBinding : heldBinding;
287
289
  if (state?.companyConfidence === "device_default" &&
288
290
  deps.authorizeDeviceDefaultTenant) {
289
291
  const allowed = await deps.authorizeDeviceDefaultTenant(state);
@@ -646,12 +648,12 @@ const SESSION_EVENT_HARNESSES = new Set([
646
648
  "agent-box",
647
649
  "agents-v2",
648
650
  ]);
649
- function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
651
+ function resolveHeldCompany(sessionId, items, state, deps) {
650
652
  if (state?.companyUid)
651
- return state;
653
+ return null;
652
654
  const sample = items[0]?.event;
653
655
  if (!sample)
654
- return state;
656
+ return null;
655
657
  const cwd = eventText(sample, "cwd");
656
658
  const hqRoot = eventText(sample, "hqRoot");
657
659
  const remoteOwnerSlug = cwd
@@ -675,14 +677,16 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
675
677
  remoteOwnerSlug,
676
678
  repoIdentityKey,
677
679
  });
678
- if (resolution.status !== "resolved" || !resolution.company.uid)
679
- return state;
680
+ if (resolution.status !== "resolved")
681
+ return null;
680
682
  if (resolution.company.source === "trusted_explicit" &&
681
683
  !eventText(sample, "companySlug")) {
682
- return state;
684
+ return null;
683
685
  }
684
- const uid = resolution.company.uid;
685
- const slug = resolution.company.slug;
686
+ return resolution.company;
687
+ }
688
+ function writeHeldCompanyBinding(sessionId, state, company, deps, now) {
689
+ const { uid, slug } = company;
686
690
  const nowIso = now().toISOString();
687
691
  const next = updateSessionState(sessionId, deps.workContextRoot, (current) => {
688
692
  const base = current ?? state;
@@ -703,7 +707,7 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
703
707
  contractVersion: base.contractVersion ?? 1,
704
708
  companyUid: uid,
705
709
  ...(slug ? { companySlug: slug } : {}),
706
- ...(resolution.company.source === "device_default"
710
+ ...(company.source === "device_default"
707
711
  ? { companyConfidence: "device_default" }
708
712
  : {}),
709
713
  contextStatus,
@@ -712,6 +716,45 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
712
716
  });
713
717
  return next ?? readSessionState(sessionId, deps.workContextRoot) ?? state;
714
718
  }
719
+ /**
720
+ * Bind a held session to a company. Local evidence with a uid binds directly.
721
+ * Local evidence that only names a slug (session meta, cwd, git remote) needs
722
+ * a membership-validated slug -> uid lookup; without it the session would stay
723
+ * NEEDS_COMPANY on every retry (Shepherd 2361).
724
+ */
725
+ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now, uidCache) {
726
+ // Stay synchronous unless a uid lookup is needed: the flush loop's
727
+ // microtask ordering is relied on by retry/back-pressure timing.
728
+ const company = resolveHeldCompany(sessionId, items, state, deps);
729
+ if (!company)
730
+ return state;
731
+ if (company.uid) {
732
+ return writeHeldCompanyBinding(sessionId, state, { uid: company.uid, slug: company.slug, source: company.source }, deps, now);
733
+ }
734
+ const slug = company.slug?.trim().toLowerCase();
735
+ if (!slug || !deps.resolveCompanyUid || company.source === "device_default")
736
+ return state;
737
+ let pending = uidCache.get(slug);
738
+ if (!pending) {
739
+ const resolver = deps.resolveCompanyUid;
740
+ pending = (async () => {
741
+ try {
742
+ const uid = await resolver(slug);
743
+ return typeof uid === "string" && uid.trim() ? uid.trim() : null;
744
+ }
745
+ catch (err) {
746
+ deps.log?.(`held company uid lookup failed for slug=${slug}: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`);
747
+ return null;
748
+ }
749
+ })();
750
+ uidCache.set(slug, pending);
751
+ }
752
+ return pending.then((uid) => {
753
+ if (!uid)
754
+ return state;
755
+ return writeHeldCompanyBinding(sessionId, state, { uid, slug: company.slug, source: company.source }, deps, now);
756
+ });
757
+ }
715
758
  function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
716
759
  if (!state)
717
760
  return state;
@@ -90,6 +90,12 @@ export function pathKey(dir) {
90
90
  export function canonicalDir(dir) {
91
91
  const abs = path.resolve(dir);
92
92
  try {
93
+ // macOS volumes are case-insensitive by default: /x/CaseRepo and
94
+ // /x/caserepo are one folder, but the JS realpath keeps the caller's
95
+ // spelling, so the same repo produced two registry keys. realpath(3)
96
+ // returns the on-disk spelling. Other platforms keep the JS realpath.
97
+ if (process.platform === "darwin")
98
+ return fs.realpathSync.native(abs);
93
99
  return fs.realpathSync(abs);
94
100
  }
95
101
  catch {
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import * as yaml from 'js-yaml';
5
5
  import { contributionLinks } from '../../utils/pack-contribution-links.js';
6
6
  import { CONTRIBUTION_TABLE } from '../../utils/contribution-table.js';
7
- import { isExcludedWorkerDirectory } from '../workers-registry/index.js';
7
+ import { isExcludedWorkerDirectory, isNestedWorkerCheckout } from '../workers-registry/index.js';
8
8
  function isDirectory(target) { try {
9
9
  return fs.statSync(target).isDirectory();
10
10
  }
@@ -77,8 +77,9 @@ function allWorkerYamlFiles(root, relative) {
77
77
  const output = [];
78
78
  const walk = (next, seen) => {
79
79
  // Prune exactly what the worker registry prunes (tenant repos/ checkouts,
80
- // _template, _overrides). A worker.yaml the registry never indexes cannot
81
- // collide with a pack worker, and walking source checkouts is slow.
80
+ // nested git checkouts, _template, _overrides). A worker.yaml the registry
81
+ // never indexes cannot collide with a pack worker, and walking source
82
+ // checkouts is slow.
82
83
  if (isExcludedWorkerDirectory(next))
83
84
  return;
84
85
  const absolute = path.join(root, next);
@@ -101,7 +102,10 @@ function allWorkerYamlFiles(root, relative) {
101
102
  catch {
102
103
  return;
103
104
  }
105
+ const checkout = isNestedWorkerCheckout(relative, next, names);
104
106
  for (const name of names) {
107
+ if (checkout && name !== 'worker.yaml')
108
+ continue;
105
109
  const child = path.join(next, name);
106
110
  const childAbsolute = path.join(root, child);
107
111
  if (name === 'worker.yaml' && (() => { try {
@@ -0,0 +1,45 @@
1
+ export type VaultGrant = {
2
+ path: string;
3
+ permission: string;
4
+ };
5
+ export type VaultAccessCompany = {
6
+ role?: string;
7
+ enforced?: boolean;
8
+ grants?: VaultGrant[];
9
+ };
10
+ export type VaultAccessManifest = {
11
+ version?: number;
12
+ companies?: Record<string, VaultAccessCompany>;
13
+ };
14
+ /** bypass | unknown | admin | write | read | none */
15
+ export type EffectivePermission = "bypass" | "unknown" | "admin" | "write" | "read" | "none";
16
+ export declare function readVaultAccessManifest(hqRoot: string): VaultAccessManifest | null;
17
+ /** Whether the manifest entry is one the write hook enforces grants for. */
18
+ export declare function isGrantEnforced(company: VaultAccessCompany | undefined): boolean;
19
+ export declare function effectivePermission(company: VaultAccessCompany | undefined, rel: string): EffectivePermission;
20
+ export type ReadOnlyLocalOnlyFile = {
21
+ path: string;
22
+ permission: "read" | "none";
23
+ };
24
+ export type ReadOnlyLocalOnlyResult = {
25
+ files: ReadOnlyLocalOnlyFile[];
26
+ total: number;
27
+ };
28
+ export type FindReadOnlyLocalOnlyOptions = {
29
+ hqRoot: string;
30
+ slug: string;
31
+ company: VaultAccessCompany;
32
+ /** Company-relative keys already in the sync journal (live or tombstoned). */
33
+ journalKeys: ReadonlySet<string>;
34
+ /** Engine ignore filter over absolute paths; true = would sync. */
35
+ shouldSync?: (absPath: string, isDir?: boolean) => boolean;
36
+ /** Max paths returned; `total` still counts every match. */
37
+ limit?: number;
38
+ };
39
+ /**
40
+ * Walk `companies/<slug>/` and return files that are not in the journal and
41
+ * whose effective permission is read or none. Returns an empty result for
42
+ * roles the hook does not enforce (owner/admin/unknown, enforced=false).
43
+ */
44
+ export declare function findReadOnlyLocalOnly(opts: FindReadOnlyLocalOnlyOptions): ReadOnlyLocalOnlyResult;
45
+ //# sourceMappingURL=read-only-local-only.d.ts.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Local-only files under read-only vault prefixes (feedback_510661bc, Shepherd #2813).
3
+ *
4
+ * A teammate with a read-only share can still hold local files under that
5
+ * prefix — for example files written before the enforce-vault-write-access
6
+ * hook existed. Push will never upload them, so `hq sync status` lists them.
7
+ *
8
+ * Read-only and network-free: the access data is the local manifest at
9
+ * `<hqRoot>/.hq/vault-access.json` (written by refresh-vault-access), and the
10
+ * "already synced" set is the company's sync journal the caller already has.
11
+ * Permission resolution mirrors `effective_permission` in
12
+ * `.claude/hooks/enforce-vault-write-access.sh`.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ export function readVaultAccessManifest(hqRoot) {
17
+ try {
18
+ const raw = JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq", "vault-access.json"), "utf-8"));
19
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function rank(permission) {
26
+ if (permission === "admin")
27
+ return 3;
28
+ if (permission === "write")
29
+ return 2;
30
+ if (permission === "read")
31
+ return 1;
32
+ return 0;
33
+ }
34
+ function grantMatches(grant, rel) {
35
+ const p = grant.path;
36
+ if (typeof p !== "string")
37
+ return false;
38
+ if (p === "*" || p === rel)
39
+ return true;
40
+ if (p.endsWith("/*")) {
41
+ return rel.startsWith(p.slice(0, -1)) || rel === p.slice(0, -2);
42
+ }
43
+ if (p.endsWith("/") && (grant.permission === "write" || grant.permission === "admin") && rel.startsWith(p)) {
44
+ const child = rel.slice(p.length);
45
+ return child.length > 0 && !child.includes("/");
46
+ }
47
+ return false;
48
+ }
49
+ /** Whether the manifest entry is one the write hook enforces grants for. */
50
+ export function isGrantEnforced(company) {
51
+ if (!company)
52
+ return false;
53
+ const role = company.role ?? "unknown";
54
+ if (role !== "member" && role !== "guest")
55
+ return false;
56
+ return company.enforced !== false;
57
+ }
58
+ export function effectivePermission(company, rel) {
59
+ if (!company)
60
+ return "unknown";
61
+ const role = company.role ?? "unknown";
62
+ if (role === "owner" || role === "admin")
63
+ return "bypass";
64
+ if (company.enforced === false)
65
+ return "bypass";
66
+ if (role !== "member" && role !== "guest")
67
+ return "unknown";
68
+ const matches = (company.grants ?? []).filter((g) => g && grantMatches(g, rel));
69
+ if (matches.length === 0)
70
+ return "none";
71
+ matches.sort((a, b) => a.path.length - b.path.length || rank(a.permission) - rank(b.permission));
72
+ const best = matches[matches.length - 1].permission;
73
+ return best === "admin" || best === "write" || best === "read" ? best : "none";
74
+ }
75
+ /**
76
+ * Walk `companies/<slug>/` and return files that are not in the journal and
77
+ * whose effective permission is read or none. Returns an empty result for
78
+ * roles the hook does not enforce (owner/admin/unknown, enforced=false).
79
+ */
80
+ export function findReadOnlyLocalOnly(opts) {
81
+ if (!isGrantEnforced(opts.company))
82
+ return { files: [], total: 0 };
83
+ const found = [];
84
+ const root = path.join(opts.hqRoot, "companies", opts.slug);
85
+ const limit = opts.limit ?? 20;
86
+ const stack = [root];
87
+ while (stack.length > 0) {
88
+ const dir = stack.pop();
89
+ let entries;
90
+ try {
91
+ entries = fs.readdirSync(dir, { withFileTypes: true });
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ for (const entry of entries) {
97
+ const abs = path.join(dir, entry.name);
98
+ if (entry.isDirectory()) {
99
+ if (entry.name === ".git" || entry.name === "node_modules")
100
+ continue;
101
+ if (opts.shouldSync && !opts.shouldSync(abs, true))
102
+ continue;
103
+ stack.push(abs);
104
+ continue;
105
+ }
106
+ if (!entry.isFile())
107
+ continue;
108
+ if (opts.shouldSync && !opts.shouldSync(abs, false))
109
+ continue;
110
+ const rel = path.relative(root, abs).split(path.sep).join("/");
111
+ if (opts.journalKeys.has(rel))
112
+ continue;
113
+ const permission = effectivePermission(opts.company, rel);
114
+ if (permission !== "read" && permission !== "none")
115
+ continue;
116
+ found.push({ path: rel, permission });
117
+ }
118
+ }
119
+ found.sort((a, b) => a.path.localeCompare(b.path));
120
+ return { files: found.slice(0, limit), total: found.length };
121
+ }
122
+ //# sourceMappingURL=read-only-local-only.js.map
@@ -92,6 +92,8 @@ export declare function prepareOrganizeDecision(input: {
92
92
  /** Skip network; leave unresolved without candidates. */
93
93
  offline?: boolean;
94
94
  }): Promise<OrganizeListResult>;
95
+ /** True when a local decision's server window has passed (or is unparsable). */
96
+ export declare function isLocalDecisionExpired(expiresAt: string | undefined, now?: Date): boolean;
95
97
  export declare function formatOrganizeList(result: OrganizeListResult): string[];
96
98
  /**
97
99
  * Mark that the ask was cancelled / timed out / surface incapable.
@@ -114,8 +114,17 @@ export async function prepareOrganizeDecision(input) {
114
114
  reason: state.contextStatus,
115
115
  };
116
116
  }
117
- // Ask-once: an existing decision is never replaced or re-armed.
118
- if (state.decision?.decisionId) {
117
+ // Ask-once: an existing decision is never replaced or re-armed — unless its
118
+ // server decision window has expired. The server stamps a fresh window only
119
+ // when candidates are listed, so returning the stale decision would make
120
+ // every later organize fail with DECISION_EXPIRED / DECISION_WINDOW_MISSING.
121
+ const refreshExpired = Boolean(state.decision?.decisionId) &&
122
+ state.decision?.askAfter === true &&
123
+ isLocalDecisionExpired(state.decision?.expiresAt, nowFn()) &&
124
+ Boolean(state.companyUid) &&
125
+ !input.offline &&
126
+ Boolean(input.fetchCandidates);
127
+ if (state.decision?.decisionId && !refreshExpired) {
119
128
  return {
120
129
  sessionId: input.sessionId,
121
130
  contextStatus: state.contextStatus,
@@ -124,7 +133,7 @@ export async function prepareOrganizeDecision(input) {
124
133
  reason: state.decision.askAfter ? "pending_ask" : "ask_already_settled",
125
134
  };
126
135
  }
127
- const ready = shouldAskAfter({
136
+ const ready = refreshExpired || shouldAskAfter({
128
137
  toolWrites: state.toolWrites,
129
138
  startedAt: state.startedAt,
130
139
  hasTurn: state.hasTurn,
@@ -168,9 +177,18 @@ export async function prepareOrganizeDecision(input) {
168
177
  contextStatus: next.contextStatus,
169
178
  pending: true,
170
179
  decision,
171
- reason: "ask_prepared",
180
+ reason: refreshExpired ? "ask_refreshed" : "ask_prepared",
172
181
  };
173
182
  }
183
+ /** True when a local decision's server window has passed (or is unparsable). */
184
+ export function isLocalDecisionExpired(expiresAt, now = new Date()) {
185
+ if (!expiresAt)
186
+ return false;
187
+ const ms = Date.parse(expiresAt);
188
+ if (!Number.isFinite(ms))
189
+ return true;
190
+ return now.getTime() >= ms;
191
+ }
174
192
  export function formatOrganizeList(result) {
175
193
  const lines = [
176
194
  `session ${result.sessionId} status=${result.contextStatus}` +
@@ -28,9 +28,20 @@ export interface GenerateWorkersRegistryResult {
28
28
  * (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
29
29
  * nested HQ root under a company project. Those copies land in the registry as
30
30
  * `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
31
- * `hq lanes create` then fails with `worker_tenancy_unresolved`.
31
+ * `hq lanes create` then fails with `worker_tenancy_unresolved`. HQ's own
32
+ * `workspace/worktrees/<name>/` is the same shape.
33
+ *
34
+ * `node_modules` and `.git` never hold workers and are large, so they are
35
+ * skipped wherever they appear. Any other checkout (a directory holding `.git`)
36
+ * is caught by the walk itself, because only the filesystem can tell.
32
37
  */
33
38
  export declare function isExcludedWorkerDirectory(relativeDirectory: string): boolean;
39
+ /**
40
+ * True when `relativeDirectory`, found while walking `scanRoot`, is a separate
41
+ * git checkout (its listing `names` holds `.git`). The caller may still take a
42
+ * `worker.yaml` directly inside it, but must not descend any further.
43
+ */
44
+ export declare function isNestedWorkerCheckout(scanRoot: string, relativeDirectory: string, names: readonly string[]): boolean;
34
45
  /**
35
46
  * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
36
47
  * are quarantined but do not prevent all valid workers from being registered.
@@ -59,7 +59,13 @@ function workerYamlFiles(root, relativeRoot) {
59
59
  return;
60
60
  }
61
61
  entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
62
+ // A `.git` file (linked worktree) or folder (clone) below the scan root marks
63
+ // a separate checkout. Its worker.yaml files belong to that checkout, so the
64
+ // walk stops there. A checkout that is itself one worker keeps its own entry.
65
+ const isNestedCheckout = isNestedWorkerCheckout(relativeRoot, relative, entries.map((entry) => entry.name));
62
66
  for (const entry of entries) {
67
+ if (isNestedCheckout && entry.name !== 'worker.yaml')
68
+ continue;
63
69
  const childRelative = path.join(relative, entry.name);
64
70
  const child = path.join(root, childRelative);
65
71
  if (entry.name === 'worker.yaml' && (entry.isFile() || isFile(child))) {
@@ -162,12 +168,21 @@ function withoutTimestamp(content) {
162
168
  * (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
163
169
  * nested HQ root under a company project. Those copies land in the registry as
164
170
  * `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
165
- * `hq lanes create` then fails with `worker_tenancy_unresolved`.
171
+ * `hq lanes create` then fails with `worker_tenancy_unresolved`. HQ's own
172
+ * `workspace/worktrees/<name>/` is the same shape.
173
+ *
174
+ * `node_modules` and `.git` never hold workers and are large, so they are
175
+ * skipped wherever they appear. Any other checkout (a directory holding `.git`)
176
+ * is caught by the walk itself, because only the filesystem can tell.
166
177
  */
167
178
  export function isExcludedWorkerDirectory(relativeDirectory) {
168
179
  const normalized = relativeDirectory.replaceAll('\\', '/');
169
180
  if (/(?:^|\/)\.(?:claude|codex)\/worktrees(?:\/|$)/.test(normalized))
170
181
  return true;
182
+ if (/(?:^|\/)workspace\/worktrees(?:\/|$)/.test(normalized))
183
+ return true;
184
+ if (/(?:^|\/)(?:node_modules|\.git)(?:\/|$)/.test(normalized))
185
+ return true;
171
186
  const [root, tenant, nested] = normalized.split('/');
172
187
  if (root !== 'companies')
173
188
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
@@ -177,6 +192,29 @@ export function isExcludedWorkerDirectory(relativeDirectory) {
177
192
  return true;
178
193
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
179
194
  }
195
+ /**
196
+ * Directories that may be git repositories without being a nested checkout.
197
+ *
198
+ * An operator can keep a whole tenant (`companies/<slug>`) or a tenant's
199
+ * `workers` folder in its own repository. Treating either as a checkout would
200
+ * drop every real worker in that tenant.
201
+ */
202
+ function isCheckoutBoundaryExempt(relativeDirectory) {
203
+ const parts = relativeDirectory.replaceAll('\\', '/').split('/');
204
+ if (parts[0] !== 'companies')
205
+ return false;
206
+ return parts.length === 2 || (parts.length === 3 && parts[2] === 'workers');
207
+ }
208
+ /**
209
+ * True when `relativeDirectory`, found while walking `scanRoot`, is a separate
210
+ * git checkout (its listing `names` holds `.git`). The caller may still take a
211
+ * `worker.yaml` directly inside it, but must not descend any further.
212
+ */
213
+ export function isNestedWorkerCheckout(scanRoot, relativeDirectory, names) {
214
+ return relativeDirectory !== scanRoot
215
+ && !isCheckoutBoundaryExempt(relativeDirectory)
216
+ && names.includes('.git');
217
+ }
180
218
  /**
181
219
  * Every exclusion is a property of the directory holding the `worker.yaml`, so
182
220
  * the file rule is the directory rule applied to its parent. Deriving it keeps
@@ -294,7 +294,10 @@ export declare function buildVaultConfig(authToken: string): VaultServiceConfig;
294
294
  * means no valid session could be ensured non-interactively; the caller gets a
295
295
  * reason string and can decide what to do next. Never opens a browser.
296
296
  */
297
- type MachineRefreshOptions = Pick<EnsureCognitoTokenOptions, "tokenSource"> & MachineTokenValidityOptions;
297
+ type MachineRefreshOptions = Pick<EnsureCognitoTokenOptions, "tokenSource"> & MachineTokenValidityOptions & {
298
+ /** Refresh the person session even when the cached token is still valid. */
299
+ force?: boolean;
300
+ };
298
301
  export declare function refreshCachedSession(options?: MachineRefreshOptions): Promise<{
299
302
  refreshed: boolean;
300
303
  reason?: string;
@@ -1158,6 +1158,15 @@ export function buildVaultConfig(authToken) {
1158
1158
  clientInfo: CLI_CLIENT_INFO,
1159
1159
  };
1160
1160
  }
1161
+ /** Person sessions renew below this many seconds of validity by default. */
1162
+ const PERSON_REFRESH_DEFAULT_SECONDS = 120;
1163
+ /** Person tokens last one hour; a caller minimum above this would always refresh. */
1164
+ const MAX_PERSON_MIN_VALIDITY_SECONDS = 1800;
1165
+ function personRefreshThresholdSeconds(minValiditySeconds) {
1166
+ if (minValiditySeconds === undefined)
1167
+ return PERSON_REFRESH_DEFAULT_SECONDS;
1168
+ return Math.max(PERSON_REFRESH_DEFAULT_SECONDS, Math.min(minValiditySeconds, MAX_PERSON_MIN_VALIDITY_SECONDS));
1169
+ }
1161
1170
  export async function refreshCachedSession(options = {}) {
1162
1171
  if (wantsMachineCognitoTokens(options))
1163
1172
  return refreshCachedSessionUnserialized(options);
@@ -1198,7 +1207,8 @@ async function refreshCachedSessionUnserialized(options) {
1198
1207
  }
1199
1208
  return { refreshed: false, reason: "no cached session" };
1200
1209
  }
1201
- if (!isExpiring(cached, 120)) {
1210
+ if (!options.force &&
1211
+ !isExpiring(cached, personRefreshThresholdSeconds(options.minValiditySeconds))) {
1202
1212
  return { refreshed: true };
1203
1213
  }
1204
1214
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.152",
3
+ "version": "5.345.154",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.152",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.152",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.152",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.152",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.152",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.152"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.154",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.154",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.154",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.154",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.154",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.154"
123
123
  }
124
124
  }