@indigoai-us/hq-cloud 6.3.6 → 6.5.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.
@@ -27,6 +27,7 @@ import {
27
27
  TOMBSTONE_TTL_MS,
28
28
  PERSONAL_VAULT_JOURNAL_SLUG,
29
29
  migratePersonalVaultJournal,
30
+ listJournals,
30
31
  } from "./journal.js";
31
32
  import type { SyncJournal, PullRecord } from "./types.js";
32
33
 
@@ -643,4 +644,123 @@ describe("journal", () => {
643
644
  expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
644
645
  });
645
646
  });
647
+
648
+ // Regression: `hq sync status` reported "No sync journal yet" right after a
649
+ // successful sync because it read one path keyed off the HQ-ROOT, while the
650
+ // engine writes slug-sharded journals (personal vault + per-company).
651
+ // `listJournals` is the all-shard enumeration the status surface now uses.
652
+ describe("listJournals", () => {
653
+ function seed(slug: string, files: SyncJournal["files"]): void {
654
+ writeJournal(slug, {
655
+ version: "2",
656
+ lastSync: "",
657
+ files,
658
+ pulls: [],
659
+ });
660
+ }
661
+
662
+ it("returns [] when the state dir has no journals (truly-empty case)", () => {
663
+ expect(listJournals()).toEqual([]);
664
+ });
665
+
666
+ it("returns [] when the state dir does not exist at all", () => {
667
+ process.env.HQ_STATE_DIR = path.join(stateDir, "does-not-exist");
668
+ expect(listJournals()).toEqual([]);
669
+ });
670
+
671
+ it("enumerates the personal-vault shard written by a `--personal` sync", () => {
672
+ seed(PERSONAL_VAULT_JOURNAL_SLUG, {
673
+ ".claude/skills/x/SKILL.md": {
674
+ hash: "a",
675
+ size: 10,
676
+ syncedAt: "2026-06-08T10:00:00.000Z",
677
+ direction: "up",
678
+ },
679
+ });
680
+
681
+ const all = listJournals();
682
+ expect(all).toHaveLength(1);
683
+ expect(all[0]!.slug).toBe(PERSONAL_VAULT_JOURNAL_SLUG);
684
+ expect(all[0]!.path).toBe(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG));
685
+ expect(Object.keys(all[0]!.journal.files)).toContain(
686
+ ".claude/skills/x/SKILL.md",
687
+ );
688
+ });
689
+
690
+ it("enumerates a per-company shard written by a company sync", () => {
691
+ seed("marshalops", {
692
+ "knowledge/a.md": {
693
+ hash: "b",
694
+ size: 20,
695
+ syncedAt: "2026-06-08T11:00:00.000Z",
696
+ direction: "down",
697
+ },
698
+ });
699
+
700
+ const all = listJournals();
701
+ expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
702
+ });
703
+
704
+ it("enumerates ALL shards together, sorted by slug", () => {
705
+ seed(PERSONAL_VAULT_JOURNAL_SLUG, {
706
+ ".claude/x": {
707
+ hash: "a",
708
+ size: 1,
709
+ syncedAt: "2026-06-08T10:00:00.000Z",
710
+ direction: "up",
711
+ },
712
+ });
713
+ seed("marshalops", {
714
+ "k/a": {
715
+ hash: "b",
716
+ size: 2,
717
+ syncedAt: "2026-06-08T11:00:00.000Z",
718
+ direction: "down",
719
+ },
720
+ });
721
+ seed("glazeymedia", {
722
+ "k/b": {
723
+ hash: "c",
724
+ size: 3,
725
+ syncedAt: "2026-06-08T12:00:00.000Z",
726
+ direction: "down",
727
+ },
728
+ });
729
+
730
+ const slugs = listJournals().map((j) => j.slug);
731
+ // Sorted: "__hq_personal_vault__" < "glazeymedia" < "marshalops".
732
+ expect(slugs).toEqual([
733
+ PERSONAL_VAULT_JOURNAL_SLUG,
734
+ "glazeymedia",
735
+ "marshalops",
736
+ ]);
737
+ });
738
+
739
+ it("skips a corrupt shard but still returns the healthy ones", () => {
740
+ seed("marshalops", {
741
+ "k/a": {
742
+ hash: "b",
743
+ size: 2,
744
+ syncedAt: "2026-06-08T11:00:00.000Z",
745
+ direction: "down",
746
+ },
747
+ });
748
+ fs.writeFileSync(
749
+ path.join(stateDir, "sync-journal.broken.json"),
750
+ "{ not valid json",
751
+ );
752
+
753
+ const all = listJournals();
754
+ expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
755
+ });
756
+
757
+ it("ignores unrelated files in the state dir", () => {
758
+ seed("marshalops", {});
759
+ fs.writeFileSync(path.join(stateDir, "config.json"), "{}");
760
+ fs.writeFileSync(path.join(stateDir, "sync-journal.json"), "{}"); // no slug
761
+ fs.writeFileSync(path.join(stateDir, "notes.txt"), "hi");
762
+
763
+ expect(listJournals().map((j) => j.slug)).toEqual(["marshalops"]);
764
+ });
765
+ });
646
766
  });
package/src/journal.ts CHANGED
@@ -135,6 +135,72 @@ export function readJournal(slug: string): SyncJournal {
135
135
  return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
136
136
  }
137
137
 
138
+ /** One enumerated journal shard: its recovered slug, on-disk path, contents. */
139
+ export interface JournalSummary {
140
+ /**
141
+ * Slug recovered from the `sync-journal.<slug>.json` filename — the
142
+ * sanitized form the engine wrote (e.g. a company slug,
143
+ * `PERSONAL_VAULT_JOURNAL_SLUG`, or the legacy `"personal"`).
144
+ */
145
+ slug: string;
146
+ /** Absolute path to the journal file. */
147
+ path: string;
148
+ /** Parsed journal contents. */
149
+ journal: SyncJournal;
150
+ }
151
+
152
+ /**
153
+ * Enumerate every sync journal present in the state dir.
154
+ *
155
+ * The engine SHARDS journals by slug (ADR-0001 Phase 5): the personal-vault
156
+ * fanout slot under `PERSONAL_VAULT_JOURNAL_SLUG`, one shard per cloud company,
157
+ * and the legacy `"personal"` shard. A caller that reads a single fixed path
158
+ * therefore only ever sees one scope — and a caller that mistakes a non-slug
159
+ * value for a slug (e.g. `hq sync status` passing the HQ-root PATH, which
160
+ * `sanitizeSlug` mangles into `_Users_<user>_hq`) sees a slug the engine never
161
+ * writes, and reports "no journal" right after a successful sync. Any surface
162
+ * that wants the COMPLETE local sync picture must read ALL shards via this
163
+ * helper rather than reconstructing a path.
164
+ *
165
+ * Slugs are recovered from each filename. A shard that fails to read or parse
166
+ * is skipped rather than thrown — one corrupt shard must not blind the caller
167
+ * to the healthy ones. Results are sorted by slug for deterministic output.
168
+ */
169
+ export function listJournals(): JournalSummary[] {
170
+ const dir = getStateDir();
171
+ let names: string[];
172
+ try {
173
+ names = fs.readdirSync(dir);
174
+ } catch {
175
+ return []; // state dir absent → no journals yet
176
+ }
177
+ const out: JournalSummary[] = [];
178
+ for (const name of names) {
179
+ if (
180
+ !name.startsWith(JOURNAL_FILE_PREFIX) ||
181
+ !name.endsWith(JOURNAL_FILE_SUFFIX)
182
+ ) {
183
+ continue;
184
+ }
185
+ const slug = name.slice(
186
+ JOURNAL_FILE_PREFIX.length,
187
+ name.length - JOURNAL_FILE_SUFFIX.length,
188
+ );
189
+ if (!slug) continue; // guard against a stray "sync-journal..json"
190
+ const filePath = path.join(dir, name);
191
+ try {
192
+ const journal = JSON.parse(
193
+ fs.readFileSync(filePath, "utf-8"),
194
+ ) as SyncJournal;
195
+ out.push({ slug, path: filePath, journal });
196
+ } catch {
197
+ // Corrupt/unreadable shard — skip; don't blind the caller to the rest.
198
+ }
199
+ }
200
+ out.sort((a, b) => a.slug.localeCompare(b.slug));
201
+ return out;
202
+ }
203
+
138
204
  /**
139
205
  * Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files`
140
206
  * map. Such clients stamped keys with the OS path separator ("\\"), e.g.