@indigoai-us/hq-cloud 5.15.0 → 5.17.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.
package/src/cli/share.ts CHANGED
@@ -185,6 +185,32 @@ export interface ShareOptions {
185
185
  * full-tree bidirectional runner opts in.
186
186
  */
187
187
  propagateDeletes?: boolean;
188
+ /**
189
+ * Policy for which journal entries `propagateDeletes` is willing to
190
+ * convert into remote `DeleteObject` calls. Only consulted when
191
+ * `propagateDeletes === true`.
192
+ *
193
+ * - `"owned-only"` (default, safer): only entries whose journal
194
+ * `direction === "up"` are eligible. That is, only files this
195
+ * machine previously uploaded can be remotely deleted on its
196
+ * behalf. Entries the journal records as pulled from elsewhere
197
+ * (`direction === "down"`) are never delete-propagated — the
198
+ * local absence may just be an unpulled state or a filter
199
+ * mismatch, both of which previously caused this machine to
200
+ * erase other machines' uploads.
201
+ * - `"all"`: legacy behaviour — every in-scope journal entry whose
202
+ * local file is missing is eligible (regardless of direction). The
203
+ * bidirectional runner's first-push and any tool that wants to
204
+ * mirror a destructive local checkout opts in here explicitly.
205
+ *
206
+ * Independently of this policy, an entry is also dropped from the
207
+ * plan when `shouldSync(localPath, false) === false` — i.e. the
208
+ * current ignore filter would have skipped the path on pull. That
209
+ * symmetry blocks the failure mode where a path was filtered locally
210
+ * but lived in the vault (and the journal) from an older HQ layout
211
+ * or a different machine, causing the next push to erase it.
212
+ */
213
+ propagateDeletePolicy?: "owned-only" | "all";
188
214
  /**
189
215
  * Identity stamped onto each uploaded object's S3 user metadata
190
216
  * (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
@@ -236,6 +262,14 @@ export interface ShareResult {
236
262
  */
237
263
  export async function share(options: ShareOptions): Promise<ShareResult> {
238
264
  const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
265
+ // Default to the safer "owned-only" policy when delete-propagation is on
266
+ // but the caller hasn't pinned a policy. Pre-existing callers that passed
267
+ // `propagateDeletes: true` (the `sync now` push leg, the runner's
268
+ // bidirectional sync, the `--all` fanout) thereby flip to the safer
269
+ // semantics automatically. Set `propagateDeletePolicy: "all"` explicitly
270
+ // to opt back into the legacy any-missing-file-deletes behaviour.
271
+ const propagateDeletePolicy: "owned-only" | "all" =
272
+ options.propagateDeletePolicy ?? "owned-only";
239
273
  const emit = options.onEvent ?? defaultConsoleLogger;
240
274
 
241
275
  // Exactly-one-of contract: either we vend (vaultConfig) or the caller did
@@ -316,7 +350,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
316
350
  ? resolveDeleteScopeRoots(paths, hqRoot, syncRoot)
317
351
  : [];
318
352
  const deletePlan = propagateDeletes === true
319
- ? computeDeletePlan(journal, syncRoot, deleteScopeRoots)
353
+ ? computeDeletePlan(
354
+ journal,
355
+ syncRoot,
356
+ deleteScopeRoots,
357
+ shouldSync,
358
+ propagateDeletePolicy,
359
+ )
320
360
  : [];
321
361
 
322
362
  emit({
@@ -681,18 +721,37 @@ function resolveDeleteScopeRoots(
681
721
 
682
722
  /**
683
723
  * Walk every journal key in `scopeRoots` whose local file is missing from
684
- * disk, and return the keys to delete. A key is in-scope when it matches
685
- * (or sits beneath) one of the resolved prefixes. Empty `scopeRoots`
686
- * empty plan (caller didn't opt in).
724
+ * disk and return the keys eligible for a remote `DeleteObject`. An entry
725
+ * is in the plan only when ALL of the following hold:
726
+ *
727
+ * 1. Its key matches (or sits beneath) one of the `scopeRoots` prefixes.
728
+ * 2. Its local file is missing from disk.
729
+ * 3. The current ignore filter (`shouldSync`) accepts the key — so paths
730
+ * filtered out by `.hqignore` / `.gitignore` / `DEFAULT_IGNORES` are
731
+ * never delete-propagated. This blocks the failure mode where a path
732
+ * lives in the vault (and the journal) but the local walk skips it
733
+ * because of asymmetric ignore rules; without this guard the push
734
+ * leg would erase it.
735
+ * 4. When `policy === "owned-only"`: the journal entry's `direction`
736
+ * is `"up"` (i.e. this machine previously uploaded the file). This
737
+ * blocks the failure mode where a behind machine's first `sync now`
738
+ * push leg would otherwise erase recent uploads from peers, since
739
+ * those entries are recorded as `direction: "down"` (pulled) or
740
+ * absent (never seen). Set `policy: "all"` to opt back into the
741
+ * legacy any-missing-file-deletes behaviour.
742
+ *
743
+ * Empty `scopeRoots` ⇒ empty plan (caller didn't opt in).
687
744
  */
688
745
  function computeDeletePlan(
689
746
  journal: SyncJournal,
690
747
  syncRoot: string,
691
748
  scopeRoots: string[],
749
+ shouldSync: (filePath: string, isDir?: boolean) => boolean,
750
+ policy: "owned-only" | "all",
692
751
  ): string[] {
693
752
  if (scopeRoots.length === 0) return [];
694
753
  const out: string[] = [];
695
- for (const relativeKey of Object.keys(journal.files)) {
754
+ for (const [relativeKey, entry] of Object.entries(journal.files)) {
696
755
  const inScope = scopeRoots.some(
697
756
  (root) =>
698
757
  root === "" ||
@@ -701,9 +760,14 @@ function computeDeletePlan(
701
760
  );
702
761
  if (!inScope) continue;
703
762
  const localPath = path.join(syncRoot, relativeKey);
704
- if (!fs.existsSync(localPath)) {
705
- out.push(relativeKey);
706
- }
763
+ if (fs.existsSync(localPath)) continue;
764
+ // (3) Symmetric filter guard. `shouldSync` is constructed from the same
765
+ // hqRoot the pull leg uses, so a key the pull would have skipped
766
+ // ("ignored") is also one we must not delete-propagate.
767
+ if (!shouldSync(localPath, false)) continue;
768
+ // (4) Direction guard under "owned-only" policy.
769
+ if (policy === "owned-only" && entry.direction !== "up") continue;
770
+ out.push(relativeKey);
707
771
  }
708
772
  return out;
709
773
  }
package/src/index.ts CHANGED
@@ -50,8 +50,14 @@ export {
50
50
  } from "./cognito-auth.js";
51
51
  export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
52
52
 
53
+ // Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
54
+ export {
55
+ PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
56
+ computePersonalVaultPaths,
57
+ } from "./personal-vault.js";
58
+
53
59
  // VaultClient SDK (VLT-7)
54
- export { VaultClient } from "./vault-client.js";
60
+ export { VaultClient, pickCanonicalPersonEntity } from "./vault-client.js";
55
61
  export {
56
62
  VaultClientError,
57
63
  VaultAuthError,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Personal-vault scope helpers — shared between the menubar runner
3
+ * (`hq-sync-runner`) and the `hq sync` CLI so every composer of a personal
4
+ * push uses the same exclusion list.
5
+ *
6
+ * The exclusion list mirrors the Rust constant of the same name in
7
+ * `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's
8
+ * first-push and this Node engine's steady-state push enforce identical
9
+ * scope. Every other top-level entry under hq_root (e.g. `.claude/`,
10
+ * `knowledge/`, `modules/`, `README.md`, `.codex/`, `core/`, `data/`,
11
+ * `personal/`) is included, subject to the usual `.hqignore` filter.
12
+ *
13
+ * Excluded entries (and why):
14
+ * - `.git`: a git repo's own metadata is hostile to multi-machine
15
+ * sync; .gitignore alone doesn't cover `.git/` because it's the repo
16
+ * itself, not a tracked path.
17
+ * - `companies/`: synced separately by the runner's per-membership
18
+ * fanout; do not double-write into the personal vault.
19
+ * - `repos/`, `workspace/`: per user directive — heavy local-only
20
+ * content (cloned remotes, session threads) that has no business in
21
+ * the personal vault.
22
+ *
23
+ * Note: `core/`, `data/`, and `personal/` were previously excluded but are
24
+ * INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
25
+ * scaffold — policies/, settings/, skills/, workers/, plus the rules
26
+ * manifest at core/core.yaml. `data/` and `personal/` carry per-user data,
27
+ * policies, hooks, and skills that follow the user across machines. The
28
+ * hq-root identity marker `core.yaml` (at hq_root, distinct from
29
+ * `core/core.yaml`) is filtered separately by the root-anchored
30
+ * `/core.yaml` DEFAULT_IGNORES rule.
31
+ */
32
+
33
+ import * as fs from "fs";
34
+ import * as path from "path";
35
+
36
+ export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
37
+ ".git",
38
+ "companies",
39
+ "repos",
40
+ "workspace",
41
+ ];
42
+
43
+ /**
44
+ * Compute absolute paths to share for the personal vault: every top-level
45
+ * entry under `hqRoot` whose basename is NOT in
46
+ * `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`. Mirrors the Rust
47
+ * `is_personal_vault_path` predicate (just hoisted to the top-level step).
48
+ * Order is whatever `fs.readdirSync` returns — share() doesn't care, and
49
+ * the per-file walk inside share() handles recursion uniformly. Missing
50
+ * hqRoot returns []; callers treat that as "no personal content to push"
51
+ * rather than a hard error.
52
+ */
53
+ export function computePersonalVaultPaths(hqRoot: string): string[] {
54
+ let entries: string[];
55
+ try {
56
+ entries = fs.readdirSync(hqRoot);
57
+ } catch {
58
+ return [];
59
+ }
60
+ return entries
61
+ .filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
62
+ .map((name) => path.join(hqRoot, name));
63
+ }