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/init.js ADDED
@@ -0,0 +1,307 @@
1
+ // dreamteamer init — write the workspace skeleton into the current directory.
2
+ // non-interactive: flags override sensible defaults (RAD phase; prompts later).
3
+ // never compiles — compile is always explicit.
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { dump } from './yaml.js';
8
+ import { slugOrHash } from './template.js';
9
+ import { discoverModules, KINDS } from './compile.js';
10
+ import { Store } from './store.js';
11
+
12
+ const SKELETON_KINDS = ['collections', 'skills', 'agents', 'commands', 'ui-views'];
13
+
14
+ const GITIGNORE = `node_modules/
15
+ git_modules/
16
+ .dreamteamer/
17
+ .claude/
18
+ .agents/
19
+ .cursor/
20
+ .env
21
+ media/
22
+ .screenshots/
23
+ `;
24
+
25
+ // A brand-new workspace with no collections gives a user nothing to run, and makes `compile` warn
26
+ // that the module `init` just created "contributed no recognised sources" — a warning about its own
27
+ // output, which reads as a broken install. One starter collection answers both. `notes` is
28
+ // deliberately the most generic thing a workspace can hold.
29
+ const STARTER_COLLECTION = `name: notes
30
+ storage:
31
+ path: data/notes
32
+ codec: md
33
+ shape: file
34
+ suffix: note
35
+ id:
36
+ generate: '{{ created | date }}--{{ title | slug }}'
37
+ pattern: ^\\d{4}-\\d{2}-\\d{2}--[a-z0-9-]+$
38
+ schema:
39
+ type: object
40
+ required:
41
+ - title
42
+ properties:
43
+ title:
44
+ type: string
45
+ description: What this note is called.
46
+ body:
47
+ type: string
48
+ format: markdown
49
+ x-body: true
50
+ description: The note itself.
51
+ icon: sticky_note_2
52
+ title: Notes
53
+ title_template: '{{ title }}'
54
+ `;
55
+
56
+ const ENV_EXAMPLE = `# secrets for skills and modules go here (copy to .env; .env is never committed).
57
+ # modules declare the env keys they require in their package.json dreamteamer.env list.
58
+ `;
59
+
60
+ export function init({ flags = {} } = {}) {
61
+ const root = process.cwd();
62
+ const name = flags.name ?? path.basename(root);
63
+ const dataPath = flags['data-path'] ?? 'data';
64
+ const harnesses = typeof flags.harnesses === 'string' ? flags.harnesses.split(',').map((s) => s.trim()) : ['claude-code'];
65
+
66
+ // package.json: create or update — the single manifest
67
+ const pkgPath = path.join(root, 'package.json');
68
+ const pkg = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf8')) : { name, private: true, version: '0.0.1' };
69
+ pkg.dreamteamer = {
70
+ 'data-path': dataPath,
71
+ harnesses,
72
+ 'gitignore-runtime-folder': true,
73
+ 'workspace-module': name, // workspace-owned system sources live in modules/<name>/ — data and logic stay separated
74
+ 'git-modules': {},
75
+ disable: [],
76
+ ...pkg.dreamteamer,
77
+ };
78
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, '\t') + '\n');
79
+
80
+ // folder skeleton — the workspace's own sources are an inline module, kinds FLAT at its root
81
+ const wm = pkg.dreamteamer['workspace-module'];
82
+ const systemRoot = wm ? path.join(root, 'modules', wm) : root;
83
+ for (const kind of SKELETON_KINDS) fs.mkdirSync(path.join(systemRoot, kind), { recursive: true });
84
+ if (wm) {
85
+ const modulePkg = path.join(systemRoot, 'package.json');
86
+ if (!fs.existsSync(modulePkg)) {
87
+ // `files` is the npm publish surface: every kind a module can ship, since a new one here
88
+ // is an engine change that would otherwise silently stop being packaged.
89
+ fs.writeFileSync(modulePkg, JSON.stringify({ name: wm, private: true, version: '0.0.1', files: [...KINDS], dreamteamer: {} }, null, '\t') + '\n');
90
+ }
91
+ }
92
+ // one starter collection, so `compile` has something to report instead of warning about the
93
+ // empty module it just made, and so a fresh workspace has something to run.
94
+ const starter = path.join(systemRoot, 'collections', 'notes.collection.yaml');
95
+ if (!fs.existsSync(starter)) fs.writeFileSync(starter, STARTER_COLLECTION);
96
+ fs.mkdirSync(path.join(root, dataPath), { recursive: true });
97
+ fs.mkdirSync(path.join(root, 'state'), { recursive: true });
98
+
99
+ // seed the operator's user record from the git identity.
100
+ //
101
+ // The id MUST be slugOrHash(git user.name) — that is exactly how `@me` resolves in filters and
102
+ // ui-views (server.js / fsdata.ts), so seeding it from the same source is what makes the core
103
+ // /inbox view work by construction. A workspace where a user record is authored by hand under a
104
+ // different id gets an EMPTY inbox with no error (decision 99b) — hence the setup-script step in
105
+ // any workspace with a second person.
106
+ //
107
+ // No `everyone` team is seeded: `teams` was removed from core 2026-07-31. It was a one-record
108
+ // abstraction with no reader — nothing in the engine, in `check`, or in any view resolved a team.
109
+ const gitName = tryGit(root, ['config', 'user.name']) ?? 'operator';
110
+ const gitEmail = tryGit(root, ['config', 'user.email']);
111
+ const userId = slugOrHash(gitName);
112
+ const usersDir = path.join(root, dataPath, 'users');
113
+ fs.mkdirSync(usersDir, { recursive: true });
114
+ const userFile = path.join(usersDir, `${userId}.user.yaml`);
115
+ if (!fs.existsSync(userFile)) fs.writeFileSync(userFile, dump({ name: gitName, ...(gitEmail ? { email: gitEmail } : {}) }));
116
+
117
+ // .gitignore + .env.example (append-if-missing, never clobber)
118
+ appendMissing(path.join(root, '.gitignore'), GITIGNORE);
119
+ if (!fs.existsSync(path.join(root, '.env.example'))) fs.writeFileSync(path.join(root, '.env.example'), ENV_EXAMPLE);
120
+
121
+ // one init commit (if we're in a git repo)
122
+ try {
123
+ // stdio ignored on purpose: this whole block is best-effort, and execFileSync forwards the
124
+ // child's stderr to ours by default — so a plain `dreamteamer init` in a non-git folder
125
+ // printed git's raw "fatal: not a git repository" above our own handled warning.
126
+ execFileSync('git', ['add', '--all'], { cwd: root, stdio: 'ignore' });
127
+ execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: init workspace ${name}`], { cwd: root, stdio: 'ignore' });
128
+ } catch { console.warn('⚠ not a git repo (or nothing to commit) — init files written, no commit'); }
129
+
130
+ console.log(`✔ workspace ${name} initialized — run \`dreamteamer compile\` to materialize the runtime`);
131
+ return 0;
132
+ }
133
+
134
+ // dreamteamer install — restore git_modules/ working clones from the committed lockfile map
135
+ export function install({ root, pkg }) {
136
+ const map = pkg.dreamteamer?.['git-modules'] ?? {};
137
+ const names = Object.keys(map);
138
+ if (!names.length) { console.log('✔ no git-modules declared — nothing to restore'); return 0; }
139
+ fs.mkdirSync(path.join(root, 'git_modules'), { recursive: true });
140
+ for (const name of names) {
141
+ const { url, ref = 'main' } = map[name];
142
+ const dest = path.join(root, 'git_modules', name);
143
+ if (fs.existsSync(dest)) {
144
+ const head = tryGit(dest, ['rev-parse', '--abbrev-ref', 'HEAD']);
145
+ const dirty = tryGit(dest, ['status', '--porcelain']);
146
+ if (head !== ref) console.warn(`⚠ git_modules/${name}: HEAD is ${head}, lockfile says ${ref} — not touching it${dirty ? ' (dirty)' : ''}`);
147
+ else console.log(`✔ git_modules/${name} present (${ref})`);
148
+ continue;
149
+ }
150
+ console.log(`… cloning ${url} → git_modules/${name} (${ref})`);
151
+ execFileSync('git', ['clone', '--branch', ref, url, dest], { stdio: 'inherit' });
152
+ buildClone(dest, name);
153
+ }
154
+ return 0;
155
+ }
156
+
157
+ // dreamteamer update [<name>] — pull each lockfile-declared git_modules clone forward
158
+ // (ff-only on its recorded ref) and rebuild it. dirty clones are skipped, never touched.
159
+ // the caller (cli) runs compile afterwards — a pulled module may change sources.
160
+ export function update({ root, pkg }, only) {
161
+ const map = pkg.dreamteamer?.['git-modules'] ?? {};
162
+ if (only && !map[only]) throw new Error(`"${only}" is not in dreamteamer.git-modules (known: ${Object.keys(map).join(', ') || 'none'})`);
163
+ const names = only ? [only] : Object.keys(map);
164
+ if (!names.length) { console.log('✔ no git-modules declared — nothing to update'); return 0; }
165
+ for (const name of names) {
166
+ const { ref = 'main' } = map[name];
167
+ const dest = path.join(root, 'git_modules', name);
168
+ if (!fs.existsSync(dest)) { console.warn(`⚠ git_modules/${name} missing — run \`dreamteamer install\` first; skipped`); continue; }
169
+ if (tryGit(dest, ['status', '--porcelain'])) { console.warn(`⚠ git_modules/${name} is dirty — skipped (commit or stash there, then re-run)`); continue; }
170
+ const before = tryGit(dest, ['rev-parse', '--short', 'HEAD']);
171
+ try {
172
+ execFileSync('git', ['pull', '--ff-only', 'origin', ref], { cwd: dest, stdio: 'inherit' });
173
+ } catch {
174
+ console.warn(`⚠ git_modules/${name}: ff-only pull of origin/${ref} failed (diverged?) — left at ${before}`);
175
+ continue;
176
+ }
177
+ const after = tryGit(dest, ['rev-parse', '--short', 'HEAD']);
178
+ if (before === after) { console.log(`✔ git_modules/${name} already up to date (${after})`); continue; }
179
+ buildClone(dest, name); // deps/dist may have moved with the pull
180
+ console.log(`✔ git_modules/${name} ${before} → ${after}`);
181
+ }
182
+ // dev-clone semantics: these clones SHADOW any node_modules copy of the same module —
183
+ // what just updated is what runs. npm-channel modules are updated via npm, not here.
184
+ console.log('… git_modules clones shadow node_modules copies — the updated clones win');
185
+ for (const m of discoverModules(root, pkg).modules) {
186
+ if (m.channel === 'npm') console.log(`… npm-channel module ${m.name} is not managed here — \`npm update ${m.name}\` to pull it forward`);
187
+ }
188
+ return 0;
189
+ }
190
+
191
+ // a fresh clone with deps or a prepare/build script needs `npm install` to be usable
192
+ // (deps land; `prepare` runs on install and builds dist). failure warns, never crashes.
193
+ function buildClone(dest, name) {
194
+ let cp;
195
+ try { cp = JSON.parse(fs.readFileSync(path.join(dest, 'package.json'), 'utf8')); } catch { return; }
196
+ if (!cp.dependencies && !cp.scripts?.prepare && !cp.scripts?.build) return;
197
+ console.log(`… npm install in git_modules/${name}`);
198
+ const r = spawnSync('npm', ['install', '--no-fund', '--no-audit'], { cwd: dest, stdio: 'inherit' });
199
+ if (r.status !== 0) console.warn(`⚠ npm install failed in git_modules/${name} (exit ${r.status ?? r.error?.message}) — clone may be unbuilt; fix and re-run npm install there`);
200
+ }
201
+
202
+ function tryGit(cwd, args) {
203
+ try { return execFileSync('git', args, { cwd }).toString().trim() || null; } catch { return null; }
204
+ }
205
+
206
+ function appendMissing(file, block) {
207
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
208
+ const missing = block.split('\n').filter((l) => l && !existing.split('\n').includes(l));
209
+ if (missing.length) fs.writeFileSync(file, (existing ? existing.trimEnd() + '\n' : '') + missing.join('\n') + '\n');
210
+ }
211
+
212
+ // dreamteamer install --clone <url> [name] — clone a module for development AND
213
+ // record it in the committed lockfile map (story 5.3)
214
+ export function installClone(ws, url, name) {
215
+ if (!url) throw new Error('usage: dreamteamer install --clone <git-url> [name]');
216
+ name = name && !name.startsWith('--') ? name : path.basename(url, '.git');
217
+ const dest = path.join(ws.root, 'git_modules', name);
218
+ if (fs.existsSync(dest)) throw new Error(`git_modules/${name} already exists`);
219
+ fs.mkdirSync(path.join(ws.root, 'git_modules'), { recursive: true });
220
+ execFileSync('git', ['clone', url, dest], { stdio: 'inherit' });
221
+ buildClone(dest, name);
222
+ const ref = tryGit(dest, ['rev-parse', '--abbrev-ref', 'HEAD']) ?? 'main';
223
+ const pkgPath = path.join(ws.root, 'package.json');
224
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
225
+ pkg.dreamteamer['git-modules'] = { ...pkg.dreamteamer['git-modules'], [name]: { url, ref } };
226
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, '\t') + '\n');
227
+ execFileSync('git', ['add', 'package.json'], { cwd: ws.root });
228
+ execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: install ${name} (git module)`, '--', 'package.json'], { cwd: ws.root });
229
+ console.log(`✔ git_modules/${name} (ref ${ref})`);
230
+ console.log('✔ package.json dreamteamer.git-modules updated');
231
+ return 0;
232
+ }
233
+
234
+ // ---- attached repos: `repos` records, materialized ON DEMAND ------------------------------
235
+ // A `repos` record declares a related git repo and NOTHING about the schema — the other half of
236
+ // what git_modules fuses together. Modules stay in package.json because compile can't read records
237
+ // until they're restored (no .dreamteamer → no schemas → no readable records); attached repos have
238
+ // no such constraint, so they get to be data.
239
+
240
+ const relPath = (root, p) => path.relative(root, p) || '.';
241
+
242
+ /**
243
+ * Where a declared repo's working tree lives. Pure — never touches disk, so callers can resolve a
244
+ * path without materializing (that is how `status` reports presence).
245
+ *
246
+ * The engine deliberately does NOT know what `identity` means: a workspace may use it to select a
247
+ * `~/.gitconfig` includeIf folder so the clone commits as the right git user, but that resolution
248
+ * happens entirely outside the engine. Here it is just a path segment.
249
+ */
250
+ export function repoPath(ws, fields) {
251
+ if (fields.path) return path.join(ws.root, fields.path);
252
+ const base = ws.pkg.dreamteamer?.['repos-path'] ?? 'projects';
253
+ return fields.identity
254
+ ? path.join(ws.root, base, fields.identity, fields.name)
255
+ : path.join(ws.root, base, fields.name);
256
+ }
257
+
258
+ /**
259
+ * dreamteamer repos ensure <id> — materialize ONE declared repo, idempotently.
260
+ * Cheap as a stat when already present, so callers never branch on presence themselves.
261
+ */
262
+ export function ensureRepo(ws, id) {
263
+ const { fields } = new Store(ws).read('repos', id);
264
+ return materializeRepo(ws, id, fields);
265
+ }
266
+
267
+ /** dreamteamer repos ensure --all — explicit opt-in (going offline, grepping across all of them). */
268
+ export function ensureAllRepos(ws) {
269
+ const out = [];
270
+ for (const { id, fields } of new Store(ws).readAll('repos')) out.push(materializeRepo(ws, id, fields));
271
+ return out;
272
+ }
273
+
274
+ /**
275
+ * Declared repos and whether each is on disk. Presence is OBSERVED, never stored on the record —
276
+ * with lazy materialization "is it here?" is the first question anyone asks, and a stored answer
277
+ * would be wrong the moment someone deletes a folder.
278
+ */
279
+ export function listRepos(ws) {
280
+ const out = [];
281
+ for (const { id, fields } of new Store(ws).readAll('repos')) {
282
+ const dest = repoPath(ws, fields);
283
+ out.push({ id, path: relPath(ws.root, dest), present: fs.existsSync(dest) });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function materializeRepo(ws, id, fields) {
289
+ const dest = repoPath(ws, fields);
290
+ const ref = fields.ref ?? 'main';
291
+ const rp = relPath(ws.root, dest);
292
+ if (fs.existsSync(dest)) {
293
+ // same contract as install(): warn on drift, never force-sync, never touch a dirty tree
294
+ const head = tryGit(dest, ['rev-parse', '--abbrev-ref', 'HEAD']);
295
+ const dirty = tryGit(dest, ['status', '--porcelain']);
296
+ if (head && head !== ref) {
297
+ console.warn(`⚠ ${rp}: HEAD is ${head}, record says ${ref} — not touching it${dirty ? ' (dirty)' : ''}`);
298
+ }
299
+ return { id, path: rp, present: true, cloned: false, ref: head ?? ref };
300
+ }
301
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
302
+ console.log(`… cloning ${fields.url} → ${rp} (${ref})`);
303
+ execFileSync('git', ['clone', '--branch', ref, fields.url, dest], { stdio: 'inherit' });
304
+ // NO buildClone() here, unlike install(): a prototype or app repo is not an npm module and
305
+ // must never have `npm install` run in it as a side effect of being materialized.
306
+ return { id, path: rp, present: true, cloned: true, ref };
307
+ }
@@ -0,0 +1,190 @@
1
+ // presentation projection — descriptor → "how to render each field" rows, served at
2
+ // GET /api/presentation. this is the ADAPTER INVERSION (review, M3): the mapping the
3
+ // studio's transitional adapter synthesized client-side is genuinely useful, so it moves
4
+ // into the clean contract as an explicit projection; the studio consumes ONE contract and
5
+ // the client adapter shrinks to a thin path translator. shapes deliberately match what
6
+ // the studio components already speak (field {field,type,meta,schema}).
7
+
8
+ /** the projection for every collection: rows keyed by collection name + collection meta. */
9
+ export function presentation(descriptors) {
10
+ const collections = [];
11
+ const fields = {};
12
+ const relations = [];
13
+ for (const d of [...descriptors.values()].sort((a, b) => (a.order ?? 999) - (b.order ?? 999))) {
14
+ collections.push(collectionRow(d));
15
+ const rows = [];
16
+ rows.push({
17
+ field: 'id',
18
+ type: 'string',
19
+ meta: { collection: d.name, field: 'id', hidden: true, readonly: true, edit: 'input' },
20
+ schema: { name: 'id', is_primary_key: true, is_nullable: false },
21
+ });
22
+ // Synthesized like `id` above — never a schema property, so it's never written to disk
23
+ // (unknownFields would reject it) and never touches a collection's real frontmatter
24
+ // contract. `readonly: true` (not `hidden`) so it's a browse column and sortable; `formHidden`
25
+ // (studio-only convention, not a Directus concept — ContentDetail filters on it before handing
26
+ // fields to ItemForm) keeps it OUT of the record form now that PageHeader shows the richer
27
+ // author/message/date line instead (operator ask 2026-07-27). Computed server-side per
28
+ // request (server.js) from `git log` on the record's file — null for anything the compiled
29
+ // `.dreamteamer/` runtime backs (skills/agents/commands/…, gitignored) since there's no
30
+ // meaningful history to read there.
31
+ rows.push({
32
+ field: 'last-modified',
33
+ type: 'timestamp',
34
+ meta: { collection: d.name, field: 'last-modified', readonly: true, formHidden: true, view: 'date', view_options: { relative: true } },
35
+ schema: { name: 'last-modified', is_primary_key: false, is_nullable: true, default_value: null },
36
+ });
37
+ for (const [name, prop] of Object.entries(d.schema?.properties ?? {})) {
38
+ if (name === 'id') continue;
39
+ rows.push(fieldRow(d, name, prop, new Set(d.schema?.required ?? []).has(name), descriptors));
40
+ const target = referenceTargetOf(prop);
41
+ if (target) {
42
+ relations.push({ collection: d.name, field: name, related_collection: target, list: prop.type === 'array' });
43
+ }
44
+ }
45
+ fields[d.name] = rows;
46
+ }
47
+ return { collections, fields, relations };
48
+ }
49
+
50
+ function collectionRow(d) {
51
+ const props = d.schema?.properties ?? {};
52
+ const titleField = ['title', 'name', 'subject'].find((f) => f in props);
53
+ const meta = { collection: d.name, record_type: d.storage?.suffix ?? d.name };
54
+ // resolved by compile — a surface renders this and never title-cases an id itself
55
+ if (typeof d.title === 'string' && d.title.length > 0) meta.title = d.title;
56
+ if (typeof d.title_template === 'string' && d.title_template.length > 0) meta.title_template = d.title_template;
57
+ if (titleField) meta.title_field = titleField;
58
+ if (typeof d.order === 'number') meta.order = d.order;
59
+ if (Array.isArray(d.list_fields)) meta.list_fields = d.list_fields;
60
+ if (typeof d.icon === 'string') meta.icon = d.icon;
61
+ if (typeof d.group === 'string') meta.group = d.group;
62
+ if (typeof d.description === 'string' && d.description.length > 0) meta.description = d.description;
63
+ return { collection: d.name, meta, system: d.storage?.base === 'runtime' };
64
+ }
65
+
66
+ function referenceTargetOf(prop) {
67
+ const ref = prop.type === 'array' ? prop.items?.['x-reference'] : prop['x-reference'];
68
+ if (typeof ref !== 'string' || ref === '' || ref === '*') return null;
69
+ return ref;
70
+ }
71
+
72
+ /**
73
+ * The template that renders a VALUE of this field as a human label.
74
+ *
75
+ * Authored per-field with `x-title-template`, else INHERITED from the target collection's
76
+ * `title_template` — because "a company is labelled by its name" is a fact about companies, not
77
+ * about each of the eleven fields that point at one. Before this, that fact was hand-copied onto
78
+ * every referencing field as `x-display: '{{ name }}'`; 51 of the 54 sites in this workspace were
79
+ * exactly what the target already implies.
80
+ */
81
+ function titleTemplateOf(prop, descriptors) {
82
+ const own = prop.type === 'array' ? prop.items?.['x-title-template'] : prop['x-title-template'];
83
+ if (typeof own === 'string' && own.length > 0) return own;
84
+ const target = referenceTargetOf(prop);
85
+ const inherited = target ? descriptors.get(target)?.title_template : undefined;
86
+ return typeof inherited === 'string' && inherited.length > 0 ? inherited : undefined;
87
+ }
88
+
89
+ function fieldRow(d, name, prop, isRequired, descriptors) {
90
+ const meta = { collection: d.name, field: name };
91
+ if (isRequired) meta.required = true;
92
+ // JSON Schema's own `description` on the property — what the field MEANS, authored in the
93
+ // module source beside the field. Carried through verbatim so a surface can explain a field
94
+ // without a second vocabulary (the UI shows it as the property row's tooltip).
95
+ if (typeof prop.description === 'string' && prop.description.length > 0) meta.description = prop.description;
96
+ // resolved by compile (titleCase of the field name unless authored) — the label every surface
97
+ // shows, so no component title-cases a field name on its own.
98
+ if (typeof prop.title === 'string' && prop.title.length > 0) meta.title = prop.title;
99
+
100
+ let type = 'string';
101
+ const target = referenceTargetOf(prop);
102
+
103
+ if (prop['x-body'] === true) {
104
+ type = 'text';
105
+ meta.special = ['dt-body'];
106
+ meta.edit = 'input-rich-text-md';
107
+ } else if (target && prop.type !== 'array') {
108
+ meta.special = ['dt-relation-path'];
109
+ } else if (target && prop.type === 'array') {
110
+ type = 'json';
111
+ meta.special = ['dt-relation-path', 'dt-relation-list'];
112
+ } else if (prop.type === 'array' && prop.items?.type === 'object') {
113
+ type = 'json';
114
+ meta.edit = 'list';
115
+ meta.view = 'list';
116
+ const listOptions = { fields: optionFieldsOf(prop.items) };
117
+ if (typeof prop.items['x-title-template'] === 'string') listOptions.template = prop.items['x-title-template'];
118
+ meta.edit_options = listOptions;
119
+ meta.view_options = listOptions;
120
+ } else if (prop.type === 'array') {
121
+ type = 'json';
122
+ meta.edit = 'tags';
123
+ meta.view = 'tags';
124
+ } else if (prop.type === 'boolean') {
125
+ type = 'boolean';
126
+ } else if (prop.type === 'integer') {
127
+ type = 'integer';
128
+ } else if (prop.type === 'number') {
129
+ type = 'float';
130
+ } else if (prop.type === 'object') {
131
+ type = 'json';
132
+ if (prop.properties && Object.keys(prop.properties).length > 0) {
133
+ meta.edit = 'nested';
134
+ meta.view = 'nested';
135
+ const nestedOptions = { fields: optionFieldsOf(prop) };
136
+ meta.edit_options = nestedOptions;
137
+ meta.view_options = nestedOptions;
138
+ }
139
+ } else if (prop.format === 'date') {
140
+ type = 'date';
141
+ } else if (prop.format === 'date-time') {
142
+ type = 'timestamp';
143
+ } else if (prop.format === 'markdown') {
144
+ type = 'text';
145
+ }
146
+
147
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
148
+ meta.edit_options = { choices: prop.enum.map((v) => ({ text: String(v), value: v })) };
149
+ }
150
+ const tpl = titleTemplateOf(prop, descriptors);
151
+ if (typeof tpl === 'string' && tpl.length > 0) meta.view_options = { ...meta.view_options, template: tpl };
152
+
153
+ return {
154
+ field: name,
155
+ type,
156
+ meta,
157
+ schema: { name, is_primary_key: false, is_nullable: !isRequired, default_value: prop.default ?? null },
158
+ };
159
+ }
160
+
161
+ // sub-form shape (EditList rows / EditNested objects) derived recursively
162
+ function optionFieldsOf(objSchema) {
163
+ return Object.entries(objSchema.properties ?? {}).map(([field, prop]) => {
164
+ const def = { field, name: field, type: 'string' };
165
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
166
+ def.edit = 'select-dropdown';
167
+ def.edit_options = { choices: prop.enum.map((v) => ({ text: String(v), value: v })) };
168
+ } else if (prop.type === 'boolean') {
169
+ def.type = 'boolean';
170
+ } else if (prop.type === 'integer' || prop.type === 'number') {
171
+ def.type = prop.type === 'integer' ? 'integer' : 'float';
172
+ } else if (prop.type === 'array' && prop.items?.type === 'object') {
173
+ def.type = 'json';
174
+ def.edit = 'list';
175
+ def.edit_options = { fields: optionFieldsOf(prop.items) };
176
+ } else if (prop.type === 'array') {
177
+ def.type = 'json';
178
+ def.edit = 'tags';
179
+ } else if (prop.type === 'object' && prop.properties && Object.keys(prop.properties).length > 0) {
180
+ def.type = 'json';
181
+ def.edit = 'nested';
182
+ def.edit_options = { fields: optionFieldsOf(prop) };
183
+ } else if (prop.type === 'object') {
184
+ def.type = 'json';
185
+ } else if (prop.format === 'markdown') {
186
+ def.type = 'text';
187
+ }
188
+ return def;
189
+ });
190
+ }
@@ -0,0 +1,84 @@
1
+ // record⇄command evaluation — which bound commands apply to a record (or a list
2
+ // selection), and in which state. a binding is a `command-bindings` record: a m2m
3
+ // join (command × collection) carrying `can-enter` / `can-exit` filter predicates,
4
+ // evaluated with the SAME operator set as list-view filters plus one-hop outbound
5
+ // ref traversal (tier 1). serves `dreamteamer commands for`, GET /api/commands/:name,
6
+ // and (through the extension's api.ts port) the studio's Commands tab.
7
+ import { matchesFilter } from './filter.js';
8
+
9
+ // memoized `<collection>/<id>` → parsed fields (or null) for ONE evaluation pass:
10
+ // overlapping refs across a 50-record selection parse once, and filter.js stays
11
+ // pure — it never learns about the store, it just gets this callback.
12
+ export function recordResolver(store) {
13
+ const memo = new Map();
14
+ return (ref) => {
15
+ if (memo.has(ref)) return memo.get(ref);
16
+ let target = null;
17
+ const slash = typeof ref === 'string' ? ref.indexOf('/') : -1;
18
+ if (slash > 0) {
19
+ try {
20
+ const { fields } = store.read(ref.slice(0, slash), ref.slice(slash + 1));
21
+ target = { ...fields, id: ref.slice(slash + 1) };
22
+ } catch { /* dangling ref or unknown collection — narrows, never widens */ }
23
+ }
24
+ memo.set(ref, target);
25
+ return target;
26
+ };
27
+ }
28
+
29
+ /**
30
+ * every command bound to `collection`, evaluated over `ids` (possibly empty).
31
+ * target=collection bindings need no record — always runnable, invocation carries the
32
+ * collection name (so a command bound to several collections knows where to write).
33
+ * target=record bindings get a per-id state:
34
+ * done can-exit passes (the command's post-condition already holds)
35
+ * available can-enter passes (or no can-enter) and can-exit doesn't
36
+ * not-applicable can-enter fails (or the record doesn't resolve)
37
+ * `invocation` covers the ELIGIBLE ids only, space-separated — a deliberate contract:
38
+ * the UI shows it verbatim in an editable textarea, so nothing is silently dropped.
39
+ */
40
+ export function commandsFor(store, collection, ids = []) {
41
+ store.descriptor(collection); // unknown collection throws here, not deep inside a filter walk
42
+ const resolve = recordResolver(store);
43
+ const commands = new Map();
44
+ for (const { id, fields } of store.readAll('commands')) commands.set(`commands/${id}`, fields);
45
+ const rows = [];
46
+ for (const { id: bindingId, fields: b } of store.readAll('command-bindings')) {
47
+ if (b.collection !== `collections/${collection}`) continue;
48
+ const cmd = commands.get(b.command);
49
+ if (!cmd) continue; // compile rejects dangling binding refs; stay safe on a stale runtime
50
+ const row = {
51
+ binding: `command-bindings/${bindingId}`,
52
+ command: b.command,
53
+ name: cmd.name,
54
+ description: b.description ?? cmd.description ?? '',
55
+ 'argument-hint': cmd['argument-hint'] ?? null,
56
+ target: b.target ?? 'record',
57
+ };
58
+ if (row.target === 'collection') {
59
+ rows.push({ ...row, invocation: `/${cmd.name} ${collection}` });
60
+ continue;
61
+ }
62
+ const states = {};
63
+ for (const id of ids) {
64
+ const record = resolve(`${collection}/${id}`);
65
+ let state = 'not-applicable';
66
+ if (record) {
67
+ if (b['can-exit'] && matchesFilter(record, b['can-exit'], resolve)) state = 'done';
68
+ else if (!b['can-enter'] || matchesFilter(record, b['can-enter'], resolve)) state = 'available';
69
+ }
70
+ states[id] = state;
71
+ }
72
+ const eligible = ids.filter((id) => states[id] === 'available');
73
+ rows.push({
74
+ ...row,
75
+ states,
76
+ eligible,
77
+ done: ids.filter((id) => states[id] === 'done'),
78
+ invocation: eligible.length ? `/${cmd.name} ${eligible.map((id) => `${collection}/${id}`).join(' ')}` : null,
79
+ });
80
+ }
81
+ // record commands first (the headline on a record page), then collection commands; name-sorted within
82
+ rows.sort((a, b) => (a.target === b.target ? a.name.localeCompare(b.name) : a.target === 'collection' ? 1 : -1));
83
+ return { collection, ids, commands: rows };
84
+ }
package/src/records.js ADDED
@@ -0,0 +1,73 @@
1
+ // shared record primitives — parsing, id patterns, error formatting — used by
2
+ // both the validating store (hard, write-time) and check (soft, report-only).
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { load } from './yaml.js';
6
+
7
+ export function parseRecord(file, d, bodyField) {
8
+ return parseRecordText(fs.readFileSync(file, 'utf8'), d, bodyField);
9
+ }
10
+
11
+ export function parseRecordText(text, d, bodyField) {
12
+ const codec = d.storage.codec ?? 'md';
13
+ if (codec === 'yaml') return load(text) ?? {};
14
+ if (codec === 'json') return JSON.parse(text);
15
+ let fields = {};
16
+ let body = text;
17
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
18
+ if (m) {
19
+ fields = load(m[1]) ?? {};
20
+ body = text.slice(m[0].length);
21
+ }
22
+ if (bodyField && body.trim()) fields[bodyField] = body;
23
+ return fields;
24
+ }
25
+
26
+ // id patterns may use unicode property escapes — compile with the u flag
27
+ export function patternRe(pattern) {
28
+ try { return new RegExp(pattern, 'u'); } catch { return new RegExp(pattern); }
29
+ }
30
+
31
+ // ajv errors, humanized: echo the offending value (the one datum the reader needs)
32
+ export function fmtAjvError(e, fields) {
33
+ const fieldPath = e.instancePath.slice(1).replace(/\//g, '.');
34
+ const value = fieldPath ? fieldPath.split('.').reduce((v, k) => v?.[k], fields) : undefined;
35
+ if (e.keyword === 'enum') return `field ${fieldPath}: "${value}" not in enum [${e.params.allowedValues.join(', ')}]`;
36
+ return `field ${fieldPath || '(root)'}: ${JSON.stringify(value) ?? ''} ${e.message}`.trim();
37
+ }
38
+
39
+ // unknown keys relative to a schema that declares properties (typo detector)
40
+ export function unknownFields(schema, fields) {
41
+ const props = schema?.properties ?? {};
42
+ if (!Object.keys(props).length) return [];
43
+ return Object.keys(fields).filter((k) => !(k in props));
44
+ }
45
+
46
+ // ---- shared reader primitives (review finding 11: walk/EXT existed 2-4×, diverging) ----
47
+
48
+ export const EXT = { md: '.md', yaml: '.yaml', json: '.json' };
49
+
50
+ const JUNK_DIRS = new Set(['__pycache__', 'node_modules']);
51
+ const JUNK_FILE = /\.(pyc|pyo)$|^\.DS_Store$/;
52
+
53
+ // THE collection walk — junk-excluding everywhere (store/check used to see .pyc files
54
+ // compile deliberately skipped; one walk, one verdict).
55
+ export function* walk(dir) {
56
+ for (const name of fs.readdirSync(dir).sort()) {
57
+ if (name.startsWith('.') || JUNK_DIRS.has(name)) continue;
58
+ if (JUNK_FILE.test(name)) continue;
59
+ const p = path.join(dir, name);
60
+ if (fs.statSync(p).isDirectory()) yield* walk(p);
61
+ else yield p;
62
+ }
63
+ }
64
+
65
+ // ids are PATHS — but only downward ones. traversal segments, absolute paths and
66
+ // backslashes are rejected before any fs join (review finding 1: an escaping --id
67
+ // wrote a record outside the repo and orphaned others inside it).
68
+ export function assertSafeId(id) {
69
+ if (typeof id !== 'string' || id === '') throw new Error(`invalid id "${id}" — nothing was written.`);
70
+ if (id.startsWith('/') || id.includes('\\') || id.split('/').some((s) => s === '' || s === '.' || s === '..')) {
71
+ throw new Error(`invalid id "${id}" — ids are relative paths, no "."/".." segments, no leading slash. nothing was written.`);
72
+ }
73
+ }