dreamteamer 0.6.2 → 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.
- package/collections/collections.collection.yaml +13 -1
- package/collections/modules.collection.yaml +81 -0
- package/package.json +58 -56
- package/src/check.js +20 -3
- package/src/collections-cli.js +20 -3
- package/src/compile.js +203 -2
- package/src/presentation.js +12 -1
- package/src/runtime.js +21 -0
- package/src/store.js +8 -2
|
@@ -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:
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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))
|
|
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/collections-cli.js
CHANGED
|
@@ -295,15 +295,32 @@ function parseViewValue(raw) {
|
|
|
295
295
|
return raw;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* Assign `a.b.c` into a nested object, creating plain objects on the way down.
|
|
300
|
+
*
|
|
301
|
+
* An empty or null value REMOVES the key rather than writing `''` — the same convention
|
|
302
|
+
* `store.set` has always used for top-level fields (`if (v === null || v === '') delete next[k]`),
|
|
303
|
+
* extended to the nested paths the meta verbs address. Without it there was no way to take a key
|
|
304
|
+
* back out of a `ui-view`'s `options`, so a superseded option lingered as `provider: ''` and the
|
|
305
|
+
* only cure was `rm` + `add`.
|
|
306
|
+
*
|
|
307
|
+
* Intermediate objects are not created on the way to a delete: unsetting `a.b` on a record with no
|
|
308
|
+
* `a` should leave the record alone, not grow an empty `a: {}`.
|
|
309
|
+
*/
|
|
299
310
|
function assignPath(target, dotted, value) {
|
|
300
311
|
const keys = dotted.split('.');
|
|
312
|
+
const leaf = keys[keys.length - 1];
|
|
313
|
+
const unset = value === null || value === '';
|
|
301
314
|
let node = target;
|
|
302
315
|
for (const k of keys.slice(0, -1)) {
|
|
303
|
-
if (node[k] == null || typeof node[k] !== 'object' || Array.isArray(node[k]))
|
|
316
|
+
if (node[k] == null || typeof node[k] !== 'object' || Array.isArray(node[k])) {
|
|
317
|
+
if (unset) return;
|
|
318
|
+
node[k] = {};
|
|
319
|
+
}
|
|
304
320
|
node = node[k];
|
|
305
321
|
}
|
|
306
|
-
node[
|
|
322
|
+
if (unset) delete node[leaf];
|
|
323
|
+
else node[leaf] = value;
|
|
307
324
|
}
|
|
308
325
|
|
|
309
326
|
const VIEW_META_FLAGS = new Set(['id', 'json', 'force']);
|
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
|
|
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) {
|
package/src/presentation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|