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/check.js ADDED
@@ -0,0 +1,193 @@
1
+ // dreamteamer check — validate every record against the compiled descriptors.
2
+ // report-only: JSON Schema (ajv), id patterns, x-reference resolution, stray files.
3
+ // NEVER modifies a file. returns the number of violations.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import Ajv from 'ajv';
7
+ import addFormats from 'ajv-formats';
8
+ import { parseRecord, patternRe, fmtAjvError, unknownFields, walk, EXT } from './records.js';
9
+ import { NO_RUNTIME, loadDescriptors, runtimeDir } from './runtime.js';
10
+
11
+ export function check({ root }) {
12
+ const RUNTIME = runtimeDir(root);
13
+ const rel = (p) => path.relative(root, p);
14
+
15
+ // useDefaults matches the STORE's validator (review finding 11: the two paths could
16
+ // reach different verdicts on identical bytes). check never writes — the defaults
17
+ // materialize into the in-memory copy only.
18
+ const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true, coerceTypes: 'array' });
19
+ addFormats(ajv);
20
+ ajv.addFormat('markdown', true); // rich-text marker, not a syntax to validate
21
+
22
+ // ---- load compiled descriptors ------------------------------------------------
23
+ const descriptors = loadDescriptors(root);
24
+ if (!descriptors) {
25
+ console.error(`✖ ${NO_RUNTIME}`);
26
+ return 2;
27
+ }
28
+
29
+ // ---- index all records: collection -> Map<id, filePath> ------------------------
30
+ const index = new Map();
31
+ const strays = [];
32
+ // declared here rather than beside the validation pass: indexing can itself produce a
33
+ // finding (an unreachable data root, below) before a single record is read.
34
+ const violations = [];
35
+ for (const [name, d] of descriptors) {
36
+ const ids = new Map();
37
+ index.set(name, ids);
38
+ // runtime-based (knowhow/meta) collections are read from the COMPILED runtime —
39
+ // their sources may live in any module; .dreamteamer is the merged read surface
40
+ const dir = path.join(d.storage.base === 'runtime' ? RUNTIME : root, d.storage.path);
41
+ // An unreachable data ROOT is a finding, not a skip: a collection whose module clone is
42
+ // missing otherwise reports zero records and a clean check — a silent success. An EMPTY
43
+ // directory stays fine (a module with no records yet is normal); only a missing owning
44
+ // repo counts.
45
+ const repoRoot = path.resolve(root, d.storage.repo ?? '.');
46
+ if (!fs.existsSync(dir) && (d.storage.repo ?? '.') !== '.' && !fs.existsSync(repoRoot)) {
47
+ violations.push({ file: d.storage.path, msg: `collection "${name}" is owned by ${d.storage.repo}, which is not present — every record in it is unreadable` });
48
+ continue;
49
+ }
50
+ if (!fs.existsSync(dir)) continue;
51
+ const shape = d.storage.shape ?? 'file';
52
+ if (shape === 'folder') {
53
+ for (const entry of fs.readdirSync(dir).sort()) {
54
+ if (entry.startsWith('.')) continue;
55
+ const p = path.join(dir, entry);
56
+ if (!fs.statSync(p).isDirectory()) { strays.push({ collection: name, file: rel(p) }); continue; }
57
+ const main = path.join(p, d.storage.entry ?? 'SKILL.md');
58
+ if (fs.existsSync(main)) ids.set(entry, main);
59
+ else strays.push({ collection: name, file: rel(p), note: `missing entry file ${d.storage.entry}` });
60
+ }
61
+ } else {
62
+ const tail = `.${d.storage.suffix}${EXT[d.storage.codec ?? 'md']}`;
63
+ for (const f of walk(dir)) {
64
+ const r = path.relative(dir, f);
65
+ if (r.endsWith(tail)) ids.set(r.slice(0, -tail.length), f);
66
+ else strays.push({ collection: name, file: rel(f) });
67
+ }
68
+ }
69
+ }
70
+
71
+ // ---- validate each record -------------------------------------------------------
72
+ const flag = (file, msg) => violations.push({ file: rel(file), msg });
73
+
74
+ // parsed fields, kept for the symmetric-ref pass below (parse each record exactly once)
75
+ const parsed = new Map();
76
+ const inverseRules = []; // [collection, fieldPath, targetCollection, inverseField]
77
+
78
+ for (const [name, d] of descriptors) {
79
+ const validate = ajv.compile(d.schema);
80
+ const refFields = collectRefFields(d.schema);
81
+ const bodyField = Object.entries(d.schema.properties ?? {}).find(([, s]) => s?.['x-body'])?.[0];
82
+ parsed.set(name, new Map());
83
+ for (const [fieldPath, target, inverse] of refFields) {
84
+ if (inverse) inverseRules.push([name, fieldPath, target, inverse]);
85
+ }
86
+
87
+ for (const [id, file] of index.get(name)) {
88
+ if (d.id?.pattern && !patternRe(d.id.pattern).test(id)) {
89
+ flag(file, `id "${id}" does not match pattern ${d.id.pattern}`);
90
+ }
91
+ let fields;
92
+ try {
93
+ fields = parseRecord(file, d, bodyField);
94
+ } catch (e) {
95
+ flag(file, `parse error: ${e.message}`);
96
+ continue;
97
+ }
98
+ if (!validate(fields)) {
99
+ for (const err of validate.errors) flag(file, fmtAjvError(err, fields));
100
+ }
101
+ for (const k of unknownFields(d.schema, fields)) {
102
+ flag(file, `unknown field "${k}" (not in the ${name} schema)`);
103
+ }
104
+ for (const [fieldPath, target] of refFields) {
105
+ for (const value of valuesAt(fields, fieldPath)) {
106
+ checkRef(file, fieldPath, value, target);
107
+ }
108
+ }
109
+ parsed.get(name).set(id, fields);
110
+ }
111
+ }
112
+
113
+ // ---- symmetric references (x-inverse) --------------------------------------------
114
+ // A two-way link is redundant state, and redundant state drifts. Filters resolve OUTBOUND refs
115
+ // only, so some predicates are only expressible from one side and both directions have to exist
116
+ // — which makes an invariant mandatory, not optional. `x-inverse` on a ref field names the field
117
+ // on the target that must point back; a one-sided link is a violation on the side that is missing.
118
+ for (const [name, fieldPath, target, inverse] of inverseRules) {
119
+ for (const [id, fields] of parsed.get(name)) {
120
+ const self = `${name}/${id}`;
121
+ for (const value of valuesAt(fields, fieldPath)) {
122
+ if (typeof value !== 'string' || value.startsWith('@')) continue;
123
+ const targetId = value.slice(value.indexOf('/') + 1);
124
+ const targetFields = parsed.get(target)?.get(targetId);
125
+ if (!targetFields) continue; // already flagged as dangling
126
+ const back = [...valuesAt(targetFields, [inverse])];
127
+ if (!back.includes(self)) {
128
+ flag(index.get(target).get(targetId),
129
+ `${inverse}: must point back to "${self}" (${self} declares ${fieldPath.join('.')}: ${value})`);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ function checkRef(file, fieldPath, value, target) {
136
+ if (typeof value !== 'string') return;
137
+ if (value.startsWith('@')) return; // runtime tokens (@me, @initiator) are legal
138
+ const slash = value.indexOf('/');
139
+ if (slash < 1) return flag(file, `${fieldPath.join('.')}: reference "${value}" is not <collection>/<id>`);
140
+ const coll = value.slice(0, slash);
141
+ const id = value.slice(slash + 1);
142
+ if (target !== '*' && coll !== target) {
143
+ return flag(file, `${fieldPath.join('.')}: reference "${value}" should target collection "${target}"`);
144
+ }
145
+ if (!descriptors.has(coll)) return flag(file, `${fieldPath.join('.')}: reference "${value}" targets unknown collection "${coll}"`);
146
+ if (!index.get(coll).has(id)) return flag(file, `${fieldPath.join('.')}: dangling reference "${value}" — no such record`);
147
+ }
148
+
149
+ // ---- report ----------------------------------------------------------------------
150
+ for (const s of strays) {
151
+ console.log(`⚠ ${s.file} — unrecognized file in ${s.collection} folder${s.note ? ` (${s.note})` : ''}`);
152
+ }
153
+ if (violations.length === 0) {
154
+ console.log(`✔ 0 violations (${[...index.values()].reduce((n, m) => n + m.size, 0)} records across ${descriptors.size} collections)`);
155
+ return 0;
156
+ }
157
+ let last = null;
158
+ for (const v of violations) {
159
+ if (v.file !== last) console.log(`✖ ${v.file}`);
160
+ console.log(` ${v.msg}`);
161
+ last = v.file;
162
+ }
163
+ console.log(`${violations.length} violation${violations.length === 1 ? '' : 's'}. files were NOT modified.`);
164
+ return 1;
165
+ }
166
+
167
+
168
+ // collect [fieldPath, targetCollection, inverseField] for every x-reference in the schema.
169
+ // `x-inverse` names the field on the TARGET collection that must point back — see checkSymmetry.
170
+ function collectRefFields(schema, prefix = []) {
171
+ const out = [];
172
+ for (const [key, s] of Object.entries(schema.properties ?? {})) {
173
+ if (!s || typeof s !== 'object') continue;
174
+ const p = [...prefix, key];
175
+ if (s['x-reference']) out.push([p, s['x-reference'], s['x-inverse']]);
176
+ if (s.items?.['x-reference']) out.push([p, s.items['x-reference'], s['x-inverse'] ?? s.items['x-inverse']]);
177
+ if (s.properties) out.push(...collectRefFields(s, p));
178
+ if (s.items?.properties) out.push(...collectRefFields(s.items, p));
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // yield all leaf values at a field path (flattening arrays)
184
+ function* valuesAt(obj, fieldPath) {
185
+ let vals = [obj];
186
+ for (const key of fieldPath) {
187
+ vals = vals
188
+ .flatMap((v) => (Array.isArray(v) ? v : [v]))
189
+ .flatMap((v) => (v && typeof v === 'object' ? [v[key]] : []));
190
+ }
191
+ for (const v of vals.flat(Infinity)) if (v != null) yield v;
192
+ }
193
+
package/src/cli.js ADDED
@@ -0,0 +1,250 @@
1
+ // dreamteamer CLI — noun-verb grammar over the same primitives every surface uses.
2
+ // this phase ships: compile, check, status. collection verbs land next.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { findWorkspace } from './workspace.js';
7
+ import { compile, staleness, warnIfStale, discoverModules, CHANNEL_LABEL, KINDS } from './compile.js';
8
+ import { check } from './check.js';
9
+ import { collectionCommand, emit } from './collections-cli.js';
10
+ import { init, install, installClone, update, listRepos } from './init.js';
11
+ import { deriveEvents } from './events.js';
12
+ import { commitPending } from './commit.js';
13
+ import { Store } from './store.js';
14
+
15
+ const USAGE = `usage: dreamteamer <command> | dreamteamer <collection> <verb> …
16
+
17
+ commands:
18
+ init write the workspace skeleton into the current directory (never compiles)
19
+ --version print the engine version (works anywhere)
20
+ install restore git_modules/ from the lockfile map; --clone <url> [name] adds one
21
+ update pull git_modules clones forward (ff-only on the lockfile ref), rebuild,
22
+ then compile; [<name>] updates just one. dirty clones are skipped
23
+ compile materialize modules + workspace sources into .dreamteamer (+ harness adapters)
24
+ check validate every record against the compiled descriptors (report-only)
25
+ status workspace status: compiled runtime freshness, per-module channel/ref, staleness
26
+ start serve the clean REST api at /api [--port <n>]
27
+ changes what changed in every repo that holds records, as record events
28
+ [--since <sha|YYYY-MM-DD>] (default: the last commit) [--json]
29
+ commit publish records already written to disk: samples git status over every
30
+ collection's record dirs, one commit PER REPO, subject composed from the
31
+ status letters. [<collection> …] to scope, [-m <subject>], [--dry-run]
32
+
33
+ collection verbs (hard validation — invalid writes are rejected before disk):
34
+ <collection> list [--filter k=v] [--where <json>] [--sort [-]<field>] [--json]
35
+ (--where takes the studio's operator set, e.g.
36
+ '{"starts":{"_gte":"2026-07-01"}}'; date-times
37
+ sort and compare as instants, across offsets)
38
+ <collection> get <id> [--json]
39
+ <collection> add --<field> <value> … [--id <explicit-id>]
40
+ <collection> set <id> <field>=<value> …
41
+ <collection> rm <id> [--force]
42
+ <collection> rename <old-id> <new-id> (rewrites all inbound refs, ONE commit)
43
+ <collection> history <id> [--json] (git revisions of this record, newest first)
44
+ <collection> diff <id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
45
+ <collection> revert <id> --hash <sha> (restore the content at <sha>, as a NEW commit)
46
+
47
+ repo attachment (working trees are materialized ON DEMAND, never at install):
48
+ repos ensure <id> [--json] (clone if missing, then print the path; idempotent)
49
+ repos ensure --all [--json] (explicit opt-in: everything, e.g. before going offline)
50
+
51
+ meta verbs (schema operations — write SOURCES through a compile gate, never the runtime):
52
+ collections add --name <name> [--template docs|entity]
53
+ collections rm <name> [--force] (--force required if it still has records)
54
+ <collection> add-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true]
55
+ [--description "what this field means"]
56
+ types: string text markdown boolean number integer date datetime
57
+ enum tags <collection> — a date-time may be written as
58
+ "2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
59
+ stamped on for you (2026-07-28T12:00:00+03:00)
60
+ <collection> update-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true|false]
61
+ [--description "…"] (an existing description survives a retype)
62
+ <collection> remove-field --name <field>
63
+ ui-views add --path </route> --target list --collection collections/<c> --layout <id> [--id <id>] [k.v=…]
64
+ ui-views set <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
65
+ ui-views rm <id>
66
+ commands for <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
67
+ available / done / not-applicable)
68
+ <collection> values <field> [--limit n] (the vocabulary a field actually uses —
69
+ what a filter/validator offers as choices)
70
+ `;
71
+
72
+ export function run(argv) {
73
+ const [cmd, ...rest] = argv;
74
+ try {
75
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
76
+ // works OUTSIDE a workspace — the post-install "did it land?" affordance
77
+ const p = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
78
+ console.log(`${p.name}@${p.version}`);
79
+ process.exit(0);
80
+ }
81
+ if (cmd === 'init') {
82
+ // init runs BEFORE a workspace exists — no findWorkspace
83
+ const flags = {};
84
+ for (let i = 0; i < rest.length; i++) if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1];
85
+ process.exit(init({ flags }));
86
+ }
87
+ const ws = findWorkspace();
88
+ switch (cmd) {
89
+ case 'install': {
90
+ const ci = rest.indexOf('--clone');
91
+ if (ci > -1) process.exit(installClone(ws, rest[ci + 1], rest[ci + 2]));
92
+ process.exit(install(ws));
93
+ }
94
+ case 'update': {
95
+ const code = update(ws, rest.find((a) => !a.startsWith('--')));
96
+ compile(ws); // pulled modules may carry new sources — prints its own summary
97
+ process.exit(code);
98
+ }
99
+ case 'start': {
100
+ warnIfStale(ws.root);
101
+ const portIdx = rest.indexOf('--port');
102
+ import('./server.js').then(({ startServer }) =>
103
+ startServer(ws, { port: portIdx > -1 ? Number(rest[portIdx + 1]) : 8080 }));
104
+ return; // keep the process alive
105
+ }
106
+ case 'compile': {
107
+ const code = compile(ws);
108
+ if (!rest.includes('--watch')) process.exit(code);
109
+ console.log('… watching sources (modules/*, git_modules/*, and the workspace root) — ctrl-c to stop');
110
+ watchAndRecompile(ws);
111
+ return;
112
+ }
113
+ case 'check':
114
+ warnIfStale(ws.root);
115
+ process.exit(check(ws));
116
+ // `changes` is what survives of the trigger/run subsystem removed 2026-07-31: deriving
117
+ // record events from git history was the genuinely used half (catch-up — "what happened
118
+ // while I was away"), while creating run records from triggers was not. Read-only by
119
+ // construction: no cursor to advance, nothing to store, so it is safe to run twice.
120
+ case 'changes': {
121
+ warnIfStale(ws.root);
122
+ const si = rest.indexOf('--since');
123
+ const since = si > -1 ? rest[si + 1] : 'HEAD~1';
124
+ const store = new Store(ws);
125
+ const events = deriveEvents(ws.root, store.descriptors, since, 'HEAD');
126
+ if (rest.includes('--json')) { emit(JSON.stringify({ since, head: 'HEAD', events }, null, 2)); process.exit(0); }
127
+ if (!events.length) { console.log(`✔ no record changes since ${since}`); process.exit(0); }
128
+ const byCollection = new Map();
129
+ for (const e of events) {
130
+ if (!byCollection.has(e.collection)) byCollection.set(e.collection, []);
131
+ byCollection.get(e.collection).push(e);
132
+ }
133
+ console.log(`${events.length} record change(s) since ${since}:`);
134
+ for (const [c, list] of [...byCollection].sort()) {
135
+ const n = (t) => list.filter((e) => e.type === t).length;
136
+ console.log(` ${c}: ${n('item-added')} added, ${n('item-updated')} updated, ${n('item-removed')} removed`);
137
+ for (const e of list) console.log(` ${e.type.replace('item-', '').padEnd(7)} ${c}/${e.id}`);
138
+ }
139
+ process.exit(0);
140
+ }
141
+ // the other half of `auto-commit: false` — record writes land on disk uncommitted, and
142
+ // this publishes them. No pending file: the set is sampled from `git status`, so a
143
+ // hand-edited record is indistinguishable from one the store wrote, which is the point.
144
+ case 'commit': {
145
+ const store = new Store(ws);
146
+ const mi = rest.indexOf('-m');
147
+ const message = mi > -1 ? rest[mi + 1] : undefined;
148
+ // bare args are collection names — minus the token `-m` consumed as its subject
149
+ const only = rest.filter((a, i) => !a.startsWith('-') && (mi === -1 || i !== mi + 1));
150
+ const results = commitPending(store, { only, message, dryRun: rest.includes('--dry-run') });
151
+ if (rest.includes('--json')) { emit(JSON.stringify(results, null, 2)); process.exit(0); }
152
+ if (!results.length) { console.log('nothing pending'); process.exit(0); }
153
+ for (const r of results) {
154
+ if (r.blocked) { console.error(`✖ ${r.repo}: ${r.blocked} — ${r.rows.length} record(s) left uncommitted`); continue; }
155
+ if (r.warning) console.warn(`⚠ ${r.repo}: ${r.warning}`);
156
+ console.log(`✔ ${r.repo === '.' ? 'workspace' : r.repo}${r.sha ? ` ${r.sha}` : ' (dry run)'} — ${r.subject}`);
157
+ for (const row of r.rows.slice(0, 20)) console.log(` ${row.verb} ${row.collection}/${row.id}`);
158
+ if (r.rows.length > 20) console.log(` + ${r.rows.length - 20} more`);
159
+ }
160
+ process.exit(results.some((r) => r.blocked) ? 1 : 0);
161
+ }
162
+ case 'status': {
163
+ const s = staleness(ws.root);
164
+ if (!s.compiled) {
165
+ console.log(`✖ ${s.message}`);
166
+ process.exit(1);
167
+ }
168
+ console.log(`compiled: ${s.manifest.compiled}`);
169
+ // provenance is LIVE discovery (not the manifest) — shows what the next compile would use
170
+ const { modules, shadows } = discoverModules(ws.root, ws.pkg);
171
+ const shadowed = new Map(shadows.map((sh) => [sh.name, sh]));
172
+ console.log('modules:');
173
+ for (const m of modules) {
174
+ let line = ` ${m.name} [${m.channel}]`;
175
+ if (m.channel === 'git') {
176
+ const ref = tryGit(m.root, ['rev-parse', '--short', 'HEAD']);
177
+ const dirty = tryGit(m.root, ['status', '--porcelain']);
178
+ line += ` @ ${ref ?? '?'}${dirty ? ' (dirty)' : ''}`;
179
+ }
180
+ const sh = shadowed.get(m.name);
181
+ if (sh) line += ` — shadows ${CHANNEL_LABEL[sh.loser]} copy`;
182
+ console.log(line);
183
+ }
184
+ console.log(`entries: ${Object.keys(s.manifest.entries).length}`);
185
+ // repos materialize LAZILY, so presence is REPORTED here rather than stored on the
186
+ // record. Wrapped: an older workspace may predate the repos descriptor, and status
187
+ // must never crash — it is the command you run when things are already wrong.
188
+ try {
189
+ const repos = listRepos(ws);
190
+ if (repos.length) {
191
+ const here = repos.filter((r) => r.present).length;
192
+ console.log(`repos: ${here}/${repos.length} materialized`);
193
+ for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer repos ensure ${r.id})`);
194
+ }
195
+ } catch { /* no repos descriptor compiled — nothing to report */ }
196
+ // Uncommitted records are invisible to `dt changes` (it diffs commits), so the
197
+ // count belongs here — otherwise deferred work accumulates silently.
198
+ try {
199
+ const pending = commitPending(new Store(ws), { dryRun: true });
200
+ const total = pending.reduce((n, r) => n + r.rows.length, 0);
201
+ if (total) {
202
+ console.log(`\npending: ${total} record(s) written but not committed — run \`dreamteamer commit\``);
203
+ for (const r of pending) console.log(` ${r.repo === '.' ? 'workspace' : r.repo}: ${r.rows.length}`);
204
+ }
205
+ } catch { /* no runtime yet, or not a git repo — status must still print */ }
206
+ if (s.stale.length) {
207
+ for (const line of s.stale) console.log(` stale: ${line}`);
208
+ console.log(`✖ .dreamteamer is stale (${s.stale.length}) — run \`dreamteamer compile\``);
209
+ process.exit(1);
210
+ }
211
+ console.log('✔ .dreamteamer is fresh');
212
+ process.exit(0);
213
+ }
214
+ case 'help':
215
+ console.log(USAGE);
216
+ process.exit(0);
217
+ default: {
218
+ if (!cmd || rest.length === 0) {
219
+ console.log(USAGE);
220
+ process.exit(cmd ? 1 : 0);
221
+ }
222
+ warnIfStale(ws.root);
223
+ process.exit(collectionCommand(ws, cmd, rest[0], rest.slice(1)));
224
+ }
225
+ }
226
+ } catch (e) {
227
+ console.error(`✖ ${e.message}`);
228
+ process.exit(1);
229
+ }
230
+ }
231
+
232
+ function tryGit(cwd, args) {
233
+ try { return execFileSync('git', args, { cwd }).toString().trim() || null; } catch { return null; }
234
+ }
235
+
236
+ function watchAndRecompile(ws) {
237
+ let timer = null;
238
+ const trigger = (_, file) => {
239
+ if (file && /^\./.test(String(file))) return;
240
+ clearTimeout(timer);
241
+ timer = setTimeout(() => {
242
+ try { compile(ws); } catch (e) { console.error(`✖ ${e.message}`); }
243
+ }, 200);
244
+ };
245
+ // 'system' plus the flat kinds: the classic layout can put sources at the workspace root under
246
+ // either spelling, and a watcher that misses one makes --watch quietly stop recompiling.
247
+ for (const dir of ['system', ...KINDS, 'modules', 'git_modules'].map((d) => path.join(ws.root, d))) {
248
+ if (fs.existsSync(dir)) fs.watch(dir, { recursive: true }, trigger);
249
+ }
250
+ }