@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.
- package/core/src/seed-packs.ts +41 -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
package/core/src/seed-packs.ts
CHANGED
|
@@ -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
|
-
|
|
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
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.
|