@indigoai-us/hq-cli 5.345.153 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.154] — 2026-10-21
6
+ - `hq sync status` now lists local files that sit under a vault prefix you can
7
+ only read and that were never synced. Sync will never upload these files, for
8
+ example notes written before the vault write-access hook blocked that folder.
9
+ The list comes from the local `.hq/vault-access.json` access data and the
10
+ sync journal, so the command makes no extra network calls.
11
+ - `hq lanes resume` and `hq lanes message` without `--account` keep a Claude lane on the account it was created with via `--account`, instead of rebalancing it onto another account (which could belong to another company). Repair of an unusable account still moves the lane.
12
+
5
13
  ## [5.345.153] — 2026-10-21
6
14
  - `hq-auth-refresh --min-validity-seconds N` now applies to person sessions,
7
15
  not only machine tokens: a person session with less than N seconds left
@@ -401,4 +401,21 @@ export declare function registerCloudCommands(program: Command): void;
401
401
  export declare function runSyncDoctorReconcileConflicts(hqRoot: string, yes: boolean): Promise<void>;
402
402
  /** Exported for the US-003 sync-health exit-path regression test. */
403
403
  export declare function runPullPersonal(hqRoot: string, onConflict?: ConflictStrategy): Promise<void>;
404
+ /**
405
+ * Human-friendly label for a journal slug in `hq sync status`. The
406
+ * personal-vault fanout slot and the legacy `companies/personal` company both
407
+ * carry opaque slugs; everything else is a real company slug shown as-is.
408
+ */
409
+ /**
410
+ * `hq sync status`: list local files under read-only vault prefixes that are
411
+ * not in the sync journal. Push never uploads them (feedback_510661bc,
412
+ * Shepherd #2813). Uses only the local vault-access manifest and the journals
413
+ * already loaded, with no network calls. Prints nothing when nothing applies.
414
+ */
415
+ export declare function printReadOnlyLocalOnly(hqRoot: string, journals: ReadonlyArray<{
416
+ slug: string;
417
+ journal: {
418
+ files?: Record<string, unknown>;
419
+ };
420
+ }>, log?: (line: string) => void): number;
404
421
  //# sourceMappingURL=cloud.d.ts.map
@@ -13,10 +13,11 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
  import { isAccessDeniedError, accessDeniedKeyOf, formatAccessDenied, } from "../utils/access-denied-hint.js";
16
+ import { findReadOnlyLocalOnly, isGrantEnforced, readVaultAccessManifest, } from "../lib/sync/read-only-local-only.js";
16
17
  import chalk from "chalk";
17
18
  import * as fs from "fs";
18
19
  import * as path from "path";
19
- import { share, getStateDir, listJournals, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, syncDoctor, } from "@indigoai-us/hq-cloud";
20
+ import { share, getStateDir, listJournals, createIgnoreFilter, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, syncDoctor, } from "@indigoai-us/hq-cloud";
20
21
  import { syncLeg } from "../utils/sync-operation-lock.js";
21
22
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, loadCachedTokens, } from "../utils/cognito-session.js";
22
23
  import { resolveUploadAuthor } from "../utils/upload-author.js";
@@ -1353,6 +1354,7 @@ export function registerCloudCommands(program) {
1353
1354
  console.log(` Total size: ${formatBytes(grandBytes)}`);
1354
1355
  console.log(` Last sync: ${grandLastSync || "never"}`);
1355
1356
  }
1357
+ printReadOnlyLocalOnly(hqRoot, journals);
1356
1358
  }
1357
1359
  catch (err) {
1358
1360
  console.error(chalk.red("✗ Status failed:"), err instanceof Error ? err.message : String(err));
@@ -2094,6 +2096,51 @@ function readActiveCompanySlug(hqRoot) {
2094
2096
  * personal-vault fanout slot and the legacy `companies/personal` company both
2095
2097
  * carry opaque slugs; everything else is a real company slug shown as-is.
2096
2098
  */
2099
+ /**
2100
+ * `hq sync status`: list local files under read-only vault prefixes that are
2101
+ * not in the sync journal. Push never uploads them (feedback_510661bc,
2102
+ * Shepherd #2813). Uses only the local vault-access manifest and the journals
2103
+ * already loaded, with no network calls. Prints nothing when nothing applies.
2104
+ */
2105
+ export function printReadOnlyLocalOnly(hqRoot, journals, log = (line) => console.log(line)) {
2106
+ const companies = readVaultAccessManifest(hqRoot)?.companies;
2107
+ if (!companies)
2108
+ return 0;
2109
+ let shouldSync;
2110
+ let grand = 0;
2111
+ for (const [slug, company] of Object.entries(companies)) {
2112
+ if (!isGrantEnforced(company))
2113
+ continue;
2114
+ if (!fs.existsSync(path.join(hqRoot, "companies", slug)))
2115
+ continue;
2116
+ if (!shouldSync) {
2117
+ try {
2118
+ shouldSync = createIgnoreFilter(hqRoot);
2119
+ }
2120
+ catch {
2121
+ shouldSync = () => true;
2122
+ }
2123
+ }
2124
+ const journal = journals.find((j) => j.slug === slug)?.journal;
2125
+ const journalKeys = new Set(Object.keys(journal?.files ?? {}));
2126
+ const found = findReadOnlyLocalOnly({ hqRoot, slug, company, journalKeys, shouldSync });
2127
+ if (found.total === 0)
2128
+ continue;
2129
+ grand += found.total;
2130
+ log(chalk.yellow(`\n ! ${slug}: ${found.total} local-only file(s) under read-only access will not upload`));
2131
+ for (const f of found.files) {
2132
+ log(` ${f.path}${f.permission === "none" ? chalk.dim(" (no access)") : ""}`);
2133
+ }
2134
+ if (found.total > found.files.length) {
2135
+ log(chalk.dim(` ... and ${found.total - found.files.length} more`));
2136
+ }
2137
+ }
2138
+ if (grand > 0) {
2139
+ log(chalk.dim(" Copy them somewhere you can write, or ask a company admin for write access. " +
2140
+ "Access data: .hq/vault-access.json (refresh: bash core/scripts/refresh-vault-access.sh)."));
2141
+ }
2142
+ return grand;
2143
+ }
2097
2144
  function scopeLabel(slug) {
2098
2145
  if (slug === PERSONAL_VAULT_JOURNAL_SLUG)
2099
2146
  return "personal vault";
@@ -7801,7 +7801,13 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
7801
7801
  accountModel: lane.account === undefined ? undefined : config.providers.claude.accounts[lane.account]?.model,
7802
7802
  providerModel: config.providers.claude.model,
7803
7803
  });
7804
- const mayRebalance = BALANCE_MOVABLE_STATES.includes(lane.state);
7804
+ // An account the operator chose with --account (account_source "cli") is a
7805
+ // pin, not a balancing hint. Plain resumes and messages keep it; only a repair
7806
+ // of an unusable account may move the lane, and only an explicit --account
7807
+ // may pick another healthy account. Rebalancing a pinned lane moved company
7808
+ // lanes onto another company's account (feedback_4a563e61).
7809
+ const cliAccountPin = lane.account !== undefined && lane.account_source === "cli";
7810
+ const mayRebalance = !cliAccountPin && BALANCE_MOVABLE_STATES.includes(lane.state);
7805
7811
  if (!accountNeedsRepair && !mayRebalance)
7806
7812
  return undefined;
7807
7813
  const balanceHosts = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config, {
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.153",
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.153",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.153",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.153",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.153",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.153",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.153"
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
  }