@openparachute/vault 0.7.3-rc.3 → 0.7.3-rc.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.
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/package.json +1 -1
- package/src/add-pack.test.ts +32 -0
- package/src/cli.ts +5 -1
- package/src/onboarding-seed.test.ts +64 -0
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
import { describe, test, expect } from "bun:test";
|
|
22
22
|
import {
|
|
23
|
+
ALL_NOTES_VIEW_PATH,
|
|
23
24
|
applySeedPack,
|
|
25
|
+
ARCHIVE_VIEW_PATH,
|
|
26
|
+
ARCHIVED_TAG,
|
|
24
27
|
CAPTURE_ANYTHING_PATH,
|
|
25
28
|
CONNECT_AI_PATH,
|
|
26
29
|
DEFAULT_VAULT_DESCRIPTION,
|
|
@@ -33,11 +36,17 @@ import {
|
|
|
33
36
|
GUIDE_TAG,
|
|
34
37
|
listSeedPacks,
|
|
35
38
|
NOTES_REQUIRED_TAGS,
|
|
39
|
+
PINNED_TAG,
|
|
40
|
+
PINNED_VIEW_PATH,
|
|
41
|
+
RECENT_VIEW_PATH,
|
|
36
42
|
SEED_PACK_NAMES,
|
|
43
|
+
STARTER_ONTOLOGY_PACK,
|
|
37
44
|
SURFACE_STARTER_CONTENT,
|
|
38
45
|
SURFACE_STARTER_PACK,
|
|
39
46
|
SURFACE_STARTER_PATH,
|
|
40
47
|
TAGS_GRAPH_PATH,
|
|
48
|
+
VIEW_TAG,
|
|
49
|
+
VIEWS_PATH_PREFIX,
|
|
41
50
|
WELCOME_PATH,
|
|
42
51
|
welcomePack,
|
|
43
52
|
YOURS_TO_KEEP_PATH,
|
|
@@ -335,12 +344,119 @@ describe("surface-starter pack", () => {
|
|
|
335
344
|
});
|
|
336
345
|
});
|
|
337
346
|
|
|
347
|
+
describe("starter-ontology pack", () => {
|
|
348
|
+
test("exactly four tags, in constitution order: view, archived, pinned, capture", () => {
|
|
349
|
+
expect(STARTER_ONTOLOGY_PACK.name).toBe("starter-ontology");
|
|
350
|
+
expect(STARTER_ONTOLOGY_PACK.tags.map((t) => t.name)).toEqual([
|
|
351
|
+
"view",
|
|
352
|
+
"archived",
|
|
353
|
+
"pinned",
|
|
354
|
+
"capture",
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("opt-in, not seeded by default", () => {
|
|
359
|
+
expect(STARTER_ONTOLOGY_PACK.description).toContain("Opt-in — not seeded by default.");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("view is the one meta tag — carries the kind/query/lane_by/date_field schema", () => {
|
|
363
|
+
expect(VIEW_TAG.name).toBe("view");
|
|
364
|
+
expect(VIEW_TAG.description).toContain("meta tag");
|
|
365
|
+
expect(VIEW_TAG.fields).toBeDefined();
|
|
366
|
+
expect(Object.keys(VIEW_TAG.fields!)).toEqual([
|
|
367
|
+
"kind",
|
|
368
|
+
"query",
|
|
369
|
+
"lane_by",
|
|
370
|
+
"date_field",
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
const kind = VIEW_TAG.fields!.kind!;
|
|
374
|
+
expect(kind.type).toBe("string");
|
|
375
|
+
expect(kind.enum).toEqual(["list", "board", "calendar", "gallery"]);
|
|
376
|
+
expect(kind.default).toBe("list");
|
|
377
|
+
|
|
378
|
+
const query = VIEW_TAG.fields!.query!;
|
|
379
|
+
expect(query.type).toBe("string");
|
|
380
|
+
|
|
381
|
+
// lane_by / date_field are optional (no default — absent stays absent).
|
|
382
|
+
expect(VIEW_TAG.fields!.lane_by!.default).toBeUndefined();
|
|
383
|
+
expect(VIEW_TAG.fields!.date_field!.default).toBeUndefined();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("archived + pinned are plain tags — no schema", () => {
|
|
387
|
+
expect(ARCHIVED_TAG.name).toBe("archived");
|
|
388
|
+
expect(ARCHIVED_TAG.fields).toBeUndefined();
|
|
389
|
+
expect(ARCHIVED_TAG.description).toContain("out of the flow of the present");
|
|
390
|
+
|
|
391
|
+
expect(PINNED_TAG.name).toBe("pinned");
|
|
392
|
+
expect(PINNED_TAG.fields).toBeUndefined();
|
|
393
|
+
expect(PINNED_TAG.description).toContain("partition");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("capture is reused verbatim from NOTES_REQUIRED_TAGS — byte-equal, no drift risk", () => {
|
|
397
|
+
const capture = STARTER_ONTOLOGY_PACK.tags.find((t) => t.name === "capture");
|
|
398
|
+
expect(capture).toBe(NOTES_REQUIRED_TAGS[0]); // SAME object reference, not just equal content
|
|
399
|
+
expect(capture!.description).toBe(NOTES_REQUIRED_TAGS[0]!.description);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("four seed #view notes mirroring the app's default pages", () => {
|
|
403
|
+
expect(STARTER_ONTOLOGY_PACK.notes.map((n) => n.path)).toEqual([
|
|
404
|
+
ALL_NOTES_VIEW_PATH,
|
|
405
|
+
RECENT_VIEW_PATH,
|
|
406
|
+
PINNED_VIEW_PATH,
|
|
407
|
+
ARCHIVE_VIEW_PATH,
|
|
408
|
+
]);
|
|
409
|
+
for (const note of STARTER_ONTOLOGY_PACK.notes) {
|
|
410
|
+
expect(note.path.startsWith(VIEWS_PATH_PREFIX)).toBe(true);
|
|
411
|
+
expect(note.tags).toEqual(["view"]);
|
|
412
|
+
expect(note.metadata?.kind).toBe("list");
|
|
413
|
+
expect(typeof note.metadata?.query).toBe("string");
|
|
414
|
+
// Every seeded query must itself be valid JSON — a downstream reader
|
|
415
|
+
// parses it as query-notes params.
|
|
416
|
+
expect(() => JSON.parse(note.metadata!.query as string)).not.toThrow();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("All notes / Recent exclude archived explicitly (authoring-time default, not magic)", () => {
|
|
421
|
+
for (const path of [ALL_NOTES_VIEW_PATH, RECENT_VIEW_PATH]) {
|
|
422
|
+
const note = noteAt(STARTER_ONTOLOGY_PACK, path);
|
|
423
|
+
const query = JSON.parse(note.metadata!.query as string);
|
|
424
|
+
expect(query.exclude_tags).toEqual(["archived"]);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("Recent orders by updated_at desc", () => {
|
|
429
|
+
const query = JSON.parse(noteAt(STARTER_ONTOLOGY_PACK, RECENT_VIEW_PATH).metadata!.query as string);
|
|
430
|
+
expect(query.order_by).toBe("updated_at");
|
|
431
|
+
expect(query.sort).toBe("desc");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("Pinned queries tag:pinned; Archive queries tag:archived (the deliberate exception to exclude_tags)", () => {
|
|
435
|
+
const pinnedQuery = JSON.parse(noteAt(STARTER_ONTOLOGY_PACK, PINNED_VIEW_PATH).metadata!.query as string);
|
|
436
|
+
expect(pinnedQuery.tag).toBe("pinned");
|
|
437
|
+
|
|
438
|
+
const archiveQuery = JSON.parse(noteAt(STARTER_ONTOLOGY_PACK, ARCHIVE_VIEW_PATH).metadata!.query as string);
|
|
439
|
+
expect(archiveQuery.tag).toBe("archived");
|
|
440
|
+
expect(archiveQuery.exclude_tags).toBeUndefined();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("no dangling wikilinks within the pack", () => {
|
|
444
|
+
const seededPaths = new Set(STARTER_ONTOLOGY_PACK.notes.map((n) => n.path));
|
|
445
|
+
for (const note of STARTER_ONTOLOGY_PACK.notes) {
|
|
446
|
+
for (const target of wikilinkTargets(note.content)) {
|
|
447
|
+
expect(seededPaths.has(target)).toBe(true);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
338
453
|
describe("pack registry", () => {
|
|
339
|
-
test("lists exactly the
|
|
454
|
+
test("lists exactly the four packs, in order", () => {
|
|
340
455
|
expect([...SEED_PACK_NAMES]).toEqual([
|
|
341
456
|
"welcome",
|
|
342
457
|
"getting-started",
|
|
343
458
|
"surface-starter",
|
|
459
|
+
"starter-ontology",
|
|
344
460
|
]);
|
|
345
461
|
expect(listSeedPacks().map((p) => p.name)).toEqual([...SEED_PACK_NAMES]);
|
|
346
462
|
for (const p of listSeedPacks()) {
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* surface (UI) over the vault. NOT default-seeded (ratified 2026-07-02:
|
|
15
15
|
* Surface Starter is out of the default seed) — added on demand via
|
|
16
16
|
* `parachute-vault add-pack surface-starter` or a console affordance.
|
|
17
|
+
* - `starter-ontology` — four starter **meta tags** (`view`, `archived`,
|
|
18
|
+
* `pinned`, `capture`) with constitution-style descriptions, plus seed
|
|
19
|
+
* `#view` notes mirroring the app's default pages (All notes, Recent,
|
|
20
|
+
* Pinned, Archive). NOT default-seeded (ratified 2026-07-16 design
|
|
21
|
+
* dialogue — deliberately opt-in; a fresh-vault materialization change
|
|
22
|
+
* needs its own decision) — added on demand via `parachute-vault
|
|
23
|
+
* add-pack starter-ontology` or a console affordance.
|
|
17
24
|
*
|
|
18
25
|
* Guides are the vault's skill files — the Parachute equivalent of a
|
|
19
26
|
* `SKILL.md`. They're tagged `#guide` (GUIDE_TAG): notes that teach how this
|
|
@@ -798,6 +805,172 @@ export const SURFACE_STARTER_PACK: SeedPack = {
|
|
|
798
805
|
],
|
|
799
806
|
};
|
|
800
807
|
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// `starter-ontology` pack — four starter meta tags (opt-in)
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The starter ontology, ratified in the 2026-07-16 design dialogue (Letter 1,
|
|
814
|
+
* Q5: "yes — tiny, opinionated, removable"): exactly four tags, no more.
|
|
815
|
+
* "Really want to start simple" / "don't be too prescriptive" — the vault
|
|
816
|
+
* ships the mechanisms; each parachute writes its own rules on top.
|
|
817
|
+
*
|
|
818
|
+
* A **meta tag** (the ratified user-facing term for "a tag with a schema")
|
|
819
|
+
* is `view` alone here — it carries `fields`. `archived`, `pinned`, and
|
|
820
|
+
* `capture` are plain tags: no schema, just a description that teaches the
|
|
821
|
+
* convention. Every description below is written as a constitution — it's
|
|
822
|
+
* read by both a human deciding whether to use the tag and an agent deciding
|
|
823
|
+
* how to behave around it — plain and warm, describing the DEFAULT
|
|
824
|
+
* convention rather than dictating one ("different people will draw that
|
|
825
|
+
* line differently").
|
|
826
|
+
*
|
|
827
|
+
* `capture` is NOT redeclared with new text — it's the exact `NOTES_REQUIRED_TAGS`
|
|
828
|
+
* entry, reused verbatim, because notes-ui's connect-time schema audit
|
|
829
|
+
* compares its `description` byte-for-byte (see the comment on
|
|
830
|
+
* `NOTES_REQUIRED_TAGS` above). Applying this pack must never be able to
|
|
831
|
+
* drift that string out from under the welcome pack, in either apply order.
|
|
832
|
+
*
|
|
833
|
+
* `pinned` shipped once before and was dropped as vestigial (PR #547,
|
|
834
|
+
* 2026-07-07): it was seeded onto `Welcome` with old sort-first semantics,
|
|
835
|
+
* which did nothing visible on a fresh vault (the Notes app had no list
|
|
836
|
+
* badge, and Welcome was already first). This is a different tag: the
|
|
837
|
+
* ratified semantics are partition-not-sort, it isn't stamped onto any
|
|
838
|
+
* existing note, and the pack ships a `Pinned` view that actually surfaces
|
|
839
|
+
* it.
|
|
840
|
+
*/
|
|
841
|
+
export const VIEW_TAG: SeedPackTag = {
|
|
842
|
+
name: "view",
|
|
843
|
+
description:
|
|
844
|
+
'A meta tag — it carries a schema, which is what makes a #view note a saved view rather than just a label. Tag a note #view and its metadata becomes the definition: `query` is the saved query itself (the same JSON shape query-notes accepts — tag, exclude_tags, search, metadata, order_by, ...), and `kind` says how to render the result (list, board, calendar, or gallery; an unrecognized or missing kind should degrade to list rather than fail — a view is never wrong to render as a list). `lane_by` names the metadata field a board\'s lanes come from; `date_field` names the date-typed field a calendar plots against — both only meaningful for their matching `kind`, and unset otherwise. The note\'s body is for people: what this view is for, when to reach for it. Because the definition lives in the vault as an ordinary note, any surface can read it and render the same view, and any agent can write one — "make me a board of my active drafts" is just creating a note. Authoring convention worth keeping: most views should write `exclude_tags: ["archived"]` into their own query rather than leaning on some surface-side default — an Archive view is the deliberate exception.',
|
|
845
|
+
fields: {
|
|
846
|
+
kind: {
|
|
847
|
+
type: "string",
|
|
848
|
+
description:
|
|
849
|
+
"How to render this view. Unrecognized or absent degrades to list — a view is never wrong to render as a list.",
|
|
850
|
+
enum: ["list", "board", "calendar", "gallery"],
|
|
851
|
+
default: "list",
|
|
852
|
+
},
|
|
853
|
+
query: {
|
|
854
|
+
type: "string",
|
|
855
|
+
description:
|
|
856
|
+
"The saved query — a JSON string of query-notes parameters (tag, exclude_tags, search, metadata, order_by, sort, ...). This is what actually determines which notes the view shows; kind only says how to draw them.",
|
|
857
|
+
},
|
|
858
|
+
lane_by: {
|
|
859
|
+
type: "string",
|
|
860
|
+
description:
|
|
861
|
+
"Board views only: the metadata field whose distinct values become the board's lanes/columns. Unset outside kind: board.",
|
|
862
|
+
},
|
|
863
|
+
date_field: {
|
|
864
|
+
type: "string",
|
|
865
|
+
description:
|
|
866
|
+
"Calendar views only: the date-typed metadata field the calendar plots notes against. Unset outside kind: calendar.",
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
export const ARCHIVED_TAG: SeedPackTag = {
|
|
872
|
+
name: "archived",
|
|
873
|
+
description:
|
|
874
|
+
"A plain tag — no schema, just a convention: this note is out of the flow of the present. Not deleted, not wrong, just done, closed, or set aside for now. The one behavior worth relying on: default views exclude archived notes unless a view's own query says otherwise (an Archive view, for instance, asks for them back on purpose). Archiving a note doesn't touch its content, tags, or links — it only changes whether it shows up by default. What actually gets archived, and when, is yours to decide: a finished project, last month's drafts, a conversation that's resolved. Different people will draw that line differently, and that's fine — the tag only promises the one thing above.",
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
export const PINNED_TAG: SeedPackTag = {
|
|
878
|
+
name: "pinned",
|
|
879
|
+
description:
|
|
880
|
+
"A plain tag — no schema, just a convention: this note gets surfaced first. Pinning is a partition, not a sort — a view that honors it renders pinned notes as their own small group above everything else, rather than nudging them up the existing order. That also means pinning doesn't fight with however the rest of a view is sorted; it just carves out a \"see this first\" spot above it. There's no built-in limit on how many notes can be pinned, and no rule about what belongs there — a current focus, something you keep needing to find, a note you're mid-thought on. Unpin by removing the tag. Different people, and different views, will use it differently; the tag only promises the placement, not the policy.",
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
export const VIEWS_PATH_PREFIX = "Views/";
|
|
884
|
+
export const ALL_NOTES_VIEW_PATH = `${VIEWS_PATH_PREFIX}All notes`;
|
|
885
|
+
export const RECENT_VIEW_PATH = `${VIEWS_PATH_PREFIX}Recent`;
|
|
886
|
+
export const PINNED_VIEW_PATH = `${VIEWS_PATH_PREFIX}Pinned`;
|
|
887
|
+
export const ARCHIVE_VIEW_PATH = `${VIEWS_PATH_PREFIX}Archive`;
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Build the `starter-ontology` pack: the four meta tags above, plus four
|
|
891
|
+
* seed `#view` notes mirroring the app's default pages — All notes, Recent,
|
|
892
|
+
* Pinned, Archive — so "default app pages are shipped views" has its data
|
|
893
|
+
* half from day one. Each view's `query` is written out explicitly
|
|
894
|
+
* (`exclude_tags: ["archived"]` where it applies) rather than relying on any
|
|
895
|
+
* surface-side default, per the authoring convention in `VIEW_TAG`'s own
|
|
896
|
+
* description. Kept tiny on purpose — these are starting points, not a
|
|
897
|
+
* demonstration of everything the schema can do.
|
|
898
|
+
*
|
|
899
|
+
* NOT applied by `src/onboarding-seed.ts`'s default materialization — opt-in
|
|
900
|
+
* only, via `parachute-vault add-pack starter-ontology` or a console
|
|
901
|
+
* affordance. A fresh-vault UX change (making this part of the default seed)
|
|
902
|
+
* is a separate decision; this pack existing makes that a one-line flip
|
|
903
|
+
* later, deliberately not taken here.
|
|
904
|
+
*/
|
|
905
|
+
export const STARTER_ONTOLOGY_PACK: SeedPack = {
|
|
906
|
+
name: "starter-ontology",
|
|
907
|
+
description:
|
|
908
|
+
"The starter ontology: one meta tag (view, with a schema) and three plain tags (archived, pinned, capture), each with a constitution-style description — plus four seed #view notes (All notes, Recent, Pinned, Archive) mirroring the app's default pages. Opt-in — not seeded by default.",
|
|
909
|
+
// `capture` reused verbatim from NOTES_REQUIRED_TAGS (byte-equal to the
|
|
910
|
+
// welcome pack's — see the module doc above and NOTES_REQUIRED_TAGS'
|
|
911
|
+
// comment). Order matches the constitution ordering, not upsert order.
|
|
912
|
+
tags: [VIEW_TAG, ARCHIVED_TAG, PINNED_TAG, ...NOTES_REQUIRED_TAGS],
|
|
913
|
+
notes: [
|
|
914
|
+
{
|
|
915
|
+
path: ALL_NOTES_VIEW_PATH,
|
|
916
|
+
tags: ["view"],
|
|
917
|
+
content: `# All notes
|
|
918
|
+
|
|
919
|
+
Everything in the vault except what's archived. The default view — no tag
|
|
920
|
+
filter, no sort override, just the whole thing minus the notes you've set
|
|
921
|
+
aside.
|
|
922
|
+
`,
|
|
923
|
+
metadata: {
|
|
924
|
+
kind: "list",
|
|
925
|
+
query: JSON.stringify({ exclude_tags: ["archived"] }),
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
path: RECENT_VIEW_PATH,
|
|
930
|
+
tags: ["view"],
|
|
931
|
+
content: `# Recent
|
|
932
|
+
|
|
933
|
+
The vault, newest edits first. Same notes as [[${ALL_NOTES_VIEW_PATH}]] —
|
|
934
|
+
archived excluded — just ordered by when they last changed.
|
|
935
|
+
`,
|
|
936
|
+
metadata: {
|
|
937
|
+
kind: "list",
|
|
938
|
+
query: JSON.stringify({
|
|
939
|
+
exclude_tags: ["archived"],
|
|
940
|
+
order_by: "updated_at",
|
|
941
|
+
sort: "desc",
|
|
942
|
+
}),
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
path: PINNED_VIEW_PATH,
|
|
947
|
+
tags: ["view"],
|
|
948
|
+
content: `# Pinned
|
|
949
|
+
|
|
950
|
+
Whatever you've marked #pinned — the notes surfaced first, regardless of how
|
|
951
|
+
anything else in the vault is organized.
|
|
952
|
+
`,
|
|
953
|
+
metadata: {
|
|
954
|
+
kind: "list",
|
|
955
|
+
query: JSON.stringify({ tag: "pinned" }),
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
path: ARCHIVE_VIEW_PATH,
|
|
960
|
+
tags: ["view"],
|
|
961
|
+
content: `# Archive
|
|
962
|
+
|
|
963
|
+
Everything tagged #archived — out of the flow of the present, but never
|
|
964
|
+
gone. The one view that asks archived notes back on purpose.
|
|
965
|
+
`,
|
|
966
|
+
metadata: {
|
|
967
|
+
kind: "list",
|
|
968
|
+
query: JSON.stringify({ tag: "archived" }),
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
],
|
|
972
|
+
};
|
|
973
|
+
|
|
801
974
|
// ---------------------------------------------------------------------------
|
|
802
975
|
// Registry
|
|
803
976
|
// ---------------------------------------------------------------------------
|
|
@@ -807,6 +980,7 @@ export const SEED_PACK_NAMES = [
|
|
|
807
980
|
"welcome",
|
|
808
981
|
"getting-started",
|
|
809
982
|
"surface-starter",
|
|
983
|
+
"starter-ontology",
|
|
810
984
|
] as const;
|
|
811
985
|
|
|
812
986
|
export type SeedPackName = (typeof SEED_PACK_NAMES)[number];
|
|
@@ -835,6 +1009,8 @@ export function getSeedPack(
|
|
|
835
1009
|
return GETTING_STARTED_PACK;
|
|
836
1010
|
case "surface-starter":
|
|
837
1011
|
return SURFACE_STARTER_PACK;
|
|
1012
|
+
case "starter-ontology":
|
|
1013
|
+
return STARTER_ONTOLOGY_PACK;
|
|
838
1014
|
default:
|
|
839
1015
|
return null;
|
|
840
1016
|
}
|
|
@@ -849,6 +1025,14 @@ export interface ApplySeedPackResult {
|
|
|
849
1025
|
pack: string;
|
|
850
1026
|
/** Tag names upserted (upserts are idempotent; always every declared tag). */
|
|
851
1027
|
tags: string[];
|
|
1028
|
+
/**
|
|
1029
|
+
* Subset of `tags` whose DESCRIPTION was left untouched this run because it
|
|
1030
|
+
* had been hand-edited away from the pack's canonical text (see
|
|
1031
|
+
* `applySeedPack`'s description-preservation rule below). `fields` /
|
|
1032
|
+
* `parent_names` / `relationships` are still upserted unconditionally for
|
|
1033
|
+
* these tags — only the description write was skipped.
|
|
1034
|
+
*/
|
|
1035
|
+
preservedTagDescriptions: string[];
|
|
852
1036
|
/** Note paths written this run (absent ones). */
|
|
853
1037
|
seededNotes: string[];
|
|
854
1038
|
/** Note paths skipped because a note already lives there (idempotency). */
|
|
@@ -861,6 +1045,30 @@ export interface ApplySeedPackResult {
|
|
|
861
1045
|
* rows — so a re-run can never duplicate, and never clobbers a note the
|
|
862
1046
|
* operator/AI has since edited or recreated.
|
|
863
1047
|
*
|
|
1048
|
+
* **Tag description preservation** (Aaron-ratified 2026-07-17): a pack writes
|
|
1049
|
+
* a tag's `description` only when (a) the tag has no prior description (new
|
|
1050
|
+
* tag, or an existing bare row nothing ever described), or (b) the prior
|
|
1051
|
+
* description is still byte-identical to the pack's own text — i.e. no one
|
|
1052
|
+
* has touched it. The moment a description differs from the pack's current
|
|
1053
|
+
* text, it's treated as a deliberate hand edit (a user-tuned "constitution")
|
|
1054
|
+
* and is never overwritten by a re-apply; omitting the `description` key from
|
|
1055
|
+
* the `upsertTagRecord` patch is what preserves it (the store keeps whatever
|
|
1056
|
+
* is already there). `fields` / `parent_names` / `relationships` are NOT
|
|
1057
|
+
* given this treatment here — they still upsert unconditionally, same as
|
|
1058
|
+
* before this change; those axes are more entangled with schema-validation
|
|
1059
|
+
* side effects (indexed-column lifecycle, cross-tag type agreement) to
|
|
1060
|
+
* safely split out in this pass. Description-only is the ratified scope.
|
|
1061
|
+
*
|
|
1062
|
+
* Known, accepted tradeoff: this compares against the pack's CURRENT text
|
|
1063
|
+
* only, not any prior canonical version. If a pack's canonical description
|
|
1064
|
+
* changes upstream, a vault that never touched it reads as "matches the OLD
|
|
1065
|
+
* text, therefore edited" from the new pack's point of view, and keeps the
|
|
1066
|
+
* old text too — same outcome as a genuine hand edit. That's fine:
|
|
1067
|
+
* constitutions aren't security patches that must propagate; a future
|
|
1068
|
+
* doctor-style scan can surface "description drifted from the current pack
|
|
1069
|
+
* text" as an informational finding without this applier needing to guess
|
|
1070
|
+
* intent. See CHANGELOG.
|
|
1071
|
+
*
|
|
864
1072
|
* Errors propagate — best-effort semantics (seed-must-never-fail-a-create)
|
|
865
1073
|
* belong to the caller, which knows its failure policy.
|
|
866
1074
|
*
|
|
@@ -875,6 +1083,7 @@ export async function applySeedPack(
|
|
|
875
1083
|
const result: ApplySeedPackResult = {
|
|
876
1084
|
pack: pack.name,
|
|
877
1085
|
tags: [],
|
|
1086
|
+
preservedTagDescriptions: [],
|
|
878
1087
|
seededNotes: [],
|
|
879
1088
|
skippedNotes: [],
|
|
880
1089
|
};
|
|
@@ -882,12 +1091,19 @@ export async function applySeedPack(
|
|
|
882
1091
|
// Parents first so `parent_names` reads naturally in logs (the store accepts
|
|
883
1092
|
// forward references, but the order matches the conceptual model).
|
|
884
1093
|
for (const decl of pack.tags) {
|
|
1094
|
+
const existing = await store.getTagRecord(decl.name);
|
|
1095
|
+
const priorDescription = existing?.description ?? null;
|
|
1096
|
+
const isUserEdited = priorDescription != null && priorDescription !== decl.description;
|
|
1097
|
+
|
|
885
1098
|
await store.upsertTagRecord(decl.name, {
|
|
886
|
-
|
|
1099
|
+
// Omitted (not `undefined`-valued) when preserving — `upsertTagRecord`
|
|
1100
|
+
// treats an absent `description` key as "keep the current value".
|
|
1101
|
+
...(isUserEdited ? {} : { description: decl.description }),
|
|
887
1102
|
...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
|
|
888
1103
|
...(decl.fields ? { fields: decl.fields } : {}),
|
|
889
1104
|
});
|
|
890
1105
|
result.tags.push(decl.name);
|
|
1106
|
+
if (isUserEdited) result.preservedTagDescriptions.push(decl.name);
|
|
891
1107
|
}
|
|
892
1108
|
|
|
893
1109
|
for (const note of pack.notes) {
|
package/package.json
CHANGED
package/src/add-pack.test.ts
CHANGED
|
@@ -140,3 +140,35 @@ describe("add-pack surface-starter", () => {
|
|
|
140
140
|
expect(stdout).toContain("~ tag capture (upserted)");
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
describe("add-pack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
|
|
145
|
+
test("a hand-edited description survives a re-apply and is reported 'kept', not 'upserted'", () => {
|
|
146
|
+
expect(runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home }).exitCode).toBe(0);
|
|
147
|
+
|
|
148
|
+
// Simulate an operator/AI hand-editing the capture tag's constitution.
|
|
149
|
+
const dbPath = join(home, "vault", "data", "packed", "vault.db");
|
|
150
|
+
const editedDescription = "Our house rules for capture — not the default text.";
|
|
151
|
+
const writeDb = new Database(dbPath);
|
|
152
|
+
writeDb.query("UPDATE tags SET description = ? WHERE name = ?").run(
|
|
153
|
+
editedDescription,
|
|
154
|
+
"capture",
|
|
155
|
+
);
|
|
156
|
+
writeDb.close();
|
|
157
|
+
|
|
158
|
+
const { exitCode, stdout } = runCli(
|
|
159
|
+
["add-pack", "welcome", "--vault", "packed"],
|
|
160
|
+
{ PARACHUTE_HOME: home },
|
|
161
|
+
);
|
|
162
|
+
expect(exitCode).toBe(0);
|
|
163
|
+
expect(stdout).toContain("~ tag capture (kept user description)");
|
|
164
|
+
// guide wasn't edited, so it still reports the old vocabulary.
|
|
165
|
+
expect(stdout).toContain("~ tag guide (upserted)");
|
|
166
|
+
|
|
167
|
+
const readDb = new Database(dbPath, { readonly: true });
|
|
168
|
+
const row = readDb
|
|
169
|
+
.query("SELECT description FROM tags WHERE name = ?")
|
|
170
|
+
.get("capture") as { description: string };
|
|
171
|
+
readDb.close();
|
|
172
|
+
expect(row.description).toBe(editedDescription);
|
|
173
|
+
});
|
|
174
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -4842,7 +4842,11 @@ async function cmdAddPack(args: string[]) {
|
|
|
4842
4842
|
console.log(` = note ${path} (already exists — left untouched)`);
|
|
4843
4843
|
}
|
|
4844
4844
|
for (const tag of result.tags) {
|
|
4845
|
-
|
|
4845
|
+
if (result.preservedTagDescriptions.includes(tag)) {
|
|
4846
|
+
console.log(` ~ tag ${tag} (kept user description)`);
|
|
4847
|
+
} else {
|
|
4848
|
+
console.log(` ~ tag ${tag} (upserted)`);
|
|
4849
|
+
}
|
|
4846
4850
|
}
|
|
4847
4851
|
if (result.seededNotes.length === 0 && result.tags.length === 0) {
|
|
4848
4852
|
console.log(" Nothing to add — everything was already in place.");
|
|
@@ -23,14 +23,17 @@ import { BunStore } from "./vault-store.ts";
|
|
|
23
23
|
import { seedOnboardingNotes } from "./onboarding-seed.ts";
|
|
24
24
|
import {
|
|
25
25
|
applySeedPack,
|
|
26
|
+
ARCHIVED_TAG,
|
|
26
27
|
CAPTURE_ANYTHING_PATH,
|
|
27
28
|
CONNECT_AI_PATH,
|
|
28
29
|
GETTING_STARTED_PATH,
|
|
29
30
|
NOTES_REQUIRED_TAGS,
|
|
31
|
+
STARTER_ONTOLOGY_PACK,
|
|
30
32
|
SURFACE_STARTER_PACK,
|
|
31
33
|
SURFACE_STARTER_PATH,
|
|
32
34
|
TAGS_GRAPH_PATH,
|
|
33
35
|
WELCOME_PATH,
|
|
36
|
+
welcomePack,
|
|
34
37
|
YOURS_TO_KEEP_PATH,
|
|
35
38
|
} from "../core/src/seed-packs.ts";
|
|
36
39
|
import {
|
|
@@ -290,6 +293,67 @@ describe("applySeedPack — surface-starter via add-pack", () => {
|
|
|
290
293
|
});
|
|
291
294
|
});
|
|
292
295
|
|
|
296
|
+
describe("applySeedPack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
|
|
297
|
+
test("fresh apply: a brand-new tag gets the pack's description, reported as touched not preserved", async () => {
|
|
298
|
+
const result = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
299
|
+
expect(result.tags).toContain(ARCHIVED_TAG.name);
|
|
300
|
+
expect(result.preservedTagDescriptions).toEqual([]);
|
|
301
|
+
|
|
302
|
+
const record = await store.getTagRecord(ARCHIVED_TAG.name);
|
|
303
|
+
expect(record!.description).toBe(ARCHIVED_TAG.description);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("re-apply unmodified: description is still the pack's text — upserted, not preserved", async () => {
|
|
307
|
+
await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
308
|
+
const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
309
|
+
expect(rerun.preservedTagDescriptions).toEqual([]);
|
|
310
|
+
|
|
311
|
+
const record = await store.getTagRecord(ARCHIVED_TAG.name);
|
|
312
|
+
expect(record!.description).toBe(ARCHIVED_TAG.description);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("re-apply after a user edit: the hand-tuned description survives and is reported preserved", async () => {
|
|
316
|
+
await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
317
|
+
const edited = "My own house rules for archiving — nothing like the default.";
|
|
318
|
+
await store.upsertTagRecord(ARCHIVED_TAG.name, { description: edited });
|
|
319
|
+
|
|
320
|
+
const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
321
|
+
expect(rerun.preservedTagDescriptions).toContain(ARCHIVED_TAG.name);
|
|
322
|
+
// Still listed in `tags` — the tag itself was touched (fields/parent_names
|
|
323
|
+
// upsert unconditionally); only the description write was skipped.
|
|
324
|
+
expect(rerun.tags).toContain(ARCHIVED_TAG.name);
|
|
325
|
+
|
|
326
|
+
const record = await store.getTagRecord(ARCHIVED_TAG.name);
|
|
327
|
+
expect(record!.description).toBe(edited);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("capture byte-identity anchor (NOTES_REQUIRED_TAGS) holds under either apply order", async () => {
|
|
331
|
+
// welcome, then starter-ontology.
|
|
332
|
+
await applySeedPack(store, welcomePack());
|
|
333
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
334
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
335
|
+
);
|
|
336
|
+
const afterOntology = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
337
|
+
expect(afterOntology.preservedTagDescriptions).not.toContain("capture");
|
|
338
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
339
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("capture byte-identity anchor holds in the reverse order too", async () => {
|
|
344
|
+
// starter-ontology, then welcome.
|
|
345
|
+
await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
346
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
347
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
348
|
+
);
|
|
349
|
+
const afterWelcome = await applySeedPack(store, welcomePack());
|
|
350
|
+
expect(afterWelcome.preservedTagDescriptions).not.toContain("capture");
|
|
351
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
352
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
293
357
|
describe("vault-info / projection pointer (A2)", () => {
|
|
294
358
|
test("projection carries getting_started when the note exists", async () => {
|
|
295
359
|
// Absent before seeding → no pointer.
|