dreamteamer 0.6.3 → 0.6.4

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.
@@ -69,9 +69,21 @@ schema:
69
69
  icon:
70
70
  type: string
71
71
  description: material-symbols-outlined icon name, drawn in the nav and page header. The VS Code tree maps it to the nearest codicon — an unmapped name falls back to a generic cylinder, so pick one that is already mapped or add the row.
72
+ owner:
73
+ type: string
74
+ x-reference: modules
75
+ description: >-
76
+ The module that OWNS this concept — DERIVED by compile from the base source, never authored.
77
+ An overlay adds fields to somebody else's collection and does not take it over, so `meetings`
78
+ stays owned by crm even though hq3 overlays it. This is the workspace's real partition, and
79
+ what the nav groups by.
72
80
  group:
73
81
  type: string
74
- description: Which nav folder this collection sits in. Ungrouped collections list at the top level; `system` is the plumbing group behind the gear.
82
+ description: >-
83
+ DEPRECATED as a nav axis since 2026-08-11 — the nav groups by `owner` (a module, which has a
84
+ title of its own) instead of by this string with a display-name map maintained in a surface.
85
+ Still read by nothing; kept so the change is a code revert rather than a data migration, and
86
+ because a workspace may yet want a partition that deliberately DIFFERS from its modules.
75
87
  order: 10
76
88
  list_fields: [name, last-modified]
77
89
  icon: schema
@@ -0,0 +1,81 @@
1
+ name: modules
2
+ # The modules this workspace compiled, PROJECTED by compile from what it discovered — the first
3
+ # derived collection in core, and the shape is deliberate enough to name.
4
+ #
5
+ # Every other runtime-stored collection (skills, commands, ui-views…) is STAGED from a module's
6
+ # source folder and read back by the engine. This one is neither: `package.json` stays the source of
7
+ # truth and compile keeps reading it, so a record here is a projection, never an input. Editing one
8
+ # would be editing a photograph.
9
+ #
10
+ # Why it earns a place anyway, against the three questions:
11
+ # 1. Does the ENGINE read it? It reads the DATA, which is the test that matters. `dependencies`
12
+ # names modules and is checked for cycles; `peerDependencies` names collections and is what
13
+ # lets a cross-module reference compile at all (compile.js — "cyclic module dependencies",
14
+ # "an overlay cannot compile without its base"). This is enforced structure, not annotation.
15
+ # 2. Recipe creeping into core? No. A module is the engine's own concept.
16
+ # 3. Could a module do it instead? No — nothing inside a module can enumerate the module set.
17
+ #
18
+ # What it buys is the thing a `group:` label cannot express: EDGES. A group is a partition; a
19
+ # dependency is a relation, and it has two kinds that mean different things. Once it is a
20
+ # collection, the browse, the diagram, `dt modules list` and the nav all work with no code written
21
+ # for any of them.
22
+ storage: { path: modules, codec: yaml, shape: file, suffix: module }
23
+ id:
24
+ generate: "{{ name | slug }}"
25
+ pattern: "^[a-z0-9-]+$"
26
+ title_template: "{{ name }}"
27
+ schema:
28
+ type: object
29
+ required: [name, channel]
30
+ properties:
31
+ name:
32
+ type: string
33
+ description: The module's package name, verbatim — `@dreamteamer/crm`, `hq3-workspace`.
34
+ title:
35
+ type: string
36
+ description: >-
37
+ What to CALL this module — authored as `dreamteamer.title` in its package.json, else derived
38
+ from the id. Authored because deriving cannot know an acronym: titleCase("crm") is "Crm".
39
+ A module names itself, which is what replaces a display-name map maintained in a surface.
40
+ channel:
41
+ type: string
42
+ enum: [path, git, npm, inline]
43
+ description: How this module reached the workspace. `inline` is the workspace's own sources.
44
+ path:
45
+ type: string
46
+ description: Workspace-relative root of the module's sources.
47
+ owns_data:
48
+ type: boolean
49
+ description: >-
50
+ The module keeps its records in its OWN clone rather than this workspace's data/ — so a
51
+ write there commits in that repo, and a rename spanning both is unavoidably two commits.
52
+ dependencies:
53
+ type: array
54
+ description: >-
55
+ Modules this one cannot compile without — an overlay needs its base. HARD and acyclic;
56
+ compile fails on a ring and names the peer escape hatch.
57
+ items:
58
+ type: string
59
+ x-reference: modules
60
+ peer_dependencies:
61
+ type: array
62
+ description: >-
63
+ Collections this module REFERENCES but does not own. Soft on purpose: naming a concept
64
+ rather than a module is what keeps two modules from forming a ring, and what lets a module
65
+ ship an unused reference without dragging in a whole CRM.
66
+ items:
67
+ type: string
68
+ x-reference: collections
69
+ collections:
70
+ type: array
71
+ description: >-
72
+ Collections this module contributed a source for. A collection merged from several modules
73
+ appears under EVERY one of them — which is the honest answer the flat "which module owns
74
+ this" provenance could not give (it took the first source and dropped the overlay).
75
+ items:
76
+ type: string
77
+ x-reference: collections
78
+ order: 140
79
+ list_fields: [title, name, channel, collections]
80
+ icon: deployed_code
81
+ group: system
package/package.json CHANGED
@@ -1,58 +1,60 @@
1
1
  {
2
- "name": "dreamteamer",
3
- "version": "0.6.3",
4
- "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
- "license": "Apache-2.0",
6
- "author": "Gilad Khen <giladkhen@gmail.com>",
7
- "homepage": "https://github.com/dreamteamer/dreamteamer#readme",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/dreamteamer/dreamteamer.git"
11
- },
12
- "bugs": {
13
- "url": "https://github.com/dreamteamer/dreamteamer/issues"
14
- },
15
- "keywords": [
16
- "agent",
17
- "coding-agent",
18
- "claude-code",
19
- "workspace",
20
- "compiler",
21
- "cli",
22
- "yaml",
23
- "markdown",
24
- "json-schema",
25
- "git"
26
- ],
27
- "type": "module",
28
- "engines": {
29
- "node": ">=20"
30
- },
31
- "bin": {
32
- "dreamteamer": "./bin/dreamteamer.js"
33
- },
34
- "files": [
35
- "NOTICE",
36
- "bin",
37
- "src",
38
- "collections",
39
- "skills",
40
- "agents",
41
- "commands",
42
- "command-bindings",
43
- "ui-views",
44
- "collection-templates"
45
- ],
46
- "dependencies": {
47
- "ajv": "^8.17.1",
48
- "ajv-formats": "^3.0.1",
49
- "express": "^5.2.1",
50
- "js-yaml": "^4.1.0"
51
- },
52
- "dreamteamer": {},
53
- "scripts": {
54
- "metrics": "node scripts/metrics.mjs",
55
- "metrics:check": "node scripts/metrics.mjs --check",
56
- "layers": "node scripts/layers.mjs"
57
- }
2
+ "name": "dreamteamer",
3
+ "version": "0.6.4",
4
+ "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
+ "license": "Apache-2.0",
6
+ "author": "Gilad Khen <giladkhen@gmail.com>",
7
+ "homepage": "https://github.com/dreamteamer/dreamteamer#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dreamteamer/dreamteamer.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/dreamteamer/dreamteamer/issues"
14
+ },
15
+ "keywords": [
16
+ "agent",
17
+ "coding-agent",
18
+ "claude-code",
19
+ "workspace",
20
+ "compiler",
21
+ "cli",
22
+ "yaml",
23
+ "markdown",
24
+ "json-schema",
25
+ "git"
26
+ ],
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "bin": {
32
+ "dreamteamer": "./bin/dreamteamer.js"
33
+ },
34
+ "files": [
35
+ "NOTICE",
36
+ "bin",
37
+ "src",
38
+ "collections",
39
+ "skills",
40
+ "agents",
41
+ "commands",
42
+ "command-bindings",
43
+ "ui-views",
44
+ "collection-templates"
45
+ ],
46
+ "dependencies": {
47
+ "ajv": "^8.17.1",
48
+ "ajv-formats": "^3.0.1",
49
+ "express": "^5.2.1",
50
+ "js-yaml": "^4.1.0"
51
+ },
52
+ "dreamteamer": {
53
+ "title": "System"
54
+ },
55
+ "scripts": {
56
+ "metrics": "node scripts/metrics.mjs",
57
+ "metrics:check": "node scripts/metrics.mjs --check",
58
+ "layers": "node scripts/layers.mjs"
59
+ }
58
60
  }
package/src/check.js CHANGED
@@ -74,10 +74,12 @@ export function check({ root }) {
74
74
  // parsed fields, kept for the symmetric-ref pass below (parse each record exactly once)
75
75
  const parsed = new Map();
76
76
  const inverseRules = []; // [collection, fieldPath, targetCollection, inverseField]
77
+ const softRefs = new Map(); // absent-but-declared peer collection -> how many refs point at it
77
78
 
78
79
  for (const [name, d] of descriptors) {
79
80
  const validate = ajv.compile(d.schema);
80
81
  const refFields = collectRefFields(d.schema);
82
+ const softTargets = d.unresolved_peers ? new Set(d.unresolved_peers) : null;
81
83
  const bodyField = Object.entries(d.schema.properties ?? {}).find(([, s]) => s?.['x-body'])?.[0];
82
84
  parsed.set(name, new Map());
83
85
  for (const [fieldPath, target, inverse] of refFields) {
@@ -103,7 +105,7 @@ export function check({ root }) {
103
105
  }
104
106
  for (const [fieldPath, target] of refFields) {
105
107
  for (const value of valuesAt(fields, fieldPath)) {
106
- checkRef(file, fieldPath, value, target);
108
+ checkRef(file, fieldPath, value, target, softTargets);
107
109
  }
108
110
  }
109
111
  parsed.get(name).set(id, fields);
@@ -132,7 +134,7 @@ export function check({ root }) {
132
134
  }
133
135
  }
134
136
 
135
- function checkRef(file, fieldPath, value, target) {
137
+ function checkRef(file, fieldPath, value, target, softTargets) {
136
138
  if (typeof value !== 'string') return;
137
139
  if (value.startsWith('@')) return; // runtime tokens (@me, @initiator) are legal
138
140
  const slash = value.indexOf('/');
@@ -142,7 +144,17 @@ export function check({ root }) {
142
144
  if (target !== '*' && coll !== target) {
143
145
  return flag(file, `${fieldPath.join('.')}: reference "${value}" should target collection "${target}"`);
144
146
  }
145
- if (!descriptors.has(coll)) return flag(file, `${fieldPath.join('.')}: reference "${value}" targets unknown collection "${coll}"`);
147
+ if (!descriptors.has(coll)) {
148
+ // A collection the owning module DECLARED as a peer and nothing installed provides is the
149
+ // normal state of a module opened on its own — the reference is unresolvable, not wrong.
150
+ // `unresolved_peers` is stamped by compile so this layer never has to know what a module
151
+ // is (see the record/workspace split in CLAUDE.md).
152
+ if (softTargets?.has(coll)) {
153
+ softRefs.set(coll, (softRefs.get(coll) ?? 0) + 1);
154
+ return;
155
+ }
156
+ return flag(file, `${fieldPath.join('.')}: reference "${value}" targets unknown collection "${coll}"`);
157
+ }
146
158
  if (!index.get(coll).has(id)) return flag(file, `${fieldPath.join('.')}: dangling reference "${value}" — no such record`);
147
159
  }
148
160
 
@@ -150,6 +162,11 @@ export function check({ root }) {
150
162
  for (const s of strays) {
151
163
  console.log(`⚠ ${s.file} — unrecognized file in ${s.collection} folder${s.note ? ` (${s.note})` : ''}`);
152
164
  }
165
+ // Warned, never silent: the references are real and currently resolve to nothing. This is the
166
+ // expected reading when a module is opened without the workspace that provides the concept.
167
+ for (const [coll, n] of [...softRefs].sort()) {
168
+ console.log(`⚠ peer collection "${coll}" is declared but not installed — ${n} reference${n === 1 ? '' : 's'} unresolvable`);
169
+ }
153
170
  if (violations.length === 0) {
154
171
  console.log(`✔ 0 violations (${[...index.values()].reduce((n, m) => n + m.size, 0)} records across ${descriptors.size} collections)`);
155
172
  return 0;
package/src/compile.js CHANGED
@@ -7,13 +7,14 @@ 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 { slug } from './template.js';
10
11
  import { walk } from './records.js';
11
12
  import { unknownOperators } from './filter.js';
12
13
  // circular on paper in earlier versions — safe: both sides only
13
14
  // call at run time, same pattern as store.js ↔ compile.js.
14
15
  import { runHarnessAdapters } from './harnesses.js';
15
16
  import { satisfies } from './semver.js';
16
- import { readManifest, runtimeDir } from './runtime.js';
17
+ import { DERIVED_KINDS, readManifest, runtimeDir } from './runtime.js';
17
18
 
18
19
  // re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
19
20
  // contract as `compileMod.readManifest` (engine.ts), and a removed export is the same cross-repo
@@ -64,8 +65,30 @@ function staleDisplayKeywords(schema, prefix = '') {
64
65
  return out;
65
66
  }
66
67
 
68
+ /**
69
+ * Every `x-reference` in a schema, as `[fieldPath, target]` — the same traversal check.js uses to
70
+ * resolve refs in records, here to verify the SHAPE against the module dependency graph. Nested
71
+ * objects and array items both carry the keyword, so both are walked.
72
+ */
73
+ function refTargets(schema, prefix = '') {
74
+ const out = [];
75
+ for (const [key, prop] of Object.entries(schema?.properties ?? {})) {
76
+ if (!prop || typeof prop !== 'object') continue;
77
+ const at = `${prefix}${key}`;
78
+ if (prop['x-reference']) out.push([at, prop['x-reference']]);
79
+ if (prop.items && typeof prop.items === 'object' && prop.items['x-reference']) out.push([`${at}[]`, prop.items['x-reference']]);
80
+ if (prop.properties) out.push(...refTargets(prop, `${at}.`));
81
+ if (prop.items?.properties) out.push(...refTargets(prop.items, `${at}[].`));
82
+ }
83
+ return out;
84
+ }
85
+
67
86
  export const KINDS = ['collections', 'skills', 'agents', 'commands', 'command-bindings', 'ui-views', 'collection-templates'];
68
87
  const FOLDER_KINDS = new Set(['skills']); // folder-shape entities: copy the whole record folder
88
+ // DERIVED_KINDS (projected, not staged) lives in runtime.js — the boundary both halves read. Not in
89
+ // KINDS on purpose: a module folder named `modules/` would be nonsense, and `isSystem` below keys
90
+ // off KINDS to decide `storage.base`, so a `modules` collection landing on `base: workspace` would
91
+ // point the store at the SOURCE directory and read every module folder as a record.
69
92
 
70
93
  /**
71
94
  * A module's source folder for one kind. The layout is FLAT — `<module>/skills`, beside `data/` —
@@ -238,6 +261,8 @@ export function compile({ root, pkg }) {
238
261
  const engineVer = engineVersion();
239
262
  const declaredEnv = new Map(); // env key -> [module names]
240
263
  const moduleIgnores = new Map(); // module name -> non-source folders it declares (strayKindDirs)
264
+ const moduleDeps = new Map(); // module name -> [module names] — HARD, must be acyclic
265
+ const modulePeers = new Map(); // module name -> [collection names] — SOFT, cannot cycle
241
266
  for (const source of sources) {
242
267
  let mpkg;
243
268
  try { mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8')); } catch { continue; }
@@ -246,6 +271,16 @@ export function compile({ root, pkg }) {
246
271
  if (!Array.isArray(ignore)) fail(`module "${source.name}": "ignore" must be a list of folder names (got ${JSON.stringify(ignore)})`);
247
272
  moduleIgnores.set(source.name, ignore.map(String));
248
273
  }
274
+ // npm's TERMINOLOGY, deliberately not npm's namespace: these live under `dreamteamer` so
275
+ // npm's own resolver never tries to fetch an inline or git-channel module.
276
+ for (const [key, sink] of [['dependencies', moduleDeps], ['peerDependencies', modulePeers]]) {
277
+ const decl = mpkg.dreamteamer?.[key];
278
+ if (decl === undefined) continue;
279
+ if (!Array.isArray(decl) || decl.some((v) => typeof v !== 'string')) {
280
+ fail(`module "${source.name}": dreamteamer.${key} must be a list of ${key === 'dependencies' ? 'module names' : 'collection names'} (got ${JSON.stringify(decl)})`);
281
+ }
282
+ sink.set(source.name, decl);
283
+ }
249
284
  const range = mpkg.dreamteamer?.engine;
250
285
  if (range) {
251
286
  const ok = satisfies(engineVer, range);
@@ -275,6 +310,36 @@ export function compile({ root, pkg }) {
275
310
  }
276
311
  }
277
312
 
313
+ // ---- the module dependency graph -------------------------------------------------
314
+ // `dependencies` names MODULES and must be acyclic. `peerDependencies` names COLLECTIONS and
315
+ // therefore cannot cycle at all — which is the whole reason it exists: two modules that each
316
+ // reference a concept the other owns (crm needs `products`, rnd needs `contacts`) would be an
317
+ // unbreakable ring under module-named deps, and are two independent peer declarations here.
318
+ const moduleNames = new Set(sources.map((s) => s.name));
319
+ for (const [mod, deps] of moduleDeps) {
320
+ for (const dep of deps) {
321
+ if (dep === mod) fail(`module "${mod}" declares itself as a dependency`);
322
+ if (!moduleNames.has(dep)) {
323
+ fail(`module "${mod}" depends on "${dep}", which is not installed — modules present: ${[...moduleNames].sort().join(', ')}`);
324
+ }
325
+ }
326
+ }
327
+ // DFS with an explicit path so the error can print the ring rather than just naming one module
328
+ {
329
+ const state = new Map(); // name -> 'open' | 'done'
330
+ const visit = (mod, trail) => {
331
+ if (state.get(mod) === 'done') return;
332
+ if (state.get(mod) === 'open') {
333
+ const ring = [...trail.slice(trail.indexOf(mod)), mod];
334
+ 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.`);
335
+ }
336
+ state.set(mod, 'open');
337
+ for (const dep of moduleDeps.get(mod) ?? []) visit(dep, [...trail, mod]);
338
+ state.set(mod, 'done');
339
+ };
340
+ for (const mod of moduleDeps.keys()) visit(mod, []);
341
+ }
342
+
278
343
  const dataOwners = dataOwningModules(sources, fail, rel);
279
344
 
280
345
  const disabled = new Set(config.disable ?? []);
@@ -413,6 +478,28 @@ export function compile({ root, pkg }) {
413
478
  templateDocs.set(m[1], { template: doc.template ?? {}, src: entry.sources[0] });
414
479
  }
415
480
 
481
+ // ---- who owns which collection, and which module IS the workspace ----------------
482
+ // Needed before the resolution loop so each descriptor can be validated against the graph as it
483
+ // is merged. The owner is the group member that does NOT declare `extends`; a group with two of
484
+ // those is a name collision, and the loop below raises it properly — this pass only maps.
485
+ // A module's record id: the npm scope stripped, so `@dreamteamer/crm` reads as `crm` — which is
486
+ // what every message in this engine already calls it.
487
+ const moduleId = (n) => slug(String(n).replace(/^@[^/]+\//, ''));
488
+ const collOwner = new Map(); // collection name -> owning module name
489
+ const moduleColls = new Map(); // module name -> Set(collection names it contributed to)
490
+ for (const [name, group] of descriptorGroups) {
491
+ const base = group.find((g) => !g.doc.extends);
492
+ if (base) collOwner.set(name, base.moduleName);
493
+ }
494
+ // The engine's own nine collections are an implicit dependency of every module: seven entity
495
+ // kinds plus the two the compiler materializes. Requiring every module to declare a dependency
496
+ // on the host it cannot run without would be ceremony, not verification.
497
+ const CORE_COLLECTIONS = new Set([...KINDS, ...DERIVED_KINDS, 'users', 'repos']);
498
+ const wsDir = config['workspace-module'];
499
+ const wsModuleName = wsDir
500
+ ? sources.find((s) => rel(s.root) === path.join('modules', wsDir))?.name
501
+ : pkg.name;
502
+
416
503
  // ---- resolve descriptor groups (templates + extends merge) ---------------------
417
504
  counts.collections = 0;
418
505
  let mergedCount = 0;
@@ -448,6 +535,11 @@ export function compile({ root, pkg }) {
448
535
  if (ext.doc.extends !== expected) {
449
536
  fail(`${ext.src.path}: extends "${ext.doc.extends}" does not name the base "${expected}"`);
450
537
  }
538
+ // `extends` is the hardest dependency there is — the extender does not compile at all
539
+ // without the base (see the "no base found" failure above), so it must say so.
540
+ if (ext.moduleName !== base.moduleName && !(moduleDeps.get(ext.moduleName) ?? []).includes(base.moduleName)) {
541
+ 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.`);
542
+ }
451
543
  merged = mergeDescriptor(merged, ext.doc);
452
544
  }
453
545
  delete merged.extends;
@@ -464,7 +556,8 @@ export function compile({ root, pkg }) {
464
556
  merged.storage ??= {};
465
557
  const owned = dataOwners.get(storageOwnerOf(group, base));
466
558
  const storagePath = String(merged.storage.path ?? '');
467
- const isSystem = KINDS.includes(storagePath) || KINDS.includes(storagePath.replace(/^system\//, ''));
559
+ const systemKinds = [...KINDS, ...DERIVED_KINDS];
560
+ const isSystem = systemKinds.includes(storagePath) || systemKinds.includes(storagePath.replace(/^system\//, ''));
468
561
  merged.storage.base = isSystem ? 'runtime' : 'workspace';
469
562
  if (owned && !isSystem) {
470
563
  const modRel = rel(owned.root);
@@ -487,6 +580,56 @@ export function compile({ root, pkg }) {
487
580
  } catch (e) {
488
581
  fail(`collection "${name}": schema is not a valid JSON Schema — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
489
582
  }
583
+ // ---- the reference contract: every target is owned, depended on, or declared a peer ----
584
+ // Attribution is unioned across the whole group rather than taken from the base, because the
585
+ // merge keeps no per-field provenance — an overlay that adds a ref field would otherwise be
586
+ // judged against the BASE module's declarations, which it never wrote.
587
+ const groupModules = [...new Set(group.map((g) => g.moduleName))];
588
+ // WHO OWNS the concept — the module whose source is the base, not the ones overlaying it.
589
+ // An overlay adds fields to somebody else's collection (hq3 adds `tags` to crm's contacts);
590
+ // it does not take the concept over. Measured 2026-08-11: letting the overlay win moves
591
+ // `contacts` and `meetings` out of CRM, and a CRM without contacts reads as broken.
592
+ //
593
+ // ⚠ This is NOT the `module` provenance field an outside review rejected this morning. That
594
+ // one duplicated `group:` while claiming to name every contributor, and got the merged case
595
+ // wrong by taking the first source. This names ONE thing — the owner — for which the base
596
+ // IS the answer, and it exists to REPLACE `group:` as the workspace's partition rather than
597
+ // to sit beside it.
598
+ merged.owner = `modules/${moduleId(base?.moduleName ?? groupModules[0])}`;
599
+ // EVERY contributing module, not just the base — a collection merged from crm + hq3 belongs
600
+ // to both, and saying otherwise is what made a flat "which module owns this" field wrong.
601
+ for (const m of groupModules) {
602
+ if (!moduleColls.has(m)) moduleColls.set(m, new Set());
603
+ moduleColls.get(m).add(name);
604
+ }
605
+ const declaredDeps = new Set(groupModules.flatMap((m) => moduleDeps.get(m) ?? []));
606
+ const declaredPeers = new Set(groupModules.flatMap((m) => modulePeers.get(m) ?? []));
607
+ const owns = (t) => groupModules.includes(collOwner.get(t));
608
+ for (const [at, target] of refTargets(merged.schema)) {
609
+ if (target === '*') {
610
+ // The workspace module is the orchestrating parent and may reference anything —
611
+ // including modules that do not exist yet, which is what `tasks.item` means.
612
+ // Anywhere else a wildcard is a cross-module surface no declaration can cover.
613
+ if (!groupModules.includes(wsModuleName)) {
614
+ console.warn(`⚠ collection ${name}: field "${at}" uses x-reference: '*' outside the workspace module — an unverifiable cross-module surface; name the collections it may target`);
615
+ }
616
+ continue;
617
+ }
618
+ if (CORE_COLLECTIONS.has(target) || owns(target)) continue;
619
+ const owner = collOwner.get(target);
620
+ if (owner && declaredDeps.has(owner)) continue;
621
+ if (declaredPeers.has(target)) continue;
622
+ const fix = owner
623
+ ? `add "${owner}" to dreamteamer.dependencies, or "${target}" to dreamteamer.peerDependencies if the module should work without it`
624
+ : `add "${target}" to dreamteamer.peerDependencies — no installed module provides it`;
625
+ fail(`collection "${name}": field "${at}" references "${target}", which ${groupModules.join('/')} neither owns nor declares.\n ${fix}.`);
626
+ }
627
+ // Declared peers that nothing provides, stated as DATA on the descriptor so `check` can
628
+ // excuse their references without learning what a module is (the `storage.base` precedent —
629
+ // check.js is in the record layer and must not know modules exist).
630
+ const unresolved = [...declaredPeers].filter((p) => !collOwner.has(p)).sort();
631
+ if (unresolved.length) merged.unresolved_peers = unresolved;
632
+
490
633
  // ---- resolved labels: what to CALL this collection, its records and its fields --------
491
634
  // Written into the artifact next to `storage.base` and for the same reason: the nav, the
492
635
  // browse page, the CLI and the extension then read ONE field instead of each carrying its
@@ -507,6 +650,64 @@ export function compile({ root, pkg }) {
507
650
  if (extenders.length) mergedCount++;
508
651
  }
509
652
 
653
+ // ---- modules, projected ---------------------------------------------------------
654
+ // One record per discovered module, written from what discovery and the package pass already
655
+ // established. `package.json` remains the source of truth and compile keeps reading it — this
656
+ // is a photograph, never an input (see collections/modules.collection.yaml for why it earns a
657
+ // place in core at all).
658
+ //
659
+ // The id strips an npm scope so `@dreamteamer/crm` reads as `crm`, which is what every message
660
+ // in this engine already calls it. A collision is a hard failure rather than a silent overwrite:
661
+ // two modules answering to one id would make `dependencies` ambiguous, and an ambiguous edge is
662
+ // worse than no diagram.
663
+ const idByModule = new Map();
664
+ for (const source of sources) {
665
+ const id = moduleId(source.name);
666
+ const clash = idByModule.get(id);
667
+ if (clash && clash !== source.name) fail(`modules "${clash}" and "${source.name}" both resolve to the id "${id}" — rename one.`);
668
+ idByModule.set(id, source.name);
669
+ }
670
+ for (const source of sources) {
671
+ const id = moduleId(source.name);
672
+ let mpkg = {};
673
+ try { mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8')).dreamteamer ?? {}; } catch { /* inline workspace source */ }
674
+ const record = {
675
+ name: source.name,
676
+ // Authored wins; the derived fallback title-cases the id the same way a collection's
677
+ // `title` is derived. `@dreamteamer/crm` -> "Crm" until crm declares "CRM" — which is
678
+ // the point: the module is the only place that knows.
679
+ title: typeof mpkg.title === 'string' && mpkg.title ? mpkg.title : titleCase(id),
680
+ channel: source.channel,
681
+ path: rel(source.root) || '.',
682
+ ...(mpkg['owns-data'] === true ? { owns_data: true } : {}),
683
+ // Declared module names become record IDS here, because that is what an x-reference
684
+ // resolves against. An undeclared/unknown name would dangle, and `check` would say so —
685
+ // but compile has already failed on that case (the acyclicity pass resolves every one).
686
+ // ⚠ A reference VALUE is `<collection>/<id>`, never a bare id — `check` rejects the bare
687
+ // form, which is exactly what it did to the first pass of this projection (63 violations).
688
+ ...(moduleDeps.get(source.name)?.length
689
+ ? { dependencies: moduleDeps.get(source.name).map((n) => `modules/${moduleId(n)}`) }
690
+ : {}),
691
+ ...(modulePeers.get(source.name)?.length
692
+ ? { peer_dependencies: modulePeers.get(source.name).map((c) => `collections/${c}`) }
693
+ : {}),
694
+ ...(moduleColls.get(source.name)?.size
695
+ ? { collections: [...moduleColls.get(source.name)].sort().map((c) => `collections/${c}`) }
696
+ : {}),
697
+ };
698
+ const bytes = Buffer.from(dump(record));
699
+ // ⚠ The source hash is the hash of the SOURCE FILE, not of the projected record. Hashing the
700
+ // output made every source "differ" on the next run, so `staleness` reported the workspace
701
+ // stale immediately after a clean compile — the one signal that has to stay trustworthy.
702
+ const pkgPath = path.join(source.root, 'package.json');
703
+ const pkgBytes = fs.existsSync(pkgPath) ? fs.readFileSync(pkgPath) : bytes;
704
+ entries.set(path.join('modules', `${id}.module.yaml`), {
705
+ sources: [{ path: rel(pkgPath), hash: sha256(pkgBytes) }],
706
+ bytes,
707
+ });
708
+ counts.modules = (counts.modules ?? 0) + 1;
709
+ }
710
+
510
711
  // ---- unresolved references are compile errors (an agent's declared skills)
511
712
  const skillIds = new Set([...entries.keys()].filter((k) => k.startsWith('skills/')).map((k) => k.split('/')[1]));
512
713
  for (const [rt, e] of entries) {
@@ -5,6 +5,8 @@
5
5
  // the client adapter shrinks to a thin path translator. shapes deliberately match what
6
6
  // the studio components already speak (field {field,type,meta,schema}).
7
7
 
8
+ import { sourceHint } from './runtime.js';
9
+
8
10
  /** the projection for every collection: rows keyed by collection name + collection meta. */
9
11
  export function presentation(descriptors) {
10
12
  const collections = [];
@@ -60,7 +62,16 @@ function collectionRow(d) {
60
62
  if (typeof d.icon === 'string') meta.icon = d.icon;
61
63
  if (typeof d.group === 'string') meta.group = d.group;
62
64
  if (typeof d.description === 'string' && d.description.length > 0) meta.description = d.description;
63
- return { collection: d.name, meta, system: d.storage?.base === 'runtime' };
65
+ // A compiled collection is READ-ONLY through the record layer, and the UI needs to say so
66
+ // BEFORE offering a button — an error after the click is a worse answer than a disabled
67
+ // control with a reason. The sentence comes from runtime.js so the store's refusal and this
68
+ // hint can never disagree.
69
+ const system = d.storage?.base === 'runtime';
70
+ if (system) {
71
+ meta.readonly = true;
72
+ meta.readonly_hint = sourceHint(d);
73
+ }
74
+ return { collection: d.name, meta, system };
64
75
  }
65
76
 
66
77
  function referenceTargetOf(prop) {
package/src/runtime.js CHANGED
@@ -16,6 +16,27 @@ import { load } from './yaml.js';
16
16
 
17
17
  export const RUNTIME_DIR = '.dreamteamer';
18
18
 
19
+ /**
20
+ * Runtime kinds compile PROJECTS rather than stages — they have no source folder under a module
21
+ * root, so nothing can be "edited and recompiled" in the usual place. Lives here, in the boundary,
22
+ * because it is a fact about the runtime's SHAPE: the compiler writes them and the record layer has
23
+ * to describe them, and neither half should learn it from the other (the `storage.base` precedent).
24
+ */
25
+ export const DERIVED_KINDS = ['modules'];
26
+
27
+ /**
28
+ * Where a human edits a compiled collection, as one sentence. ONE definition, because there are two
29
+ * consumers who must never drift: the store's refusal (`dt modules set …`) and the presentation
30
+ * projection the UI reads to explain a disabled button. This repo's own history is the argument —
31
+ * `git log`/`git diff` and the `?sort=` comparator were each hand-copied into the extension and
32
+ * went wrong in both places.
33
+ */
34
+ export function sourceHint(d) {
35
+ return DERIVED_KINDS.includes(d?.storage?.path)
36
+ ? "the source it was projected from (for `modules`, the module's package.json)"
37
+ : `the file under the owning module (modules/<module>/${d?.storage?.path}/)`;
38
+ }
39
+
19
40
  /** One message, two callers with different manners: the store throws it, `check` prints it. */
20
41
  export const NO_RUNTIME = 'no compiled runtime — run `dreamteamer compile` first';
21
42
 
package/src/store.js CHANGED
@@ -11,7 +11,7 @@ import { dump } from './yaml.js';
11
11
  import { generateId } from './template.js';
12
12
  import { parseRecord, parseRecordText, patternRe, fmtAjvError, unknownFields, walk, EXT, assertSafeId } from './records.js';
13
13
  import { normalizeRecord } from './temporal.js';
14
- import { NO_RUNTIME, loadDescriptors, runtimeDir, sourceRoots as compiledSourceRoots } from './runtime.js';
14
+ import { NO_RUNTIME, sourceHint, loadDescriptors, runtimeDir, sourceRoots as compiledSourceRoots } from './runtime.js';
15
15
 
16
16
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
17
17
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -46,7 +46,13 @@ export class Store {
46
46
  writableDescriptor(collection) {
47
47
  const d = this.descriptor(collection);
48
48
  if (d.storage.base === 'runtime') {
49
- throw new Error(`"${collection}" records are compiled sources edit the file under the owning module (modules/<module>/${d.storage.path}/) and run \`dreamteamer compile\``);
49
+ // Two different runtime shapes, and pointing at the wrong one is worse than saying
50
+ // nothing: a STAGED kind (skills, commands, ui-views…) really does have a source file
51
+ // under `modules/<module>/<kind>/`, while a PROJECTED one (modules) has no such folder
52
+ // and never should — its source is the module's package.json. `x-source` says which,
53
+ // stated as data on the descriptor so the store never has to know what a module is.
54
+ const from = sourceHint(d);
55
+ throw new Error(`"${collection}" records are compiled sources — edit ${from} and run \`dreamteamer compile\``);
50
56
  }
51
57
  return d;
52
58
  }