@indigoai-us/hq-cli 5.144.0 → 5.146.0

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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/command-catalog.generated.d.ts +24 -6
  3. package/dist/command-catalog.generated.js +29 -6
  4. package/dist/commands/_patterns.d.ts +16 -0
  5. package/dist/commands/_patterns.js +20 -3
  6. package/dist/commands/files-browse.d.ts +21 -10
  7. package/dist/commands/files-browse.js +95 -32
  8. package/dist/commands/files.js +44 -13
  9. package/dist/commands/mesh.js +115 -1
  10. package/dist/commands/report.d.ts +13 -0
  11. package/dist/commands/report.js +35 -13
  12. package/dist/commands/sync-narrow.js +36 -5
  13. package/dist/commands/workers.js +5 -2
  14. package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
  15. package/dist/lib/mesh/live/daemon/credentials.js +141 -10
  16. package/dist/lib/mesh/live/daemon/doctor.js +4 -3
  17. package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
  18. package/dist/lib/mesh/live/daemon/presence.js +69 -1
  19. package/dist/lib/mesh/live/daemon/run.js +3 -1
  20. package/dist/lib/mesh/project-board.d.ts +14 -0
  21. package/dist/lib/mesh/project-board.js +24 -1
  22. package/dist/lib/work-context/bind-best.d.ts +2 -0
  23. package/dist/lib/work-context/bind-best.js +34 -1
  24. package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
  25. package/dist/lib/work-context/bind-calling-session.js +63 -0
  26. package/dist/lib/work-context/prd-sync.d.ts +34 -0
  27. package/dist/lib/work-context/prd-sync.js +120 -0
  28. package/dist/lib/work-context/prompt-rebind.d.ts +21 -0
  29. package/dist/lib/work-context/prompt-rebind.js +76 -0
  30. package/dist/lib/work-context/reconcile.d.ts +2 -0
  31. package/dist/lib/work-context/reconcile.js +31 -0
  32. package/dist/lib/work-context/state.d.ts +2 -0
  33. package/dist/lib/work-context/state.js +8 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.146.0] — 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - The mesh daemon asks for presence in the device-default company and the companies with a session updated in the last day (at most five), and asks again within a minute when that set changes. `hq mesh doctor` says when a company was left out because presence is off or because one credential cannot cover every company.
10
+ - `hq mesh project ensure` closes a genesis thread only when that same call created it and register returns a different server `proj-` thread. An already-open project thread stays open on a re-run. The register route does not accept a client thread id.
11
+ - `hq mesh project ensure` on a new project closes the client genesis thread when register returns the server `proj-` thread, so the project has one open thread. The register route does not accept a client thread id.
12
+ - A session that registers or creates a project is bound to it. `hq mesh project ensure` and `hq mesh project set --create` bind the calling session when it is the same company and still `needs_project` or unresolved, and they leave a session that is already on a different project alone. The next prompt re-checks `needs_project` against the current catalog. `hq mesh context prd-sync --session <sid> --file <prd.json>` emits `task_status` when a bound session's story `passes` or `status` changes (`passes: true` is done, `in_progress` is in progress) and does not call the network when nothing changed.
13
+ - `hq report publish` now fills a report's missing company from its channel and keeps it across updates. Before, a report saved without a company was published under a personal access policy and the deploy refused it.
14
+
15
+ ## [5.145.0] — 2026-09-23
16
+
17
+ - `hq files` now preserves a trailing-slash private-folder pattern such as
18
+ `foo/` rather than rewriting it to `foo/*`, identifies private folders in
19
+ ACL and discovery output, and explains the conflict with an existing shared
20
+ glob. Quote a shared glob such as `'foo/*'` when invoking it from a shell.
21
+ - `hq report` now finds reports on project channels. It listed only personal channels and treated only company-wide channels as valid, so every publish from a bot on a project channel failed with a generic deploy error. Channel lookup errors now say what went wrong.
22
+
5
23
  ## [5.144.0] — 2026-09-23
6
24
 
7
25
  - Worker-profile models now use the same provider catalog validation as
@@ -50,6 +68,7 @@
50
68
  ### Fixed
51
69
 
52
70
  - `hq lanes monitor-check` now recognizes a live Claude Monitor task launched through the CLI's shell/node process wrapper, so active lanes are not reported as uncovered.
71
+
53
72
  - A session hint now binds to the best existing project. An exact Board is used as-is. An exact local PRD gets a Board through the same genesis-thread, view, then register path as `project ensure`, and is bound. A single clear match is bound with a recorded reason. When nothing matches, or two candidates are close, the session stays `needs_project` and the start hook lists the candidates. `hq mesh context bind-project` applies the answer. A flush HTTP 404 downgrades that session, holds its events, and logs one line instead of dead-lettering them. `hq mesh doctor` lists `needs_project` sessions.
54
73
 
55
74
  ## [5.139.0] — 2026-09-23
@@ -1642,7 +1642,7 @@ export declare const COMMAND_CATALOG: readonly [{
1642
1642
  }];
1643
1643
  }, {
1644
1644
  readonly name: "files";
1645
- readonly description: "Manage file access controls in HQ vault";
1645
+ readonly description: "Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.";
1646
1646
  readonly aliases: readonly [];
1647
1647
  readonly hidden: false;
1648
1648
  readonly usage: "[options] [command]";
@@ -1653,7 +1653,7 @@ export declare const COMMAND_CATALOG: readonly [{
1653
1653
  }];
1654
1654
  readonly subcommands: readonly [{
1655
1655
  readonly name: "share";
1656
- readonly description: "Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.";
1656
+ readonly description: "Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.";
1657
1657
  readonly aliases: readonly [];
1658
1658
  readonly hidden: false;
1659
1659
  readonly usage: "[options] [paths...]";
@@ -1687,7 +1687,7 @@ export declare const COMMAND_CATALOG: readonly [{
1687
1687
  readonly subcommands: readonly [];
1688
1688
  }, {
1689
1689
  readonly name: "unshare";
1690
- readonly description: "Remove a file access grant";
1690
+ readonly description: "Remove a file access grant. A trailing slash targets the private-folder row exactly.";
1691
1691
  readonly aliases: readonly [];
1692
1692
  readonly hidden: false;
1693
1693
  readonly usage: "[options] <prefix>";
@@ -1703,7 +1703,7 @@ export declare const COMMAND_CATALOG: readonly [{
1703
1703
  readonly subcommands: readonly [];
1704
1704
  }, {
1705
1705
  readonly name: "acl";
1706
- readonly description: "Show the ACL (access control list) for a file prefix";
1706
+ readonly description: "Show the ACL (access control list) for a file prefix. foo/ is a private folder whose children are creator-only; the quoted glob 'foo/*' is shared.";
1707
1707
  readonly aliases: readonly [];
1708
1708
  readonly hidden: false;
1709
1709
  readonly usage: "[options] <prefix>";
@@ -1797,7 +1797,7 @@ export declare const COMMAND_CATALOG: readonly [{
1797
1797
  readonly subcommands: readonly [];
1798
1798
  }, {
1799
1799
  readonly name: "browse";
1800
- readonly description: "List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.";
1800
+ readonly description: "List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.";
1801
1801
  readonly aliases: readonly [];
1802
1802
  readonly hidden: false;
1803
1803
  readonly usage: "[options] [path]";
@@ -1844,7 +1844,7 @@ export declare const COMMAND_CATALOG: readonly [{
1844
1844
  readonly subcommands: readonly [];
1845
1845
  }, {
1846
1846
  readonly name: "shared-with-me";
1847
- readonly description: "List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).";
1847
+ readonly description: "List the files/prefixes explicitly shared with you. Private folders (foo/) expose only the folder; children are creator-only. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).";
1848
1848
  readonly aliases: readonly [];
1849
1849
  readonly hidden: false;
1850
1850
  readonly usage: "[options]";
@@ -5882,6 +5882,24 @@ export declare const COMMAND_CATALOG: readonly [{
5882
5882
  readonly description: "Print machine-readable JSON";
5883
5883
  }];
5884
5884
  readonly subcommands: readonly [];
5885
+ }, {
5886
+ readonly name: "prd-sync";
5887
+ readonly description: "Emit task_status for PRD story passes/status changes on a bound session";
5888
+ readonly aliases: readonly [];
5889
+ readonly hidden: false;
5890
+ readonly usage: "[options]";
5891
+ readonly arguments: readonly [];
5892
+ readonly options: readonly [{
5893
+ readonly flags: "--session <sid>";
5894
+ readonly description: "Canonical session id";
5895
+ }, {
5896
+ readonly flags: "--file <path>";
5897
+ readonly description: "prd.json written by the bound session";
5898
+ }, {
5899
+ readonly flags: "--json";
5900
+ readonly description: "Print machine-readable JSON";
5901
+ }];
5902
+ readonly subcommands: readonly [];
5885
5903
  }, {
5886
5904
  readonly name: "organize";
5887
5905
  readonly description: "List or submit one-time project/task decisions (US-007B / US-005B)";
@@ -2093,7 +2093,7 @@ export const COMMAND_CATALOG = [
2093
2093
  },
2094
2094
  {
2095
2095
  "name": "files",
2096
- "description": "Manage file access controls in HQ vault",
2096
+ "description": "Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.",
2097
2097
  "aliases": [],
2098
2098
  "hidden": false,
2099
2099
  "usage": "[options] [command]",
@@ -2107,7 +2107,7 @@ export const COMMAND_CATALOG = [
2107
2107
  "subcommands": [
2108
2108
  {
2109
2109
  "name": "share",
2110
- "description": "Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.",
2110
+ "description": "Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.",
2111
2111
  "aliases": [],
2112
2112
  "hidden": false,
2113
2113
  "usage": "[options] [paths...]",
@@ -2152,7 +2152,7 @@ export const COMMAND_CATALOG = [
2152
2152
  },
2153
2153
  {
2154
2154
  "name": "unshare",
2155
- "description": "Remove a file access grant",
2155
+ "description": "Remove a file access grant. A trailing slash targets the private-folder row exactly.",
2156
2156
  "aliases": [],
2157
2157
  "hidden": false,
2158
2158
  "usage": "[options] <prefix>",
@@ -2173,7 +2173,7 @@ export const COMMAND_CATALOG = [
2173
2173
  },
2174
2174
  {
2175
2175
  "name": "acl",
2176
- "description": "Show the ACL (access control list) for a file prefix",
2176
+ "description": "Show the ACL (access control list) for a file prefix. foo/ is a private folder whose children are creator-only; the quoted glob 'foo/*' is shared.",
2177
2177
  "aliases": [],
2178
2178
  "hidden": false,
2179
2179
  "usage": "[options] <prefix>",
@@ -2296,7 +2296,7 @@ export const COMMAND_CATALOG = [
2296
2296
  },
2297
2297
  {
2298
2298
  "name": "browse",
2299
- "description": "List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
2299
+ "description": "List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
2300
2300
  "aliases": [],
2301
2301
  "hidden": false,
2302
2302
  "usage": "[options] [path]",
@@ -2358,7 +2358,7 @@ export const COMMAND_CATALOG = [
2358
2358
  },
2359
2359
  {
2360
2360
  "name": "shared-with-me",
2361
- "description": "List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).",
2361
+ "description": "List the files/prefixes explicitly shared with you. Private folders (foo/) expose only the folder; children are creator-only. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).",
2362
2362
  "aliases": [],
2363
2363
  "hidden": false,
2364
2364
  "usage": "[options]",
@@ -7593,6 +7593,29 @@ export const COMMAND_CATALOG = [
7593
7593
  ],
7594
7594
  "subcommands": []
7595
7595
  },
7596
+ {
7597
+ "name": "prd-sync",
7598
+ "description": "Emit task_status for PRD story passes/status changes on a bound session",
7599
+ "aliases": [],
7600
+ "hidden": false,
7601
+ "usage": "[options]",
7602
+ "arguments": [],
7603
+ "options": [
7604
+ {
7605
+ "flags": "--session <sid>",
7606
+ "description": "Canonical session id"
7607
+ },
7608
+ {
7609
+ "flags": "--file <path>",
7610
+ "description": "prd.json written by the bound session"
7611
+ },
7612
+ {
7613
+ "flags": "--json",
7614
+ "description": "Print machine-readable JSON"
7615
+ }
7616
+ ],
7617
+ "subcommands": []
7618
+ },
7596
7619
  {
7597
7620
  "name": "organize",
7598
7621
  "description": "List or submit one-time project/task decisions (US-007B / US-005B)",
@@ -1,5 +1,21 @@
1
1
  export declare const SECRET_NAME_PATTERN: RegExp;
2
2
  export declare const GROUP_ID_PATTERN: RegExp;
3
3
  export declare const EMAIL_PATTERN: RegExp;
4
+ /**
5
+ * Keep an ACL pattern exactly as the caller supplied it.
6
+ *
7
+ * `foo/` is a private folder row: it authorizes creating a direct child, but
8
+ * does not grant access to any child. `foo/*` is the distinct shared-folder
9
+ * glob. The server owns validation (including the conflict between them), so
10
+ * the CLI must never silently turn one into the other.
11
+ */
4
12
  export declare function normalizeFilePrefix(prefix: string): string;
13
+ /**
14
+ * Make a recursive folder glob only where the caller intentionally asks for
15
+ * one. This is not an ACL-input normalizer: callers must opt in because a
16
+ * trailing slash has private-folder semantics on ACL routes.
17
+ */
18
+ export declare function toSharedFolderGlob(prefix: string): string;
19
+ /** A non-root trailing slash is the private-folder ACL pattern. */
20
+ export declare function isPrivateFolderPattern(prefix: string): boolean;
5
21
  //# sourceMappingURL=_patterns.d.ts.map
@@ -1,10 +1,27 @@
1
1
  export const SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*$/;
2
2
  export const GROUP_ID_PATTERN = /^grp_[A-Za-z0-9_-]+$/;
3
3
  export const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
4
+ /**
5
+ * Keep an ACL pattern exactly as the caller supplied it.
6
+ *
7
+ * `foo/` is a private folder row: it authorizes creating a direct child, but
8
+ * does not grant access to any child. `foo/*` is the distinct shared-folder
9
+ * glob. The server owns validation (including the conflict between them), so
10
+ * the CLI must never silently turn one into the other.
11
+ */
4
12
  export function normalizeFilePrefix(prefix) {
5
- if (prefix.endsWith("/")) {
6
- return prefix + "*";
7
- }
8
13
  return prefix;
9
14
  }
15
+ /**
16
+ * Make a recursive folder glob only where the caller intentionally asks for
17
+ * one. This is not an ACL-input normalizer: callers must opt in because a
18
+ * trailing slash has private-folder semantics on ACL routes.
19
+ */
20
+ export function toSharedFolderGlob(prefix) {
21
+ return prefix.endsWith("/") ? `${prefix}*` : prefix;
22
+ }
23
+ /** A non-root trailing slash is the private-folder ACL pattern. */
24
+ export function isPrivateFolderPattern(prefix) {
25
+ return prefix.length > 1 && prefix.endsWith("/");
26
+ }
10
27
  //# sourceMappingURL=_patterns.js.map
@@ -31,7 +31,7 @@
31
31
  * `toCompanyAnchored`.
32
32
  *
33
33
  * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
- * methods and `grantPathToPrefix` from hq-cloud.
34
+ * methods from hq-cloud.
35
35
  */
36
36
  import { Command } from "commander";
37
37
  import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
@@ -110,14 +110,18 @@ export type CompanyBrowseClientFactory = (input: {
110
110
  /**
111
111
  * ACL provenance for a single listed key.
112
112
  * - `shared-with-you`: an explicit grant the caller holds covers the key.
113
+ * - `creator`: a creator-only child grant (or its folder marker) covers the
114
+ * key; this is explicit creator access, not an owner/admin role bypass.
113
115
  * - `role-bypass`: the caller has no covering explicit grant, but
114
116
  * owner/admin role widened the browse-vend policy to include it.
115
117
  * - `personal-vault`: the key lives in the caller's own person-entity
116
118
  * vault, where no grants graph applies — the caller is the only
117
119
  * principal with access by construction. Emitted only when
118
120
  * `runBrowse({ personalMode: true })`.
121
+ * - `private-folder`: this is the private folder row itself. Its children
122
+ * are creator-only, so it never covers a child key.
119
123
  */
120
- export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
124
+ export type AclSource = "shared-with-you" | "creator" | "role-bypass" | "personal-vault" | "private-folder";
121
125
  export interface BrowseRow {
122
126
  key: string;
123
127
  size: number;
@@ -148,16 +152,23 @@ export declare function toBucketRelative(pathOrPrefix: string, slug: string): st
148
152
  export declare function toCompanyAnchored(bucketRelKey: string, slug: string): string;
149
153
  /**
150
154
  * Classify a single company-relative S3 key against the caller's
151
- * (already-normalized) explicit-grant prefixes. Any prefix that covers the
152
- * key contributes `shared-with-you`; otherwise the key is visible only via
153
- * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
154
- * prefix is a company-wide grant and matches everything.
155
+ * raw explicit-grant patterns. `foo/*` covers all keys below `foo/`, while
156
+ * `foo/` is the private folder itself and never covers a child key. Otherwise
157
+ * the key is visible only via the owner/admin role-bypass the `/sts/vend`
158
+ * policy applied. An empty-string prefix is a company-wide grant and matches
159
+ * everything.
155
160
  *
156
- * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
157
- * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
158
- * calling this — keeping this helper pure and trivially testable.
161
+ * The caller translates only the redundant company anchor before calling this;
162
+ * it must not coalesce a trailing slash into a glob, which would imply child
163
+ * access that the server did not grant.
159
164
  */
160
- export declare function classifyAclSource(bucketRelKey: string, grantPrefixes: string[]): AclSource;
165
+ export declare function classifyAclSource(bucketRelKey: string, grantPatterns: Array<string | BrowseGrantPattern>): AclSource;
166
+ export interface BrowseGrantPattern {
167
+ pattern: string;
168
+ source?: ExplicitGrant["source"];
169
+ }
170
+ /** Remove only the redundant company anchor; retain the ACL pattern itself. */
171
+ export declare function toBrowseGrantPattern(grantPath: string, slug: string): string;
161
172
  /**
162
173
  * Bright-line guard for `--out`: refuse to write any byte beneath
163
174
  * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
@@ -31,7 +31,7 @@
31
31
  * `toCompanyAnchored`.
32
32
  *
33
33
  * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
- * methods and `grantPathToPrefix` from hq-cloud.
34
+ * methods from hq-cloud.
35
35
  */
36
36
  import { isAccessDeniedError, accessDeniedKeyOf, reportAccessDenied, accessDeniedCompanyOf, } from "../utils/access-denied-hint.js";
37
37
  import chalk from "chalk";
@@ -40,7 +40,8 @@ import * as path from "node:path";
40
40
  import { Readable } from "node:stream";
41
41
  import { pipeline } from "node:stream/promises";
42
42
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
43
- import { VaultClient, grantPathToPrefix, } from "@indigoai-us/hq-cloud";
43
+ import { VaultClient, } from "@indigoai-us/hq-cloud";
44
+ import { isPrivateFolderPattern } from "./_patterns.js";
44
45
  import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, buildVaultConfig, } from "../utils/cognito-session.js";
45
46
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
46
47
  import { getCompanyUid, looksLikeCompanyUid, looksLikePersonVaultUid, vaultApiFetch, } from "../utils/vault-api.js";
@@ -70,9 +71,15 @@ export function parseCompanySlugFromPath(prefix) {
70
71
  */
71
72
  export function toBucketRelative(pathOrPrefix, slug) {
72
73
  const anchor = `companies/${slug}/`;
74
+ const slugAnchor = `${slug}/`;
73
75
  const normalized = pathOrPrefix.replace(/^\/+/, "");
74
- return normalized.startsWith(anchor)
75
- ? normalized.slice(anchor.length)
76
+ if (normalized.startsWith(anchor))
77
+ return normalized.slice(anchor.length);
78
+ // Explicit-grant responses can use either the full company anchor or the
79
+ // historical bare-slug anchor. Strip both before classification, while
80
+ // preserving the caller's trailing `/` versus `/*` semantics.
81
+ return normalized.startsWith(slugAnchor)
82
+ ? normalized.slice(slugAnchor.length)
76
83
  : normalized;
77
84
  }
78
85
  /**
@@ -85,22 +92,62 @@ export function toCompanyAnchored(bucketRelKey, slug) {
85
92
  }
86
93
  /**
87
94
  * Classify a single company-relative S3 key against the caller's
88
- * (already-normalized) explicit-grant prefixes. Any prefix that covers the
89
- * key contributes `shared-with-you`; otherwise the key is visible only via
90
- * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
91
- * prefix is a company-wide grant and matches everything.
95
+ * raw explicit-grant patterns. `foo/*` covers all keys below `foo/`, while
96
+ * `foo/` is the private folder itself and never covers a child key. Otherwise
97
+ * the key is visible only via the owner/admin role-bypass the `/sts/vend`
98
+ * policy applied. An empty-string prefix is a company-wide grant and matches
99
+ * everything.
92
100
  *
93
- * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
94
- * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
95
- * calling this — keeping this helper pure and trivially testable.
101
+ * The caller translates only the redundant company anchor before calling this;
102
+ * it must not coalesce a trailing slash into a glob, which would imply child
103
+ * access that the server did not grant.
96
104
  */
97
- export function classifyAclSource(bucketRelKey, grantPrefixes) {
98
- for (const p of grantPrefixes) {
99
- if (p === "" || bucketRelKey.startsWith(p))
105
+ export function classifyAclSource(bucketRelKey, grantPatterns) {
106
+ const normalized = grantPatterns.map((entry) => typeof entry === "string"
107
+ ? { pattern: entry, source: undefined }
108
+ : entry);
109
+ for (const entry of normalized) {
110
+ const pattern = entry.pattern;
111
+ if (pattern === "" || pattern === "*")
100
112
  return "shared-with-you";
113
+ if (pattern.endsWith("/*")) {
114
+ if (bucketRelKey.startsWith(pattern.slice(0, -1))) {
115
+ return entry.source === "creator" ? "creator" : "shared-with-you";
116
+ }
117
+ continue;
118
+ }
119
+ if (isPrivateFolderPattern(pattern)) {
120
+ if (bucketRelKey === pattern)
121
+ return "private-folder";
122
+ continue;
123
+ }
124
+ if (bucketRelKey === pattern) {
125
+ return entry.source === "creator" ? "creator" : "shared-with-you";
126
+ }
127
+ }
128
+ // The server can expose a creator-owned child row without returning the
129
+ // creator's original `foo/` row. Keep the zero-byte `foo/` marker visible
130
+ // and label it as creator access when a creator grant is underneath it.
131
+ if (bucketRelKey.endsWith("/")) {
132
+ const creatorOwnsMarker = normalized.some((entry) => entry.source === "creator" &&
133
+ entry.pattern.startsWith(bucketRelKey));
134
+ if (creatorOwnsMarker)
135
+ return "creator";
101
136
  }
102
137
  return "role-bypass";
103
138
  }
139
+ /** Remove only the redundant company anchor; retain the ACL pattern itself. */
140
+ export function toBrowseGrantPattern(grantPath, slug) {
141
+ const companyRelative = toBucketRelative(grantPath, slug);
142
+ return companyRelative === "*" ? "" : companyRelative;
143
+ }
144
+ function displayAclSource(source) {
145
+ return source === "private-folder"
146
+ ? "private folder — children are creator-only"
147
+ : source === "creator"
148
+ ? "creator access — creator-only"
149
+ : source;
150
+ }
104
151
  /**
105
152
  * Bright-line guard for `--out`: refuse to write any byte beneath
106
153
  * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
@@ -132,7 +179,7 @@ export function formatBrowseTable(rows) {
132
179
  r.key,
133
180
  String(r.size),
134
181
  r.lastModified ? r.lastModified.toISOString() : "—",
135
- r.aclSource,
182
+ displayAclSource(r.aclSource),
136
183
  ]);
137
184
  const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
138
185
  const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
@@ -225,14 +272,17 @@ export async function runBrowse(input) {
225
272
  const listPrefix = personalMode || slug === undefined
226
273
  ? pathPrefix
227
274
  : toBucketRelative(pathPrefix, slug);
228
- // Pull the caller's explicit-grant graph once so per-key classification
229
- // is O(grants) without N round-trips. Grant `path`s are normalized to
230
- // company-relative `startsWith` prefixes (matching the bucket key space)
231
- // via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
232
- // company concept; a person bucket marks every row `"personal-vault"`.
233
- const grantPrefixes = personalMode || slug === undefined
275
+ // Pull the caller's explicit-grant graph once so per-key classification is
276
+ // O(grants) without N round-trips. Retain the raw pattern after stripping
277
+ // only the redundant company anchor: `foo/` and `foo/*` have different
278
+ // server semantics. Skipped in personal mode — the grants graph is a company
279
+ // concept; a person bucket marks every row `"personal-vault"`.
280
+ const grantPatterns = personalMode || slug === undefined
234
281
  ? []
235
- : (await vaultClient.listMyExplicitGrants(entityUid)).map((g) => grantPathToPrefix(g.path, slug));
282
+ : (await vaultClient.listMyExplicitGrants(entityUid)).map((g) => ({
283
+ pattern: toBrowseGrantPattern(g.path, slug),
284
+ source: g.source,
285
+ }));
236
286
  const rows = [];
237
287
  let continuationToken;
238
288
  do {
@@ -244,8 +294,14 @@ export async function runBrowse(input) {
244
294
  for (const obj of resp.Contents ?? []) {
245
295
  if (!obj.Key)
246
296
  continue;
247
- // Skip S3 "directory marker" objects (0-byte, trailing slash).
248
- if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
297
+ const aclSource = personalMode
298
+ ? "personal-vault"
299
+ : classifyAclSource(obj.Key, grantPatterns);
300
+ // Keep a private-folder marker visible so its ACL meaning can be shown.
301
+ // Other zero-byte directory markers remain list implementation details.
302
+ if (obj.Key.endsWith("/") &&
303
+ (obj.Size ?? 0) === 0 &&
304
+ aclSource !== "private-folder")
249
305
  continue;
250
306
  // `obj.Key` is company-relative. Classify in that space, then re-anchor
251
307
  // for display so the CLI keeps speaking `companies/<slug>/...`.
@@ -255,9 +311,7 @@ export async function runBrowse(input) {
255
311
  : toCompanyAnchored(obj.Key, slug),
256
312
  size: obj.Size ?? 0,
257
313
  lastModified: obj.LastModified,
258
- aclSource: personalMode
259
- ? "personal-vault"
260
- : classifyAclSource(obj.Key, grantPrefixes),
314
+ aclSource,
261
315
  });
262
316
  }
263
317
  continuationToken = resp.NextContinuationToken ?? undefined;
@@ -434,14 +488,23 @@ export function formatSharedWithMeTable(rows) {
434
488
  return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
435
489
  }
436
490
  const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
437
- const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
491
+ const data = rows.map((r) => [
492
+ r.companySlug,
493
+ isPrivateFolderPattern(r.path) ? `${r.path} (private folder)` : r.path,
494
+ r.permission,
495
+ r.source,
496
+ ]);
438
497
  const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
439
498
  const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
440
- return [
499
+ const lines = [
441
500
  chalk.bold(renderRow(cols)),
442
501
  chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
443
502
  ...data.map(renderRow),
444
- ].join("\n");
503
+ ];
504
+ if (rows.some((row) => isPrivateFolderPattern(row.path))) {
505
+ lines.push("Private folders expose the folder only; children are creator-only.");
506
+ }
507
+ return lines.join("\n");
445
508
  }
446
509
  /**
447
510
  * `hq files search <query>` orchestrator. Lists the company (or personal)
@@ -863,7 +926,7 @@ export async function resolveBrowseSession() {
863
926
  export function registerFilesBrowseCommands(filesCmd) {
864
927
  filesCmd
865
928
  .command("browse [path]")
866
- .description("List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
929
+ .description("List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
867
930
  .option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
868
931
  .option("--personal", "Browse the caller's canonical personal vault. [path] is treated as " +
869
932
  "bucket-relative (omit it to list the vault root). Mutually exclusive " +
@@ -1035,7 +1098,7 @@ export function registerFilesBrowseCommands(filesCmd) {
1035
1098
  });
1036
1099
  filesCmd
1037
1100
  .command("shared-with-me")
1038
- .description("List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).")
1101
+ .description("List the files/prefixes explicitly shared with you. Private folders (foo/) expose only the folder; children are creator-only. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).")
1039
1102
  .option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
1040
1103
  .action(async (options, command) => {
1041
1104
  try {