dreamteamer 0.6.3 → 0.7.0

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/src/compile.js CHANGED
@@ -7,13 +7,17 @@ import path from 'node:path';
7
7
  import Ajv from 'ajv';
8
8
  import addFormats from 'ajv-formats';
9
9
  import { load, dump } from './yaml.js';
10
- import { walk } from './records.js';
10
+ import { slug } from './template.js';
11
+ import { walk, patternRe } from './records.js';
11
12
  import { unknownOperators } from './filter.js';
13
+ import {
14
+ normalizeNamespaces, namespaceProblems, unqualifiedProblems, defaultStoragePath, storageOverlaps,
15
+ } from './namespace.js';
12
16
  // circular on paper in earlier versions — safe: both sides only
13
17
  // call at run time, same pattern as store.js ↔ compile.js.
14
18
  import { runHarnessAdapters } from './harnesses.js';
15
19
  import { satisfies } from './semver.js';
16
- import { readManifest, runtimeDir } from './runtime.js';
20
+ import { DERIVED_KINDS, readManifest, runtimeDir } from './runtime.js';
17
21
 
18
22
  // re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
19
23
  // contract as `compileMod.readManifest` (engine.ts), and a removed export is the same cross-repo
@@ -64,8 +68,30 @@ function staleDisplayKeywords(schema, prefix = '') {
64
68
  return out;
65
69
  }
66
70
 
71
+ /**
72
+ * Every `x-reference` in a schema, as `[fieldPath, target]` — the same traversal check.js uses to
73
+ * resolve refs in records, here to verify the SHAPE against the module dependency graph. Nested
74
+ * objects and array items both carry the keyword, so both are walked.
75
+ */
76
+ function refTargets(schema, prefix = '') {
77
+ const out = [];
78
+ for (const [key, prop] of Object.entries(schema?.properties ?? {})) {
79
+ if (!prop || typeof prop !== 'object') continue;
80
+ const at = `${prefix}${key}`;
81
+ if (prop['x-reference']) out.push([at, prop['x-reference']]);
82
+ if (prop.items && typeof prop.items === 'object' && prop.items['x-reference']) out.push([`${at}[]`, prop.items['x-reference']]);
83
+ if (prop.properties) out.push(...refTargets(prop, `${at}.`));
84
+ if (prop.items?.properties) out.push(...refTargets(prop.items, `${at}[].`));
85
+ }
86
+ return out;
87
+ }
88
+
67
89
  export const KINDS = ['collections', 'skills', 'agents', 'commands', 'command-bindings', 'ui-views', 'collection-templates'];
68
90
  const FOLDER_KINDS = new Set(['skills']); // folder-shape entities: copy the whole record folder
91
+ // DERIVED_KINDS (projected, not staged) lives in runtime.js — the boundary both halves read. Not in
92
+ // KINDS on purpose: a module folder named `modules/` would be nonsense, and `isSystem` below keys
93
+ // off KINDS to decide `storage.base`, so a `modules` collection landing on `base: workspace` would
94
+ // point the store at the SOURCE directory and read every module folder as a record.
69
95
 
70
96
  /**
71
97
  * A module's source folder for one kind. The layout is FLAT — `<module>/skills`, beside `data/` —
@@ -238,6 +264,8 @@ export function compile({ root, pkg }) {
238
264
  const engineVer = engineVersion();
239
265
  const declaredEnv = new Map(); // env key -> [module names]
240
266
  const moduleIgnores = new Map(); // module name -> non-source folders it declares (strayKindDirs)
267
+ const moduleDeps = new Map(); // module name -> [module names] — HARD, must be acyclic
268
+ const modulePeers = new Map(); // module name -> [collection names] — SOFT, cannot cycle
241
269
  for (const source of sources) {
242
270
  let mpkg;
243
271
  try { mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8')); } catch { continue; }
@@ -246,6 +274,16 @@ export function compile({ root, pkg }) {
246
274
  if (!Array.isArray(ignore)) fail(`module "${source.name}": "ignore" must be a list of folder names (got ${JSON.stringify(ignore)})`);
247
275
  moduleIgnores.set(source.name, ignore.map(String));
248
276
  }
277
+ // npm's TERMINOLOGY, deliberately not npm's namespace: these live under `dreamteamer` so
278
+ // npm's own resolver never tries to fetch an inline or git-channel module.
279
+ for (const [key, sink] of [['dependencies', moduleDeps], ['peerDependencies', modulePeers]]) {
280
+ const decl = mpkg.dreamteamer?.[key];
281
+ if (decl === undefined) continue;
282
+ if (!Array.isArray(decl) || decl.some((v) => typeof v !== 'string')) {
283
+ fail(`module "${source.name}": dreamteamer.${key} must be a list of ${key === 'dependencies' ? 'module names' : 'collection names'} (got ${JSON.stringify(decl)})`);
284
+ }
285
+ sink.set(source.name, decl);
286
+ }
249
287
  const range = mpkg.dreamteamer?.engine;
250
288
  if (range) {
251
289
  const ok = satisfies(engineVer, range);
@@ -275,6 +313,36 @@ export function compile({ root, pkg }) {
275
313
  }
276
314
  }
277
315
 
316
+ // ---- the module dependency graph -------------------------------------------------
317
+ // `dependencies` names MODULES and must be acyclic. `peerDependencies` names COLLECTIONS and
318
+ // therefore cannot cycle at all — which is the whole reason it exists: two modules that each
319
+ // reference a concept the other owns (crm needs `products`, rnd needs `contacts`) would be an
320
+ // unbreakable ring under module-named deps, and are two independent peer declarations here.
321
+ const moduleNames = new Set(sources.map((s) => s.name));
322
+ for (const [mod, deps] of moduleDeps) {
323
+ for (const dep of deps) {
324
+ if (dep === mod) fail(`module "${mod}" declares itself as a dependency`);
325
+ if (!moduleNames.has(dep)) {
326
+ fail(`module "${mod}" depends on "${dep}", which is not installed — modules present: ${[...moduleNames].sort().join(', ')}`);
327
+ }
328
+ }
329
+ }
330
+ // DFS with an explicit path so the error can print the ring rather than just naming one module
331
+ {
332
+ const state = new Map(); // name -> 'open' | 'done'
333
+ const visit = (mod, trail) => {
334
+ if (state.get(mod) === 'done') return;
335
+ if (state.get(mod) === 'open') {
336
+ const ring = [...trail.slice(trail.indexOf(mod)), mod];
337
+ fail(`cyclic module dependencies: ${ring.join(' → ')}\n a reference to a CONCEPT another module owns belongs in dreamteamer.peerDependencies (a collection name), which cannot cycle.`);
338
+ }
339
+ state.set(mod, 'open');
340
+ for (const dep of moduleDeps.get(mod) ?? []) visit(dep, [...trail, mod]);
341
+ state.set(mod, 'done');
342
+ };
343
+ for (const mod of moduleDeps.keys()) visit(mod, []);
344
+ }
345
+
278
346
  const dataOwners = dataOwningModules(sources, fail, rel);
279
347
 
280
348
  const disabled = new Set(config.disable ?? []);
@@ -322,7 +390,18 @@ export function compile({ root, pkg }) {
322
390
  const srcDir = kindDir(source.root, kind);
323
391
  if (!fs.existsSync(srcDir)) continue;
324
392
  counts[kind] ??= 0;
325
- for (const name of fs.readdirSync(srcDir).sort()) {
393
+ // `collections/` is enumerated RECURSIVELY, so a namespaced descriptor can be authored at
394
+ // `collections/health/doctors.collection.yaml` — mirroring where it lands in the runtime and
395
+ // letting a workspace group its descriptors the same way its data is grouped.
396
+ //
397
+ // ⚠ This is load-bearing, not cosmetic. `schema-ops` derives a descriptor's source path from
398
+ // its name, so `add-field` on `health/doctors` writes the nested path; with a flat readdir
399
+ // that file was written, silently skipped, and the verb reported ✔ while changing nothing —
400
+ // the decision-156 shape again. Every other kind stays flat: their ids are single segments.
401
+ const names = kind === 'collections'
402
+ ? [...walk(srcDir)].map((f) => path.relative(srcDir, f).split(path.sep).join('/'))
403
+ : fs.readdirSync(srcDir).sort();
404
+ for (const name of names) {
326
405
  if (name.startsWith('.')) continue;
327
406
  const entityId = name.replace(/\.[^.]+\.(yaml|md|json)$/, '');
328
407
  if (disabled.has(`${source.name}/${entityId}`)) { disabledHits.add(`${source.name}/${entityId}`); continue; }
@@ -413,10 +492,48 @@ export function compile({ root, pkg }) {
413
492
  templateDocs.set(m[1], { template: doc.template ?? {}, src: entry.sources[0] });
414
493
  }
415
494
 
495
+ // ---- namespaces: the declared list, validated against what actually compiled ----------
496
+ // Declared in the WORKSPACE package.json only, never per-module. A module that could declare a
497
+ // namespace could rename where another module's records live, and the whole point of a namespace
498
+ // is that the workspace decides how its own data is partitioned. `namespaces` is also config
499
+ // rather than records for the same bootstrap reason `git-modules` is (docs/repos-and-modules.md):
500
+ // a reference has to be parseable before anything has been compiled.
501
+ const namespaces = normalizeNamespaces(config.namespaces);
502
+ const collectionNames = [...descriptorGroups.keys()];
503
+ for (const p of namespaceProblems(namespaces, collectionNames)) fail(p);
504
+ // The silent failure this whole feature had to fix: a slash in a collection name used to compile
505
+ // clean, land at `.dreamteamer/collections/<ns>/<name>.collection.yaml`, and then vanish — the
506
+ // descriptor loader read one directory level, so the collection was simply absent from the
507
+ // runtime while compile reported ✔ (the same shape as decision 156).
508
+ for (const p of unqualifiedProblems(collectionNames, namespaces)) fail(p);
509
+
510
+ // ---- who owns which collection, and which module IS the workspace ----------------
511
+ // Needed before the resolution loop so each descriptor can be validated against the graph as it
512
+ // is merged. The owner is the group member that does NOT declare `extends`; a group with two of
513
+ // those is a name collision, and the loop below raises it properly — this pass only maps.
514
+ // A module's record id: the npm scope stripped, so `@dreamteamer/crm` reads as `crm` — which is
515
+ // what every message in this engine already calls it.
516
+ const moduleId = (n) => slug(String(n).replace(/^@[^/]+\//, ''));
517
+ const collOwner = new Map(); // collection name -> owning module name
518
+ const moduleColls = new Map(); // module name -> Set(collection names it contributed to)
519
+ for (const [name, group] of descriptorGroups) {
520
+ const base = group.find((g) => !g.doc.extends);
521
+ if (base) collOwner.set(name, base.moduleName);
522
+ }
523
+ // The engine's own nine collections are an implicit dependency of every module: seven entity
524
+ // kinds plus the two the compiler materializes. Requiring every module to declare a dependency
525
+ // on the host it cannot run without would be ceremony, not verification.
526
+ const CORE_COLLECTIONS = new Set([...KINDS, ...DERIVED_KINDS, 'users', 'repos']);
527
+ const wsDir = config['workspace-module'];
528
+ const wsModuleName = wsDir
529
+ ? sources.find((s) => rel(s.root) === path.join('modules', wsDir))?.name
530
+ : pkg.name;
531
+
416
532
  // ---- resolve descriptor groups (templates + extends merge) ---------------------
417
533
  counts.collections = 0;
418
534
  let mergedCount = 0;
419
535
  let templatedCount = 0;
536
+ const storageEntries = []; // {name, path, base} per collection — checked for overlap after the loop
420
537
  for (const [name, group] of descriptorGroups) {
421
538
  // a template's bytes feed the compiled descriptor, so it MUST be one of that descriptor's
422
539
  // declared sources — otherwise editing the template leaves every consumer silently stale
@@ -448,6 +565,11 @@ export function compile({ root, pkg }) {
448
565
  if (ext.doc.extends !== expected) {
449
566
  fail(`${ext.src.path}: extends "${ext.doc.extends}" does not name the base "${expected}"`);
450
567
  }
568
+ // `extends` is the hardest dependency there is — the extender does not compile at all
569
+ // without the base (see the "no base found" failure above), so it must say so.
570
+ if (ext.moduleName !== base.moduleName && !(moduleDeps.get(ext.moduleName) ?? []).includes(base.moduleName)) {
571
+ fail(`${ext.src.path}: extends "${expected}" but module "${ext.moduleName}" does not declare "${base.moduleName}" in dreamteamer.dependencies — an overlay cannot compile without its base.`);
572
+ }
451
573
  merged = mergeDescriptor(merged, ext.doc);
452
574
  }
453
575
  delete merged.extends;
@@ -463,8 +585,15 @@ export function compile({ root, pkg }) {
463
585
  // the workspace root, read as zero records, and become writable through the store.
464
586
  merged.storage ??= {};
465
587
  const owned = dataOwners.get(storageOwnerOf(group, base));
588
+ // A namespaced collection's folder IS its namespace, nested: `health/doctors` →
589
+ // `data/health/doctors`. Derived rather than required so a descriptor never has to repeat its
590
+ // own name in a path, and so moving a collection between namespaces is a one-line edit.
591
+ // An authored `storage.path` still wins — registering an existing folder is a first-class case
592
+ // (skills/building-dreamteamer/references/collections.md).
593
+ merged.storage.path ??= defaultStoragePath(name, namespaces, config['data-path'] ?? 'data');
466
594
  const storagePath = String(merged.storage.path ?? '');
467
- const isSystem = KINDS.includes(storagePath) || KINDS.includes(storagePath.replace(/^system\//, ''));
595
+ const systemKinds = [...KINDS, ...DERIVED_KINDS];
596
+ const isSystem = systemKinds.includes(storagePath) || systemKinds.includes(storagePath.replace(/^system\//, ''));
468
597
  merged.storage.base = isSystem ? 'runtime' : 'workspace';
469
598
  if (owned && !isSystem) {
470
599
  const modRel = rel(owned.root);
@@ -473,6 +602,7 @@ export function compile({ root, pkg }) {
473
602
  } else {
474
603
  merged.storage.repo = '.';
475
604
  }
605
+ storageEntries.push({ name, path: merged.storage.path, base: merged.storage.base });
476
606
  for (const [at, tpl, target] of staleDisplayKeywords(merged.schema)) {
477
607
  const fix = target
478
608
  ? `either DELETE it (a reference to "${target}" now inherits that collection's \`title_template\`) or rename it to \`x-title-template\` if this field really needs its own`
@@ -487,6 +617,66 @@ export function compile({ root, pkg }) {
487
617
  } catch (e) {
488
618
  fail(`collection "${name}": schema is not a valid JSON Schema — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
489
619
  }
620
+ // Same reasoning one line up, for the OTHER regex a descriptor carries. `patternRe` throws on a
621
+ // malformed pattern, and it is called from `store.add` and from `check` — so without this gate a
622
+ // typo'd `id.pattern` surfaces as a raw "Invalid regular expression" from inside a write instead
623
+ // of as a compile error naming the descriptor.
624
+ if (merged.id?.pattern !== undefined) {
625
+ if (typeof merged.id.pattern !== 'string') fail(`collection "${name}": id.pattern must be a string (got ${JSON.stringify(merged.id.pattern)})`);
626
+ try { patternRe(merged.id.pattern); } catch (e) {
627
+ fail(`collection "${name}": id.pattern is not a valid regular expression — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
628
+ }
629
+ }
630
+ // ---- the reference contract: every target is owned, depended on, or declared a peer ----
631
+ // Attribution is unioned across the whole group rather than taken from the base, because the
632
+ // merge keeps no per-field provenance — an overlay that adds a ref field would otherwise be
633
+ // judged against the BASE module's declarations, which it never wrote.
634
+ const groupModules = [...new Set(group.map((g) => g.moduleName))];
635
+ // WHO OWNS the concept — the module whose source is the base, not the ones overlaying it.
636
+ // An overlay adds fields to somebody else's collection (hq3 adds `tags` to crm's contacts);
637
+ // it does not take the concept over. Measured 2026-08-11: letting the overlay win moves
638
+ // `contacts` and `meetings` out of CRM, and a CRM without contacts reads as broken.
639
+ //
640
+ // ⚠ This is NOT the `module` provenance field an outside review rejected this morning. That
641
+ // one duplicated `group:` while claiming to name every contributor, and got the merged case
642
+ // wrong by taking the first source. This names ONE thing — the owner — for which the base
643
+ // IS the answer, and it exists to REPLACE `group:` as the workspace's partition rather than
644
+ // to sit beside it.
645
+ merged.owner = `modules/${moduleId(base?.moduleName ?? groupModules[0])}`;
646
+ // EVERY contributing module, not just the base — a collection merged from crm + hq3 belongs
647
+ // to both, and saying otherwise is what made a flat "which module owns this" field wrong.
648
+ for (const m of groupModules) {
649
+ if (!moduleColls.has(m)) moduleColls.set(m, new Set());
650
+ moduleColls.get(m).add(name);
651
+ }
652
+ const declaredDeps = new Set(groupModules.flatMap((m) => moduleDeps.get(m) ?? []));
653
+ const declaredPeers = new Set(groupModules.flatMap((m) => modulePeers.get(m) ?? []));
654
+ const owns = (t) => groupModules.includes(collOwner.get(t));
655
+ for (const [at, target] of refTargets(merged.schema)) {
656
+ if (target === '*') {
657
+ // The workspace module is the orchestrating parent and may reference anything —
658
+ // including modules that do not exist yet, which is what `tasks.item` means.
659
+ // Anywhere else a wildcard is a cross-module surface no declaration can cover.
660
+ if (!groupModules.includes(wsModuleName)) {
661
+ console.warn(`⚠ collection ${name}: field "${at}" uses x-reference: '*' outside the workspace module — an unverifiable cross-module surface; name the collections it may target`);
662
+ }
663
+ continue;
664
+ }
665
+ if (CORE_COLLECTIONS.has(target) || owns(target)) continue;
666
+ const owner = collOwner.get(target);
667
+ if (owner && declaredDeps.has(owner)) continue;
668
+ if (declaredPeers.has(target)) continue;
669
+ const fix = owner
670
+ ? `add "${owner}" to dreamteamer.dependencies, or "${target}" to dreamteamer.peerDependencies if the module should work without it`
671
+ : `add "${target}" to dreamteamer.peerDependencies — no installed module provides it`;
672
+ fail(`collection "${name}": field "${at}" references "${target}", which ${groupModules.join('/')} neither owns nor declares.\n ${fix}.`);
673
+ }
674
+ // Declared peers that nothing provides, stated as DATA on the descriptor so `check` can
675
+ // excuse their references without learning what a module is (the `storage.base` precedent —
676
+ // check.js is in the record layer and must not know modules exist).
677
+ const unresolved = [...declaredPeers].filter((p) => !collOwner.has(p)).sort();
678
+ if (unresolved.length) merged.unresolved_peers = unresolved;
679
+
490
680
  // ---- resolved labels: what to CALL this collection, its records and its fields --------
491
681
  // Written into the artifact next to `storage.base` and for the same reason: the nav, the
492
682
  // browse page, the CLI and the extension then read ONE field instead of each carrying its
@@ -507,6 +697,70 @@ export function compile({ root, pkg }) {
507
697
  if (extenders.length) mergedCount++;
508
698
  }
509
699
 
700
+ // ---- no collection may sit inside another's folder -------------------------------
701
+ // Checked HERE because it is the first moment every path is resolved (namespace nesting, the
702
+ // `owns-data` module prefix and any authored override all already applied). See
703
+ // namespace.storageOverlaps for what this silently did before it was checked.
704
+ for (const p of storageOverlaps(storageEntries)) fail(p);
705
+
706
+ // ---- modules, projected ---------------------------------------------------------
707
+ // One record per discovered module, written from what discovery and the package pass already
708
+ // established. `package.json` remains the source of truth and compile keeps reading it — this
709
+ // is a photograph, never an input (see collections/modules.collection.yaml for why it earns a
710
+ // place in core at all).
711
+ //
712
+ // The id strips an npm scope so `@dreamteamer/crm` reads as `crm`, which is what every message
713
+ // in this engine already calls it. A collision is a hard failure rather than a silent overwrite:
714
+ // two modules answering to one id would make `dependencies` ambiguous, and an ambiguous edge is
715
+ // worse than no diagram.
716
+ const idByModule = new Map();
717
+ for (const source of sources) {
718
+ const id = moduleId(source.name);
719
+ const clash = idByModule.get(id);
720
+ if (clash && clash !== source.name) fail(`modules "${clash}" and "${source.name}" both resolve to the id "${id}" — rename one.`);
721
+ idByModule.set(id, source.name);
722
+ }
723
+ for (const source of sources) {
724
+ const id = moduleId(source.name);
725
+ let mpkg = {};
726
+ try { mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8')).dreamteamer ?? {}; } catch { /* inline workspace source */ }
727
+ const record = {
728
+ name: source.name,
729
+ // Authored wins; the derived fallback title-cases the id the same way a collection's
730
+ // `title` is derived. `@dreamteamer/crm` -> "Crm" until crm declares "CRM" — which is
731
+ // the point: the module is the only place that knows.
732
+ title: typeof mpkg.title === 'string' && mpkg.title ? mpkg.title : titleCase(id),
733
+ channel: source.channel,
734
+ path: rel(source.root) || '.',
735
+ ...(mpkg['owns-data'] === true ? { owns_data: true } : {}),
736
+ // Declared module names become record IDS here, because that is what an x-reference
737
+ // resolves against. An undeclared/unknown name would dangle, and `check` would say so —
738
+ // but compile has already failed on that case (the acyclicity pass resolves every one).
739
+ // ⚠ A reference VALUE is `<collection>/<id>`, never a bare id — `check` rejects the bare
740
+ // form, which is exactly what it did to the first pass of this projection (63 violations).
741
+ ...(moduleDeps.get(source.name)?.length
742
+ ? { dependencies: moduleDeps.get(source.name).map((n) => `modules/${moduleId(n)}`) }
743
+ : {}),
744
+ ...(modulePeers.get(source.name)?.length
745
+ ? { peer_dependencies: modulePeers.get(source.name).map((c) => `collections/${c}`) }
746
+ : {}),
747
+ ...(moduleColls.get(source.name)?.size
748
+ ? { collections: [...moduleColls.get(source.name)].sort().map((c) => `collections/${c}`) }
749
+ : {}),
750
+ };
751
+ const bytes = Buffer.from(dump(record));
752
+ // ⚠ The source hash is the hash of the SOURCE FILE, not of the projected record. Hashing the
753
+ // output made every source "differ" on the next run, so `staleness` reported the workspace
754
+ // stale immediately after a clean compile — the one signal that has to stay trustworthy.
755
+ const pkgPath = path.join(source.root, 'package.json');
756
+ const pkgBytes = fs.existsSync(pkgPath) ? fs.readFileSync(pkgPath) : bytes;
757
+ entries.set(path.join('modules', `${id}.module.yaml`), {
758
+ sources: [{ path: rel(pkgPath), hash: sha256(pkgBytes) }],
759
+ bytes,
760
+ });
761
+ counts.modules = (counts.modules ?? 0) + 1;
762
+ }
763
+
510
764
  // ---- unresolved references are compile errors (an agent's declared skills)
511
765
  const skillIds = new Set([...entries.keys()].filter((k) => k.startsWith('skills/')).map((k) => k.split('/')[1]));
512
766
  for (const [rt, e] of entries) {
@@ -594,12 +848,17 @@ export function compile({ root, pkg }) {
594
848
  const anyFlat = sources.some((s) => KINDS.some((k) => fs.existsSync(path.join(s.root, k))));
595
849
  const anyNested = sources.some((s) => KINDS.some((k) => fs.existsSync(path.join(s.root, 'system', k))));
596
850
  const sourceLayout = anyFlat && anyNested ? 'mixed' : anyNested ? 'nested' : 'flat';
597
- const { outputs: adapterOutputs, summary: harnessSummary } = runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout });
851
+ const { outputs: adapterOutputs, summary: harnessSummary } = runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout, namespaces, version: engineVer });
598
852
 
599
853
  // ---- provenance manifest ------------------------------------------------------
600
854
  const manifest = {
601
855
  compiled: new Date().toISOString(),
602
856
  host: engineId(),
857
+ // The declared namespace list, carried across the boundary so the RECORD layer can split a
858
+ // reference without importing the compiler or re-reading package.json — the same reason
859
+ // `storage.base` is a field instead of a path test. An older runtime has no key here, which
860
+ // reads as "no namespaces", which is exactly right for a workspace that never declared any.
861
+ namespaces,
603
862
  modules: sources.map((s) => ({ name: s.name, channel: s.channel, root: rel(s.root) || '.' })),
604
863
  ui: uiModules.sort(),
605
864
  'adapter-outputs': adapterOutputs.sort(),
package/src/harnesses.js CHANGED
@@ -21,7 +21,7 @@ export const STAMP = '<!-- generated by dreamteamer compile — do not edit; sou
21
21
  const BEGIN = '<!-- dreamteamer:begin (generated — do not edit inside this block) -->';
22
22
  const END = '<!-- dreamteamer:end -->';
23
23
 
24
- export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat' }) {
24
+ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat', namespaces = [], version = 'unknown' }) {
25
25
  const outputs = [];
26
26
  const summary = [];
27
27
  const rel = (p) => path.relative(root, p);
@@ -56,7 +56,7 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
56
56
  }
57
57
  summary.push(`claude-code → .claude (${n} files)`);
58
58
  }
59
- writeBlock(root, 'CLAUDE.md', on('claude-code') ? orientationBlock('claude-code', skillsIndex, sourceLayout) : null);
59
+ writeBlock(root, 'CLAUDE.md', on('claude-code') ? orientationBlock('claude-code', skillsIndex, sourceLayout, namespaces, version) : null);
60
60
 
61
61
  // ---- shared cross-agent skills mirror (.agents/skills) — codex/pi discover it,
62
62
  // cursor/gemini blocks point at it. written once no matter how many harnesses use it.
@@ -73,17 +73,17 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
73
73
  }
74
74
 
75
75
  // ---- codex + pi: both read root AGENTS.md; one block serves both ----------------
76
- writeBlock(root, 'AGENTS.md', on('codex') || on('pi') ? orientationBlock('agents-md', skillsIndex, sourceLayout) : null);
76
+ writeBlock(root, 'AGENTS.md', on('codex') || on('pi') ? orientationBlock('agents-md', skillsIndex, sourceLayout, namespaces, version) : null);
77
77
  if (on('codex')) summary.push('codex → AGENTS.md block');
78
78
  if (on('pi')) summary.push('pi → AGENTS.md block + .agents/skills');
79
79
 
80
80
  // ---- gemini-cli: GEMINI.md is its context file -----------------------------------
81
- writeBlock(root, 'GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout) : null);
81
+ writeBlock(root, 'GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout, namespaces, version) : null);
82
82
  if (on('gemini-cli')) summary.push('gemini-cli → GEMINI.md block');
83
83
 
84
84
  // ---- cursor: native .mdc rule (alwaysApply) ---------------------------------------
85
85
  if (on('cursor')) {
86
- const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${orientationBlock('cursor', skillsIndex, sourceLayout)}\n\n${STAMP}\n`;
86
+ const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version)}\n\n${STAMP}\n`;
87
87
  write('.cursor/rules/dreamteamer.mdc', Buffer.from(mdc));
88
88
  summary.push('cursor → .cursor/rules/dreamteamer.mdc');
89
89
  }
@@ -127,15 +127,25 @@ function buildSkillsIndex(entries) {
127
127
  * 'nested' (the pre-2026-08-05 `<module>/system/skills/`), or 'mixed'. It is passed in rather than
128
128
  * assumed because generated prose that contradicts the workspace is worse than no prose: this block
129
129
  * is the first thing an agent session reads, and a workspace still on the old layout was being told
130
- * to write somewhere it does not keep its sources. */
131
- function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat') {
130
+ * to write somewhere it does not keep its sources.
131
+ *
132
+ * `namespaces` is here for exactly the same reason, and it matters more. A reference is
133
+ * `<collection>/<id>`, and an id is ALSO a slash path — so in a workspace with declared namespaces an
134
+ * agent that splits `health/doctors/dana-levi` at the first slash reads a collection that does not
135
+ * exist. Naming the declared list is what makes the grammar decidable from this block alone, without
136
+ * the agent having to go read the manifest. A workspace with no namespaces gets no extra sentence. */
137
+ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces = [], version = 'unknown') {
132
138
  const sourcesLine = {
133
139
  flat: '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
134
140
  nested: '`modules/<module>/system/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
135
141
  mixed: '`modules/<module>/<kind>/`, or `<module>/system/<kind>/` where a module still nests it —\n`collections/`, `skills/`, `agents/`, `commands/`,',
136
142
  }[sourceLayout] ?? '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,';
137
143
  const lines = [
138
- 'this workspace is operated by dreamteamer v0.6. **read the `using-dreamteamer` skill before',
144
+ // PASSED IN, never hardcoded: this string used to say "v0.6" literally, which was correct for
145
+ // exactly one minor release and then quietly wrong in every workspace it had been written into.
146
+ // Passed rather than imported because compile.js already computes it and imports THIS module —
147
+ // reaching back for `engineVersion` would close a cycle for the sake of one string.
148
+ `this workspace is operated by dreamteamer v${version}. **read the \`using-dreamteamer\` skill before`,
139
149
  'working with data.** schemas (read): `.dreamteamer/collections/` (provenance:',
140
150
  '`.dreamteamer/manifest.yaml`). sources (write): ' + sourcesLine,
141
151
  '`command-bindings/`, `ui-views/`, `collection-templates/`',
@@ -145,6 +155,17 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat') {
145
155
  'check`) after bulk edits; run `dreamteamer compile` (`npm run compile`) after changing any',
146
156
  'source or installing modules.',
147
157
  ];
158
+ // ⚠ Only when the workspace HAS namespaces. Telling an agent about a feature this workspace does
159
+ // not use is the same failure as telling it the wrong source layout — prose that contradicts the
160
+ // workspace is worse than no prose.
161
+ if (namespaces.length) {
162
+ lines.push(
163
+ `this workspace declares NAMESPACES: ${namespaces.map((n) => `\`${n}\``).join(', ')}. a collection`,
164
+ 'in one is named with its prefix (`health/doctors`), stores records under `data/<namespace>/`,',
165
+ 'and is referenced as `<namespace>/<collection>/<id>` — so split a reference at the end of the',
166
+ 'DECLARED prefix, not at the first slash. collections with no prefix are unaffected.',
167
+ );
168
+ }
148
169
  // claude-code discovers skills natively (Skill tool) — an index in CLAUDE.md is pure
149
170
  // context bloat there. every other harness gets the trigger index + discovery pointers.
150
171
  if (flavor !== 'claude-code') {
@@ -0,0 +1,181 @@
1
+ // NAMESPACES — how a collection name is scoped, and how a reference splits back apart.
2
+ //
3
+ // A namespace is a slash-delimited prefix on a collection name: `health/doctors` is the collection
4
+ // `doctors` in the namespace `health`, and its records live under `data/health/doctors/`. A
5
+ // reference to one of those records is `health/doctors/dana-levi`.
6
+ //
7
+ // ⚠ THE WHOLE PROBLEM IN ONE LINE: an id is ALSO a slash-delimited path (`meetings/2026/07/kickoff`
8
+ // is one collection and a three-segment id), so `a/b/c` is either collection `a` + id `b/c` or
9
+ // collection `a/b` + id `c`, and nothing about the STRING says which.
10
+ //
11
+ // So namespaces are DECLARED, in the workspace package.json:
12
+ //
13
+ // "dreamteamer": { "namespaces": ["health", "finance", "work/clients"] }
14
+ //
15
+ // and every split consults that closed set, longest match first. The alternative — inferring the
16
+ // boundary from whichever collections happen to exist — was rejected: it makes the meaning of a
17
+ // reference depend on the current descriptor set, so installing a module could silently re-point
18
+ // references in records nobody edited. A declared list also turns the dangerous case (a namespace
19
+ // whose name collides with a collection's) into a compile error instead of a longest-prefix win.
20
+ //
21
+ // The DEFAULT namespace is the empty prefix. `tasks/kickoff` is a reference into it, `data/tasks/`
22
+ // is where it lives, and that is exactly what every workspace already has — which is why adopting
23
+ // namespaces migrates nothing. `default` is reserved precisely so there is never a second spelling
24
+ // for the same collection.
25
+ //
26
+ // This module is deliberately PURE — the declared list arrives as an argument. compile validates it
27
+ // and writes it into the manifest; runtime.js hands it to the record layer. Nothing here reads a
28
+ // file, so all of it is unit-testable without a workspace.
29
+
30
+ /** Never a namespace: it would give the default namespace a second, prefixed spelling. */
31
+ export const RESERVED_NAMESPACES = new Set(['default']);
32
+
33
+ /** One segment of a namespace or collection name: the id-safe alphabet the rest of the engine uses. */
34
+ const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
35
+
36
+ /**
37
+ * The declared list, cleaned and ordered for prefix matching: de-duplicated, slash-trimmed, and
38
+ * sorted LONGEST FIRST so a nested namespace (`work/clients`) is tested before its parent (`work`).
39
+ * Order is the correctness property here, not a nicety — parent-first would claim `work/clients/acme`
40
+ * for the namespace `work`, making the collection `clients` and the id `acme` on a workspace where
41
+ * `work/clients` is the namespace and the collection is something else entirely.
42
+ */
43
+ export function normalizeNamespaces(list) {
44
+ if (!Array.isArray(list)) return [];
45
+ const seen = new Set();
46
+ for (const raw of list) {
47
+ if (typeof raw !== 'string') continue;
48
+ const ns = raw.trim().replace(/^\/+|\/+$/g, '');
49
+ if (ns) seen.add(ns);
50
+ }
51
+ return [...seen].sort((a, b) => b.length - a.length || a.localeCompare(b));
52
+ }
53
+
54
+ /**
55
+ * Everything wrong with a declared list, as sentences — or `[]`. `collectionNames` is every compiled
56
+ * collection's qualified name, which is what makes the collision check possible at all.
57
+ *
58
+ * compile calls this and fails on a non-empty result. It is a separate function from the compiler so
59
+ * the rules can be tested directly, and so the error text lives beside the semantics it protects.
60
+ */
61
+ export function namespaceProblems(namespaces, collectionNames = []) {
62
+ const problems = [];
63
+ const names = new Set(collectionNames);
64
+ for (const ns of namespaces) {
65
+ const segments = ns.split('/');
66
+ for (const seg of segments) {
67
+ if (RESERVED_NAMESPACES.has(seg)) {
68
+ problems.push(`namespace "${ns}": "${seg}" is reserved — the default namespace is the EMPTY prefix, so a collection in it is spelled \`tasks\`, never \`default/tasks\`.`);
69
+ } else if (!SEGMENT.test(seg)) {
70
+ problems.push(`namespace "${ns}": segment "${seg}" must be lowercase alphanumeric with single hyphens (it becomes a folder name and part of every reference).`);
71
+ }
72
+ }
73
+ // The collision that makes a slash-delimited namespace dangerous: with BOTH a namespace
74
+ // `health` and a collection `health`, the reference `health/doctors/dana-levi` is a record of
75
+ // collection `health/doctors` AND a record of collection `health` with the nested id
76
+ // `doctors/dana-levi`. Longest-match would silently pick the first and make the second
77
+ // unreferenceable. Refused up front instead.
78
+ if (names.has(ns)) {
79
+ problems.push(`namespace "${ns}" collides with the collection of the same name — a reference like "${ns}/x/y" would be ambiguous. Rename one.`);
80
+ }
81
+ }
82
+ return problems;
83
+ }
84
+
85
+ /** `health` + `doctors` → `health/doctors`; the default namespace (empty) → `doctors`. */
86
+ export function qualify(namespace, name) {
87
+ const ns = String(namespace ?? '').replace(/^\/+|\/+$/g, '');
88
+ return ns ? `${ns}/${name}` : String(name);
89
+ }
90
+
91
+ /**
92
+ * The declared namespace a qualified collection name sits in, or `''` for the default namespace.
93
+ * Matched against the declared list rather than by cutting at the last slash, because a collection
94
+ * name is only namespaced if its prefix was actually declared — see `unqualifiedProblems`.
95
+ */
96
+ export function namespaceOf(qualified, namespaces) {
97
+ for (const ns of namespaces) if (qualified.startsWith(ns + '/')) return ns;
98
+ return '';
99
+ }
100
+
101
+ /** The bare collection name inside its namespace: `health/doctors` → `doctors`. */
102
+ export function baseNameOf(qualified, namespaces) {
103
+ const ns = namespaceOf(qualified, namespaces);
104
+ return ns ? qualified.slice(ns.length + 1) : qualified;
105
+ }
106
+
107
+ /**
108
+ * A collection name carrying a slash whose prefix is NOT declared, which is the silent-failure this
109
+ * whole module exists to prevent: every reference to it would split at the first slash, name a
110
+ * collection that does not exist, and dangle. Returns problems as sentences, or `[]`.
111
+ */
112
+ export function unqualifiedProblems(collectionNames, namespaces) {
113
+ const problems = [];
114
+ for (const name of collectionNames) {
115
+ if (!name.includes('/')) continue;
116
+ if (namespaceOf(name, namespaces)) continue;
117
+ const guess = name.slice(0, name.lastIndexOf('/'));
118
+ problems.push(`collection "${name}" is namespaced, but "${guess}" is not declared — add it to \`dreamteamer.namespaces\` in package.json, or every reference to this collection will split at the first slash and dangle.`);
119
+ }
120
+ return problems;
121
+ }
122
+
123
+ /**
124
+ * Split a reference into `{ collection, id }`, or `null` when it is not a reference shape.
125
+ *
126
+ * THE one place the boundary is decided. Everything that parses a reference — the store's write-time
127
+ * check, `check`'s report, the extension's go-to-definition — goes through here, so a namespace can
128
+ * never mean one thing on write and another on read.
129
+ */
130
+ export function parseRef(ref, namespaces = []) {
131
+ if (typeof ref !== 'string' || !ref) return null;
132
+ for (const ns of namespaces) {
133
+ if (!ref.startsWith(ns + '/')) continue;
134
+ const rest = ref.slice(ns.length + 1);
135
+ const slash = rest.indexOf('/');
136
+ // `health/doctors` alone names a COLLECTION, not a record — there is no id, so it is not a
137
+ // reference. Falling through to the unnamespaced split would call it collection `health`,
138
+ // which is the ambiguity this module refuses everywhere else.
139
+ if (slash < 1 || slash === rest.length - 1) return null;
140
+ return { collection: `${ns}/${rest.slice(0, slash)}`, id: rest.slice(slash + 1) };
141
+ }
142
+ const slash = ref.indexOf('/');
143
+ if (slash < 1 || slash === ref.length - 1) return null;
144
+ return { collection: ref.slice(0, slash), id: ref.slice(slash + 1) };
145
+ }
146
+
147
+ /**
148
+ * The folder a collection's records belong in, workspace-relative and WITHOUT any module prefix
149
+ * (compile adds that for an `owns-data` module). The namespace becomes real directory nesting, which
150
+ * is the point: `health/doctors` lands in `data/health/doctors/` rather than beside `data/tasks/`.
151
+ */
152
+ export function defaultStoragePath(qualified, namespaces, dataPath = 'data') {
153
+ const ns = namespaceOf(qualified, namespaces);
154
+ const base = ns ? qualified.slice(ns.length + 1) : qualified;
155
+ return ns ? `${dataPath}/${ns}/${base}` : `${dataPath}/${base}`;
156
+ }
157
+
158
+ /**
159
+ * Storage paths that swallow each other, as sentences — or `[]`. `entries` is `[{name, path}]`.
160
+ *
161
+ * ⚠ MEASURED DATA LOSS, not a hypothetical. Give collection A the path `data/health` and collection
162
+ * B `data/health/doctors`, and A's recursive walk indexes B's records as its own: `dt A list` prints
163
+ * B's records under A's name, `check` reports B's fields as unknown fields of A, and a write through
164
+ * A can overwrite a record of B. compile reported ✔ through all of it, because nothing ever compared
165
+ * two collections' paths. Namespaces make near-misses like this ordinary, so the check is no longer
166
+ * optional.
167
+ *
168
+ * Segment-wise on purpose: `data/health` must not flag `data/health-notes`.
169
+ */
170
+ export function storageOverlaps(entries) {
171
+ const problems = [];
172
+ const sorted = [...entries].filter((e) => e.path).sort((a, b) => a.path.localeCompare(b.path));
173
+ for (const outer of sorted) {
174
+ for (const inner of sorted) {
175
+ if (outer === inner || outer.base !== inner.base) continue;
176
+ if (!inner.path.startsWith(outer.path + '/')) continue;
177
+ problems.push(`collection "${inner.name}" stores records under "${inner.path}", which is INSIDE "${outer.name}"'s folder "${outer.path}" — the outer collection would index the inner one's records as its own. Give one of them a folder of its own.`);
178
+ }
179
+ }
180
+ return problems;
181
+ }