@inetafrica/open-claudia 2.6.39 → 2.6.40

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/dream.js CHANGED
@@ -149,7 +149,7 @@ Your job — decide what consolidation, if any, is warranted:
149
149
 
150
150
  1. merges: packs that are clearly the SAME topic under different names get merged into one. Supply the merged Stance/Procedure/State (synthesised, not concatenated; null leaves the target's section alone) and a one-sentence journal note. Be conservative: when in doubt, do not merge. A Stance marked USER-AUTHORED is off-limits — leave its stance null; the system preserves it verbatim regardless. Packs marked [ABILITY] are reusable cross-project how-tos, NOT project trackers: only ever merge an [ABILITY] into another [ABILITY] (the merged result must stay an ability), and never merge a project pack into an [ABILITY] or an [ABILITY] into a project pack — that would lose the reusable how-to or pollute it with one project's specifics.
151
151
  2. umbrellas: when 3+ packs are siblings under one theme, create an umbrella pack whose State is a 3-6 line map of the family ("for X see pack Y"), and list its children. The umbrella is a router, not a duplicate.
152
- 3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything.
152
+ 3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything. NOTE: a separate deterministic pass already auto-files packs whose slug is a prefix-child of another (e.g. foo-bar under foo), so DON'T spend a parents op on those — reserve parents (and umbrellas) for semantic groupings the slug prefix can't catch (differently-named siblings of one theme).
153
153
  4. retag: tighten descriptions and tags. The router FTS-matches incoming messages against name/description/tags, so generic words there cause false matches. Descriptions should be one specific line; tags specific nouns.
154
154
  5. entity_merges: the same real-world entity recorded twice gets merged (the better slug wins).
155
155
  6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name.
@@ -700,6 +700,33 @@ function staleTaskReport() {
700
700
  } catch (e) { return ""; }
701
701
  }
702
702
 
703
+ // Deterministic hierarchy backfill. Files every orphan context pack under the
704
+ // LONGEST existing context pack whose dir is a slug-prefix of it (kazee-people-x
705
+ // → kazee-people). Runs every dream AFTER the model's own parents/umbrellas
706
+ // decision, so it only mops up the obvious prefix families the model left flat.
707
+ // Prefix parents are always shorter, so this can't cycle; never overrides a
708
+ // parent already set (by user, reviewer, or this run's model). Backs up each
709
+ // pack it moves. Returns per-parent summary lines + a count.
710
+ function nestPrefixFamilies(backupRoot) {
711
+ const all = packs.listPacks();
712
+ const contextDirs = all.filter((p) => p.kind !== "ability").map((p) => p.dir);
713
+ const byParent = new Map();
714
+ for (const p of all) {
715
+ if (p.kind === "ability" || p.parent) continue;
716
+ const parent = packs.parentByPrefix(p.dir, contextDirs);
717
+ if (!parent || !packs.readPack(parent) || wouldCycle(p.dir, parent)) continue;
718
+ try {
719
+ if (backupRoot) backupPack(p.dir, backupRoot);
720
+ packs.setParent(p.dir, parent);
721
+ byParent.set(parent, (byParent.get(parent) || 0) + 1);
722
+ } catch (e) { console.warn(`[dream] nest failed for ${p.dir}: ${e.message}`); }
723
+ }
724
+ const lines = [...byParent.entries()].map(([parent, n]) =>
725
+ `🌳 Filed ${n} sub-pack${n === 1 ? "" : "s"} under ${parent}`);
726
+ const filed = [...byParent.values()].reduce((a, b) => a + b, 0);
727
+ return { lines, filed };
728
+ }
729
+
703
730
  async function runDream({ trigger = "manual" } = {}) {
704
731
  if (!enabled()) return { skipped: "dream is disabled (DREAM=off)" };
705
732
  if (_dreaming) return { skipped: "a dream is already in progress" };
@@ -720,7 +747,14 @@ async function runDream({ trigger = "manual" } = {}) {
720
747
 
721
748
  const backupRoot = makeBackupRoot();
722
749
  const applied = applyDream(decision, backupRoot);
723
- const report = decision.report || (applied.length > 0 ? "Tidied up my memory overnight." : "");
750
+ // Deterministic hierarchy backfill (kazee-people-* kazee-people), after the
751
+ // model's parents/umbrellas decision so it only fills the obvious prefix
752
+ // families left flat. Backed up + announced like everything else.
753
+ let nestLines = [];
754
+ try { nestLines = nestPrefixFamilies(backupRoot).lines; }
755
+ catch (e) { console.warn(`[dream] nesting pass failed: ${e.message}`); }
756
+ const consolidationLines = applied.concat(nestLines);
757
+ const report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
724
758
 
725
759
  // Phase 2: self-improvement introspection. Reviews the day's seeds, reads
726
760
  // its own code/memory READ-ONLY (allowedTools whitelist), promotes lessons
@@ -767,11 +801,11 @@ async function runDream({ trigger = "manual" } = {}) {
767
801
  } catch (e) { /* graph is best-effort */ }
768
802
 
769
803
  const staleNote = staleTaskReport();
770
- const dreamLines = applied.concat(introApplied);
804
+ const dreamLines = consolidationLines.concat(introApplied);
771
805
 
772
806
  const reportPath = writeDreamReport({
773
807
  model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
774
- consolidation: { report: decision.report, lines: applied },
808
+ consolidation: { report: decision.report, lines: consolidationLines },
775
809
  introspection: { report: introReport, lines: introApplied, proposed: introProposed },
776
810
  graphNote, staleNote, backupRoot,
777
811
  });
@@ -826,7 +860,7 @@ function initDream(adapters) {
826
860
  }
827
861
 
828
862
  module.exports = {
829
- runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers,
863
+ runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies,
830
864
  buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
831
865
  enabled, summaryEnabled, introspectEnabled, selfApplyEnabled,
832
866
  DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS,
@@ -167,31 +167,24 @@ function guardCheck(a) {
167
167
  return hit;
168
168
  }
169
169
 
170
- // Deterministic dedupe backstop for the create path. Even with the prompt rule,
171
- // a cheap model tends to spawn a fresh pack for each version/release/facet of a
172
- // project an existing pack already owns. This catches two robust signals a
173
- // slug that is a hyphen-boundary prefix/extension of an existing pack dir, and a
174
- // version-named proposal whose name/description strongly matches an existing
175
- // pack and the caller folds the proposal into that pack instead. Conservative
176
- // by design (only context packs; only a clear prefix relationship or a versioned
177
- // proposal with a strong text match) so genuinely distinct topics still get their
178
- // own pack. Mirrors the structural "no lesson without a trigger" enforcement.
170
+ // Deterministic dedupe backstop for the create path. A cheap model tends to
171
+ // spawn a fresh pack for each VERSION/RELEASE of a project an existing pack
172
+ // already owns ("<project> v2.6.39"); this folds such a proposal into the
173
+ // project pack instead of spawning a sibling. Scoped tightly: only context
174
+ // packs, only a *versioned* proposal whose name/description strongly matches an
175
+ // existing pack. NOTE: a non-versioned prefix-child (kazee-people-leave under
176
+ // kazee-people) is NOT folded here it is filed as a sub-pack under its parent
177
+ // at creation (see birth-time parenting in applyAction), which keeps sub-area
178
+ // detail as its own node instead of collapsing it into the parent's State.
179
179
  function findDuplicatePack({ dir, name, description, tags }) {
180
- const slug = packs.slugify(dir || name || "");
181
- if (!slug) return null;
182
- const contextDirs = packs.listPacks().filter((p) => p.kind !== "ability").map((p) => p.dir);
183
- for (const d of contextDirs) {
184
- if (d === slug) continue;
185
- if (slug.startsWith(d + "-") || d.startsWith(slug + "-")) return d;
186
- }
187
180
  const tagStr = Array.isArray(tags) ? tags.join(" ") : "";
188
- const versioned = /\bv?\d+\.\d+(?:\.\d+)?\b|\brelease\b|\bv\d+\b/i.test(`${slug} ${name || ""} ${description || ""} ${tagStr}`);
189
- if (versioned) {
190
- const ctx = new Set(contextDirs);
191
- const text = `${name || ""} ${description || ""} ${tagStr}`.trim();
192
- for (const m of packs.matchPacks(text, { limit: 3, threshold: 4 })) {
193
- if (m.dir !== slug && ctx.has(m.dir)) return m.dir;
194
- }
181
+ const text = `${name || ""} ${description || ""} ${tagStr}`.trim();
182
+ const versioned = /\bv?\d+\.\d+(?:\.\d+)?\b|\brelease\b|\bv\d+\b/i.test(text);
183
+ if (!versioned) return null;
184
+ const slug = packs.slugify(dir || name || "");
185
+ const ctx = new Set(packs.listPacks().filter((p) => p.kind !== "ability").map((p) => p.dir));
186
+ for (const m of packs.matchPacks(text, { limit: 3, threshold: 4 })) {
187
+ if (m.dir !== slug && ctx.has(m.dir)) return m.dir;
195
188
  }
196
189
  return null;
197
190
  }
@@ -243,11 +236,19 @@ function applyAction(a) {
243
236
  const ex = packs.readPack(dupeDir);
244
237
  return { kind: "update", dir: dupeDir, name: (ex && ex.name) || dupeDir, note: a.journal || "folded a new-pack proposal into the existing pack" };
245
238
  }
239
+ // Birth-time hierarchy: a new context pack whose slug is a prefix-child of an
240
+ // existing pack (kazee-people-leave → kazee-people) is filed under it at
241
+ // creation, so families never accumulate flat at the top level. Abilities are
242
+ // cross-project and never prefix-nested.
243
+ const parentDir = kind === "context"
244
+ ? packs.parentByPrefix(dir, packs.listPacks().filter((p) => p.kind !== "ability").map((p) => p.dir))
245
+ : null;
246
246
  const pack = packs.createPack({
247
247
  dir,
248
248
  name: a.name,
249
249
  description: a.description,
250
250
  tags: Array.isArray(a.tags) ? a.tags.slice(0, 6) : [],
251
+ parent: parentDir && packs.readPack(parentDir) ? parentDir : null,
251
252
  stance: a.stance || "",
252
253
  procedure: a.procedure || "",
253
254
  state: a.state || "",
@@ -256,7 +257,7 @@ function applyAction(a) {
256
257
  learned_on: kind === "ability" ? learned_on : "",
257
258
  applied_on: kind === "ability" ? projects : [],
258
259
  }, "reviewer");
259
- return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "", ability: kind === "ability", learned_on: pack.learned_on };
260
+ return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "", ability: kind === "ability", learned_on: pack.learned_on, parent: pack.parent };
260
261
  }
261
262
  return null;
262
263
  }
@@ -319,7 +320,7 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
319
320
  } else if (r && r.kind === "create" && r.ability) {
320
321
  lines.push(`🧩 New ability: ${r.name}${r.learned_on ? ` (learned on ${r.learned_on})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
321
322
  } else if (r && r.kind === "create") {
322
- lines.push(`📦 New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
323
+ lines.push(`📦 New pack: ${r.name}${r.parent ? ` (filed under ${r.parent})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
323
324
  } else if (r) {
324
325
  lines.push(`✏️ ${r.name} — ${clipWords(r.note, 180)}${r.appliedTo ? ` 🧩 (now also applies to ${r.appliedTo})` : ""}\n↳ open-claudia pack show ${r.dir}`);
325
326
  }
package/core/packs.js CHANGED
@@ -171,6 +171,35 @@ function setKind(nameOrDir, kind) {
171
171
  return pack;
172
172
  }
173
173
 
174
+ // The natural parent of a pack within a slug family: the LONGEST existing dir
175
+ // that is a hyphen-boundary prefix of `dir` (kazee-people-mobile-x →
176
+ // kazee-people if it exists, else kazee). Pure — the caller supplies candidate
177
+ // dirs (so it can scope to context packs / exclude self). A strict prefix is
178
+ // always shorter than the slug, so a prefix parent can never be a descendant:
179
+ // nesting by prefix cannot create a cycle.
180
+ function parentByPrefix(dir, candidateDirs) {
181
+ const slug = slugify(dir);
182
+ if (!slug) return null;
183
+ let best = null;
184
+ for (const d of candidateDirs || []) {
185
+ if (!d || d === slug) continue;
186
+ if (slug.startsWith(d + "-") && (!best || d.length > best.length)) best = d;
187
+ }
188
+ return best;
189
+ }
190
+
191
+ // Structural setter for a pack's parent (hierarchy). Separate from updatePack:
192
+ // parent is frontmatter metadata, not an authored content section, so it carries
193
+ // no provenance and never touches Stance/State/etc. Pass null/"" to un-parent.
194
+ function setParent(nameOrDir, parentDir) {
195
+ const pack = findPack(nameOrDir);
196
+ if (!pack) return null;
197
+ pack.parent = parentDir ? slugify(parentDir) : null;
198
+ pack.updated = new Date().toISOString();
199
+ writePack(pack);
200
+ return pack;
201
+ }
202
+
174
203
  // Record that an ability was applied on a project (provenance of reuse): appends
175
204
  // to applied_on (deduped) and sets learned_on to the first project if unset.
176
205
  function recordApplied(nameOrDir, project) {
@@ -562,7 +591,7 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
562
591
  module.exports = {
563
592
  PACKS_DIR, SECTIONS, slugify,
564
593
  listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
565
- listSkillPacks, setSkill, listAbilities, setKind, recordApplied, recordCoUse,
594
+ listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
566
595
  touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
567
596
  archivePack, restorePack, listArchived,
568
597
  readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.39",
3
+ "version": "2.6.40",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -41,7 +41,8 @@
41
41
  "test-ability-transfer.js",
42
42
  "test-ability-tiers.js",
43
43
  "test-ability-merge-guard.js",
44
- "test-learning-e2e.js"
44
+ "test-learning-e2e.js",
45
+ "test-pack-nesting.js"
45
46
  ],
46
47
  "keywords": [
47
48
  "claude",
@@ -0,0 +1,100 @@
1
+ // Hierarchy nesting: the deterministic prefix-family pass that keeps pack
2
+ // families (kazee-people-*) filed under their parent instead of sprawling flat
3
+ // at the top level. Covers the pure selector (parentByPrefix), the structural
4
+ // setter (setParent), reviewer birth-time parenting (applyAction create), the
5
+ // dream backfill (nestPrefixFamilies), and the versioned-only fold guard.
6
+ const assert = require("assert");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pack-nesting-"));
12
+ process.env.PACKS_DIR = path.join(tmp, "packs");
13
+
14
+ const packs = require("./core/packs");
15
+ const review = require("./core/pack-review");
16
+ const dream = require("./core/dream");
17
+
18
+ // ── parentByPrefix: pure longest-prefix selection ──
19
+ assert.strictEqual(
20
+ packs.parentByPrefix("kazee-people-leave-display", ["kazee", "kazee-people", "other"]),
21
+ "kazee-people",
22
+ "longest matching prefix wins"
23
+ );
24
+ assert.strictEqual(packs.parentByPrefix("kazee", ["kazee"]), null, "a pack is never its own parent");
25
+ assert.strictEqual(packs.parentByPrefix("kazeebot", ["kazee"]), null, "prefix must end on a dash boundary");
26
+ assert.strictEqual(packs.parentByPrefix("orphan", ["kazee", "kazee-people"]), null, "no prefix → no parent");
27
+ assert.strictEqual(packs.parentByPrefix("anything", []), null, "no candidates → null");
28
+
29
+ // ── setParent: structural setter writes + clears frontmatter ──
30
+ packs.createPack({ dir: "np-root", name: "NP Root", description: "a root" });
31
+ packs.createPack({ dir: "np-solo", name: "NP Solo", description: "a leaf" });
32
+ packs.setParent("np-solo", "np-root");
33
+ assert.strictEqual(packs.readPack("np-solo").parent, "np-root", "setParent persists parent");
34
+ packs.setParent("np-solo", null);
35
+ assert.strictEqual(packs.readPack("np-solo").parent, null, "setParent(null) un-parents");
36
+
37
+ // ── reviewer birth-time parenting: a new prefix-child files under its parent ──
38
+ packs.createPack({ dir: "kazee", name: "Kazee", description: "the platform" });
39
+ packs.createPack({ dir: "kazee-people", name: "Kazee People", description: "the HR module" });
40
+ const born = review.applyAction({
41
+ action: "create", dir: "kazee-people-payroll", name: "Kazee People Payroll",
42
+ description: "payroll inside the HR module", state: "started",
43
+ });
44
+ assert.strictEqual(born.kind, "create", "a fresh prefix-child is created, not folded");
45
+ assert.strictEqual(born.parent, "kazee-people", "birth-time parent is the LONGEST prefix (kazee-people, not kazee)");
46
+ assert.strictEqual(packs.readPack("kazee-people-payroll").parent, "kazee-people", "parent persisted to disk");
47
+
48
+ const orphanBorn = review.applyAction({
49
+ action: "create", dir: "standalone-thing", name: "Standalone Thing",
50
+ description: "belongs to no family", state: "started",
51
+ });
52
+ assert.strictEqual(orphanBorn.parent, null, "a pack with no prefix-parent stays top-level");
53
+
54
+ // abilities are cross-project and must never be prefix-nested
55
+ const abilityBorn = review.applyAction({
56
+ action: "create", dir: "kazee-people-export", name: "Kazee People Export",
57
+ description: "reusable export how-to", kind: "ability", learned_on: "kazee",
58
+ });
59
+ assert.strictEqual(abilityBorn.parent, null, "an ability is never prefix-nested");
60
+
61
+ // ── dream.nestPrefixFamilies: backfills existing flat families ──
62
+ // Three flat siblings + the root, plus an ability that must be left alone.
63
+ packs.createPack({ dir: "billing", name: "Billing", description: "billing system" });
64
+ packs.createPack({ dir: "billing-invoices", name: "Billing Invoices", description: "invoice generation" });
65
+ packs.createPack({ dir: "billing-refunds", name: "Billing Refunds", description: "refund flow" });
66
+ packs.createPack({ dir: "billing-cli", name: "Billing CLI", description: "reusable billing CLI", kind: "ability", learned_on: "billing" });
67
+
68
+ const res = dream.nestPrefixFamilies(null); // null backupRoot → skip backups in test
69
+ assert.strictEqual(packs.readPack("billing-invoices").parent, "billing", "flat sibling filed under root");
70
+ assert.strictEqual(packs.readPack("billing-refunds").parent, "billing", "second flat sibling filed under root");
71
+ assert.strictEqual(packs.readPack("billing").parent, null, "the family root stays top-level");
72
+ assert.strictEqual(packs.readPack("billing-cli").parent, null, "the billing ability is NOT nested");
73
+ assert.ok(res.filed >= 2, "nest pass reports what it filed");
74
+ assert.ok(res.lines.some((l) => l.includes("billing")), "nest pass announces the family it filed");
75
+
76
+ // running it again is a no-op (idempotent — everything already parented)
77
+ const again = dream.nestPrefixFamilies(null);
78
+ assert.strictEqual(again.filed, 0, "second pass files nothing new");
79
+
80
+ // ── versioned-only fold (FTS-gated): a versioned proposal folds into its pack,
81
+ // a non-versioned near-duplicate does NOT. Skipped when node:sqlite is absent. ──
82
+ if (packs.reindex()) {
83
+ packs.createPack({ dir: "payroll-tax-filing", name: "Payroll Tax Filing", description: "quarterly payroll tax filing" });
84
+ packs.reindex();
85
+ const folded = review.applyAction({
86
+ action: "create", dir: "payroll-tax-filing-v2", name: "Payroll Tax Filing v2 release",
87
+ description: "payroll tax filing notes", state: "v2 shipped",
88
+ });
89
+ assert.strictEqual(folded.kind, "update", "a versioned near-dup folds into the existing pack");
90
+ assert.strictEqual(folded.dir, "payroll-tax-filing", "fold targets the existing pack");
91
+
92
+ const notFolded = review.applyAction({
93
+ action: "create", dir: "payroll-tax-summary", name: "Payroll Tax Summary",
94
+ description: "payroll tax filing summary view", state: "started",
95
+ });
96
+ assert.strictEqual(notFolded.kind, "create", "a non-versioned near-dup is a real new pack, not a fold");
97
+ console.log("pack nesting OK (incl. versioned-fold)");
98
+ } else {
99
+ console.log("pack nesting OK (versioned-fold skipped — no node:sqlite)");
100
+ }