@indigoai-us/hq-cloud 6.14.8 → 6.14.10

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 (67) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +8 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +15 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner.d.ts +12 -0
  9. package/dist/bin/sync-runner.d.ts.map +1 -1
  10. package/dist/bin/sync-runner.js +26 -0
  11. package/dist/bin/sync-runner.js.map +1 -1
  12. package/dist/bin/sync-runner.test.js +68 -1
  13. package/dist/bin/sync-runner.test.js.map +1 -1
  14. package/dist/cli/share.d.ts.map +1 -1
  15. package/dist/cli/share.js +89 -14
  16. package/dist/cli/share.js.map +1 -1
  17. package/dist/cli/share.test.js +105 -3
  18. package/dist/cli/share.test.js.map +1 -1
  19. package/dist/cli/sync-scope.test.js +3 -1
  20. package/dist/cli/sync-scope.test.js.map +1 -1
  21. package/dist/cli/sync.d.ts +7 -7
  22. package/dist/cli/sync.d.ts.map +1 -1
  23. package/dist/cli/sync.js +166 -82
  24. package/dist/cli/sync.js.map +1 -1
  25. package/dist/cli/sync.test.js +292 -27
  26. package/dist/cli/sync.test.js.map +1 -1
  27. package/dist/journal.d.ts +7 -1
  28. package/dist/journal.d.ts.map +1 -1
  29. package/dist/journal.js +22 -1
  30. package/dist/journal.js.map +1 -1
  31. package/dist/manifest-reconcile.d.ts +27 -0
  32. package/dist/manifest-reconcile.d.ts.map +1 -0
  33. package/dist/manifest-reconcile.js +120 -0
  34. package/dist/manifest-reconcile.js.map +1 -0
  35. package/dist/manifest-reconcile.test.d.ts +2 -0
  36. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  37. package/dist/manifest-reconcile.test.js +97 -0
  38. package/dist/manifest-reconcile.test.js.map +1 -0
  39. package/dist/personal-vault.d.ts +6 -0
  40. package/dist/personal-vault.d.ts.map +1 -1
  41. package/dist/personal-vault.js +12 -0
  42. package/dist/personal-vault.js.map +1 -1
  43. package/dist/s3.d.ts +15 -2
  44. package/dist/s3.d.ts.map +1 -1
  45. package/dist/s3.js +54 -19
  46. package/dist/s3.js.map +1 -1
  47. package/dist/s3.test.js +12 -1
  48. package/dist/s3.test.js.map +1 -1
  49. package/dist/types.d.ts +13 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/bin/sync-runner-company.ts +7 -0
  53. package/src/bin/sync-runner-planning.ts +16 -2
  54. package/src/bin/sync-runner.test.ts +82 -1
  55. package/src/bin/sync-runner.ts +43 -0
  56. package/src/cli/share.test.ts +129 -3
  57. package/src/cli/share.ts +114 -15
  58. package/src/cli/sync-scope.test.ts +3 -1
  59. package/src/cli/sync.test.ts +352 -26
  60. package/src/cli/sync.ts +226 -103
  61. package/src/journal.ts +28 -0
  62. package/src/manifest-reconcile.test.ts +107 -0
  63. package/src/manifest-reconcile.ts +160 -0
  64. package/src/personal-vault.ts +15 -0
  65. package/src/s3.test.ts +18 -0
  66. package/src/s3.ts +67 -30
  67. package/src/types.ts +13 -0
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import yaml from "js-yaml";
6
+
7
+ import { reconcileCompanyManifest } from "./manifest-reconcile.js";
8
+ import type { EntityInfo } from "./vault-client.js";
9
+
10
+ let hqRoot: string | undefined;
11
+
12
+ afterEach(() => {
13
+ if (hqRoot) {
14
+ fs.rmSync(hqRoot, { recursive: true, force: true });
15
+ hqRoot = undefined;
16
+ }
17
+ });
18
+
19
+ describe("reconcileCompanyManifest", () => {
20
+ it("atomically restores one successful cloud entry after the personal leg without replacing existing manifest state", async () => {
21
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-manifest-reconcile-"));
22
+ const companiesDir = path.join(hqRoot, "companies");
23
+ fs.mkdirSync(path.join(companiesDir, "acme"), { recursive: true });
24
+ fs.mkdirSync(path.join(companiesDir, "failed"), { recursive: true });
25
+ fs.mkdirSync(path.join(companiesDir, "deleted"), { recursive: true });
26
+
27
+ // This is the older manifest the personal-vault leg pulled after the
28
+ // cloud-company legs completed. The final merge must retain this state and
29
+ // add only the live, successful cloud target.
30
+ const manifestPath = path.join(companiesDir, "manifest.yaml");
31
+ fs.writeFileSync(
32
+ manifestPath,
33
+ `version: 3\nsettings:\n keep: true\ncompanies:\n local-only:\n name: Local Only\n repos:\n - repos/local\n preserved:\n name: Keep Me\n cloud_uid: cmp_preserved\n`,
34
+ );
35
+
36
+ const entities: Record<string, EntityInfo> = {
37
+ cmp_acme: {
38
+ uid: "cmp_acme",
39
+ slug: "acme",
40
+ type: "company",
41
+ status: "active",
42
+ name: "Acme Inc",
43
+ bucketName: "hq-vault-cmp-acme",
44
+ createdAt: "2026-01-01T00:00:00Z",
45
+ },
46
+ cmp_failed: {
47
+ uid: "cmp_failed",
48
+ slug: "failed",
49
+ type: "company",
50
+ status: "active",
51
+ name: "Failed Corp",
52
+ bucketName: "hq-vault-cmp-failed",
53
+ createdAt: "2026-01-01T00:00:00Z",
54
+ },
55
+ cmp_deleted: {
56
+ uid: "cmp_deleted",
57
+ slug: "deleted",
58
+ type: "company",
59
+ status: "deleted",
60
+ name: "Deleted Corp",
61
+ bucketName: "hq-vault-cmp-deleted",
62
+ createdAt: "2026-01-01T00:00:00Z",
63
+ },
64
+ };
65
+ const options = {
66
+ hqRoot,
67
+ targets: [
68
+ { uid: "cmp_acme", slug: "acme" },
69
+ { uid: "cmp_failed", slug: "failed" },
70
+ { uid: "cmp_deleted", slug: "deleted" },
71
+ { uid: "cmp_missing-directory", slug: "missing-directory" },
72
+ { uid: "prs_me", slug: "personal", personalMode: true },
73
+ ],
74
+ completedCompanySlugs: new Set(["acme", "deleted", "missing-directory", "personal"]),
75
+ getEntity: async (uid: string) => {
76
+ const entity = entities[uid];
77
+ if (!entity) throw new Error("entity not found");
78
+ return entity;
79
+ },
80
+ };
81
+
82
+ await reconcileCompanyManifest(options);
83
+ const firstWrite = fs.readFileSync(manifestPath, "utf8");
84
+ await reconcileCompanyManifest(options);
85
+
86
+ expect(fs.readFileSync(manifestPath, "utf8")).toBe(firstWrite);
87
+ expect(yaml.load(firstWrite)).toEqual({
88
+ version: 3,
89
+ settings: { keep: true },
90
+ companies: {
91
+ "local-only": {
92
+ name: "Local Only",
93
+ repos: ["repos/local"],
94
+ },
95
+ preserved: {
96
+ name: "Keep Me",
97
+ cloud_uid: "cmp_preserved",
98
+ },
99
+ acme: {
100
+ name: "Acme Inc",
101
+ cloud_uid: "cmp_acme",
102
+ bucket_name: "hq-vault-cmp-acme",
103
+ },
104
+ },
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Reconcile successfully pulled cloud companies into the local manifest.
3
+ *
4
+ * The personal-vault leg also carries `companies/manifest.yaml`, so this runs
5
+ * only after the complete fanout has settled. That makes the locally
6
+ * materialized cloud companies authoritative for their own manifest entries
7
+ * without discarding entries the personal vault already had.
8
+ */
9
+
10
+ import { randomUUID } from "node:crypto";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import yaml from "js-yaml";
14
+
15
+ import type { EntityInfo } from "./vault-client.js";
16
+
17
+ export interface ManifestReconcileTarget {
18
+ uid: string;
19
+ slug: string;
20
+ personalMode?: boolean;
21
+ }
22
+
23
+ export interface ManifestReconcileOptions {
24
+ hqRoot: string;
25
+ targets: readonly ManifestReconcileTarget[];
26
+ completedCompanySlugs: ReadonlySet<string>;
27
+ getEntity: (uid: string) => Promise<EntityInfo>;
28
+ }
29
+
30
+ interface ManifestCompany {
31
+ name?: unknown;
32
+ cloud_uid?: unknown;
33
+ bucket_name?: unknown;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ interface ManifestDocument {
38
+ companies?: Record<string, ManifestCompany>;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === "object" && !Array.isArray(value);
44
+ }
45
+
46
+ function isLiveCompany(entity: EntityInfo, uid: string): boolean {
47
+ return (
48
+ entity.uid === uid &&
49
+ entity.type === "company" &&
50
+ entity.status === "active" &&
51
+ typeof entity.name === "string" &&
52
+ entity.name.length > 0 &&
53
+ typeof entity.bucketName === "string" &&
54
+ entity.bucketName.length > 0
55
+ );
56
+ }
57
+
58
+ function hasCompanyDirectory(companiesDir: string, slug: string): boolean {
59
+ const resolvedCompaniesDir = path.resolve(companiesDir);
60
+ const companyDir = path.resolve(resolvedCompaniesDir, slug);
61
+ if (path.dirname(companyDir) !== resolvedCompaniesDir) return false;
62
+ try {
63
+ return fs.statSync(companyDir).isDirectory();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function loadManifest(manifestPath: string): ManifestDocument | null {
70
+ try {
71
+ const raw = fs.readFileSync(manifestPath, "utf8");
72
+ const parsed = yaml.load(raw);
73
+ return isRecord(parsed) ? (parsed as ManifestDocument) : null;
74
+ } catch (err) {
75
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeManifestAtomically(manifestPath: string, document: ManifestDocument): void {
81
+ const temporaryPath = path.join(
82
+ path.dirname(manifestPath),
83
+ `.manifest-${process.pid}-${randomUUID()}.yaml`,
84
+ );
85
+ fs.writeFileSync(temporaryPath, yaml.dump(document, { lineWidth: -1 }), "utf8");
86
+ fs.renameSync(temporaryPath, manifestPath);
87
+ }
88
+
89
+ /**
90
+ * Atomically merge live, successfully pulled company targets into
91
+ * `companies/manifest.yaml`. Targets that are personal, incomplete, deleted,
92
+ * no longer resolvable, or not materialized on disk are intentionally ignored.
93
+ */
94
+ export async function reconcileCompanyManifest(
95
+ options: ManifestReconcileOptions,
96
+ ): Promise<void> {
97
+ const companiesDir = path.join(options.hqRoot, "companies");
98
+ const candidates: Array<{ slug: string; entity: EntityInfo }> = [];
99
+
100
+ for (const target of options.targets) {
101
+ if (
102
+ target.personalMode === true ||
103
+ !options.completedCompanySlugs.has(target.slug) ||
104
+ !hasCompanyDirectory(companiesDir, target.slug)
105
+ ) {
106
+ continue;
107
+ }
108
+
109
+ try {
110
+ const entity = await options.getEntity(target.uid);
111
+ if (isLiveCompany(entity, target.uid)) {
112
+ candidates.push({ slug: target.slug, entity });
113
+ }
114
+ } catch {
115
+ // The target is not presently a live, resolvable company. Never mint a
116
+ // manifest entry from stale fanout data.
117
+ }
118
+ }
119
+
120
+ if (candidates.length === 0) return;
121
+
122
+ const manifestPath = path.join(companiesDir, "manifest.yaml");
123
+ const document = loadManifest(manifestPath);
124
+ if (!document) return;
125
+
126
+ let companies: Record<string, ManifestCompany>;
127
+ if (document.companies === undefined) {
128
+ companies = {};
129
+ document.companies = companies;
130
+ } else if (isRecord(document.companies)) {
131
+ companies = document.companies as Record<string, ManifestCompany>;
132
+ } else {
133
+ // A malformed `companies` key is user state; refusing to replace it is
134
+ // safer than losing a manifest that cannot be interpreted unambiguously.
135
+ return;
136
+ }
137
+
138
+ let changed = false;
139
+ for (const { slug, entity } of candidates) {
140
+ const existing = companies[slug];
141
+ if (existing !== undefined && !isRecord(existing)) continue;
142
+ const entry: ManifestCompany = existing ?? {};
143
+ if (
144
+ entry.name === entity.name &&
145
+ entry.cloud_uid === entity.uid &&
146
+ entry.bucket_name === entity.bucketName
147
+ ) {
148
+ continue;
149
+ }
150
+ companies[slug] = {
151
+ ...entry,
152
+ name: entity.name,
153
+ cloud_uid: entity.uid,
154
+ bucket_name: entity.bucketName,
155
+ };
156
+ changed = true;
157
+ }
158
+
159
+ if (changed) writeManifestAtomically(manifestPath, document);
160
+ }
@@ -105,6 +105,21 @@ export interface PersonalVaultOptions {
105
105
  */
106
106
  export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
107
107
 
108
+ /**
109
+ * Release-owned scaffold and reindex-generated wrappers must never be
110
+ * delete-propagated from the personal vault. They are recreated locally by a
111
+ * release or `hq reindex`, so an ordinary local absence is not user intent.
112
+ */
113
+ export function isPersonalVaultDeletionExcluded(key: string): boolean {
114
+ const normalized = key.split("\\").join("/").replace(/^\.\//, "");
115
+ return (
116
+ normalized === "core" ||
117
+ normalized.startsWith("core/") ||
118
+ normalized === ".claude/skills" ||
119
+ normalized.startsWith(".claude/skills/")
120
+ );
121
+ }
122
+
108
123
  export function computePersonalVaultPaths(
109
124
  hqRoot: string,
110
125
  opts: PersonalVaultOptions = {},
package/src/s3.test.ts CHANGED
@@ -93,6 +93,7 @@ import {
93
93
  decodeSymlinkMetadataValue,
94
94
  FILE_MTIME_META_KEY,
95
95
  FILE_BTIME_META_KEY,
96
+ classifyVaultKey,
96
97
  validateVaultUploadKey,
97
98
  } from "./s3.js";
98
99
  import {
@@ -1450,6 +1451,23 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
1450
1451
  },
1451
1452
  );
1452
1453
 
1454
+ it.each([
1455
+ [
1456
+ "companies/indigo/knowledge/x.md",
1457
+ "company",
1458
+ "INVALID_KEY_COMPANIES_SCOPED",
1459
+ ],
1460
+ ["bad\x00key.md", "personal", "INVALID_KEY_CONTROL_CHARS"],
1461
+ ["docs/good.md", "personal", null],
1462
+ ] as const)(
1463
+ "classifyVaultKey reports the validator outcome without throwing: %j",
1464
+ (key, scope, code) => {
1465
+ expect(classifyVaultKey(key, scope)).toEqual(
1466
+ code === null ? null : expect.objectContaining({ code }),
1467
+ );
1468
+ },
1469
+ );
1470
+
1453
1471
  it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1454
1472
  await expect(
1455
1473
  uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
package/src/s3.ts CHANGED
@@ -561,67 +561,101 @@ export function toPosixKey(key: string): string {
561
561
  * (`cmp_*` → "company", otherwise "personal").
562
562
  *
563
563
  * Errors carry the same `code` values the server throws so telemetry and
564
- * operator messaging line up across client and server.
564
+ * operator messaging line up across client and server. Pull planning also
565
+ * uses the non-throwing classifier below to keep poison keys away from the
566
+ * presign transport.
565
567
  */
566
568
  // Matching control chars is the whole point — block NUL/0x00–0x1F/0x7F in keys
567
569
  // (they can smuggle past path checks or corrupt HTTP headers on the signed URL).
568
570
  // eslint-disable-next-line no-control-regex
569
571
  const KEY_CONTROL_CHARS = /[\x00-\x1F\x7F]/;
570
572
 
571
- export function validateVaultUploadKey(
573
+ export type VaultKeyScope = "company" | "personal";
574
+
575
+ export interface VaultKeyValidationIssue {
576
+ code: string;
577
+ message: string;
578
+ }
579
+
580
+ /**
581
+ * Classify a vault key with the same rules as {@link validateVaultUploadKey}
582
+ * without throwing. Callers that enumerate remote objects can use this before
583
+ * presigning so permanently-invalid legacy keys remain benign skips.
584
+ */
585
+ export function classifyVaultKey(
572
586
  key: string,
573
- scope: "company" | "personal",
574
- ): void {
575
- const fail = (message: string, code: string): never => {
576
- throw Object.assign(new Error(message), { code });
577
- };
587
+ scope: VaultKeyScope,
588
+ ): VaultKeyValidationIssue | null {
578
589
  if (key.length === 0) {
579
- fail("Invalid key: must not be empty", "INVALID_KEY_EMPTY");
590
+ return {
591
+ code: "INVALID_KEY_EMPTY",
592
+ message: "Invalid key: must not be empty",
593
+ };
580
594
  }
581
595
  if (key.startsWith("/")) {
582
- fail("Invalid key: leading '/' not allowed", "INVALID_KEY_LEADING_SLASH");
596
+ return {
597
+ code: "INVALID_KEY_LEADING_SLASH",
598
+ message: "Invalid key: leading '/' not allowed",
599
+ };
583
600
  }
584
601
  if (key.endsWith("/")) {
585
- fail(
586
- "Invalid key: trailing '/' (folder marker, not an object)",
587
- "INVALID_KEY_TRAILING_SLASH",
588
- );
602
+ return {
603
+ code: "INVALID_KEY_TRAILING_SLASH",
604
+ message: "Invalid key: trailing '/' (folder marker, not an object)",
605
+ };
589
606
  }
590
607
  if (scope === "company" && key.startsWith("companies/")) {
591
- fail(
592
- "Invalid key: vault keys are bucket-relative and the vault is already " +
608
+ return {
609
+ code: "INVALID_KEY_COMPANIES_SCOPED",
610
+ message:
611
+ "Invalid key: vault keys are bucket-relative and the vault is already " +
593
612
  "company-scoped — do not prefix with 'companies/<slug>/'. This " +
594
613
  "usually means a stale doubled local tree " +
595
614
  "(companies/<slug>/companies/<slug>/…); remove the inner copy.",
596
- "INVALID_KEY_COMPANIES_SCOPED",
597
- );
615
+ };
598
616
  }
599
617
  if (KEY_CONTROL_CHARS.test(key)) {
600
- fail("Invalid key: contains control characters", "INVALID_KEY_CONTROL_CHARS");
618
+ return {
619
+ code: "INVALID_KEY_CONTROL_CHARS",
620
+ message: "Invalid key: contains control characters",
621
+ };
601
622
  }
602
623
  if (key.includes("\\")) {
603
- // Unreachable after toPosixKey normalization at the upload boundary, but
604
- // kept so the validator mirrors the server rule set 1:1 for other callers.
605
- fail(
606
- "Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
607
- "INVALID_KEY_BACKSLASH",
608
- );
624
+ return {
625
+ code: "INVALID_KEY_BACKSLASH",
626
+ message:
627
+ "Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
628
+ };
609
629
  }
610
630
  if (key.includes("//")) {
611
- fail("Invalid key: consecutive slashes not allowed", "INVALID_KEY_DOUBLE_SLASH");
631
+ return {
632
+ code: "INVALID_KEY_DOUBLE_SLASH",
633
+ message: "Invalid key: consecutive slashes not allowed",
634
+ };
612
635
  }
613
636
  for (const segment of key.split("/")) {
614
637
  if (segment === "." || segment === "..") {
615
- fail(
616
- "Invalid key: '.' and '..' path components are not allowed",
617
- "INVALID_KEY_DOT_COMPONENT",
618
- );
638
+ return {
639
+ code: "INVALID_KEY_DOT_COMPONENT",
640
+ message: "Invalid key: '.' and '..' path components are not allowed",
641
+ };
619
642
  }
620
643
  }
644
+ return null;
645
+ }
646
+
647
+ export function validateVaultUploadKey(
648
+ key: string,
649
+ scope: VaultKeyScope,
650
+ ): void {
651
+ const issue = classifyVaultKey(key, scope);
652
+ if (issue !== null) {
653
+ throw Object.assign(new Error(issue.message), { code: issue.code });
654
+ }
621
655
  }
622
656
 
623
657
  /** Scope for {@link validateVaultUploadKey}, derived from the entity uid. */
624
- function uploadScopeFor(ctx: EntityContext): "company" | "personal" {
658
+ function uploadScopeFor(ctx: EntityContext): VaultKeyScope {
625
659
  return ctx.uid.startsWith("cmp_") ? "company" : "personal";
626
660
  }
627
661
 
@@ -775,6 +809,7 @@ export async function downloadFile(
775
809
  ctx: EntityContext,
776
810
  key: string,
777
811
  localPath: string,
812
+ options: { beforeReplace?: () => void } = {},
778
813
  ): Promise<{
779
814
  metadata?: Record<string, string>;
780
815
  contentHash?: string;
@@ -868,6 +903,7 @@ export async function downloadFile(
868
903
  const tempPath = downloadTempPath(localPath);
869
904
  try {
870
905
  fs.symlinkSync(symlinkTarget, tempPath);
906
+ options.beforeReplace?.();
871
907
  fs.renameSync(tempPath, localPath);
872
908
  } catch (err) {
873
909
  removeTempPath(tempPath);
@@ -977,6 +1013,7 @@ export async function downloadFile(
977
1013
  }
978
1014
  }
979
1015
 
1016
+ options.beforeReplace?.();
980
1017
  fs.renameSync(tempPath, localPath);
981
1018
  tempReady = false;
982
1019
  } finally {
package/src/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface JournalEntry {
26
26
  size: number;
27
27
  syncedAt: string;
28
28
  direction: "up" | "down";
29
+ /** Kind of local object represented by this entry. */
30
+ kind?: "file" | "symlink";
29
31
  /**
30
32
  * Cognito `sub` of the file's author, captured from the object's
31
33
  * `created-by-sub` S3 user-metadata at download time (zero extra network —
@@ -72,6 +74,17 @@ export interface JournalEntry {
72
74
  */
73
75
  removedAt?: string;
74
76
  removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
77
+ /**
78
+ * Explicit local-delete intent. Unlike the legacy `removedReason` marker,
79
+ * this is bound to the exact synced remote revision and local object shape
80
+ * that the user approved for deletion.
81
+ */
82
+ localDeleteIntent?: {
83
+ version: 1;
84
+ remoteEtag: string;
85
+ localHash: string;
86
+ localKind: "file" | "symlink";
87
+ };
75
88
  /**
76
89
  * Durable automatic-pull retention marker. Set when a scope shrink keeps an
77
90
  * out-of-scope caller-authored or unknown-author entry on disk instead of