@indigoai-us/hq-cloud 6.14.1 → 6.14.3
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/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +85 -12
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +95 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +29 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +48 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +44 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +49 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +49 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +113 -13
- package/src/cli/reindex.ts +123 -23
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +24 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/personal-vault.test.ts +57 -0
- package/src/personal-vault.ts +48 -2
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +70 -0
- package/src/watcher.ts +68 -0
package/src/cli/sync.ts
CHANGED
|
@@ -12,7 +12,11 @@ import type {
|
|
|
12
12
|
VaultServiceConfig,
|
|
13
13
|
SyncJournal,
|
|
14
14
|
} from "../types.js";
|
|
15
|
-
import type
|
|
15
|
+
import { VaultClient, type SyncMode } from "../vault-client.js";
|
|
16
|
+
import {
|
|
17
|
+
emitCloudTelemetry,
|
|
18
|
+
type TelemetryClaims,
|
|
19
|
+
} from "../telemetry-events.js";
|
|
16
20
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
17
21
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
18
22
|
import {
|
|
@@ -333,13 +337,31 @@ export type SyncProgressEvent =
|
|
|
333
337
|
* pushed it into the runner's `errors[]` and made every watch pass return
|
|
334
338
|
* exit 2; the menubar supervisor then reported that as
|
|
335
339
|
* "auto-sync watcher exited unexpectedly (code=Some(2))" on every tick —
|
|
336
|
-
* the
|
|
340
|
+
* the HQ-SYNC-4 flood across user machines. Surfaced here purely for visibility
|
|
337
341
|
* (`bytes` is the offending file's size) so a UI/CLI can tell the user
|
|
338
342
|
* which file was too big without treating it as a failure.
|
|
339
343
|
*/
|
|
340
344
|
type: "skip-size-limit";
|
|
341
345
|
path: string;
|
|
342
346
|
bytes: number;
|
|
347
|
+
}
|
|
348
|
+
| {
|
|
349
|
+
/**
|
|
350
|
+
* Emitted by the PULL leg once per remote key skipped because it is
|
|
351
|
+
* invalid for the vault's scope — today, a `companies/…`-prefixed key in
|
|
352
|
+
* a company-scoped vault (the server rejects these with
|
|
353
|
+
* INVALID_KEY_COMPANIES_SCOPED; see hq-pro files-acl validateObjectKey).
|
|
354
|
+
* Such keys are bucket poisoning from a client with a stale doubled
|
|
355
|
+
* local tree writing via direct S3 (incident 2026-07-11). Like
|
|
356
|
+
* `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
|
|
357
|
+
* object must never error a whole company sync (erroring gated
|
|
358
|
+
* heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
|
|
359
|
+
* visibility + cloud telemetry; per policy
|
|
360
|
+
* hq-alert-baseline-calibration the healthy baseline is ZERO such keys,
|
|
361
|
+
* so any occurrence is signal.
|
|
362
|
+
*/
|
|
363
|
+
type: "skip-invalid-scoped-key";
|
|
364
|
+
path: string;
|
|
343
365
|
};
|
|
344
366
|
|
|
345
367
|
export interface SyncOptions {
|
|
@@ -409,6 +431,23 @@ export interface SyncOptions {
|
|
|
409
431
|
* failure can never silently prune the local tree.
|
|
410
432
|
*/
|
|
411
433
|
prefixSet?: ScopePrefixInput[];
|
|
434
|
+
/**
|
|
435
|
+
* Company-relative prefixes SUBTRACTED from the effective pull scope in EVERY
|
|
436
|
+
* mode — the push-only set (US-006). Carries `sessions/`: session transcripts
|
|
437
|
+
* are pushed into the vault but never auto-pulled onto grantee laptops, even
|
|
438
|
+
* under `syncMode: "all"` (which otherwise pulls the whole bucket). A remote
|
|
439
|
+
* key downloads iff it is covered by the inclusion scope (`prefixSet`, or all
|
|
440
|
+
* of `all` mode) AND NOT covered by any `excludePrefixes` entry; excluded
|
|
441
|
+
* keys are classified `skip-out-of-scope`.
|
|
442
|
+
*
|
|
443
|
+
* CRITICAL: this set is applied ONLY to the download filter. It is
|
|
444
|
+
* deliberately NOT fed into the scope-shrink pass (which keeps using the
|
|
445
|
+
* inclusion `prefixSet` + pins), so a locally-authored (`direction:"up"`) or
|
|
446
|
+
* pinned on-demand-materialized session is NEVER orphaned/pruned — it is
|
|
447
|
+
* merely not re-pulled. The runner derives this from
|
|
448
|
+
* `resolvePullScope().excludePrefixes`.
|
|
449
|
+
*/
|
|
450
|
+
excludePrefixes?: ScopePrefixInput[];
|
|
412
451
|
/**
|
|
413
452
|
* When the effective scope shrinks relative to the last pull and the shrink
|
|
414
453
|
* would orphan locally-modified ("dirty") files, `sync()` aborts with a
|
|
@@ -444,6 +483,12 @@ export interface SyncOptions {
|
|
|
444
483
|
* it from disk, so it stays pure/hermetic; undefined degrades safely.
|
|
445
484
|
*/
|
|
446
485
|
callerSub?: string;
|
|
486
|
+
/**
|
|
487
|
+
* Optional decoded Cognito claims used only for action telemetry attribution
|
|
488
|
+
* (agentUid when `custom:entityType=agent`). Sync behavior never branches on
|
|
489
|
+
* this field.
|
|
490
|
+
*/
|
|
491
|
+
telemetryClaims?: TelemetryClaims | null;
|
|
447
492
|
/**
|
|
448
493
|
* Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
|
|
449
494
|
* mirrors + workers registry). By default, when a sync changes on-disk
|
|
@@ -547,6 +592,14 @@ interface PullRunContext {
|
|
|
547
592
|
remoteFiles: RemoteFile[];
|
|
548
593
|
syncMode: SyncMode;
|
|
549
594
|
currentPrefixSet: string[];
|
|
595
|
+
/**
|
|
596
|
+
* Coalesced push-only exclusion prefixes (US-006: `sessions/`), subtracted
|
|
597
|
+
* from the download filter in EVERY mode. Kept SEPARATE from
|
|
598
|
+
* `currentPrefixSet` on purpose: the scope-shrink pass reads only
|
|
599
|
+
* `currentPrefixSet` (+ pins), so excluded-but-authored/pinned session files
|
|
600
|
+
* are never orphaned.
|
|
601
|
+
*/
|
|
602
|
+
currentExcludeSet: string[];
|
|
550
603
|
fileTombstones: ReadonlyMap<string, CompanyTombstone>;
|
|
551
604
|
}
|
|
552
605
|
|
|
@@ -625,6 +678,7 @@ export async function reportNewFilesToNotify(
|
|
|
625
678
|
companyUid: string,
|
|
626
679
|
companySlug: string,
|
|
627
680
|
files: Array<{ path: string; bytes: number; addedBy: string | null }>,
|
|
681
|
+
telemetryClaims?: TelemetryClaims | null,
|
|
628
682
|
): Promise<void> {
|
|
629
683
|
if (files.length === 0) return;
|
|
630
684
|
|
|
@@ -639,16 +693,19 @@ export async function reportNewFilesToNotify(
|
|
|
639
693
|
return;
|
|
640
694
|
}
|
|
641
695
|
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
696
|
+
const telemetryClient = new VaultClient(vaultConfig);
|
|
697
|
+
const batchCount = Math.ceil(files.length / NOTIFY_FILE_ADDED_MAX_BATCH);
|
|
642
698
|
|
|
643
699
|
for (let i = 0; i < files.length; i += NOTIFY_FILE_ADDED_MAX_BATCH) {
|
|
644
700
|
const batch = files.slice(i, i + NOTIFY_FILE_ADDED_MAX_BATCH);
|
|
701
|
+
const batchIndex = Math.floor(i / NOTIFY_FILE_ADDED_MAX_BATCH) + 1;
|
|
645
702
|
const controller = new AbortController();
|
|
646
703
|
const timer = setTimeout(
|
|
647
704
|
() => controller.abort(),
|
|
648
705
|
NOTIFY_FILE_ADDED_TIMEOUT_MS,
|
|
649
706
|
);
|
|
650
707
|
try {
|
|
651
|
-
await fetch(`${base}/v1/notify/file-added`, {
|
|
708
|
+
const response = await fetch(`${base}/v1/notify/file-added`, {
|
|
652
709
|
method: "POST",
|
|
653
710
|
headers: {
|
|
654
711
|
Authorization: `Bearer ${token}`,
|
|
@@ -665,16 +722,44 @@ export async function reportNewFilesToNotify(
|
|
|
665
722
|
}),
|
|
666
723
|
signal: controller.signal,
|
|
667
724
|
});
|
|
725
|
+
void emitCloudTelemetry(telemetryClient, {
|
|
726
|
+
eventName: "new_files_notification_reported",
|
|
727
|
+
source: "hq-sync",
|
|
728
|
+
companyUid,
|
|
729
|
+
properties: {
|
|
730
|
+
result: response.ok ? "success" : "failure",
|
|
731
|
+
fileCount: batch.length,
|
|
732
|
+
batchIndex,
|
|
733
|
+
batchCount,
|
|
734
|
+
statusCode: response.status,
|
|
735
|
+
},
|
|
736
|
+
}, { claims: telemetryClaims });
|
|
668
737
|
} catch (err) {
|
|
669
738
|
// Best-effort per chunk: never let notification reporting affect the sync
|
|
670
739
|
// result, and a failed chunk must not abort the remaining chunks.
|
|
671
740
|
logNotifyFailure(err);
|
|
741
|
+
void emitCloudTelemetry(telemetryClient, {
|
|
742
|
+
eventName: "new_files_notification_reported",
|
|
743
|
+
source: "hq-sync",
|
|
744
|
+
companyUid,
|
|
745
|
+
properties: {
|
|
746
|
+
result: "failure",
|
|
747
|
+
fileCount: batch.length,
|
|
748
|
+
batchIndex,
|
|
749
|
+
batchCount,
|
|
750
|
+
errorCode: safeErrorName(err),
|
|
751
|
+
},
|
|
752
|
+
}, { claims: telemetryClaims });
|
|
672
753
|
} finally {
|
|
673
754
|
clearTimeout(timer);
|
|
674
755
|
}
|
|
675
756
|
}
|
|
676
757
|
}
|
|
677
758
|
|
|
759
|
+
function safeErrorName(err: unknown): string {
|
|
760
|
+
return err instanceof Error && err.name ? err.name : "error";
|
|
761
|
+
}
|
|
762
|
+
|
|
678
763
|
/** Log a non-fatal notify failure without ever throwing out of the logger. */
|
|
679
764
|
function logNotifyFailure(err: unknown): void {
|
|
680
765
|
try {
|
|
@@ -724,6 +809,7 @@ async function syncWithOperationLockHeld(
|
|
|
724
809
|
const plan = planPull(run);
|
|
725
810
|
|
|
726
811
|
emitPullPlan(run.emit, plan);
|
|
812
|
+
reportInvalidScopedKeys(run, plan);
|
|
727
813
|
|
|
728
814
|
const scopePlan = planScopeShrink(run);
|
|
729
815
|
const scopeRun = executeScopeShrink(run, scopePlan);
|
|
@@ -795,6 +881,13 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
|
795
881
|
const syncMode: SyncMode = options.syncMode ?? "all";
|
|
796
882
|
const currentPrefixSet: string[] =
|
|
797
883
|
syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
|
|
884
|
+
// Push-only exclusions (US-006). Coalesced independently of `currentPrefixSet`
|
|
885
|
+
// and applied to EVERY mode's download filter — including `all` — but kept out
|
|
886
|
+
// of the scope-shrink inputs (see `planScopeShrink`). Empty when the caller
|
|
887
|
+
// passes nothing (legacy behavior: no exclusions).
|
|
888
|
+
const currentExcludeSet: string[] = coalescePrefixes(
|
|
889
|
+
options.excludePrefixes ?? [],
|
|
890
|
+
);
|
|
798
891
|
|
|
799
892
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
800
893
|
const fileTombstones =
|
|
@@ -817,6 +910,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
|
817
910
|
remoteFiles,
|
|
818
911
|
syncMode,
|
|
819
912
|
currentPrefixSet,
|
|
913
|
+
currentExcludeSet,
|
|
820
914
|
fileTombstones,
|
|
821
915
|
};
|
|
822
916
|
}
|
|
@@ -831,6 +925,7 @@ function planPull(run: PullRunContext): PullPlan {
|
|
|
831
925
|
run.options.teamSyncedSlugs ?? null,
|
|
832
926
|
run.currentPrefixSet,
|
|
833
927
|
run.fileTombstones,
|
|
928
|
+
run.currentExcludeSet,
|
|
834
929
|
);
|
|
835
930
|
}
|
|
836
931
|
|
|
@@ -847,6 +942,33 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
|
|
|
847
942
|
});
|
|
848
943
|
}
|
|
849
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Surface scope-invalid remote keys the planner skipped (see the
|
|
947
|
+
* `invalidScopedKeys` doc on PullPlan). One warning event per key on the
|
|
948
|
+
* existing sync event channel (deliberately NOT `type: "error"` — see the
|
|
949
|
+
* `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
|
|
950
|
+
* event on the channel sync already uses (emitCloudTelemetry). Per policy
|
|
951
|
+
* hq-alert-baseline-calibration the documented healthy baseline is ZERO
|
|
952
|
+
* INVALID_KEY_COMPANIES_SCOPED occurrences fleet-wide, so consumers can alert
|
|
953
|
+
* on any non-zero count with no threshold tuning.
|
|
954
|
+
*/
|
|
955
|
+
function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
|
|
956
|
+
if (plan.invalidScopedKeys.length === 0) return;
|
|
957
|
+
for (const key of plan.invalidScopedKeys) {
|
|
958
|
+
run.emit({ type: "skip-invalid-scoped-key", path: key });
|
|
959
|
+
}
|
|
960
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
961
|
+
eventName: "sync_invalid_scoped_key_skipped",
|
|
962
|
+
source: "hq-sync",
|
|
963
|
+
companyUid: run.ctx.uid,
|
|
964
|
+
properties: {
|
|
965
|
+
count: plan.invalidScopedKeys.length,
|
|
966
|
+
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
967
|
+
leg: "pull",
|
|
968
|
+
},
|
|
969
|
+
}, { claims: run.options.telemetryClaims });
|
|
970
|
+
}
|
|
971
|
+
|
|
850
972
|
function createPullCounters(): PullCounters {
|
|
851
973
|
return {
|
|
852
974
|
filesDownloaded: 0,
|
|
@@ -870,6 +992,12 @@ function planScopeShrink(run: PullRunContext): ScopeShrinkPlanState {
|
|
|
870
992
|
journal: run.journal,
|
|
871
993
|
hqRoot: run.companyRoot,
|
|
872
994
|
lastPrefixSet,
|
|
995
|
+
// INCLUSION scope only (+ pins, already folded into currentPrefixSet by the
|
|
996
|
+
// resolver). The push-only exclude set (US-006: `sessions/`) is DELIBERATELY
|
|
997
|
+
// NOT subtracted here: a session file that was authored locally
|
|
998
|
+
// (`direction:"up"`) or pinned + materialized on demand must never be
|
|
999
|
+
// orphaned just because sessions are excluded from the download filter.
|
|
1000
|
+
// Feeding `currentExcludeSet` in here would prune exactly those files.
|
|
873
1001
|
currentPrefixSet: run.currentPrefixSet,
|
|
874
1002
|
callerSub: run.options.callerSub,
|
|
875
1003
|
protectUnknownAuthors: true,
|
|
@@ -1429,6 +1557,7 @@ async function emitAndReportNewFiles(
|
|
|
1429
1557
|
run.ctx.uid,
|
|
1430
1558
|
run.ctx.slug,
|
|
1431
1559
|
enrichedNewFiles,
|
|
1560
|
+
run.options.telemetryClaims,
|
|
1432
1561
|
);
|
|
1433
1562
|
}
|
|
1434
1563
|
|
|
@@ -1438,6 +1567,29 @@ async function verifyPlannedJournalTombstones(
|
|
|
1438
1567
|
): Promise<void> {
|
|
1439
1568
|
if (plan.tombstones.length === 0) return;
|
|
1440
1569
|
|
|
1570
|
+
// Scope-invalid journal keys (company mode, `companies/…` prefix) skip the
|
|
1571
|
+
// HEAD verify: the presign transport's server validator rejects such keys
|
|
1572
|
+
// outright (INVALID_KEY_COMPANIES_SCOPED), so a HEAD can never succeed and
|
|
1573
|
+
// would defer the tombstone forever — leaving the poisoned journal entry
|
|
1574
|
+
// (and its doubled-tree local file) re-firing on every sync. The key can
|
|
1575
|
+
// never legitimately exist in a company vault, so treating it as verified
|
|
1576
|
+
// is safe: the executor cleans the local copy + journal entry instead of
|
|
1577
|
+
// the push leg ever re-uploading it (tombstone-not-reupload).
|
|
1578
|
+
const autoVerified: string[] = [];
|
|
1579
|
+
const toHeadVerify: string[] = [];
|
|
1580
|
+
for (const key of plan.tombstones) {
|
|
1581
|
+
if (run.options.personalMode !== true && key.startsWith("companies/")) {
|
|
1582
|
+
autoVerified.push(key);
|
|
1583
|
+
} else {
|
|
1584
|
+
toHeadVerify.push(key);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
plan.tombstones = toHeadVerify;
|
|
1588
|
+
if (plan.tombstones.length === 0) {
|
|
1589
|
+
plan.tombstones = autoVerified;
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1441
1593
|
await primeObjectTransport(run.ctx, "get", plan.tombstones);
|
|
1442
1594
|
|
|
1443
1595
|
const HEAD_VERIFY_CONCURRENCY = 5;
|
|
@@ -1466,7 +1618,7 @@ async function verifyPlannedJournalTombstones(
|
|
|
1466
1618
|
if (k !== null) verified.push(k);
|
|
1467
1619
|
}
|
|
1468
1620
|
}
|
|
1469
|
-
plan.tombstones = verified;
|
|
1621
|
+
plan.tombstones = [...autoVerified, ...verified];
|
|
1470
1622
|
}
|
|
1471
1623
|
|
|
1472
1624
|
function executeJournalTombstoneDeletes(
|
|
@@ -1545,7 +1697,19 @@ function finalizePullRun(
|
|
|
1545
1697
|
scopeRun.scopeOrphansRemoved > 0;
|
|
1546
1698
|
if (!run.options.skipReindex && changedOnDisk) {
|
|
1547
1699
|
try {
|
|
1548
|
-
reindex({ repoRoot: run.hqRoot, skipLock: true });
|
|
1700
|
+
const result = reindex({ repoRoot: run.hqRoot, skipLock: true });
|
|
1701
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
1702
|
+
eventName: "reindex_run",
|
|
1703
|
+
source: "hq-sync",
|
|
1704
|
+
companyUid: run.ctx.uid,
|
|
1705
|
+
properties: {
|
|
1706
|
+
result: result.status === 0 ? "completed" : "failed",
|
|
1707
|
+
statusCode: result.status,
|
|
1708
|
+
lockResult: "skipped",
|
|
1709
|
+
changedCount:
|
|
1710
|
+
counters.changedPaths.length + scopeRun.changedPaths.length,
|
|
1711
|
+
},
|
|
1712
|
+
}, { claims: run.options.telemetryClaims });
|
|
1549
1713
|
} catch (err) {
|
|
1550
1714
|
logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
|
|
1551
1715
|
// best-effort: a post-sync refresh failure never fails the sync
|
|
@@ -1780,6 +1944,15 @@ interface PullPlan {
|
|
|
1780
1944
|
* `filesToDelete` axis; the per-item executor applies the local delete.
|
|
1781
1945
|
*/
|
|
1782
1946
|
filesToTombstoneDelete: number;
|
|
1947
|
+
/**
|
|
1948
|
+
* Remote keys skipped because they are invalid for the vault's scope
|
|
1949
|
+
* (company mode only: literal `companies/…` keys — bucket poisoning from a
|
|
1950
|
+
* doubled-tree client via direct S3). Counted in `filesExcludedByPolicy`;
|
|
1951
|
+
* carried by key so the caller can log one warning per key and emit
|
|
1952
|
+
* telemetry. Healthy baseline is an empty array (policy
|
|
1953
|
+
* hq-alert-baseline-calibration: zero expected, any occurrence is signal).
|
|
1954
|
+
*/
|
|
1955
|
+
invalidScopedKeys: string[];
|
|
1783
1956
|
}
|
|
1784
1957
|
|
|
1785
1958
|
/**
|
|
@@ -1847,8 +2020,18 @@ function computePullPlan(
|
|
|
1847
2020
|
// preserves the legacy behavior bit-for-bit. (Named `fileTombstones` to avoid
|
|
1848
2021
|
// shadowing the local `tombstones` string[] — the journal-vs-LIST delete set.)
|
|
1849
2022
|
fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
|
|
2023
|
+
// Push-only exclusion prefixes (US-006: `sessions/`). Keys covered by any of
|
|
2024
|
+
// these are classified `skip-out-of-scope` in EVERY mode — including `all` —
|
|
2025
|
+
// AFTER passing the inclusion check. Empty (the default) preserves legacy
|
|
2026
|
+
// behavior bit-for-bit. Applied ONLY here (the download filter), never to
|
|
2027
|
+
// scope-shrink, so an authored/pinned excluded key is not re-pulled yet also
|
|
2028
|
+
// never orphaned.
|
|
2029
|
+
excludePrefixes: readonly ScopePrefixInput[] = [],
|
|
1850
2030
|
): PullPlan {
|
|
1851
2031
|
const items: PullPlanItem[] = [];
|
|
2032
|
+
// Company-mode remote keys invalid for the vault's scope (`companies/…` in a
|
|
2033
|
+
// company bucket) — collected so the caller can warn + emit telemetry.
|
|
2034
|
+
const invalidScopedKeys: string[] = [];
|
|
1852
2035
|
// Clean/current/non-divergent files missing locally — collected here, then
|
|
1853
2036
|
// resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
|
|
1854
2037
|
const intentionalDeleteCandidates: Array<{
|
|
@@ -1874,6 +2057,23 @@ function computePullPlan(
|
|
|
1874
2057
|
continue;
|
|
1875
2058
|
}
|
|
1876
2059
|
|
|
2060
|
+
// Scope-invalid key filter (incident 2026-07-11). A company vault bucket
|
|
2061
|
+
// is already company-scoped, so a literal `companies/…` key can only be
|
|
2062
|
+
// bucket poisoning — a client with a stale doubled local tree
|
|
2063
|
+
// (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
|
|
2064
|
+
// transport, which bypasses the server validator. The presign download
|
|
2065
|
+
// API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
|
|
2066
|
+
// download them errors the WHOLE company sync. Skip them here — one bad
|
|
2067
|
+
// object must never take down a company sync — and record them on the
|
|
2068
|
+
// plan so the caller can warn + emit telemetry (baseline is zero; any
|
|
2069
|
+
// occurrence is signal). Mirrors the personalMode/malformed-key filters
|
|
2070
|
+
// above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
|
|
2071
|
+
if (!personalMode && remoteFile.key.startsWith("companies/")) {
|
|
2072
|
+
invalidScopedKeys.push(remoteFile.key);
|
|
2073
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
1877
2077
|
if (
|
|
1878
2078
|
personalMode &&
|
|
1879
2079
|
remoteFile.key.startsWith("companies/") &&
|
|
@@ -1917,6 +2117,20 @@ function computePullPlan(
|
|
|
1917
2117
|
continue;
|
|
1918
2118
|
}
|
|
1919
2119
|
|
|
2120
|
+
// Push-only exclusion filter (US-006). A key that PASSED the inclusion check
|
|
2121
|
+
// above is STILL not downloaded if it falls under a push-only exclude prefix
|
|
2122
|
+
// (`sessions/`). This is what makes sessions push-only in EVERY mode — it
|
|
2123
|
+
// subtracts from `all` (whose `prefixSet` is `[""]`, covering everything)
|
|
2124
|
+
// and from an owner's role-wide scope alike. Empty `excludePrefixes` (the
|
|
2125
|
+
// default) makes this a no-op. Deliberately AFTER inclusion and reported on
|
|
2126
|
+
// the same `filesOutOfScope` axis. The matching local file (if a session was
|
|
2127
|
+
// previously pulled, authored locally, or pinned+materialized) is NOT pruned
|
|
2128
|
+
// here or by scope-shrink — see `planScopeShrink`, which never sees this set.
|
|
2129
|
+
if (isCoveredByAny(remoteFile.key, excludePrefixes)) {
|
|
2130
|
+
items.push({ action: "skip-out-of-scope", remoteFile, localPath });
|
|
2131
|
+
continue;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1920
2134
|
// LIST gives us no kind signal for the remote object — we don't
|
|
1921
2135
|
// know whether this key is a regular file or a symlink record
|
|
1922
2136
|
// until we either HEAD it (expensive — N extra calls per pull) or
|
|
@@ -2420,6 +2634,7 @@ function computePullPlan(
|
|
|
2420
2634
|
filesOutOfScope,
|
|
2421
2635
|
filesToTombstoneDelete,
|
|
2422
2636
|
tombstones,
|
|
2637
|
+
invalidScopedKeys,
|
|
2423
2638
|
};
|
|
2424
2639
|
}
|
|
2425
2640
|
|
|
@@ -2502,5 +2717,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2502
2717
|
console.warn(
|
|
2503
2718
|
` ! ${event.path} skipped — ${mb} MB exceeds the 50 MB sync size limit`,
|
|
2504
2719
|
);
|
|
2720
|
+
} else if (event.type === "skip-invalid-scoped-key") {
|
|
2721
|
+
console.warn(
|
|
2722
|
+
` ! ${event.path} skipped — invalid 'companies/'-prefixed key in a company-scoped vault (bucket poisoning; INVALID_KEY_COMPANIES_SCOPED)`,
|
|
2723
|
+
);
|
|
2505
2724
|
}
|
|
2506
2725
|
}
|
|
@@ -11,6 +11,7 @@ import * as path from "path";
|
|
|
11
11
|
import {
|
|
12
12
|
buildRepoCompanyMap,
|
|
13
13
|
resolveCompanyForCwd,
|
|
14
|
+
resolveCompanyForSkill,
|
|
14
15
|
} from "./company-resolver.js";
|
|
15
16
|
|
|
16
17
|
const INDIGO = "cmp_01INDIGO";
|
|
@@ -132,5 +133,55 @@ describe("buildRepoCompanyMap + resolveCompanyForCwd", () => {
|
|
|
132
133
|
hqRoot = setupHqRoot(":\n not: [valid: yaml");
|
|
133
134
|
const map = await buildRepoCompanyMap(hqRoot);
|
|
134
135
|
expect(map.entries).toHaveLength(0);
|
|
136
|
+
expect(map.bySlug.size).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("resolveCompanyForSkill", () => {
|
|
141
|
+
let hqRoot: string;
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
if (hqRoot) fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("attributes a company-namespaced skill to its company, regardless of cwd", async () => {
|
|
147
|
+
hqRoot = setupHqRoot();
|
|
148
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
149
|
+
// The slug map is populated from the manifest keys with a cloud_uid.
|
|
150
|
+
expect(resolveCompanyForSkill("indigo:signals", map)).toBe(INDIGO);
|
|
151
|
+
expect(resolveCompanyForSkill("hpo:some-skill", map)).toBe(HPO);
|
|
152
|
+
// Multi-segment skill name: only the leading slug is the company.
|
|
153
|
+
expect(resolveCompanyForSkill("indigo:hq-dev-thread", map)).toBe(INDIGO);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefined for a core (un-namespaced) skill", async () => {
|
|
157
|
+
hqRoot = setupHqRoot();
|
|
158
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
159
|
+
expect(resolveCompanyForSkill("startwork", map)).toBeUndefined();
|
|
160
|
+
expect(resolveCompanyForSkill("review", map)).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns undefined for a non-company namespace (personal/pack) and a leading colon", async () => {
|
|
164
|
+
hqRoot = setupHqRoot();
|
|
165
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
166
|
+
// `personal` has no cloud_uid → not in the slug map.
|
|
167
|
+
expect(resolveCompanyForSkill("personal:startwork", map)).toBeUndefined();
|
|
168
|
+
expect(
|
|
169
|
+
resolveCompanyForSkill("hq-pack-engineering:review", map),
|
|
170
|
+
).toBeUndefined();
|
|
171
|
+
expect(resolveCompanyForSkill(":oops", map)).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns undefined for a slug whose company is not cloud-backed", async () => {
|
|
175
|
+
hqRoot = setupHqRoot();
|
|
176
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
177
|
+
// `liverecover` is in the manifest but carries no cloud_uid.
|
|
178
|
+
expect(resolveCompanyForSkill("liverecover:foo", map)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("handles a missing/undefined skill safely", async () => {
|
|
182
|
+
hqRoot = setupHqRoot();
|
|
183
|
+
const map = await buildRepoCompanyMap(hqRoot);
|
|
184
|
+
expect(resolveCompanyForSkill(undefined, map)).toBeUndefined();
|
|
185
|
+
expect(resolveCompanyForSkill("", map)).toBeUndefined();
|
|
135
186
|
});
|
|
136
187
|
});
|
package/src/company-resolver.ts
CHANGED
|
@@ -54,6 +54,14 @@ import yaml from "js-yaml";
|
|
|
54
54
|
export interface RepoCompanyMap {
|
|
55
55
|
/** Absolute, normalized repo root → owning company `cmp_*` uid. */
|
|
56
56
|
entries: Array<{ repoRoot: string; companyUid: string }>;
|
|
57
|
+
/**
|
|
58
|
+
* Company SLUG → owning company `cmp_*` uid, from the manifest keys. Backs
|
|
59
|
+
* skill-based attribution: a company skill is invoked as `<slug>:<skill>`
|
|
60
|
+
* (mirroring `companies/<slug>/skills/`), so its usage belongs to the company
|
|
61
|
+
* named by that slug regardless of the cwd it ran in. Manifest keys are unique,
|
|
62
|
+
* so a slug resolves to exactly one cloud-backed company here.
|
|
63
|
+
*/
|
|
64
|
+
bySlug: Map<string, string>;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
interface ManifestCompany {
|
|
@@ -87,23 +95,28 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
|
|
|
87
95
|
const raw = await fs.readFile(manifestPath, "utf-8");
|
|
88
96
|
const parsed = yaml.load(raw);
|
|
89
97
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
90
|
-
return { entries: [] };
|
|
98
|
+
return { entries: [], bySlug: new Map() };
|
|
91
99
|
}
|
|
92
100
|
doc = parsed as ManifestShape;
|
|
93
101
|
} catch {
|
|
94
102
|
// Missing / unreadable / unparseable — no attribution this run.
|
|
95
|
-
return { entries: [] };
|
|
103
|
+
return { entries: [], bySlug: new Map() };
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
const companies = doc.companies;
|
|
99
|
-
if (!companies || typeof companies !== "object")
|
|
107
|
+
if (!companies || typeof companies !== "object") {
|
|
108
|
+
return { entries: [], bySlug: new Map() };
|
|
109
|
+
}
|
|
100
110
|
|
|
101
111
|
const entries: Array<{ repoRoot: string; companyUid: string }> = [];
|
|
102
|
-
|
|
112
|
+
const bySlug = new Map<string, string>();
|
|
113
|
+
for (const [slug, company] of Object.entries(companies)) {
|
|
103
114
|
if (!company || typeof company !== "object") continue;
|
|
104
115
|
const cloudUid = company.cloud_uid;
|
|
105
116
|
// Only cloud-backed companies (with a cmp_* uid) attribute events.
|
|
106
117
|
if (typeof cloudUid !== "string" || !cloudUid.startsWith("cmp_")) continue;
|
|
118
|
+
// Slug → uid for skill-based attribution (a `<slug>:<skill>` invocation).
|
|
119
|
+
if (typeof slug === "string" && slug.length > 0) bySlug.set(slug, cloudUid);
|
|
107
120
|
const repos = company.repos;
|
|
108
121
|
if (!Array.isArray(repos)) continue;
|
|
109
122
|
for (const repo of repos) {
|
|
@@ -118,7 +131,29 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
|
|
|
118
131
|
// Longest repoRoot first so the first containment match is the most specific
|
|
119
132
|
// (handles nested repo layouts where one repo path prefixes another).
|
|
120
133
|
entries.sort((a, b) => b.repoRoot.length - a.repoRoot.length);
|
|
121
|
-
return { entries };
|
|
134
|
+
return { entries, bySlug };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a skill INVOCATION to its owning company's `cmp_*` uid, or `undefined`
|
|
139
|
+
* when the skill is not a cloud-backed company's skill.
|
|
140
|
+
*
|
|
141
|
+
* A company skill is invoked as `<slug>:<skill>` (the namespaced form of
|
|
142
|
+
* `companies/<slug>/skills/<skill>`); the leading `<slug>` names the owning
|
|
143
|
+
* company. We look that slug up in the manifest's slug→uid map, so a company
|
|
144
|
+
* skill attributes to ITS company no matter which cwd it ran in — this is what
|
|
145
|
+
* surfaces company-skill usage in the company console. A core / `personal:` /
|
|
146
|
+
* `hq-pack-*:` / `anthropic-skills:` skill has no matching manifest company, so
|
|
147
|
+
* it resolves to `undefined` (the cwd resolver remains the fallback).
|
|
148
|
+
*/
|
|
149
|
+
export function resolveCompanyForSkill(
|
|
150
|
+
skill: string | undefined,
|
|
151
|
+
map: RepoCompanyMap,
|
|
152
|
+
): string | undefined {
|
|
153
|
+
if (typeof skill !== "string") return undefined;
|
|
154
|
+
const idx = skill.indexOf(":");
|
|
155
|
+
if (idx <= 0) return undefined; // un-namespaced (core) skill
|
|
156
|
+
return map.bySlug.get(skill.slice(0, idx));
|
|
122
157
|
}
|
|
123
158
|
|
|
124
159
|
/**
|
package/src/ignore.test.ts
CHANGED
|
@@ -29,6 +29,8 @@ describe("isExpectedIgnore", () => {
|
|
|
29
29
|
expect(isExpectedIgnore("x/.env")).toBe(true);
|
|
30
30
|
expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
|
|
31
31
|
expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
|
|
32
|
+
// Cloud-only skill hero images (US-014) are an expected by-design exclusion.
|
|
33
|
+
expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
it("keeps nested repos/workspace and ordinary content noteworthy", () => {
|
|
@@ -138,7 +140,7 @@ describe("createIgnoreFilter", () => {
|
|
|
138
140
|
|
|
139
141
|
it("v12–v14 layout (no core/core.yaml): root core.yaml IS the manifest and MUST sync", () => {
|
|
140
142
|
// Regression for DEV-1729 / the ~98 "no core.yaml in vault, version
|
|
141
|
-
// undetectable"
|
|
143
|
+
// undetectable" sync users. On v12–v14 roots there is NO `core/core.yaml`
|
|
142
144
|
// — the ROOT `core.yaml` is the scaffold/version manifest. The old
|
|
143
145
|
// unconditional `/core.yaml` exclusion silently dropped it (while
|
|
144
146
|
// computePersonalVaultPaths still enumerated it), so the vault never
|
|
@@ -181,6 +183,21 @@ describe("createIgnoreFilter", () => {
|
|
|
181
183
|
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
182
184
|
});
|
|
183
185
|
|
|
186
|
+
it("permissive mode: .skill-media/ (cloud-only skill images) is ignored (US-014)", () => {
|
|
187
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
188
|
+
// Skill hero images are cloud-only — excluded from push AND pull at any depth.
|
|
189
|
+
expect(
|
|
190
|
+
shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/.skill-media/hero.png")),
|
|
191
|
+
).toBe(false);
|
|
192
|
+
expect(
|
|
193
|
+
shouldSync(path.join(hqRoot, "companies/indigo/skills/foo/.skill-media/cover.jpg")),
|
|
194
|
+
).toBe(false);
|
|
195
|
+
expect(shouldSync(path.join(hqRoot, "skills/bar/.skill-media"), true)).toBe(false);
|
|
196
|
+
// The skill's SKILL.md and other content still sync — the rule is scoped to
|
|
197
|
+
// the media dir only, never the skill.
|
|
198
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/SKILL.md"))).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
184
201
|
it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
|
|
185
202
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
186
203
|
// Internal state files that must never round-trip through the bucket.
|
package/src/ignore.ts
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* three exclusion layers still subtract on top — so even allowlisted
|
|
19
19
|
* subtrees won't push `node_modules/` or `.env`. Privacy-by-default for
|
|
20
20
|
* HQ trees that contain mixed personal + shareable data.
|
|
21
|
+
*
|
|
22
|
+
* SESSIONS ARE PUSH-ONLY, NOT PUSH-IGNORED (US-006). The company-vault
|
|
23
|
+
* `sessions/{personUid}/...` prefix (session transcripts) is intentionally NOT
|
|
24
|
+
* added to `DEFAULT_IGNORES`: push-only means it MUST still upload on the push
|
|
25
|
+
* walk. Its never-AUTO-pull semantics are enforced ONE layer over, in the pull
|
|
26
|
+
* scope resolver — `resolvePullScope().excludePrefixes` (src/sync/pull-scope.ts)
|
|
27
|
+
* subtracts `sessions/` from the effective pull scope in EVERY mode (including
|
|
28
|
+
* `all`), and `computePullPlan` classifies those keys `skip-out-of-scope`.
|
|
29
|
+
* Ignoring `sessions/` HERE would wrongly block the upload too, so the split is
|
|
30
|
+
* deliberate: this module governs the push walk; pull-scope governs never-pull.
|
|
21
31
|
*/
|
|
22
32
|
|
|
23
33
|
import * as fs from "fs";
|
|
@@ -93,7 +103,7 @@ export const DEFAULT_IGNORES = [
|
|
|
93
103
|
// `core.yaml` is a stale per-machine duplicate that must not round-trip; on
|
|
94
104
|
// v12–v14 roots there is NO `core/core.yaml` and the ROOT `core.yaml` IS the
|
|
95
105
|
// scaffold/version manifest, which MUST sync (else the vault has no version
|
|
96
|
-
// marker and the file is undetectable
|
|
106
|
+
// marker and the file is undetectable across user installs).
|
|
97
107
|
// hq modules manifest — local module-resolution state, never synced.
|
|
98
108
|
"modules/modules.yaml",
|
|
99
109
|
// per-company identity file — written locally on first sync, never round-tripped.
|
|
@@ -130,6 +140,16 @@ export const DEFAULT_IGNORES = [
|
|
|
130
140
|
"**/.claude/settings.json",
|
|
131
141
|
"**/.claude/settings.local.json",
|
|
132
142
|
|
|
143
|
+
// Skill hero images (skills-first-class US-014) — cloud-only artifacts stored
|
|
144
|
+
// under a skill's `.skill-media/` dir (e.g. `skills/<name>/.skill-media/hero.png`).
|
|
145
|
+
// They live in the vault/DB and are surfaced by the console, but must NEVER
|
|
146
|
+
// round-trip through the file sync onto agent machines: agents run the skill
|
|
147
|
+
// from SKILL.md and have no use for the image, and pulling it would litter
|
|
148
|
+
// every skill dir with binary blobs. Anchored `**/` so it matches at any depth
|
|
149
|
+
// (hqRoot skills, company skills, embedded knowledge repos) — excludes them
|
|
150
|
+
// from BOTH push and pull. Mirrors the `**/.claude/worktrees/` anchor shape.
|
|
151
|
+
"**/.skill-media/",
|
|
152
|
+
|
|
133
153
|
// HQ repos directory (managed separately, not synced). Root-anchored so
|
|
134
154
|
// only hqRoot/repos/ is excluded; bare `repos/` would match any nested
|
|
135
155
|
// directory named repos (for example companies/<co>/knowledge/repos/),
|
|
@@ -247,6 +267,8 @@ export const EXPECTED_IGNORE_SEGMENTS = new Set([
|
|
|
247
267
|
".tmp",
|
|
248
268
|
".hq",
|
|
249
269
|
".claude",
|
|
270
|
+
// Cloud-only skill hero images (US-014) — never synced onto agent machines.
|
|
271
|
+
".skill-media",
|
|
250
272
|
]);
|
|
251
273
|
|
|
252
274
|
/**
|
|
@@ -310,7 +332,7 @@ export function createIgnoreFilter(
|
|
|
310
332
|
// stale/duplicate per-machine marker, never the authoritative manifest.
|
|
311
333
|
// - v12–v14 (core/core.yaml absent): do NOT exclude root `core.yaml` — it
|
|
312
334
|
// IS the manifest and must round-trip, else the file is undetectable
|
|
313
|
-
//
|
|
335
|
+
// across user installs (the ~98 "no core.yaml in vault" users were all old-layout).
|
|
314
336
|
// Root-anchored (`/core.yaml`) so this never affects `core/core.yaml`.
|
|
315
337
|
if (fs.existsSync(path.join(hqRoot, "core", "core.yaml"))) {
|
|
316
338
|
ig.add("/core.yaml");
|