@openparachute/vault 0.6.5-rc.13 → 0.6.5-rc.15

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.
@@ -1,35 +1,46 @@
1
1
  /**
2
2
  * Content + registry pins for the named seed packs (core/src/seed-packs.ts).
3
3
  *
4
- * Pure content tests — no DB. The applier's behavior (idempotence, seeding)
5
- * is exercised store-level in src/onboarding-seed.test.ts and CLI-level in
6
- * src/add-pack.test.ts.
4
+ * Pure content tests — no DB. The applier's behavior (idempotence, seeding,
5
+ * tag/metadata write-through) is exercised store-level in
6
+ * src/onboarding-seed.test.ts and CLI-level in src/add-pack.test.ts.
7
7
  *
8
- * The load-bearing pin: the `welcome` pack's tags must stay byte-equal to
9
- * notes-ui's NOTES_REQUIRED_SCHEMA the Notes PWA's connect-time audit
10
- * compares `description` verbatim, and its schema banner only clears when the
11
- * tags genuinely carry the semantics Notes declares. ONE tag since 2026-07-03:
12
- * `#capture` only entry method (text|voice) is note `metadata.source`
13
- * provenance, not taxonomy (sibling notes-ui PR carries the client side).
8
+ * The load-bearing pins:
9
+ * - the `welcome` pack's tags must CONTAIN notes-ui's NOTES_REQUIRED_SCHEMA
10
+ * byte-for-byte the Notes PWA's connect-time audit compares `description`
11
+ * verbatim, and its schema banner only clears when the capture tag genuinely
12
+ * carries the semantics Notes declares. (Containment, not equality: the
13
+ * welcome pack now also ships the #guide + #pinned tags.)
14
+ * - `#guide` is the vault's skill-file tag, carrying a `written_for` enum
15
+ * schema (`ai` | `human` | `both`, `ai` first = default). Every seeded guide
16
+ * note is tagged `#guide` + `metadata.written_for`.
17
+ * - the five-guide welcome ring links into a connected web with no dangling
18
+ * wikilinks across the default seed.
14
19
  */
15
20
 
16
21
  import { describe, test, expect } from "bun:test";
17
22
  import {
18
23
  applySeedPack,
24
+ CAPTURE_ANYTHING_PATH,
19
25
  CONNECT_AI_PATH,
20
26
  GETTING_STARTED_CONTENT,
21
27
  GETTING_STARTED_PACK,
22
28
  GETTING_STARTED_PATH,
23
29
  getSeedPack,
30
+ GUIDE_TAG,
24
31
  listSeedPacks,
25
32
  NOTES_REQUIRED_TAGS,
33
+ PINNED_TAG,
26
34
  SEED_PACK_NAMES,
27
35
  SURFACE_STARTER_CONTENT,
28
36
  SURFACE_STARTER_PACK,
29
37
  SURFACE_STARTER_PATH,
30
- TRY_LINKING_PATH,
38
+ TAGS_GRAPH_PATH,
31
39
  WELCOME_PATH,
32
40
  welcomePack,
41
+ YOURS_TO_KEEP_PATH,
42
+ type SeedPack,
43
+ type SeedPackNote,
33
44
  } from "./seed-packs.ts";
34
45
  import * as onboardingShim from "./onboarding.ts";
35
46
 
@@ -47,17 +58,74 @@ const NOTES_UI_REQUIRED_SCHEMA_TAGS = [
47
58
  },
48
59
  ];
49
60
 
50
- describe("welcome pack — notes-ui schema parity", () => {
51
- test("welcome tags are byte-equal to notes-ui's NOTES_REQUIRED_SCHEMA", () => {
61
+ /**
62
+ * Extract every REAL `[[wikilink]]` target from note content — mirroring the
63
+ * store's parser (core/src/wikilinks.ts), which ignores wikilinks inside
64
+ * fenced/inline code. So an illustrative `[[wikilinks]]` in a code span (as in
65
+ * the Getting Started guide) is not counted — it never becomes a link.
66
+ */
67
+ function wikilinkTargets(content: string): string[] {
68
+ const stripped = content
69
+ .replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length)) // fenced code
70
+ .replace(/`[^`]*`/g, (m) => " ".repeat(m.length)); // inline code
71
+ return [...stripped.matchAll(/\[\[([^\]]+)\]\]/g)].map((m) => m[1]!);
72
+ }
73
+
74
+ /** A note in a pack, by path (fails loudly if absent). */
75
+ function noteAt(pack: SeedPack, path: string): SeedPackNote {
76
+ const note = pack.notes.find((n) => n.path === path);
77
+ if (!note) throw new Error(`no note at path ${path}`);
78
+ return note;
79
+ }
80
+
81
+ describe("guide tag — the vault's skill-file tag", () => {
82
+ test("GUIDE_TAG carries the written_for enum, ai first (= default)", () => {
83
+ expect(GUIDE_TAG.name).toBe("guide");
84
+ // The description's last sentence names the schema field.
85
+ expect(GUIDE_TAG.description).toContain(
86
+ "`written_for` says who a guide is written for.",
87
+ );
88
+ const wf = GUIDE_TAG.fields?.written_for;
89
+ expect(wf).toBeDefined();
90
+ expect(wf!.type).toBe("string");
91
+ expect(wf!.enum).toEqual(["ai", "human", "both"]);
92
+ // First enum value is the schema default — guides lean AI.
93
+ expect(wf!.enum![0]).toBe("ai");
94
+ });
95
+
96
+ test("PINNED_TAG is a plain identity tag (no schema)", () => {
97
+ expect(PINNED_TAG.name).toBe("pinned");
98
+ expect(PINNED_TAG.description).toBe(
99
+ "Notes pinned to the top of the Notes app.",
100
+ );
101
+ expect(PINNED_TAG.fields).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe("welcome pack — notes-ui schema parity (containment)", () => {
106
+ test("welcome tags CONTAIN notes-ui's NOTES_REQUIRED_SCHEMA byte-for-byte", () => {
107
+ // The independent copy still matches, and the welcome pack still ships it.
52
108
  expect(NOTES_REQUIRED_TAGS).toEqual(NOTES_UI_REQUIRED_SCHEMA_TAGS);
53
- expect(welcomePack().tags).toEqual(NOTES_UI_REQUIRED_SCHEMA_TAGS);
54
- // Field-level byte pins (toEqual ignores key order; these don't).
55
- for (const [i, expected] of NOTES_UI_REQUIRED_SCHEMA_TAGS.entries()) {
56
- expect(NOTES_REQUIRED_TAGS[i]!.name).toBe(expected.name);
57
- expect(NOTES_REQUIRED_TAGS[i]!.description).toBe(expected.description);
109
+ for (const req of NOTES_UI_REQUIRED_SCHEMA_TAGS) {
110
+ const found = welcomePack().tags.find((t) => t.name === req.name);
111
+ expect(found).toBeDefined();
112
+ // Field-level byte pins (toEqual ignores key order; these don't).
113
+ expect(found!.name).toBe(req.name);
114
+ expect(found!.description).toBe(req.description);
115
+ expect(found!.parent_names).toBeUndefined();
58
116
  }
59
117
  });
60
118
 
119
+ test("welcome tags = capture + guide + pinned (the guide/pinned tags ride along)", () => {
120
+ expect(welcomePack().tags.map((t) => t.name)).toEqual([
121
+ "capture",
122
+ "guide",
123
+ "pinned",
124
+ ]);
125
+ expect(welcomePack().tags).toContain(GUIDE_TAG);
126
+ expect(welcomePack().tags).toContain(PINNED_TAG);
127
+ });
128
+
61
129
  test("exactly ONE capture tag — no subtype tags, no parent_names (2026-07-03)", () => {
62
130
  // Entry method is metadata.source (text|voice), not taxonomy. The old
63
131
  // capture/text + capture/voice subtypes must not creep back into the seed
@@ -68,65 +136,146 @@ describe("welcome pack — notes-ui schema parity", () => {
68
136
  });
69
137
  });
70
138
 
71
- describe("welcome pack — person-voiced welcome web", () => {
72
- test("three notes forming a linked web (welcome ⇄ try-linking, connect-AI → welcome)", () => {
139
+ describe("welcome pack — the five-guide ring", () => {
140
+ test("five guides at the ratified paths, in order", () => {
73
141
  const pack = welcomePack();
74
142
  expect(pack.name).toBe("welcome");
75
143
  expect(pack.notes.map((n) => n.path)).toEqual([
76
144
  WELCOME_PATH,
77
- TRY_LINKING_PATH,
145
+ CAPTURE_ANYTHING_PATH,
146
+ TAGS_GRAPH_PATH,
78
147
  CONNECT_AI_PATH,
148
+ YOURS_TO_KEEP_PATH,
79
149
  ]);
80
-
81
- const [welcome, tryLinking, connectAi] = pack.notes;
82
- expect(welcome!.content).toContain(`[[${TRY_LINKING_PATH}]]`);
83
- expect(tryLinking!.content).toContain(`[[${WELCOME_PATH}]]`);
84
- expect(connectAi!.content).toContain(`[[${WELCOME_PATH}]]`);
85
150
  });
86
151
 
87
- test("content with consoleOrigin is byte-equal to the cloud's welcome copy", () => {
88
- // Pinned against parachute-cloud workers/vault/src/welcome.ts
89
- // `welcomeNotes(consoleOrigin)` — the sibling cloud PR swaps that module
90
- // for this pack, so the ported content must not drift.
91
- const origin = "https://cloud.parachute.computer";
92
- const pack = welcomePack({ consoleOrigin: origin });
152
+ test("the link web: welcome all four; 2→3→4→5→1; connect → Getting Started", () => {
153
+ const pack = welcomePack();
154
+
155
+ // Welcome wikilinks to all four siblings.
156
+ const welcomeLinks = wikilinkTargets(noteAt(pack, WELCOME_PATH).content);
157
+ for (const target of [
158
+ CAPTURE_ANYTHING_PATH,
159
+ TAGS_GRAPH_PATH,
160
+ CONNECT_AI_PATH,
161
+ YOURS_TO_KEEP_PATH,
162
+ ]) {
163
+ expect(welcomeLinks).toContain(target);
164
+ }
165
+
166
+ // The chain forward: 2→3, 3→4, 4→5, 5→1.
167
+ expect(wikilinkTargets(noteAt(pack, CAPTURE_ANYTHING_PATH).content)).toContain(
168
+ TAGS_GRAPH_PATH,
169
+ );
170
+ expect(wikilinkTargets(noteAt(pack, TAGS_GRAPH_PATH).content)).toContain(
171
+ CONNECT_AI_PATH,
172
+ );
173
+ expect(wikilinkTargets(noteAt(pack, CONNECT_AI_PATH).content)).toContain(
174
+ YOURS_TO_KEEP_PATH,
175
+ );
176
+ expect(wikilinkTargets(noteAt(pack, YOURS_TO_KEEP_PATH).content)).toContain(
177
+ WELCOME_PATH,
178
+ );
179
+
180
+ // Connect your AI points the human at the AI-facing Getting Started note.
181
+ expect(wikilinkTargets(noteAt(pack, CONNECT_AI_PATH).content)).toContain(
182
+ GETTING_STARTED_PATH,
183
+ );
184
+ });
93
185
 
94
- expect(pack.notes[0]!.content).toBe(`# ${WELCOME_PATH}
186
+ test("NO dangling wikilinks across the default seed (welcome + getting-started)", () => {
187
+ // Both packs auto-seed on create, so every [[target]] across their notes
188
+ // must resolve to a note one of them writes — including [[Getting Started]].
189
+ const seededPaths = new Set<string>([
190
+ ...welcomePack().notes.map((n) => n.path),
191
+ ...GETTING_STARTED_PACK.notes.map((n) => n.path),
192
+ ]);
193
+ for (const pack of [welcomePack(), GETTING_STARTED_PACK]) {
194
+ for (const note of pack.notes) {
195
+ for (const target of wikilinkTargets(note.content)) {
196
+ expect(seededPaths.has(target)).toBe(true);
197
+ }
198
+ }
199
+ }
200
+ });
95
201
 
96
- This vault is yours.
97
- Write anything.
98
- Notes can link to each other, like this: [[${TRY_LINKING_PATH}]].
99
- `);
100
- expect(pack.notes[1]!.content).toBe(`# ${TRY_LINKING_PATH}
202
+ test("every guide is tagged #guide with metadata.written_for = human", () => {
203
+ for (const note of welcomePack().notes) {
204
+ expect(note.tags).toContain("guide");
205
+ expect(note.metadata).toEqual({ written_for: "human" });
206
+ }
207
+ });
101
208
 
102
- Wrap a note's name in double square brackets to make a wikilink, like this one back to [[${WELCOME_PATH}]].
103
- `);
104
- expect(pack.notes[2]!.content).toBe(`# ${CONNECT_AI_PATH}
209
+ test("Welcome is the ONLY pinned note", () => {
210
+ const pinned = welcomePack().notes.filter((n) => n.tags?.includes("pinned"));
211
+ expect(pinned.map((n) => n.path)).toEqual([WELCOME_PATH]);
212
+ // Welcome carries both guide + pinned.
213
+ expect(noteAt(welcomePack(), WELCOME_PATH).tags).toEqual(["guide", "pinned"]);
214
+ });
105
215
 
106
- Your vault speaks MCP. Grab the connection URL from your console at ${origin}.
107
- Start from [[${WELCOME_PATH}]].
108
- `);
216
+ test("content anchors: the ratified copy is present (guards body drift)", () => {
217
+ const pack = welcomePack();
218
+ expect(noteAt(pack, WELCOME_PATH).content).toContain("This vault is yours.");
219
+ expect(noteAt(pack, WELCOME_PATH).content).toContain("tagged #guide");
220
+ expect(noteAt(pack, CAPTURE_ANYTHING_PATH).content).toContain(
221
+ "The one habit that makes a vault work",
222
+ );
223
+ expect(noteAt(pack, TAGS_GRAPH_PATH).content).toContain(
224
+ "Notes and links come first. Tags come later",
225
+ );
226
+ expect(noteAt(pack, YOURS_TO_KEEP_PATH).content).toContain(
227
+ "the moral center of the",
228
+ );
229
+ // Yours to keep carries the self-host export command (backtick-wrapped).
230
+ expect(noteAt(pack, YOURS_TO_KEEP_PATH).content).toContain(
231
+ "`parachute-vault export <dir>`",
232
+ );
109
233
  });
110
234
 
111
- test("without consoleOrigin the Connect-AI line stays generic (no baked origin)", () => {
112
- const connectAi = welcomePack().notes[2]!;
113
- expect(connectAi.content).toContain(
114
- "Your vault speaks MCP. Grab the connection URL from your console.",
235
+ test("consoleOrigin renders both modes on the Connect-your-AI note", () => {
236
+ const origin = "https://cloud.parachute.computer";
237
+
238
+ // With origin: the console sentence names it, no `undefined`, no `at .`.
239
+ const withOrigin = noteAt(welcomePack({ consoleOrigin: origin }), CONNECT_AI_PATH);
240
+ expect(withOrigin.content).toContain(
241
+ `Grab the connection URL from your console at ${origin}.`,
242
+ );
243
+ expect(withOrigin.content).not.toContain("undefined");
244
+
245
+ // Without origin: the line stays generic — no baked loopback, no `at .`.
246
+ const generic = noteAt(welcomePack(), CONNECT_AI_PATH);
247
+ expect(generic.content).toContain(
248
+ "Grab the connection URL from your console.",
115
249
  );
116
- expect(connectAi.content).not.toContain("undefined");
117
- expect(connectAi.content).not.toContain("at .");
250
+ expect(generic.content).not.toContain("undefined");
251
+ expect(generic.content).not.toContain("at .");
252
+ expect(generic.content).not.toContain(origin);
118
253
  });
119
254
  });
120
255
 
121
256
  describe("getting-started pack", () => {
122
- test("carries the doctrine note at the canonical path", () => {
257
+ test("carries the AI-facing guide note, tagged #guide / written_for ai", () => {
123
258
  expect(GETTING_STARTED_PACK.name).toBe("getting-started");
124
- expect(GETTING_STARTED_PACK.tags).toEqual([]);
259
+ // Declares the guide tag (self-sufficient — converges with the welcome pack).
260
+ expect(GETTING_STARTED_PACK.tags).toEqual([GUIDE_TAG]);
125
261
  expect(GETTING_STARTED_PACK.notes).toEqual([
126
- { path: GETTING_STARTED_PATH, content: GETTING_STARTED_CONTENT },
262
+ {
263
+ path: GETTING_STARTED_PATH,
264
+ tags: ["guide"],
265
+ metadata: { written_for: "ai" },
266
+ content: GETTING_STARTED_CONTENT,
267
+ },
127
268
  ]);
128
269
  });
129
270
 
271
+ test("the Getting Started body is byte-unchanged", () => {
272
+ // The AI-facing doctrine copy is not touched by the ring rewrite.
273
+ expect(noteAt(GETTING_STARTED_PACK, GETTING_STARTED_PATH).content).toBe(
274
+ GETTING_STARTED_CONTENT,
275
+ );
276
+ expect(GETTING_STARTED_CONTENT.startsWith("# Getting Started\n")).toBe(true);
277
+ });
278
+
130
279
  test("no dangling [[Surface Starter]] wikilink — points at the add-pack flow instead", () => {
131
280
  // Surface Starter is out of the default seed (ratified 2026-07-02), so the
132
281
  // default-seeded guide must not carry a wikilink to a note that doesn't
@@ -141,11 +290,16 @@ describe("getting-started pack", () => {
141
290
  });
142
291
 
143
292
  describe("surface-starter pack", () => {
144
- test("carries the surface guide at the canonical path (opt-in, not default)", () => {
293
+ test("carries the surface guide, tagged #guide / written_for ai (opt-in, not default)", () => {
145
294
  expect(SURFACE_STARTER_PACK.name).toBe("surface-starter");
146
- expect(SURFACE_STARTER_PACK.tags).toEqual([]);
295
+ expect(SURFACE_STARTER_PACK.tags).toEqual([GUIDE_TAG]);
147
296
  expect(SURFACE_STARTER_PACK.notes).toEqual([
148
- { path: SURFACE_STARTER_PATH, content: SURFACE_STARTER_CONTENT },
297
+ {
298
+ path: SURFACE_STARTER_PATH,
299
+ tags: ["guide"],
300
+ metadata: { written_for: "ai" },
301
+ content: SURFACE_STARTER_CONTENT,
302
+ },
149
303
  ]);
150
304
  expect(SURFACE_STARTER_PACK.description).toContain("not seeded by default");
151
305
  });