@openparachute/vault 0.6.5-rc.2 → 0.6.5

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 (70) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +1 -1
  6. package/core/src/notes.ts +33 -15
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/schema.ts +5 -5
  9. package/core/src/seed-packs.test.ts +357 -0
  10. package/core/src/seed-packs.ts +823 -0
  11. package/core/src/sql-in.test.ts +58 -0
  12. package/core/src/sql-in.ts +76 -0
  13. package/core/src/tag-hierarchy.ts +1 -1
  14. package/core/src/tag-schemas.ts +2 -2
  15. package/core/src/transcription/provider.ts +141 -0
  16. package/core/src/types.ts +1 -1
  17. package/core/src/vault-projection.ts +1 -1
  18. package/core/src/wikilinks.ts +10 -4
  19. package/package.json +1 -1
  20. package/src/add-pack.test.ts +142 -0
  21. package/src/admin-spa.ts +2 -1
  22. package/src/auth.ts +1 -1
  23. package/src/cli.ts +806 -7
  24. package/src/export-watch.ts +1 -1
  25. package/src/live-frame-parity.test.ts +201 -0
  26. package/src/module-manifest.ts +8 -0
  27. package/src/onboarding-seed.test.ts +188 -40
  28. package/src/onboarding-seed.ts +41 -46
  29. package/src/routes.ts +20 -3
  30. package/src/routing.test.ts +2 -2
  31. package/src/routing.ts +18 -6
  32. package/src/self-register.test.ts +19 -0
  33. package/src/self-register.ts +6 -1
  34. package/src/server.ts +133 -31
  35. package/src/services-manifest.ts +8 -0
  36. package/src/subscriptions.ts +100 -42
  37. package/src/tag-scope.ts +3 -3
  38. package/src/test-support/live-frame-corpus.ts +72 -0
  39. package/src/token-store.ts +2 -2
  40. package/src/transcription/build.test.ts +305 -0
  41. package/src/transcription/build.ts +332 -0
  42. package/src/transcription/capability.test.ts +118 -0
  43. package/src/transcription/capability.ts +96 -0
  44. package/src/transcription/download.test.ts +101 -0
  45. package/src/transcription/download.ts +71 -0
  46. package/src/transcription/install-python.test.ts +366 -0
  47. package/src/transcription/install-python.ts +471 -0
  48. package/src/transcription/install.test.ts +167 -0
  49. package/src/transcription/install.ts +296 -0
  50. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  51. package/src/transcription/providers/onnx-asr.ts +239 -0
  52. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  53. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  54. package/src/transcription/providers/scribe-http.test.ts +195 -0
  55. package/src/transcription/providers/scribe-http.ts +144 -0
  56. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  57. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  58. package/src/transcription/select.test.ts +299 -0
  59. package/src/transcription/select.ts +397 -0
  60. package/src/transcription/tiers.test.ts +197 -0
  61. package/src/transcription/tiers.ts +184 -0
  62. package/src/transcription-worker.test.ts +44 -0
  63. package/src/transcription-worker.ts +57 -122
  64. package/src/vault-create.test.ts +43 -10
  65. package/src/vault.test.ts +49 -1
  66. package/src/ws-server.ts +408 -0
  67. package/src/ws-subscribe.test.ts +474 -0
  68. package/src/ws-subscribe.ts +242 -0
  69. package/src/subscribe.test.ts +0 -609
  70. package/src/subscribe.ts +0 -248
@@ -10,7 +10,7 @@
10
10
  * cursor captured at the *start* of the previous cycle. Vault writes are
11
11
  * HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
12
12
  * is opaque), so polling on `updated_at >= cursor` is the simplest robust
13
- * detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
13
+ * detection. See `docs/contracts/vault-portable-export.md`.
14
14
  */
15
15
 
16
16
  import { ensureGitAvailable } from "./git-preflight.ts";
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
3
+ * self-host door).
4
+ *
5
+ * The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
6
+ * (mirrored byte-for-byte from the cloud door), the self-host WS message folds
7
+ * the event name into a `type` discriminator (plus the snapshot `done` chunk
8
+ * flag) over an inner payload (`notes` / `note` / `id`) that serializes
9
+ * byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
10
+ * are byte-shaped identical to cloud's, this transitively proves: self-host WS
11
+ * bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
12
+ * removed in Phase 5 — WebSocket is now the sole live transport, polling the
13
+ * client floor.)
14
+ *
15
+ * Plus unit coverage of the pure helpers that back the Bun.serve integration:
16
+ * snapshot chunking, first-message parsing, verb-rank, and the query rejects.
17
+ */
18
+ import { describe, it, expect } from "bun:test";
19
+ import { wsFrame, WsSink } from "./subscriptions.ts";
20
+ import {
21
+ buildSnapshotFrames,
22
+ parseClientMessage,
23
+ vaultVerbRank,
24
+ sameTagScope,
25
+ validateWsSubscribeQuery,
26
+ subscriptionCapResponse,
27
+ urlFromQuery,
28
+ SNAPSHOT_CHUNK_MAX_NOTES,
29
+ WS_CLOSE,
30
+ } from "./ws-subscribe.ts";
31
+ import { unsupportedSubscriptionReason } from "./live-match.ts";
32
+ import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
33
+
34
+ describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
35
+ for (const c of FRAME_CORPUS) {
36
+ it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
37
+ const ws = wsFrame(c.event, c.data);
38
+
39
+ // WS message is `{ type, ...data }` — the event name folds into `type`.
40
+ const parsedWs = JSON.parse(ws) as Record<string, unknown>;
41
+ expect(parsedWs.type).toBe(c.event);
42
+ const { type, ...rest } = parsedWs;
43
+ void type;
44
+ expect(rest).toEqual(c.data);
45
+
46
+ // BYTE parity of the inner payload: whatever value the event carries
47
+ // (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
48
+ // serialization (the cross-door invariant).
49
+ const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
50
+ const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
51
+ expect(ws.includes(innerBytes)).toBe(true);
52
+ });
53
+ }
54
+ });
55
+
56
+ describe("sink classes render through the ONE formatter", () => {
57
+ it("WsSink.send emits exactly wsFrame(event, data)", () => {
58
+ const sent: string[] = [];
59
+ const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
60
+ for (const c of FRAME_CORPUS) {
61
+ sink.send(c.event, c.data);
62
+ expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("buildSnapshotFrames — chunking + done flag", () => {
68
+ it("empty set → exactly one done:true frame", () => {
69
+ const frames = buildSnapshotFrames([]);
70
+ expect(frames.length).toBe(1);
71
+ expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
72
+ });
73
+
74
+ it("small set → one done:true frame carrying every note", () => {
75
+ const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
76
+ expect(frames.length).toBe(1);
77
+ const f = JSON.parse(frames[0]!);
78
+ expect(f.done).toBe(true);
79
+ expect(f.notes.length).toBe(2);
80
+ });
81
+
82
+ it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
83
+ const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
84
+ const frames = buildSnapshotFrames(many as any);
85
+ expect(frames.length).toBeGreaterThan(1);
86
+ const parsed = frames.map((f) => JSON.parse(f));
87
+ expect(parsed.filter((p) => p.done).length).toBe(1);
88
+ expect(parsed[parsed.length - 1]!.done).toBe(true);
89
+ parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
90
+ const concat = parsed.flatMap((p) => p.notes);
91
+ expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
92
+ });
93
+
94
+ it("each frame stays under the WS message ceiling with large notes", () => {
95
+ const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
96
+ const frames = buildSnapshotFrames(big as any);
97
+ expect(frames.length).toBeGreaterThan(1);
98
+ for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
99
+ expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
100
+ });
101
+ });
102
+
103
+ describe("parseClientMessage", () => {
104
+ it("valid auth", () => {
105
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
106
+ });
107
+ it("auth without a token → malformed", () => {
108
+ expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
109
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
110
+ });
111
+ it("non-json / non-object → malformed", () => {
112
+ expect(parseClientMessage("not json").kind).toBe("malformed");
113
+ expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
114
+ });
115
+ it("other typed message → other", () => {
116
+ expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
117
+ });
118
+ it("decodes a Uint8Array frame", () => {
119
+ const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
120
+ expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
121
+ });
122
+ });
123
+
124
+ describe("vaultVerbRank — narrow-or-equal ordering", () => {
125
+ it("ranks read < write < admin, -1 for none/other-vault", () => {
126
+ expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
127
+ expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
128
+ expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
129
+ expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
130
+ expect(vaultVerbRank([], "v")).toBe(-1);
131
+ });
132
+ it("a widen is strictly greater (the 4403 trigger)", () => {
133
+ expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
134
+ expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
139
+ it("both unscoped (null) → same", () => {
140
+ expect(sameTagScope(null, null)).toBe(true);
141
+ });
142
+ it("null vs scoped → differ (a drop/gain of scoping)", () => {
143
+ expect(sameTagScope(null, ["work/eng"])).toBe(false);
144
+ expect(sameTagScope(["work/eng"], null)).toBe(false);
145
+ });
146
+ it("same set (order/dupe-insensitive) → same", () => {
147
+ expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
148
+ expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
149
+ });
150
+ it("different membership → differ", () => {
151
+ expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
152
+ expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
157
+ const reject = async (q: string) => {
158
+ const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
159
+ expect("error" in v).toBe(true);
160
+ const body = await (v as { error: Response }).error.json();
161
+ expect((v as { error: Response }).error.status).toBe(400);
162
+ expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
163
+ };
164
+ it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
165
+ await reject("search=hi");
166
+ await reject("near[note_id]=abc");
167
+ await reject("cursor=xyz");
168
+ await reject("has_links=true");
169
+ });
170
+ it("accepts a supported query", () => {
171
+ const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
172
+ expect("queryOpts" in v).toBe(true);
173
+ });
174
+ it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
175
+ // The belt-and-suspenders layer both doors + the SSE route route through:
176
+ // a date filter isn't expressible as a flat URL param (removed 0.6.4), but
177
+ // if one ever reaches queryOpts it is rejected identically.
178
+ expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
179
+ expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
180
+ expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
181
+ expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
182
+ expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
183
+ });
184
+ });
185
+
186
+ describe("misc pure helpers", () => {
187
+ it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
188
+ const res = subscriptionCapResponse();
189
+ expect(res.status).toBe(503);
190
+ expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
191
+ });
192
+ it("urlFromQuery reconstructs searchParams from a raw query string", () => {
193
+ expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
194
+ expect(urlFromQuery("").search).toBe("");
195
+ });
196
+ it("WS_CLOSE carries the four application close codes", () => {
197
+ expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
198
+ 4400, 4401, 4403, 4408,
199
+ ]);
200
+ });
201
+ });
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
48
48
  readonly paths: readonly string[];
49
49
  readonly health: string;
50
50
  readonly stripPrefix?: boolean;
51
+ /**
52
+ * When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
53
+ * binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
54
+ * vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
55
+ * self-registered services.json row too (`ServiceEntry.websocket`); the hub
56
+ * honors either source. See `parachute-hub/src/ws-bridge.ts`.
57
+ */
58
+ readonly websocket?: boolean;
51
59
  }
52
60
 
53
61
  /**
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Unit tests for the onboarding-guide seeding (demo-prep Workstream A — A1/A2/A3).
2
+ * Unit tests for the default-pack seeding (originally demo-prep Workstream A —
3
+ * A1/A2/A3; reshaped for named seed packs).
3
4
  *
4
5
  * Store-direct (no CLI subprocess) so they're fast. Covers:
5
- * - A1: seedOnboardingNotes writes Getting Started + Surface Starter.
6
- * - A3: Surface Starter is linked from Getting Started (wikilink).
6
+ * - default seed = `welcome` (3-note web + the capture tag) + `getting-started`
7
+ * and NOT `surface-starter` (out of the default seed, ratified 2026-07-02).
8
+ * - A1: the Getting Started doctrine markers.
9
+ * - A3 (reshaped): the welcome web's wikilinks resolve; Getting Started
10
+ * points at the surface-starter PACK without a dangling wikilink.
7
11
  * - idempotency: a re-run never clobbers an edited note.
12
+ * - applySeedPack(surface-starter): the add-pack path works + is idempotent,
13
+ * and the guide carries the surface doctrine (GAP 1/2 markers).
8
14
  * - A2: the projection / vault-info pointer steers the AI at Getting Started.
9
15
  */
10
16
 
@@ -16,14 +22,33 @@ import { tmpdir } from "os";
16
22
  import { BunStore } from "./vault-store.ts";
17
23
  import { seedOnboardingNotes } from "./onboarding-seed.ts";
18
24
  import {
25
+ applySeedPack,
26
+ CAPTURE_ANYTHING_PATH,
27
+ CONNECT_AI_PATH,
19
28
  GETTING_STARTED_PATH,
29
+ NOTES_REQUIRED_TAGS,
30
+ SURFACE_STARTER_PACK,
20
31
  SURFACE_STARTER_PATH,
21
- } from "../core/src/onboarding.ts";
32
+ TAGS_GRAPH_PATH,
33
+ WELCOME_PATH,
34
+ YOURS_TO_KEEP_PATH,
35
+ } from "../core/src/seed-packs.ts";
22
36
  import {
23
37
  buildVaultProjection,
24
38
  projectionToMarkdown,
25
39
  } from "../core/src/vault-projection.ts";
26
40
 
41
+ /** Every note path the default seed writes on a blank vault (the five-guide
42
+ * welcome ring + the AI-facing Getting Started). */
43
+ const DEFAULT_SEED_PATHS = [
44
+ WELCOME_PATH,
45
+ CAPTURE_ANYTHING_PATH,
46
+ TAGS_GRAPH_PATH,
47
+ CONNECT_AI_PATH,
48
+ YOURS_TO_KEEP_PATH,
49
+ GETTING_STARTED_PATH,
50
+ ];
51
+
27
52
  let db: Database;
28
53
  let store: BunStore;
29
54
  let tmpDir: string;
@@ -40,14 +65,15 @@ afterEach(() => {
40
65
  rmSync(tmpDir, { recursive: true, force: true });
41
66
  });
42
67
 
43
- describe("seedOnboardingNotes (A1/A3)", () => {
44
- test("seeds Getting Started + Surface Starter on a blank vault", async () => {
68
+ describe("seedOnboardingNotes — default packs (welcome + getting-started)", () => {
69
+ test("seeds the welcome web + Getting Started on a blank vault — and NOT Surface Starter", async () => {
45
70
  const result = await seedOnboardingNotes(store);
46
- expect(result.seeded.sort()).toEqual(
47
- [GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
48
- );
71
+ expect(result.seeded.sort()).toEqual([...DEFAULT_SEED_PATHS].sort());
49
72
  expect(result.skipped).toEqual([]);
50
73
 
74
+ // Surface Starter is OUT of the default seed (button/CLI only).
75
+ expect(await store.getNoteByPath(SURFACE_STARTER_PATH)).toBeNull();
76
+
51
77
  const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
52
78
  expect(gs).not.toBeNull();
53
79
  expect(gs!.content).toContain("# Getting Started");
@@ -56,29 +82,75 @@ describe("seedOnboardingNotes (A1/A3)", () => {
56
82
  expect(gs!.content).toContain("Tags = types");
57
83
  expect(gs!.content).toContain("Paths = organization");
58
84
  expect(gs!.content).toContain("Schemas = typed metadata fields");
59
- expect(gs!.content).toContain("parachute-vault import");
85
+ // Data-in leads with the import UI + MCP bridge; the old `parachute-vault
86
+ // import` CLI line (a live bug on cloud) is gone (2026-07-06 rewrite).
87
+ expect(gs!.content).not.toContain("parachute-vault import");
88
+ expect(gs!.content).toContain("Bringing existing notes in");
60
89
  expect(gs!.content).toContain("Adapt this note");
61
90
 
62
- const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
63
- expect(ss).not.toBeNull();
64
- expect(ss!.content).toContain("# Surface Starter");
65
- // A3: the two surface packages by name.
66
- expect(ss!.content).toContain("@openparachute/surface-client");
67
- expect(ss!.content).toContain("@openparachute/surface-render");
68
- expect(ss!.content).toContain("createVaultSurface");
69
- expect(ss!.content).toContain("NoteRenderer");
91
+ // The welcome ring: five person-voiced guide notes.
92
+ for (const path of [
93
+ WELCOME_PATH,
94
+ CAPTURE_ANYTHING_PATH,
95
+ TAGS_GRAPH_PATH,
96
+ CONNECT_AI_PATH,
97
+ YOURS_TO_KEEP_PATH,
98
+ ]) {
99
+ expect(await store.getNoteByPath(path)).not.toBeNull();
100
+ }
101
+ });
70
102
 
71
- // GAP 2: warn that a surface can't be run/developed from this MCP session.
72
- expect(ss!.content).toContain("not from this");
73
- expect(ss!.content.toLowerCase()).toContain("browser");
103
+ test("seeds the capture tag Notes requires (byte-equal semantics) + the guide tag", async () => {
104
+ const result = await seedOnboardingNotes(store);
105
+ // welcome upserts [capture, guide]; getting-started re-upserts [guide]
106
+ // (converges). applySeedPack reports every declared tag per pack, so the
107
+ // concatenation carries guide twice — deterministic.
108
+ expect(result.tags).toEqual(["capture", "guide", "guide"]);
74
109
 
75
- // GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
76
- // present, verified against the surface-client / surface-render source.
77
- expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
78
- expect(ss!.content).toContain("getClient");
79
- expect(ss!.content).toContain("queryNotes");
80
- expect(ss!.content).toContain("handleCallback");
81
- expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
110
+ for (const decl of NOTES_REQUIRED_TAGS) {
111
+ const record = await store.getTagRecord(decl.name);
112
+ expect(record).not.toBeNull();
113
+ expect(record!.description).toBe(decl.description);
114
+ expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
115
+ }
116
+
117
+ // The guide tag lands with no metadata schema — guides are for you and your
118
+ // AI alike, plain markdown, so no per-guide audience field (written_for dropped).
119
+ const guide = await store.getTagRecord("guide");
120
+ expect(guide).not.toBeNull();
121
+ expect(guide!.fields?.written_for).toBeUndefined();
122
+ // #pinned is NOT seeded (dropped 2026-07-07 — did nothing visible on a fresh
123
+ // vault). Pinning still works when the user pins a note in the app.
124
+ expect(await store.getTagRecord("pinned")).toBeNull();
125
+
126
+ // The retired subtype tags are NOT seeded — entry method is note
127
+ // metadata.source (text|voice), not taxonomy (2026-07-03). Existing
128
+ // vaults carrying these tags stay valid; new vaults don't get them.
129
+ expect(await store.getTagRecord("capture/text")).toBeNull();
130
+ expect(await store.getTagRecord("capture/voice")).toBeNull();
131
+ });
132
+
133
+ test("seeded guides carry #guide (create-time tag write-through)", async () => {
134
+ await seedOnboardingNotes(store);
135
+
136
+ // Welcome guide: tagged guide (no longer pinned).
137
+ const welcome = await store.getNoteByPath(WELCOME_PATH);
138
+ expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide"]);
139
+
140
+ // The four sibling guides: guide only.
141
+ for (const path of [
142
+ CAPTURE_ANYTHING_PATH,
143
+ TAGS_GRAPH_PATH,
144
+ CONNECT_AI_PATH,
145
+ YOURS_TO_KEEP_PATH,
146
+ ]) {
147
+ const note = await store.getNoteByPath(path);
148
+ expect(note!.tags).toEqual(["guide"]);
149
+ }
150
+
151
+ // The AI-facing Getting Started guide: guide.
152
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
153
+ expect(gs!.tags).toEqual(["guide"]);
82
154
  });
83
155
 
84
156
  test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
@@ -98,29 +170,51 @@ describe("seedOnboardingNotes (A1/A3)", () => {
98
170
  expect(gs!.content).toContain("first listed value");
99
171
  });
100
172
 
101
- test("A3: Getting Started links to Surface Starter via a resolved wikilink", async () => {
173
+ test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
102
174
  await seedOnboardingNotes(store);
175
+
176
+ const welcome = await store.getNoteByPath(WELCOME_PATH);
177
+ const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
178
+ const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
179
+ const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
180
+ const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
103
181
  const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
104
- expect(gs!.content).toContain("[[Surface Starter]]");
105
182
 
106
- // Wikilink auto-resolution: Getting Started Surface Starter link row.
107
- const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
108
- const outbound = await store.getLinks(gs!.id, { direction: "outbound" });
109
- expect(outbound.some((l) => l.targetId === ss!.id)).toBe(true);
183
+ const outbound = async (id: string) =>
184
+ (await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
185
+
186
+ // Welcome all four siblings.
187
+ const welcomeOut = await outbound(welcome!.id);
188
+ for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
189
+ expect(welcomeOut).toContain(t);
190
+ }
191
+ // The chain forward: capture → tags → connect → yours → welcome.
192
+ expect(await outbound(capture!.id)).toContain(tags!.id);
193
+ expect(await outbound(tags!.id)).toContain(connectAi!.id);
194
+ const connectOut = await outbound(connectAi!.id);
195
+ expect(connectOut).toContain(yours!.id);
196
+ // Connect your AI also links the AI-facing Getting Started note (seeded by
197
+ // the getting-started pack) — the ring's cross-link resolves, no dangle.
198
+ expect(connectOut).toContain(gs!.id);
199
+ expect(await outbound(yours!.id)).toContain(welcome!.id);
200
+
201
+ // Getting Started mentions the surface-starter PACK — not a wikilink to a
202
+ // note that the default seed no longer creates.
203
+ expect(gs!.content).not.toContain("[[Surface Starter]]");
204
+ expect(gs!.content).toContain("add-pack");
110
205
  });
111
206
 
112
- test("idempotent: a second seed run skips both (does not duplicate)", async () => {
207
+ test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
113
208
  await seedOnboardingNotes(store);
114
209
  const second = await seedOnboardingNotes(store);
115
210
  expect(second.seeded).toEqual([]);
116
- expect(second.skipped.sort()).toEqual(
117
- [GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
118
- );
211
+ expect(second.skipped.sort()).toEqual([...DEFAULT_SEED_PATHS].sort());
119
212
 
120
213
  // Exactly one note per path — no duplicates.
121
214
  const all = await store.queryNotes({});
122
- expect(all.filter((n) => n.path === GETTING_STARTED_PATH)).toHaveLength(1);
123
- expect(all.filter((n) => n.path === SURFACE_STARTER_PATH)).toHaveLength(1);
215
+ for (const path of DEFAULT_SEED_PATHS) {
216
+ expect(all.filter((n) => n.path === path)).toHaveLength(1);
217
+ }
124
218
  });
125
219
 
126
220
  test("idempotent: never clobbers an operator-edited Getting Started", async () => {
@@ -139,6 +233,60 @@ describe("seedOnboardingNotes (A1/A3)", () => {
139
233
  });
140
234
  });
141
235
 
236
+ describe("applySeedPack — surface-starter via add-pack", () => {
237
+ test("applies the Surface Starter guide onto a default-seeded vault", async () => {
238
+ await seedOnboardingNotes(store);
239
+ const result = await applySeedPack(store, SURFACE_STARTER_PACK);
240
+ expect(result.pack).toBe("surface-starter");
241
+ expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
242
+ expect(result.skippedNotes).toEqual([]);
243
+ // surface-starter now declares GUIDE_TAG (it's a guide).
244
+ expect(result.tags).toEqual(["guide"]);
245
+
246
+ const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
247
+ expect(ss).not.toBeNull();
248
+ expect(ss!.content).toContain("# Surface Starter");
249
+ // A3: the two surface packages by name.
250
+ expect(ss!.content).toContain("@openparachute/surface-client");
251
+ expect(ss!.content).toContain("@openparachute/surface-render");
252
+ expect(ss!.content).toContain("createVaultSurface");
253
+ expect(ss!.content).toContain("NoteRenderer");
254
+
255
+ // GAP 2: warn that a surface can't be run/developed from this MCP session.
256
+ expect(ss!.content).toContain("not from this");
257
+ expect(ss!.content.toLowerCase()).toContain("browser");
258
+
259
+ // GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
260
+ // present, verified against the surface-client / surface-render source.
261
+ expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
262
+ expect(ss!.content).toContain("getClient");
263
+ expect(ss!.content).toContain("queryNotes");
264
+ expect(ss!.content).toContain("handleCallback");
265
+ expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
266
+
267
+ // Its [[Getting Started]] wikilink resolves against the default seed.
268
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
269
+ const outbound = await store.getLinks(ss!.id, { direction: "outbound" });
270
+ expect(outbound.some((l) => l.targetId === gs!.id)).toBe(true);
271
+ });
272
+
273
+ test("idempotent: a re-apply skips and never clobbers an edited note", async () => {
274
+ await applySeedPack(store, SURFACE_STARTER_PACK);
275
+ const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
276
+ const edited = "# Surface Starter\n\nWe built ours with Solid.";
277
+ await store.updateNote(ss!.id, { content: edited });
278
+
279
+ const rerun = await applySeedPack(store, SURFACE_STARTER_PACK);
280
+ expect(rerun.seededNotes).toEqual([]);
281
+ expect(rerun.skippedNotes).toEqual([SURFACE_STARTER_PATH]);
282
+
283
+ const after = await store.getNoteByPath(SURFACE_STARTER_PATH);
284
+ expect(after!.content).toBe(edited);
285
+ const all = await store.queryNotes({});
286
+ expect(all.filter((n) => n.path === SURFACE_STARTER_PATH)).toHaveLength(1);
287
+ });
288
+ });
289
+
142
290
  describe("vault-info / projection pointer (A2)", () => {
143
291
  test("projection carries getting_started when the note exists", async () => {
144
292
  // Absent before seeding → no pointer.
@@ -1,74 +1,69 @@
1
1
  /**
2
- * Seed the in-vault onboarding guide on vault creation.
2
+ * Seed the default packs on vault creation.
3
3
  *
4
- * A freshly-created vault gets a `Getting Started` note (AI-legible doctrine)
5
- * and a linked `Surface Starter` note, so a connected AI can read the guide and
6
- * help the operator set the vault upand keep growing it over time.
4
+ * A freshly-created vault gets the `welcome` pack (the five-guide welcome ring
5
+ * + the `capture` / `guide` / `pinned` tags the Notes surface expects) and
6
+ * the `getting-started` pack (the AI-legible start-here doctrine) so the
7
+ * first minute shows a small living graph, and a connected AI can read the
8
+ * guide and help the operator set the vault up.
7
9
  *
8
- * IDEMPOTENT + create-time only: each note is written ONLY when absent. A
9
- * re-init, restart, or re-run never clobbers a note the operator/AI has since
10
- * edited. Existing vaults are unaffected this runs on the create path, not as
11
- * a sweep over existing vaults.
10
+ * The `surface-starter` pack is deliberately NOT part of the default seed
11
+ * (ratified 2026-07-02): it's added on demand via
12
+ * `parachute-vault add-pack surface-starter` or a console affordance.
12
13
  *
13
- * Best-effort + non-fatal: seeding must NEVER fail a vault create. Any error is
14
- * caught and logged; the vault is still usable without the guide.
14
+ * IDEMPOTENT + create-time only: each note is written ONLY when absent (tag
15
+ * upserts converge on the same rows). A re-init, restart, or re-run never
16
+ * clobbers a note the operator/AI has since edited. Existing vaults are
17
+ * unaffected — this runs on the create path, not as a sweep over existing
18
+ * vaults.
15
19
  *
16
- * See the demo-prep Workstream A (A1/A2/A3).
20
+ * Best-effort + non-fatal: seeding must NEVER fail a vault create. Any error
21
+ * is caught and logged; the vault is still usable without the starter content.
22
+ *
23
+ * Pack contents live in core/src/seed-packs.ts (shared with the cloud vault).
17
24
  */
18
25
 
19
26
  import {
20
- GETTING_STARTED_PATH,
21
- GETTING_STARTED_CONTENT,
22
- SURFACE_STARTER_PATH,
23
- SURFACE_STARTER_CONTENT,
24
- } from "../core/src/onboarding.ts";
27
+ applySeedPack,
28
+ GETTING_STARTED_PACK,
29
+ welcomePack,
30
+ } from "../core/src/seed-packs.ts";
25
31
  import type { Store } from "../core/src/types.ts";
26
32
 
27
33
  interface SeedResult {
28
- /** Paths actually written this run (absent ones). Empty when both pre-existed. */
34
+ /** Note paths actually written this run (absent ones). Empty when all pre-existed. */
29
35
  seeded: string[];
30
- /** Paths skipped because the note already existed (idempotency). */
36
+ /** Note paths skipped because the note already existed (idempotency). */
31
37
  skipped: string[];
38
+ /** Tag names upserted (idempotent by nature). */
39
+ tags: string[];
32
40
  }
33
41
 
34
42
  /**
35
- * Write a single onboarding note only if no note already lives at `path`.
36
- * Returns true when it wrote (was absent), false when it skipped (present).
37
- */
38
- async function seedNoteIfAbsent(
39
- store: Store,
40
- path: string,
41
- content: string,
42
- ): Promise<boolean> {
43
- const existing = await store.getNoteByPath(path);
44
- if (existing) return false;
45
- await store.createNote(content, { path });
46
- return true;
47
- }
48
-
49
- /**
50
- * Seed the onboarding notes into `store`, idempotently. Surface Starter is
51
- * created first so the `[[Surface Starter]]` wikilink in Getting Started
52
- * resolves immediately (the create path also auto-resolves later, but this
53
- * keeps the link live from the first write).
43
+ * Seed the default packs (`welcome` + `getting-started`) into `store`,
44
+ * idempotently. Welcome first so the welcome web exists before the guide
45
+ * lands (wikilinks auto-resolve either way; this just keeps the seed's
46
+ * unresolved-link window minimal).
54
47
  */
55
48
  export async function seedOnboardingNotes(store: Store): Promise<SeedResult> {
56
49
  const seeded: string[] = [];
57
50
  const skipped: string[] = [];
51
+ const tags: string[] = [];
58
52
 
59
- for (const [path, content] of [
60
- [SURFACE_STARTER_PATH, SURFACE_STARTER_CONTENT],
61
- [GETTING_STARTED_PATH, GETTING_STARTED_CONTENT],
62
- ] as const) {
63
- const wrote = await seedNoteIfAbsent(store, path, content);
64
- (wrote ? seeded : skipped).push(path);
53
+ // No consoleOrigin: create-time runs are often pre-expose, and a baked-in
54
+ // loopback origin would go stale — the generic line is the safe default.
55
+ for (const pack of [welcomePack(), GETTING_STARTED_PACK]) {
56
+ const result = await applySeedPack(store, pack);
57
+ seeded.push(...result.seededNotes);
58
+ skipped.push(...result.skippedNotes);
59
+ tags.push(...result.tags);
65
60
  }
66
61
 
67
- return { seeded, skipped };
62
+ return { seeded, skipped, tags };
68
63
  }
69
64
 
70
65
  /**
71
- * Best-effort wrapper for the create path: seed the onboarding notes, swallow
66
+ * Best-effort wrapper for the create path: seed the default packs, swallow
72
67
  * and log any error so a seeding failure never fails the vault create.
73
68
  */
74
69
  export async function seedOnboardingNotesBestEffort(store: Store): Promise<void> {
@@ -76,7 +71,7 @@ export async function seedOnboardingNotesBestEffort(store: Store): Promise<void>
76
71
  await seedOnboardingNotes(store);
77
72
  } catch (err) {
78
73
  console.error(
79
- `Note: onboarding guide not seeded (${(err as Error)?.message ?? err}). ` +
74
+ `Note: starter content not seeded (${(err as Error)?.message ?? err}). ` +
80
75
  `The vault was still created; you can connect an AI and ask it to set things up.`,
81
76
  );
82
77
  }