@openparachute/vault 0.7.3-rc.4 → 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.
@@ -1025,6 +1025,14 @@ export interface ApplySeedPackResult {
1025
1025
  pack: string;
1026
1026
  /** Tag names upserted (upserts are idempotent; always every declared tag). */
1027
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[];
1028
1036
  /** Note paths written this run (absent ones). */
1029
1037
  seededNotes: string[];
1030
1038
  /** Note paths skipped because a note already lives there (idempotency). */
@@ -1037,6 +1045,30 @@ export interface ApplySeedPackResult {
1037
1045
  * rows — so a re-run can never duplicate, and never clobbers a note the
1038
1046
  * operator/AI has since edited or recreated.
1039
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
+ *
1040
1072
  * Errors propagate — best-effort semantics (seed-must-never-fail-a-create)
1041
1073
  * belong to the caller, which knows its failure policy.
1042
1074
  *
@@ -1051,6 +1083,7 @@ export async function applySeedPack(
1051
1083
  const result: ApplySeedPackResult = {
1052
1084
  pack: pack.name,
1053
1085
  tags: [],
1086
+ preservedTagDescriptions: [],
1054
1087
  seededNotes: [],
1055
1088
  skippedNotes: [],
1056
1089
  };
@@ -1058,12 +1091,19 @@ export async function applySeedPack(
1058
1091
  // Parents first so `parent_names` reads naturally in logs (the store accepts
1059
1092
  // forward references, but the order matches the conceptual model).
1060
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
+
1061
1098
  await store.upsertTagRecord(decl.name, {
1062
- description: decl.description,
1099
+ // Omitted (not `undefined`-valued) when preserving — `upsertTagRecord`
1100
+ // treats an absent `description` key as "keep the current value".
1101
+ ...(isUserEdited ? {} : { description: decl.description }),
1063
1102
  ...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
1064
1103
  ...(decl.fields ? { fields: decl.fields } : {}),
1065
1104
  });
1066
1105
  result.tags.push(decl.name);
1106
+ if (isUserEdited) result.preservedTagDescriptions.push(decl.name);
1067
1107
  }
1068
1108
 
1069
1109
  for (const note of pack.notes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.4",
3
+ "version": "0.7.3-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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
- console.log(` ~ tag ${tag} (upserted)`);
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.