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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +83 -0
- package/agents/dreamteamer.agent.md +7 -0
- package/bin/dreamteamer.js +65 -0
- package/collection-templates/docs.collection-template.yaml +14 -0
- package/collection-templates/entity.collection-template.yaml +16 -0
- package/collections/agents.collection.yaml +33 -0
- package/collections/collection-templates.collection.yaml +20 -0
- package/collections/collections.collection.yaml +78 -0
- package/collections/command-bindings.collection.yaml +46 -0
- package/collections/commands.collection.yaml +36 -0
- package/collections/repos.collection.yaml +42 -0
- package/collections/skills.collection.yaml +22 -0
- package/collections/ui-views.collection.yaml +48 -0
- package/collections/users.collection.yaml +21 -0
- package/package.json +58 -0
- package/skills/building-dreamteamer/SKILL.md +117 -0
- package/skills/building-dreamteamer/references/agents.md +44 -0
- package/skills/building-dreamteamer/references/before-you-build.md +42 -0
- package/skills/building-dreamteamer/references/collections.md +120 -0
- package/skills/building-dreamteamer/references/commands.md +69 -0
- package/skills/building-dreamteamer/references/skills.md +73 -0
- package/skills/building-dreamteamer/references/ui-components.md +78 -0
- package/skills/building-dreamteamer/references/ui-views.md +59 -0
- package/skills/using-dreamteamer/SKILL.md +100 -0
- package/skills/using-dreamteamer/references/git-events.md +64 -0
- package/skills/using-dreamteamer/references/records.md +102 -0
- package/src/check.js +193 -0
- package/src/cli.js +250 -0
- package/src/collections-cli.js +389 -0
- package/src/commit.js +117 -0
- package/src/compile.js +747 -0
- package/src/events.js +124 -0
- package/src/field-values.js +69 -0
- package/src/filter.js +107 -0
- package/src/harnesses.js +233 -0
- package/src/history.js +64 -0
- package/src/init.js +307 -0
- package/src/presentation.js +190 -0
- package/src/record-commands.js +84 -0
- package/src/records.js +73 -0
- package/src/runtime.js +96 -0
- package/src/schema-ops.js +263 -0
- package/src/semver.js +32 -0
- package/src/server.js +291 -0
- package/src/store.js +450 -0
- package/src/template.js +98 -0
- package/src/temporal.js +149 -0
- package/src/workspace.js +51 -0
- 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
|
+
}
|