dreamteamer 0.6.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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +83 -0
  4. package/agents/dreamteamer.agent.md +7 -0
  5. package/bin/dreamteamer.js +65 -0
  6. package/collection-templates/docs.collection-template.yaml +14 -0
  7. package/collection-templates/entity.collection-template.yaml +16 -0
  8. package/collections/agents.collection.yaml +33 -0
  9. package/collections/collection-templates.collection.yaml +20 -0
  10. package/collections/collections.collection.yaml +78 -0
  11. package/collections/command-bindings.collection.yaml +46 -0
  12. package/collections/commands.collection.yaml +36 -0
  13. package/collections/repos.collection.yaml +42 -0
  14. package/collections/skills.collection.yaml +22 -0
  15. package/collections/ui-views.collection.yaml +48 -0
  16. package/collections/users.collection.yaml +21 -0
  17. package/package.json +58 -0
  18. package/skills/building-dreamteamer/SKILL.md +117 -0
  19. package/skills/building-dreamteamer/references/agents.md +44 -0
  20. package/skills/building-dreamteamer/references/before-you-build.md +42 -0
  21. package/skills/building-dreamteamer/references/collections.md +120 -0
  22. package/skills/building-dreamteamer/references/commands.md +69 -0
  23. package/skills/building-dreamteamer/references/skills.md +73 -0
  24. package/skills/building-dreamteamer/references/ui-components.md +78 -0
  25. package/skills/building-dreamteamer/references/ui-views.md +59 -0
  26. package/skills/using-dreamteamer/SKILL.md +100 -0
  27. package/skills/using-dreamteamer/references/git-events.md +64 -0
  28. package/skills/using-dreamteamer/references/records.md +102 -0
  29. package/src/check.js +193 -0
  30. package/src/cli.js +250 -0
  31. package/src/collections-cli.js +389 -0
  32. package/src/commit.js +117 -0
  33. package/src/compile.js +747 -0
  34. package/src/events.js +124 -0
  35. package/src/field-values.js +69 -0
  36. package/src/filter.js +107 -0
  37. package/src/harnesses.js +233 -0
  38. package/src/history.js +64 -0
  39. package/src/init.js +307 -0
  40. package/src/presentation.js +190 -0
  41. package/src/record-commands.js +84 -0
  42. package/src/records.js +73 -0
  43. package/src/runtime.js +96 -0
  44. package/src/schema-ops.js +263 -0
  45. package/src/semver.js +32 -0
  46. package/src/server.js +291 -0
  47. package/src/store.js +450 -0
  48. package/src/template.js +98 -0
  49. package/src/temporal.js +149 -0
  50. package/src/workspace.js +51 -0
  51. package/src/yaml.js +6 -0
package/src/compile.js ADDED
@@ -0,0 +1,747 @@
1
+ // dreamteamer compile — materialize (modules × workspace sources) into .dreamteamer,
2
+ // the single runtime read surface: copies + provenance manifest, then harness adapters.
3
+ // explicit only; nothing rebuilds implicitly.
4
+ import { createHash } from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import Ajv from 'ajv';
8
+ import addFormats from 'ajv-formats';
9
+ import { load, dump } from './yaml.js';
10
+ import { walk } from './records.js';
11
+ import { unknownOperators } from './filter.js';
12
+ // circular on paper in earlier versions — safe: both sides only
13
+ // call at run time, same pattern as store.js ↔ compile.js.
14
+ import { runHarnessAdapters } from './harnesses.js';
15
+ import { satisfies } from './semver.js';
16
+ import { readManifest, runtimeDir } from './runtime.js';
17
+
18
+ // re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
19
+ // contract as `compileMod.readManifest` (engine.ts), and a removed export is the same cross-repo
20
+ // break as a removed file — decision 139.
21
+ export { readManifest };
22
+
23
+ /**
24
+ * Identifier → display label: `finance-accounts` → "Finance Accounts".
25
+ *
26
+ * The ONE derivation of a label from an id. compile resolves it INTO the descriptor so that no
27
+ * surface re-implements it — the same lesson as `storage.base`, which lived as a re-derived path
28
+ * test in five places before it became a field. An authored `title` always wins over this.
29
+ *
30
+ * `/` is a separator because a collection id may contain one (`titles.ts` splits routes on that
31
+ * assumption). Field names cannot, which is why the extension's browser-side copy of this rule
32
+ * (`webview/src/lib/format-title.ts`, which cannot import node code) stays byte-compatible for the
33
+ * only comparison that matters — the round-trip guard in schema-ops.js.
34
+ */
35
+ export function titleCase(id) {
36
+ return String(id)
37
+ .split(/[_\-\s/]+/)
38
+ .filter(Boolean)
39
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
40
+ .join(' ');
41
+ }
42
+
43
+ /**
44
+ * Every `x-display` left in a schema, as `[fieldPath, template, referenceTarget|null]`.
45
+ *
46
+ * The keyword was renamed to `x-title-template` and mostly DELETED — its value is inherited from
47
+ * the target collection's `title_template`. There is deliberately no alias: recipes and dt-hq pin
48
+ * this engine by SHA, so nothing breaks until someone bumps a pin, and that person needs a message
49
+ * rather than silence. JSON Schema IGNORES unknown keywords, so the alternative to failing here is
50
+ * a label that quietly stops working and regresses to a raw id.
51
+ */
52
+ function staleDisplayKeywords(schema, prefix = '') {
53
+ const out = [];
54
+ for (const [key, prop] of Object.entries(schema?.properties ?? {})) {
55
+ if (!prop || typeof prop !== 'object') continue;
56
+ const at = `${prefix}${key}`;
57
+ if ('x-display' in prop) out.push([at, prop['x-display'], prop['x-reference'] ?? null]);
58
+ if (prop.items && typeof prop.items === 'object' && 'x-display' in prop.items) {
59
+ out.push([at, prop.items['x-display'], prop.items['x-reference'] ?? null]);
60
+ }
61
+ if (prop.properties) out.push(...staleDisplayKeywords(prop, `${at}.`));
62
+ if (prop.items?.properties) out.push(...staleDisplayKeywords(prop.items, `${at}[].`));
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export const KINDS = ['collections', 'skills', 'agents', 'commands', 'command-bindings', 'ui-views', 'collection-templates'];
68
+ const FOLDER_KINDS = new Set(['skills']); // folder-shape entities: copy the whole record folder
69
+
70
+ /**
71
+ * A module's source folder for one kind. The layout is FLAT — `<module>/skills`, beside `data/` —
72
+ * because KINDS is already the allowlist and the extra `system/` level named nothing the engine
73
+ * reads. `<module>/system/<kind>` is still accepted so a module can be moved independently of the
74
+ * engine that reads it (they are separate repos on separate pins).
75
+ *
76
+ * Returns the FLAT path when neither exists, so a caller that creates the folder creates it in the
77
+ * layout we want. `bothLayouts` reports the split case, which compile warns about — a module with
78
+ * half its sources in each place compiles the flat half and silently drops the rest otherwise.
79
+ */
80
+ export function kindDir(root, kind) {
81
+ const flat = path.join(root, kind);
82
+ if (fs.existsSync(flat)) return flat;
83
+ const nested = path.join(root, 'system', kind);
84
+ return fs.existsSync(nested) ? nested : flat;
85
+ }
86
+
87
+ function bothLayouts(root, kind) {
88
+ return fs.existsSync(path.join(root, kind)) && fs.existsSync(path.join(root, 'system', kind));
89
+ }
90
+
91
+ /**
92
+ * Folders a module may hold that are not sources. With kinds at the module root, "not a kind" can no
93
+ * longer mean "ignore it" — that is precisely how a kind the engine stopped knowing (`workflows`,
94
+ * removed 2026-07-31) sat in a module for two days while compile reported ✔ and a README described a
95
+ * pipeline nothing read (decision 156).
96
+ *
97
+ * So the root is ENUMERATED and an unrecognised folder is an ERROR. This list covers what a package
98
+ * generically contains; anything else the module declares in its own package.json
99
+ * (`dreamteamer.ignore`). That is real per-module variance — `services` has `dashboard/`, `agentlog`
100
+ * has `data/` — not a layout knob every module would set identically.
101
+ */
102
+ const NON_SOURCE_DIRS = new Set([
103
+ 'node_modules', 'data', 'state', 'media', 'bin', 'src', 'lib', 'scripts', 'studio',
104
+ 'docs', 'dist', 'build', 'test', 'tests', 'coverage', 'system', // 'system': the pre-flatten layout
105
+ ]);
106
+
107
+ /** Unrecognised source-root folders in a module, or [] for the workspace root (a vault legitimately
108
+ * holds arbitrary directories — this gate is about PACKAGES, whose folders all mean something). */
109
+ function strayKindDirs(source, wsRoot, declaredIgnore) {
110
+ if (path.resolve(source.root) === path.resolve(wsRoot)) return [];
111
+ const allow = new Set([...KINDS, ...NON_SOURCE_DIRS, ...declaredIgnore]);
112
+ return fs.readdirSync(source.root, { withFileTypes: true })
113
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !allow.has(e.name))
114
+ .map((e) => e.name)
115
+ .sort();
116
+ }
117
+
118
+ const sha256 = (buf) => 'sha256:' + createHash('sha256').update(buf).digest('hex');
119
+
120
+ // channel -> the directory the operator knows it by (used in shadow warnings)
121
+ export const CHANNEL_LABEL = { inline: 'modules', git: 'git_modules', npm: 'node_modules' };
122
+
123
+ // module discovery, three channels in precedence order: inline modules/* >
124
+ // git_modules/* > npm deps (declared in package.json, NOT a node_modules scan).
125
+ // same NAME in two channels = the same module delivered twice — the more local
126
+ // copy wins (npm-link semantics); shadows are returned for warning/status, never
127
+ // compiled. different-name identity collisions stay hard errors downstream.
128
+ export function discoverModules(root, pkg) {
129
+ const byName = new Map(); // name -> {name, root, channel}
130
+ const shadows = []; // {name, winner, loser} — channels
131
+ const tryAdd = (name, srcRoot, channel) => {
132
+ const existing = byName.get(name);
133
+ if (existing) { shadows.push({ name, winner: existing.channel, loser: channel }); return; }
134
+ byName.set(name, { name, root: srcRoot, channel });
135
+ };
136
+ const scanDir = (dir, channel) => {
137
+ if (!fs.existsSync(dir)) return;
138
+ for (const name of fs.readdirSync(dir).sort()) {
139
+ const srcRoot = path.join(dir, name);
140
+ const pkgPath = path.join(srcRoot, 'package.json');
141
+ if (!fs.existsSync(pkgPath)) continue;
142
+ try {
143
+ const mpkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
144
+ if ('dreamteamer' in mpkg) tryAdd(mpkg.name ?? name, srcRoot, channel);
145
+ } catch { /* unparseable package.json — skip */ }
146
+ }
147
+ };
148
+ scanDir(path.join(root, 'modules'), 'inline');
149
+ scanDir(path.join(root, 'git_modules'), 'git');
150
+ for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).sort()) {
151
+ const srcRoot = path.join(root, 'node_modules', dep);
152
+ try {
153
+ const mpkg = JSON.parse(fs.readFileSync(path.join(srcRoot, 'package.json'), 'utf8'));
154
+ if ('dreamteamer' in mpkg) tryAdd(mpkg.name ?? dep, srcRoot, 'npm');
155
+ } catch { /* dep not installed or no package.json — skip */ }
156
+ }
157
+ return { modules: [...byName.values()], shadows };
158
+ }
159
+
160
+ // ---- module-owned data ----------------------------------------------------------
161
+ // A module with `owns-data: true` keeps its records BESIDE ITSELF rather than in the
162
+ // workspace's data/. The descriptor still says `data/<collection>`; compile is what turns
163
+ // that into a path, so the module never names its host.
164
+
165
+ /** modules that own their data, by module name → {root, channel}. Validates the flag and the
166
+ * channel: records that could never be committed are a compile ERROR, not a silent zero. */
167
+ function dataOwningModules(sources, fail, rel) {
168
+ const owners = new Map();
169
+ for (const s of sources) {
170
+ let mpkg;
171
+ try { mpkg = JSON.parse(fs.readFileSync(path.join(s.root, 'package.json'), 'utf8')); } catch { continue; }
172
+ const flag = mpkg.dreamteamer?.['owns-data'];
173
+ if (flag === undefined || flag === false) continue;
174
+ if (flag !== true) fail(`module "${s.name}": "owns-data" must be true or false (got ${JSON.stringify(flag)})`);
175
+ // Decided from the CHANNEL, never by asking git — compile shells out to git nowhere and
176
+ // must keep working in a freshly-`init`ed directory that is not a repo yet.
177
+ if (s.channel === 'npm') {
178
+ fail(`module "${s.name}" sets owns-data, but it is installed under node_modules/ — that path is never committed, so its records could not be saved. Vendor it into modules/ or install it as a git module.`);
179
+ }
180
+ if (s.channel === 'git' && !fs.existsSync(path.join(s.root, '.git'))) {
181
+ fail(`module "${s.name}" sets owns-data, but ${rel(s.root)} is not a git clone — git_modules/ is gitignored by the workspace, so its records could never be committed.`);
182
+ }
183
+ owners.set(s.name, { root: s.root, channel: s.channel });
184
+ }
185
+ return owners;
186
+ }
187
+
188
+ /** The git repo that will hold a module's records: nearest `.git` at or above the module root,
189
+ * as a workspace-relative path (`.` = the workspace itself). `.git` may be a FILE — worktrees
190
+ * and submodules write a pointer file rather than a directory — so existsSync, not isDirectory. */
191
+ function repoRootOf(moduleRoot, wsRoot) {
192
+ const stop = path.resolve(wsRoot);
193
+ let dir = path.resolve(moduleRoot);
194
+ while (dir.startsWith(stop)) {
195
+ if (fs.existsSync(path.join(dir, '.git'))) return path.relative(stop, dir) || '.';
196
+ if (dir === stop) break;
197
+ dir = path.dirname(dir);
198
+ }
199
+ return '.';
200
+ }
201
+
202
+ export function shadowWarning({ name, winner, loser }) {
203
+ return `⚠ module ${name}: ${CHANNEL_LABEL[winner]} copy shadows ${CHANNEL_LABEL[loser]} copy`;
204
+ }
205
+
206
+ export function compile({ root, pkg }) {
207
+ const RUNTIME = runtimeDir(root);
208
+ const config = pkg.dreamteamer ?? {};
209
+ const harnesses = config.harnesses ?? ['claude-code'];
210
+ const rel = (p) => path.relative(root, p);
211
+
212
+ // ---- discover sources: channel modules then the workspace's own -----------------
213
+ const { modules: discovered, shadows } = discoverModules(root, pkg);
214
+ for (const s of shadows) console.warn(shadowWarning(s));
215
+ const sources = [...discovered];
216
+ // workspace-owned sources: either at the root (classic layout) or in the designated
217
+ // workspace module under modules/ (config `workspace-module` — "the workspace is itself a
218
+ // module", made literal). when the key is set the root is NOT read, so the two layouts can
219
+ // never fork — and a stray source folder up there is a loud error rather than a silent drop.
220
+ if (!config['workspace-module']) {
221
+ sources.push({ name: pkg.name, root, channel: 'inline' });
222
+ } else {
223
+ const strays = [];
224
+ if (fs.existsSync(path.join(root, 'system')) && [...walk(path.join(root, 'system'))].length) strays.push('system/');
225
+ for (const kind of KINDS) {
226
+ const dir = path.join(root, kind);
227
+ if (fs.existsSync(dir) && [...walk(dir)].length) strays.push(`${kind}/`);
228
+ }
229
+ if (strays.length) {
230
+ fail(`the workspace root contains sources (${strays.join(', ')}) but workspace-module="${config['workspace-module']}" is set — they would be silently ignored.\n move them into modules/${config['workspace-module']}/ (decision 22).`);
231
+ }
232
+ }
233
+
234
+ // ---- module package pass: engine ranges + env declarations (M4) ---------------
235
+ // both are WARNINGS, never errors — a version skew or missing secret must not
236
+ // brick a solo operator's workspace at compile time.
237
+ const engineVer = engineVersion();
238
+ const declaredEnv = new Map(); // env key -> [module names]
239
+ const moduleIgnores = new Map(); // module name -> non-source folders it declares (strayKindDirs)
240
+ for (const source of sources) {
241
+ let mpkg;
242
+ try { mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8')); } catch { continue; }
243
+ const ignore = mpkg.dreamteamer?.ignore;
244
+ if (ignore !== undefined) {
245
+ if (!Array.isArray(ignore)) fail(`module "${source.name}": "ignore" must be a list of folder names (got ${JSON.stringify(ignore)})`);
246
+ moduleIgnores.set(source.name, ignore.map(String));
247
+ }
248
+ const range = mpkg.dreamteamer?.engine;
249
+ if (range) {
250
+ const ok = satisfies(engineVer, range);
251
+ if (ok === false) console.warn(`⚠ module ${source.name} declares engine "${range}" — running engine is ${engineVer} (out of range; compile continues)`);
252
+ else if (ok === null) console.warn(`⚠ module ${source.name}: engine range "${range}" not understood by the built-in checker (see src/semver.js) — not verified`);
253
+ }
254
+ for (const k of mpkg.dreamteamer?.env ?? []) {
255
+ if (!declaredEnv.has(k)) declaredEnv.set(k, []);
256
+ declaredEnv.get(k).push(source.name);
257
+ }
258
+ }
259
+ if (declaredEnv.size) {
260
+ // .env is parsed for KEY names ONLY — values never reach any output or the manifest
261
+ const envPath = path.join(root, '.env');
262
+ if (!fs.existsSync(envPath)) {
263
+ console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
264
+ } else {
265
+ const present = new Set();
266
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
267
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
268
+ if (m) present.add(m[1]);
269
+ }
270
+ for (const [k, mods] of declaredEnv) {
271
+ if (present.has(k)) continue;
272
+ for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k} — missing from .env (see .env.example)`);
273
+ }
274
+ }
275
+ }
276
+
277
+ const dataOwners = dataOwningModules(sources, fail, rel);
278
+
279
+ const disabled = new Set(config.disable ?? []);
280
+ const disabledHits = new Set();
281
+
282
+ /** entries: runtime-relative path -> { sources: [workspace-relative], bytes } */
283
+ const entries = new Map();
284
+ const counts = {};
285
+ /** collection descriptors collected per name for extends-merging: name -> [{src, doc, moduleName}] */
286
+ const descriptorGroups = new Map();
287
+
288
+ function addEntry(runtimePath, srcPath) {
289
+ if (entries.has(runtimePath)) {
290
+ const [, kind, entity] = /^([^/]+)\/([^/]+)/.exec(runtimePath) ?? [];
291
+ const entityId = (entity ?? '').replace(/\.[^.]+\.(yaml|md|json)$/, '');
292
+ const prev = entries.get(runtimePath).sources[0].path;
293
+ fail(`name collision on ${kind?.replace(/s$/, '') ?? 'entity'} "${entityId}"
294
+ - ${prev}
295
+ - ${rel(srcPath)}
296
+ identity entities are never merged or shadowed (schemas may use 'extends').
297
+ either rename yours, or disable one: add "<module>/${entityId}" to dreamteamer.disable in package.json.`);
298
+ }
299
+ const bytes = fs.readFileSync(srcPath);
300
+ entries.set(runtimePath, { sources: [{ path: rel(srcPath), hash: sha256(bytes) }], bytes });
301
+ }
302
+
303
+ /** module names that actually put something into the compiled runtime — see the warning below */
304
+ const contributed = new Set();
305
+
306
+ for (const source of sources) {
307
+ // an unrecognised folder at a module root is a typo'd kind or a kind the engine dropped —
308
+ // both of which used to compile ✔ and contribute nothing (see NON_SOURCE_DIRS)
309
+ const strays = strayKindDirs(source, root, moduleIgnores.get(source.name) ?? []);
310
+ if (strays.length) {
311
+ fail(`module "${source.name}" (${rel(source.root)}) has folder(s) that are not a known kind: ${strays.join(', ')}
312
+ known kinds: ${KINDS.join(', ')}
313
+ if these are not sources, declare them: "dreamteamer": { "ignore": [${strays.map((s) => `"${s}"`).join(', ')}] } in ${rel(path.join(source.root, 'package.json'))}`);
314
+ }
315
+ for (const kind of KINDS) {
316
+ // a half-moved module compiles its flat half and drops the rest — say so rather than
317
+ // reporting ✔ over a silent partial read (the decision-156 failure shape)
318
+ if (bothLayouts(source.root, kind)) {
319
+ console.warn(`⚠ module ${source.name}: both ${kind}/ and system/${kind}/ exist — the flat copy wins and system/${kind}/ is NOT compiled. finish the move.`);
320
+ }
321
+ const srcDir = kindDir(source.root, kind);
322
+ if (!fs.existsSync(srcDir)) continue;
323
+ counts[kind] ??= 0;
324
+ for (const name of fs.readdirSync(srcDir).sort()) {
325
+ if (name.startsWith('.')) continue;
326
+ const entityId = name.replace(/\.[^.]+\.(yaml|md|json)$/, '');
327
+ if (disabled.has(`${source.name}/${entityId}`)) { disabledHits.add(`${source.name}/${entityId}`); continue; }
328
+ const srcPath = path.join(srcDir, name);
329
+ const isDir = fs.statSync(srcPath).isDirectory();
330
+ if (kind === 'collections' && !isDir) {
331
+ // descriptors merge via 'extends' — collect per collection name
332
+ const bytes = fs.readFileSync(srcPath);
333
+ const doc = load(bytes.toString('utf8'));
334
+ if (!doc.name || (!doc.schema && !doc.extends)) fail(`${rel(srcPath)}: descriptor needs 'name' and 'schema' (or 'extends')`);
335
+ if (!descriptorGroups.has(doc.name)) descriptorGroups.set(doc.name, []);
336
+ descriptorGroups.get(doc.name).push({ src: { path: rel(srcPath), hash: sha256(bytes) }, doc, moduleName: source.name });
337
+ contributed.add(source.name);
338
+ } else if (FOLDER_KINDS.has(kind) && isDir) {
339
+ for (const file of walk(srcPath)) {
340
+ addEntry(path.join(kind, name, path.relative(srcPath, file)), file);
341
+ contributed.add(source.name);
342
+ }
343
+ counts[kind]++;
344
+ } else if (!isDir) {
345
+ addEntry(path.join(kind, name), srcPath);
346
+ contributed.add(source.name);
347
+ counts[kind]++;
348
+ } else {
349
+ // nested dirs for file-shape kinds (e.g. date-partitioned) — recurse
350
+ for (const file of walk(srcPath)) {
351
+ addEntry(path.join(kind, path.relative(srcDir, file)), file);
352
+ contributed.add(source.name);
353
+ counts[kind]++;
354
+ }
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ // A module that ships only folders the engine does not recognise compiles ✔ and contributes
361
+ // NOTHING. Warn; do not fail, since a module that is temporarily source-free is the
362
+ // operator's business, not the compiler's.
363
+ for (const source of sources) {
364
+ if (contributed.has(source.name)) continue;
365
+ console.warn(`⚠ module "${source.name}" (${rel(source.root)}) contributed no recognised sources — its folder names must match a known kind (${KINDS.join(', ')})`);
366
+ }
367
+
368
+ // ---- stage module UI bundles ---------------------------------------------------
369
+ // modules ship a PRE-BUILT app.js that registers components/layouts against the studio
370
+ // registry (design "the UI": components are module code, never records). staged under
371
+ // .dreamteamer/ui/<module>/app.js; the server serves /ui, the studio imports and calls it.
372
+ // studio/dist/app.js (a built bundle) wins over studio/app.js (plain-JS, host-provided Vue).
373
+ const uiModules = [];
374
+ const uiOwners = new Map(); // shortName -> module name, for a readable collision error
375
+ for (const source of sources) {
376
+ const cand = ['studio/dist/app.js', 'studio/app.js']
377
+ .map((p) => path.join(source.root, p))
378
+ .find((p) => fs.existsSync(p));
379
+ if (!cand) continue;
380
+ // short name = full package name, url-safe: "@" stripped, "/" → "--"
381
+ // (@a/crm and @b/crm used to both stage ui/crm — audit finding 4). unscoped
382
+ // names are unchanged, so existing /ui/<name>/app.js paths survive.
383
+ const shortName = source.name.replace(/^@/, '').replace(/\//g, '--');
384
+ const prevOwner = uiOwners.get(shortName);
385
+ if (prevOwner) fail(`ui bundle collision: modules "${prevOwner}" and "${source.name}" both stage ui/${shortName}/app.js — rename one package.`);
386
+ uiOwners.set(shortName, source.name);
387
+ addEntry(path.join('ui', shortName, 'app.js'), cand);
388
+ uiModules.push(shortName);
389
+ }
390
+
391
+ // ---- collection-templates, for `templates:` merging ----------------------------
392
+ // A template is a live, shared field set — not the copy-once scaffold `collections add
393
+ // --template` used to stamp out. A descriptor declaring `templates: [collection-templates/x]`
394
+ // gets x's `template:` merged in BEFORE base/extender discrimination, with precedence
395
+ // template < base < overlay. (The key is `templates:`, not `extends:` — `extends:` already
396
+ // means "this descriptor overlays another module's collection of the same name".)
397
+ const templateDocs = new Map(); // id -> { template, src }
398
+ for (const [rt, entry] of entries) {
399
+ const m = /^collection-templates\/(.+)\.collection-template\.yaml$/.exec(rt);
400
+ if (!m) continue;
401
+ const doc = load(entry.bytes.toString('utf8'));
402
+ templateDocs.set(m[1], { template: doc.template ?? {}, src: entry.sources[0] });
403
+ }
404
+
405
+ // ---- resolve descriptor groups (templates + extends merge) ---------------------
406
+ counts.collections = 0;
407
+ let mergedCount = 0;
408
+ let templatedCount = 0;
409
+ for (const [name, group] of descriptorGroups) {
410
+ // a template's bytes feed the compiled descriptor, so it MUST be one of that descriptor's
411
+ // declared sources — otherwise editing the template leaves every consumer silently stale
412
+ // and `warnIfStale` has nothing to compare against.
413
+ const templateSources = [];
414
+ for (const g of group) {
415
+ const decl = g.doc.templates;
416
+ if (decl === undefined) continue;
417
+ if (!Array.isArray(decl)) fail(`${g.src.path}: 'templates' must be a list of collection-templates/<id> refs`);
418
+ for (const ref of decl) {
419
+ const id = String(ref).replace(/^collection-templates\//, '');
420
+ const t = templateDocs.get(id);
421
+ if (!t) fail(`${g.src.path}: templates references "${ref}" — no such collection-template (have: ${[...templateDocs.keys()].join(', ') || 'none'})`);
422
+ g.doc = applyTemplate(g.doc, t.template);
423
+ templateSources.push(t.src);
424
+ templatedCount++;
425
+ }
426
+ delete g.doc.templates;
427
+ }
428
+
429
+ const bases = group.filter((g) => !g.doc.extends);
430
+ const extenders = group.filter((g) => g.doc.extends);
431
+ if (bases.length === 0) fail(`collection "${name}": every descriptor declares 'extends' — no base found (${group.map((g) => g.src.path).join(', ')})`);
432
+ if (bases.length > 1) fail(`name collision on collection "${name}"\n${bases.map((b) => ` - ${b.src.path}`).join('\n')}\n same-name descriptors must declare 'extends: <module>/<collection>'.`);
433
+ const base = bases[0];
434
+ let merged = structuredClone(base.doc);
435
+ for (const ext of extenders) {
436
+ const expected = `${base.moduleName}/${name}`;
437
+ if (ext.doc.extends !== expected) {
438
+ fail(`${ext.src.path}: extends "${ext.doc.extends}" does not name the base "${expected}"`);
439
+ }
440
+ merged = mergeDescriptor(merged, ext.doc);
441
+ }
442
+ delete merged.extends;
443
+ // ---- resolved storage: the path, the owning repo, and which root it hangs off ---
444
+ // The three facts the record layer needs stated as DATA, so it never has to re-derive
445
+ // them from the shape of a path (see runtime.js). `storage.path` stays root-relative;
446
+ // `storage.repo` is read by the git layer alone; `storage.base` says WHICH root — and
447
+ // this is the only place that decides it.
448
+ //
449
+ // A runtime-based collection's storage path IS a kind folder (`skills`), so an exact KINDS
450
+ // match is the test. It used to be a `system/` prefix check, which the flatten silently
451
+ // inverted: every one of the seven would have compiled as `base: workspace`, resolved under
452
+ // the workspace root, read as zero records, and become writable through the store.
453
+ merged.storage ??= {};
454
+ const owned = dataOwners.get(storageOwnerOf(group, base));
455
+ const storagePath = String(merged.storage.path ?? '');
456
+ const isSystem = KINDS.includes(storagePath) || KINDS.includes(storagePath.replace(/^system\//, ''));
457
+ merged.storage.base = isSystem ? 'runtime' : 'workspace';
458
+ if (owned && !isSystem) {
459
+ const modRel = rel(owned.root);
460
+ merged.storage.path = modRel ? `${modRel}/${merged.storage.path}` : merged.storage.path;
461
+ merged.storage.repo = repoRootOf(owned.root, root);
462
+ } else {
463
+ merged.storage.repo = '.';
464
+ }
465
+ for (const [at, tpl, target] of staleDisplayKeywords(merged.schema)) {
466
+ const fix = target
467
+ ? `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`
468
+ : 'rename it to `x-title-template`';
469
+ fail(`collection "${name}": field "${at}" uses \`x-display: ${tpl}\` — that keyword was renamed; ${fix}. (${group.map((g) => g.src.path).join(', ')})`);
470
+ }
471
+ // the merged schema must itself be a compilable JSON Schema — a malformed property
472
+ // (e.g. a string where an object belongs) used to pass compile and detonate at the
473
+ // first record validation. caught HERE so the schema-ops dry-run gate is airtight.
474
+ try {
475
+ descriptorAjv().compile(structuredClone(merged.schema));
476
+ } catch (e) {
477
+ fail(`collection "${name}": schema is not a valid JSON Schema — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
478
+ }
479
+ // ---- resolved labels: what to CALL this collection, its records and its fields --------
480
+ // Written into the artifact next to `storage.base` and for the same reason: the nav, the
481
+ // browse page, the CLI and the extension then read ONE field instead of each carrying its
482
+ // own title-caser. Authored values always win — `??=` never overwrites. After the ajv gate
483
+ // on purpose: a malformed property must fail as a bad schema, not as a TypeError here.
484
+ merged.title ??= titleCase(name);
485
+ const labelProps = merged.schema?.properties ?? {};
486
+ // how a RECORD of this collection is labelled — the probe presentation.js has always used
487
+ // for `meta.title_field`, promoted to an authorable field. Reference fields pointing here
488
+ // inherit it (presentation.js), which is what replaces 51 hand-written `x-display` lines.
489
+ merged.title_template ??= `{{ ${['title', 'name', 'subject'].find((f) => f in labelProps) ?? 'id'} }}`;
490
+ for (const [fieldName, prop] of Object.entries(labelProps)) {
491
+ if (prop && typeof prop === 'object' && !Array.isArray(prop)) prop.title ??= titleCase(fieldName);
492
+ }
493
+ const rt = path.join('collections', `${name}.collection.yaml`);
494
+ entries.set(rt, { sources: [...group.map((g) => g.src), ...templateSources], bytes: Buffer.from(dump(merged)) });
495
+ counts.collections++;
496
+ if (extenders.length) mergedCount++;
497
+ }
498
+
499
+ // ---- unresolved references are compile errors (an agent's declared skills)
500
+ const skillIds = new Set([...entries.keys()].filter((k) => k.startsWith('skills/')).map((k) => k.split('/')[1]));
501
+ for (const [rt, e] of entries) {
502
+ if (rt.startsWith('agents/')) {
503
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(e.bytes.toString('utf8'));
504
+ const doc = fm ? load(fm[1]) : {};
505
+ for (const sk of doc.skills ?? []) {
506
+ if (!skillIds.has(String(sk).replace(/^skills\//, ''))) fail(`${rt}: references unknown skill "${sk}"`);
507
+ }
508
+ }
509
+ }
510
+ if (disabledHits.size < disabled.size) {
511
+ for (const d of disabled) if (!disabledHits.has(d)) console.warn(`⚠ dreamteamer.disable entry "${d}" matched nothing`);
512
+ }
513
+
514
+ // ---- ui-view layout validation --------------------------------------------------
515
+ // layouts are registered module code; a view naming an unregistered layout fails loudly
516
+ // naming the registered set (design guardrail: "unknown layout = compile error").
517
+ // core set = the studio's built-ins; modules declare theirs in package.json
518
+ // dreamteamer.studio.layouts (the same file their app.js registration lives beside).
519
+ // KEEP IN SYNC with the UI's `lists.register(...)` calls (dreamteamer-vscode
520
+ // webview/src/registry/register-defaults.ts). kanban/calendar/map landed there as core Lists in
521
+ // the 2026-07-27 layouts wave but this set was never widened, so the only way to get a
522
+ // `layout: kanban` view past compile was for a module to CLAIM the layout it didn't own — which
523
+ // is what a workspace module was once caught doing, shadowing the core board in the registry (a
524
+ // module's app.js loads after the built-ins and Map.set wins). Fixed both ends 2026-07-29.
525
+ const registeredLayouts = new Set(['table', 'cards', 'kanban', 'calendar', 'map']);
526
+ for (const source of sources) {
527
+ try {
528
+ const mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8'));
529
+ for (const l of mpkg.dreamteamer?.studio?.layouts ?? []) registeredLayouts.add(l);
530
+ } catch { /* root-workspace source without package.json */ }
531
+ }
532
+ for (const [rt, e] of entries) {
533
+ if (!rt.startsWith('ui-views/')) continue;
534
+ const view = load(e.bytes.toString('utf8'));
535
+ if (view?.target === 'list' && view?.layout && !registeredLayouts.has(view.layout)) {
536
+ fail(`${rt}: layout "${view.layout}" is not registered (registered: ${[...registeredLayouts].sort().join(', ')}).\n a module registers layouts in its studio app.js AND declares them in package.json under dreamteamer.studio.layouts.`);
537
+ }
538
+ // filters are load-bearing (they narrow what the operator SEES) — typo'd operators
539
+ // fail at compile, not silently at render (review finding 5)
540
+ const badOps = view?.filter ? [...unknownOperators(view.filter)] : [];
541
+ if (badOps.length) fail(`${rt}: unknown filter operator(s) ${badOps.join(', ')}`);
542
+ }
543
+
544
+ // ---- command-binding validation --------------------------------------------------
545
+ // a binding joins a command to a collection under can-enter/can-exit predicates;
546
+ // dangling refs and typo'd operators fail HERE, not silently at evaluation (the same
547
+ // guarantee ui-view filters get — validators are load-bearing, they gate what runs).
548
+ const commandIds = new Set([...entries.keys()].filter((k) => k.startsWith('commands/')).map((k) => path.basename(k).replace(/\.command\.md$/, '')));
549
+ for (const [rt, e] of entries) {
550
+ if (!rt.startsWith('command-bindings/')) continue;
551
+ const b = load(e.bytes.toString('utf8'));
552
+ const cmd = String(b?.command ?? '').replace(/^commands\//, '');
553
+ if (!cmd || !commandIds.has(cmd)) fail(`${rt}: references unknown command "${b?.command ?? ''}"`);
554
+ const coll = String(b?.collection ?? '').replace(/^collections\//, '');
555
+ if (!coll || !descriptorGroups.has(coll)) fail(`${rt}: references unknown collection "${b?.collection ?? ''}"`);
556
+ for (const key of ['can-enter', 'can-exit']) {
557
+ const badBindOps = b?.[key] ? [...unknownOperators(b[key])] : [];
558
+ if (badBindOps.length) fail(`${rt}: ${key} has unknown filter operator(s) ${badBindOps.join(', ')}`);
559
+ if (b?.[key] && b?.target === 'collection') console.warn(`⚠ ${rt}: ${key} is ignored — target=collection bindings evaluate no record`);
560
+ }
561
+ }
562
+
563
+ // ---- materialize .dreamteamer ------------------------------------------------
564
+ // mkdir the runtime ROOT unconditionally: with zero entries nothing below created it, so the
565
+ // manifest write at the end failed ENOENT — `init` followed by `compile` in a fresh workspace
566
+ // crashed on the one path a new user takes first.
567
+ fs.mkdirSync(RUNTIME, { recursive: true });
568
+ // clear each kind's folder, plus `system/` — a runtime compiled by a pre-flatten engine has the
569
+ // whole tree under there, and leaving it would keep stale descriptors on disk beside the fresh
570
+ // ones. Never `rm -rf` the runtime root itself: it also holds the write lock.
571
+ for (const kind of KINDS) fs.rmSync(path.join(RUNTIME, kind), { recursive: true, force: true });
572
+ fs.rmSync(path.join(RUNTIME, 'system'), { recursive: true, force: true });
573
+ fs.rmSync(path.join(RUNTIME, 'ui'), { recursive: true, force: true });
574
+ for (const [rt, e] of entries) {
575
+ const dest = path.join(RUNTIME, rt);
576
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
577
+ fs.writeFileSync(dest, e.bytes);
578
+ }
579
+
580
+ // ---- harness adapters (dispatch table lives in harnesses.js) -------------------
581
+ const prevManifest = readManifest(root);
582
+ // What the harness blocks should TELL an agent about where sources live — measured, not assumed.
583
+ // A workspace still on the nested layout was being handed prose naming the flat one, and that
584
+ // block is the first thing a session reads.
585
+ const anyFlat = sources.some((s) => KINDS.some((k) => fs.existsSync(path.join(s.root, k))));
586
+ const anyNested = sources.some((s) => KINDS.some((k) => fs.existsSync(path.join(s.root, 'system', k))));
587
+ const sourceLayout = anyFlat && anyNested ? 'mixed' : anyNested ? 'nested' : 'flat';
588
+ const { outputs: adapterOutputs, summary: harnessSummary } = runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout });
589
+
590
+ // ---- provenance manifest ------------------------------------------------------
591
+ const manifest = {
592
+ compiled: new Date().toISOString(),
593
+ host: engineId(),
594
+ modules: sources.map((s) => ({ name: s.name, channel: s.channel, root: rel(s.root) || '.' })),
595
+ ui: uiModules.sort(),
596
+ 'adapter-outputs': adapterOutputs.sort(),
597
+ entries: Object.fromEntries(
598
+ [...entries].map(([rt, e]) => [rt, { sources: e.sources, hash: sha256(e.bytes) }]) // sources: [{path, hash}] — per-SOURCE hashes power staleness
599
+ ),
600
+ };
601
+ fs.writeFileSync(path.join(RUNTIME, 'manifest.yaml'), dump(manifest));
602
+
603
+ const summary = KINDS.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}${k === 'collections' && mergedCount ? ` (${mergedCount} merged)` : ''}`).join(', ');
604
+ const sourceLabel = config['workspace-module']
605
+ ? `${sources.length} module(s) (workspace-module: ${config['workspace-module']})`
606
+ : `${sources.length - 1} module(s) + workspace`;
607
+ console.log(`✔ compiled ${summary || 'nothing'} from ${sourceLabel} → .dreamteamer`);
608
+ for (const line of harnessSummary) console.log(`✔ harness ${line}`);
609
+ return 0;
610
+ }
611
+
612
+ function engineId() {
613
+ try {
614
+ const p = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
615
+ return `${p.name}@${p.version}`;
616
+ } catch { return 'dreamteamer@unknown'; }
617
+ }
618
+
619
+ // bare version of the RUNNING engine (dev clone or installed copy — whichever loaded)
620
+ export function engineVersion() {
621
+ return engineId().split('@').pop();
622
+ }
623
+
624
+ // staleness: does any manifest entry's SOURCE differ from what was compiled, or is a
625
+ // source file missing/new? used by `status` and warned about at every tool entry.
626
+ export function staleness(root) {
627
+ const manifest = readManifest(root);
628
+ if (!manifest) return { compiled: false, stale: [], message: 'no compiled runtime — run `dreamteamer compile`' };
629
+ const stale = [];
630
+ for (const [rt, e] of Object.entries(manifest.entries ?? {})) {
631
+ for (const src of e.sources) {
632
+ // sources are {path, hash}; tolerate the pre-merge string form
633
+ const srcPath = typeof src === 'string' ? src : src.path;
634
+ const srcHash = typeof src === 'string' ? e.hash : src.hash;
635
+ const p = path.join(root, srcPath);
636
+ if (!fs.existsSync(p)) stale.push(`${srcPath} (removed)`);
637
+ else if (sha256(fs.readFileSync(p)) !== srcHash) stale.push(`${srcPath} (changed)`);
638
+ }
639
+ }
640
+ // new source files not present in the manifest — scan winning module roots across
641
+ // ALL channels (shadowed copies were not compiled, so their files are not "new")
642
+ const known = new Set(Object.values(manifest.entries ?? {}).flatMap((e) => e.sources.map((s) => (typeof s === 'string' ? s : s.path))));
643
+ let pkg = {};
644
+ try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* no pkg */ }
645
+ const wm = pkg.dreamteamer?.['workspace-module'];
646
+ const roots = [...(wm ? [] : [root]), ...discoverModules(root, pkg).modules.map((m) => m.root)];
647
+ for (const r of roots) {
648
+ for (const kind of KINDS) {
649
+ const dir = kindDir(r, kind);
650
+ if (!fs.existsSync(dir)) continue;
651
+ for (const f of walk(dir)) {
652
+ const relPath = path.relative(root, f);
653
+ if (!known.has(relPath)) stale.push(`${relPath} (new, uncompiled)`);
654
+ }
655
+ }
656
+ }
657
+ return { compiled: true, stale, manifest };
658
+ }
659
+
660
+ export function warnIfStale(root) {
661
+ const s = staleness(root);
662
+ if (!s.compiled) console.warn(`⚠ ${s.message}`);
663
+ else if (s.stale.length) console.warn(`⚠ .dreamteamer is stale (${s.stale.length} source(s) differ) — run \`dreamteamer compile\``);
664
+ return s;
665
+ }
666
+
667
+
668
+ // `templates:` merge — the DESCRIPTOR always wins, and its own key order is preserved (unlike
669
+ // mergeDescriptor, whose extender wins). Two properties of this that matter:
670
+ //
671
+ // - the descriptor keeps `extends` and `name`: losing `extends` would silently demote an overlay
672
+ // to a second base and collide with the real one.
673
+ // - template-added properties are inserted BEFORE the x-body property, not appended after it.
674
+ // Property order is form order in the studio, and the record's body belongs last — metadata
675
+ // about a record should not render below the record's content.
676
+ function applyTemplate(doc, tpl) {
677
+ const out = structuredClone(doc);
678
+ for (const [k, v] of Object.entries(tpl)) {
679
+ if (k === 'schema') continue;
680
+ if (out[k] === undefined) out[k] = structuredClone(v);
681
+ }
682
+ if (!tpl.schema) return out;
683
+ out.schema ??= { type: 'object' };
684
+ for (const [sk, sv] of Object.entries(tpl.schema)) {
685
+ if (sk === 'properties') {
686
+ const own = out.schema.properties ?? {};
687
+ const add = Object.entries(sv).filter(([pk]) => own[pk] === undefined);
688
+ const bodyKey = Object.entries(own).find(([, s]) => s?.['x-body'])?.[0];
689
+ const merged = {};
690
+ for (const [pk, pv] of Object.entries(own)) {
691
+ if (pk === bodyKey) for (const [ak, av] of add) merged[ak] = structuredClone(av);
692
+ merged[pk] = pv;
693
+ }
694
+ if (!bodyKey) for (const [ak, av] of add) merged[ak] = structuredClone(av);
695
+ out.schema.properties = merged;
696
+ } else if (sk === 'required') {
697
+ out.schema.required = [...new Set([...(out.schema.required ?? []), ...sv])];
698
+ } else if (out.schema[sk] === undefined) out.schema[sk] = sv;
699
+ }
700
+ return out;
701
+ }
702
+
703
+ /** Which module supplied the descriptor's WINNING storage block. mergeDescriptor lets an
704
+ * extender win on any non-schema key (`else out[k] = v`), so "storage comes from the base" is
705
+ * the default, not a guarantee — an overlay that declares its own storage overrides it, and
706
+ * ownership must follow the block that actually survived. */
707
+ function storageOwnerOf(group, base) {
708
+ let owner = base.moduleName;
709
+ for (const g of group) if (g.doc.extends && g.doc.storage) owner = g.moduleName;
710
+ return owner;
711
+ }
712
+
713
+ // extends merge: schema.properties merge per-property, required unions, other
714
+ // keys extender-wins; storage/id come from the base unless explicitly overridden.
715
+ function mergeDescriptor(base, ext) {
716
+ const out = structuredClone(base);
717
+ for (const [k, v] of Object.entries(ext)) {
718
+ if (k === 'extends' || k === 'name') continue;
719
+ if (k === 'schema') {
720
+ out.schema ??= { type: 'object', properties: {} };
721
+ for (const [sk, sv] of Object.entries(v)) {
722
+ if (sk === 'properties') out.schema.properties = { ...out.schema.properties, ...sv };
723
+ else if (sk === 'required') out.schema.required = [...new Set([...(out.schema.required ?? []), ...sv])];
724
+ else out.schema[sk] = sv;
725
+ }
726
+ } else out[k] = v;
727
+ }
728
+ return out;
729
+ }
730
+
731
+ // a bad source THROWS (review finding 8: process.exit killed --watch on the first typo
732
+ // and made server-triggered recompiles impossible). the CLI boundary prints and exits.
733
+ export class CompileError extends Error {}
734
+
735
+ let _descriptorAjv = null;
736
+ function descriptorAjv() {
737
+ if (!_descriptorAjv) {
738
+ _descriptorAjv = new Ajv({ allErrors: true, strict: false });
739
+ addFormats(_descriptorAjv);
740
+ _descriptorAjv.addFormat('markdown', true);
741
+ }
742
+ return _descriptorAjv;
743
+ }
744
+
745
+ function fail(msg) {
746
+ throw new CompileError(`compile error: ${msg}`);
747
+ }