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
@@ -0,0 +1,389 @@
1
+ // noun-verb collection commands: dreamteamer <collection> list|get|add|set|rm|rename|history|diff|revert
2
+ // + meta verbs: `collections add|rm`, `<collection> add-field|update-field|remove-field`,
3
+ // `ui-views add|set|rm`
4
+ import { execFileSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { Store, bodyField } from './store.js';
8
+ import { load, dump } from './yaml.js';
9
+ import { slug } from './template.js';
10
+ import {
11
+ createCollection, removeCollection, addField, updateField, removeField, fieldDef, saveUiView, removeUiView,
12
+ // was copy-pasted here, and the copy went stale the moment the source layout gained a second
13
+ // spelling — one implementation, two callers
14
+ workspaceSystemDir,
15
+ } from './schema-ops.js';
16
+ import { history, historyDiff } from './history.js';
17
+ import { commandsFor, recordResolver } from './record-commands.js';
18
+ import { distinctValues } from './field-values.js';
19
+ import { matchesFilter } from './filter.js';
20
+ import { sortRows } from './temporal.js';
21
+ import { ensureRepo, ensureAllRepos } from './init.js';
22
+
23
+ /**
24
+ * Emit MACHINE-READABLE output synchronously. Use this for every `--json` payload.
25
+ *
26
+ * `console.log` to a pipe is asynchronous, and every CLI path ends in `process.exit()`, which
27
+ * discards whatever is still buffered. A shell pipeline hides the bug completely — the reader
28
+ * drains concurrently, so `dreamteamer contacts list --json | wc -c` reports all 32381 bytes — but
29
+ * the way a script or a coding agent actually calls this is execFileSync/spawnSync, and there the
30
+ * child exits with the pipe still full. Measured before this fix: the same command captured with
31
+ * execFileSync returned exactly **8190 bytes** (one pipe buffer) of that 32381-byte document, i.e.
32
+ * silently invalid JSON, with a zero exit status.
33
+ *
34
+ * Found while writing a setup script for a second operator — the first consumer to read `--json`
35
+ * from a real program rather than a terminal.
36
+ *
37
+ * ⚠ 2026-07-30: a SINGLE `fs.writeSync` was NOT enough, and the previous version of this comment
38
+ * claimed it was. Writing to a pipe performs one `write(2)`, which returns a SHORT COUNT once the
39
+ * 64KB pipe buffer is full — it does not throw, it reports fewer bytes written and the caller
40
+ * ignores the number. So the same truncation reappeared at a larger size: measured at exactly
41
+ * **65126 bytes** of a ~100KB payload, again invalid JSON with a zero exit status. `meetings list
42
+ * --json` (141767 bytes) was affected. Found in a sibling script that reproduced it twice, once
43
+ * per fix.
44
+ *
45
+ * So: write in a LOOP until every byte lands, and retry EAGAIN — stdout can be a non-blocking pipe.
46
+ */
47
+ export const emit = (s) => {
48
+ const buf = Buffer.from(s + '\n');
49
+ let off = 0;
50
+ while (off < buf.length) {
51
+ try {
52
+ off += fs.writeSync(1, buf, off, buf.length - off);
53
+ } catch (e) {
54
+ if (e.code === 'EAGAIN') continue;
55
+ throw e;
56
+ }
57
+ }
58
+ };
59
+
60
+ /** `list` flags that are options, not `field=value` shorthand filters. */
61
+ const LIST_META_FLAGS = new Set(['json', 'filter', 'where', 'sort']);
62
+
63
+ export function collectionCommand(ws, collection, verb, args) {
64
+ const store = new Store(ws);
65
+ const { flags, pos } = parseArgs(args);
66
+
67
+ // ---- meta verbs: schema operations write SOURCES, never the runtime ----------
68
+ // These MUST come before the generic switch: their collections are system-stored, so the
69
+ // ordinary record path refuses them ("… are system sources") and always would.
70
+ if (collection === 'collections' && verb === 'add') return metaCollectionsAdd(ws, store, flags);
71
+ if (collection === 'collections' && verb === 'rm') return metaCollectionsRm(ws, store, flags, pos);
72
+ if (collection === 'commands' && verb === 'for') return metaCommandsFor(ws, store, flags, pos);
73
+ if (collection === 'ui-views' && ['add', 'set', 'rm'].includes(verb)) return metaUiView(ws, store, verb, flags, pos);
74
+ if (collection === 'repos' && verb === 'ensure') return metaReposEnsure(ws, flags, pos);
75
+ if (verb === 'add-field') return metaAddField(ws, store, collection, flags);
76
+ if (verb === 'update-field') return metaUpdateField(ws, store, collection, flags);
77
+ if (verb === 'remove-field') return metaRemoveField(ws, store, collection, flags);
78
+
79
+ const d = store.descriptor(collection);
80
+
81
+ switch (verb) {
82
+ case 'list': {
83
+ const filters = Object.entries(flags).filter(([k]) => !LIST_META_FLAGS.has(k));
84
+ if (typeof flags.filter === 'string') {
85
+ const eq = flags.filter.indexOf('=');
86
+ filters.push([flags.filter.slice(0, eq), flags.filter.slice(eq + 1)]);
87
+ }
88
+ // `--where` is the SAME operator set the studio's filter panel emits and saved views
89
+ // store — one `matchesFilter`, so `--where '{"starts":{"_gte":"2026-07-01"}}'` and the
90
+ // panel that produced that JSON cannot disagree about which records match.
91
+ const where = typeof flags.where === 'string' ? load(flags.where) : null;
92
+ const resolve = where ? recordResolver(store) : null;
93
+ const bf = bodyField(d);
94
+ const rows = [];
95
+ for (const { id, fields } of store.readAll(collection)) { // ONE walk, not one per record
96
+ if (!filters.every(([k, v]) => String(fields[k] ?? '') === String(v))) continue;
97
+ if (where && !matchesFilter({ ...fields, id }, where, resolve)) continue;
98
+ if (bf) delete fields[bf]; // bodies don't belong in listings
99
+ rows.push({ ...fields, id }); // record id WINS over any schema field named "id"
100
+ }
101
+ // sorting was studio-only until now: the browse table ordered records and no CLI
102
+ // invocation could. Same `sortRows` the server and api.ts call, so `--sort -starts`
103
+ // orders date-times by INSTANT across mixed offsets rather than by string.
104
+ if (typeof flags.sort === 'string') sortRows(rows, flags.sort);
105
+ if (flags.json) { emit(JSON.stringify(rows, null, 2)); return 0; }
106
+ const cols = ['id', ...(d.list_fields ?? []).filter((c) => c !== 'id')];
107
+ for (const r of rows) console.log(cols.map((c) => fmtCell(r[c])).join(' '));
108
+ if (!rows.length) console.log(`(no ${collection}${filters.length || where ? ' matching' : ''})`);
109
+ return 0;
110
+ }
111
+ case 'get': {
112
+ const id = need(pos, 0, 'id');
113
+ const { fields } = store.read(collection, id);
114
+ flags.json ? emit(JSON.stringify({ ...fields, id }, null, 2)) : console.log(dump(fields).trimEnd());
115
+ return 0;
116
+ }
117
+ case 'add': {
118
+ const fields = coerceArrays(d, stripMeta(flags));
119
+ const { id, file } = store.add(collection, fields, { id: flags.id });
120
+ flags.json ? emit(JSON.stringify({ id, path: rel(ws.root, file) })) : console.log(`✔ ${rel(ws.root, file)}`);
121
+ return 0;
122
+ }
123
+ case 'set': {
124
+ const id = need(pos, 0, 'id');
125
+ const changes = coerceArrays(d, Object.fromEntries(
126
+ pos.slice(1).filter((p) => p.includes('=')).map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
127
+ ));
128
+ Object.assign(changes, coerceArrays(d, stripMeta(flags)));
129
+ if (!Object.keys(changes).length) throw new Error('nothing to set — pass key=value pairs or --key value flags');
130
+ store.set(collection, id, changes);
131
+ flags.json ? emit(JSON.stringify({ id })) : console.log('✔ updated');
132
+ return 0;
133
+ }
134
+ case 'rm': {
135
+ const id = need(pos, 0, 'id');
136
+ const { inboundIgnored } = store.rm(collection, id, { force: !!flags.force });
137
+ flags.json ? emit(JSON.stringify({ id, removed: true, inboundIgnored })) : console.log(`✔ removed${inboundIgnored ? ` (${inboundIgnored} inbound reference(s) left dangling — run \`dreamteamer check\`)` : ''}`);
138
+ return 0;
139
+ }
140
+ case 'rename': {
141
+ const out = store.rename(collection, need(pos, 0, 'old id'), need(pos, 1, 'new id'));
142
+ if (flags.json) { emit(JSON.stringify(out)); return 0; }
143
+ console.log(`✔ renamed ${collection}/${need(pos, 0, 'old id')} → ${collection}/${out.id}`);
144
+ if (out.touched) console.log(`✔ rewrote ${out.rewrites} inbound reference(s) across ${out.touched} file(s)`);
145
+ return 0;
146
+ }
147
+ // `dreamteamer meetings values status` — the vocabulary a field ACTUALLY uses, so a filter
148
+ // or a command-binding validator can offer a dropdown for a plain `type: string` field that
149
+ // no enum describes (operator: "still no dropdown for many things, visibility, status").
150
+ case 'values': {
151
+ const field = need(pos, 0, 'field');
152
+ const out = distinctValues(store, collection, field, {
153
+ limit: flags.limit === undefined ? undefined : Number(flags.limit),
154
+ });
155
+ if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
156
+ if (out.skipped) { console.log(`(${collection}.${field} is a ${out.skipped} field — no value vocabulary)`); return 0; }
157
+ if (!out.values.length) { console.log(`(no values set on ${collection}.${field})`); return 0; }
158
+ for (const { value, count } of out.values) console.log(count == null ? String(value) : `${String(count).padStart(5)} ${value}`);
159
+ console.log(`— ${out.total} distinct${out.truncated ? ` (showing ${out.values.length})` : ''}, from ${out.source}`);
160
+ return 0;
161
+ }
162
+ case 'history': {
163
+ const id = need(pos, 0, 'id');
164
+ const log = history(store, collection, id);
165
+ if (flags.json) { emit(JSON.stringify(log, null, 2)); return 0; }
166
+ if (!log.length) { console.log(`(no history for ${collection}/${id} — not committed yet)`); return 0; }
167
+ for (const c of log) console.log(`${c.hash.slice(0, 7)} ${c.date.slice(0, 10)} ${c.author} ${c.subject}`);
168
+ return 0;
169
+ }
170
+ case 'diff': {
171
+ const id = need(pos, 0, 'id');
172
+ const out = historyDiff(store, collection, id, typeof flags.hash === 'string' ? flags.hash : 'HEAD');
173
+ if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
174
+ console.log(out.diff.trimEnd() || `(no change to ${out.path} in ${out.hash})`);
175
+ return 0;
176
+ }
177
+ case 'revert': {
178
+ const id = need(pos, 0, 'id');
179
+ // the hash is REQUIRED and has no default: "revert" with an implied target is how you
180
+ // destroy the wrong record. `<c> history <id>` is where you get one.
181
+ const hash = typeof flags.hash === 'string' ? flags.hash : pos[1];
182
+ if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer ${collection} history ${id}\` to pick one`);
183
+ const out = store.revert(collection, id, hash);
184
+ flags.json ? emit(JSON.stringify(out)) : console.log(out.reverted ? `✔ reverted ${collection}/${id} to ${String(hash).slice(0, 7)}` : `= already identical to ${String(hash).slice(0, 7)} — nothing changed`);
185
+ return 0;
186
+ }
187
+ default:
188
+ throw new Error(`unknown verb "${verb}" — use list | get | add | set | rm | rename | values | history | diff | revert`);
189
+ }
190
+ }
191
+
192
+
193
+
194
+ // `dreamteamer commands for <collection>[/<id>] [--ids <id>[,…]] [--json]` — which bound
195
+ // commands apply, in which state (available / done / not-applicable). THE engine surface
196
+ // behind the studio's Commands tab (engine/UI parity: the verb lands first, the button second).
197
+ function metaCommandsFor(ws, store, flags, pos) {
198
+ const target = need(pos, 0, 'collection[/id]');
199
+ const slash = target.indexOf('/');
200
+ const collection = slash > 0 ? target.slice(0, slash) : target;
201
+ const ids = slash > 0
202
+ ? [target.slice(slash + 1)]
203
+ : typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
204
+ const out = commandsFor(store, collection, ids);
205
+ if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
206
+ if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
207
+ for (const c of out.commands) {
208
+ if (c.target === 'collection') { console.log(`${c.name} [collection] ${c.invocation}`); continue; }
209
+ const counts = ids.length ? ` ${c.eligible.length}/${ids.length} eligible${c.done.length ? `, ${c.done.length} done` : ''}` : ' (no ids given)';
210
+ console.log(`${c.name} [record]${counts}${c.invocation ? `\n ${c.invocation}` : ''}`);
211
+ }
212
+ return 0;
213
+ }
214
+
215
+ /**
216
+ * `repos ensure <id>` / `repos ensure --all` — materialize declared repos on demand.
217
+ * Lazy by design: `install` deliberately does NOT do this, so a fresh workspace clone is
218
+ * immediately workable without pulling every attached repo (and one unreachable remote can only
219
+ * fail the action you asked for, not the whole install).
220
+ */
221
+ function metaReposEnsure(ws, flags, pos) {
222
+ const results = flags.all ? ensureAllRepos(ws) : [ensureRepo(ws, need(pos, 0, 'id'))];
223
+ if (flags.json) { emit(JSON.stringify(results, null, 2)); return 0; }
224
+ for (const r of results) console.log(r.cloned ? `✔ cloned ${r.path}` : `✔ ${r.path} (present)`);
225
+ if (!results.length) console.log('(no repos declared)');
226
+ return 0;
227
+ }
228
+
229
+ // `dreamteamer collections add --name research-docs --template docs`
230
+ function metaCollectionsAdd(ws, store, flags) {
231
+ const { file } = createCollection(ws, store, { name: flags.name, template: flags.template });
232
+ console.log(`✔ ${rel(ws.root, file)}`);
233
+ console.log('✔ compiled — the collection is live (schema ops prove sources with a real compile)');
234
+ return 0;
235
+ }
236
+
237
+ // `dreamteamer collections rm widgets [--force]` — --force is required to drop a collection
238
+ // that still has records (removeCollection refuses otherwise, and says so).
239
+ function metaCollectionsRm(ws, store, flags, pos) {
240
+ const name = need(pos, 0, 'collection name');
241
+ const out = removeCollection(ws, store, name, { force: !!flags.force });
242
+ flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed collection ${out.removed}`);
243
+ console.log('✔ compiled — the collection is gone');
244
+ return 0;
245
+ }
246
+
247
+ // `dreamteamer tasks add-field --name urgent --type boolean --default-value false`
248
+ function metaAddField(ws, store, collection, flags) {
249
+ const prop = fieldDef(store, flags);
250
+ const out = addField(ws, store, collection, { name: flags.name, prop, required: flags.required === 'true' });
251
+ console.log(`✔ ${rel(ws.root, out.file)}${out.extends ? ` (extends ${out.extends})` : ''}`);
252
+ console.log('✔ compiled — the field is live');
253
+ return 0;
254
+ }
255
+
256
+ // `dreamteamer tasks update-field --name urgent --type enum --options a,b --required false`
257
+ // Same flag vocabulary as add-field (one `fieldDef`), so the two read as one operation with two
258
+ // preconditions rather than two dialects.
259
+ function metaUpdateField(ws, store, collection, flags) {
260
+ if (!flags.name) throw new Error('missing --name <field>');
261
+ const prop = fieldDef(store, flags);
262
+ // tri-state: omitting --required leaves requiredness ALONE, rather than silently clearing it
263
+ const required = flags.required === undefined ? undefined : flags.required === 'true' || flags.required === true;
264
+ const out = updateField(ws, store, collection, flags.name, { prop, required });
265
+ console.log(`✔ ${rel(ws.root, out.file)}${out.extends ? ` (extends ${out.extends})` : ''}`);
266
+ console.log('✔ compiled — the field is updated');
267
+ return 0;
268
+ }
269
+
270
+ // `dreamteamer tasks remove-field --name urgent`
271
+ function metaRemoveField(ws, store, collection, flags) {
272
+ const name = flags.name ?? flags.field;
273
+ if (!name) throw new Error('missing --name <field>');
274
+ const out = removeField(ws, store, collection, name);
275
+ flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed field ${collection}.${out.removed}`);
276
+ console.log('✔ compiled — the field is gone');
277
+ return 0;
278
+ }
279
+
280
+ // ---- ui-views ---------------------------------------------------------------------------------
281
+ // A view is an ordinary record conceptually (decision 49) but a SYSTEM-stored one, so it goes
282
+ // through saveUiView's compile gate rather than the record store. Without these verbs everything
283
+ // the Layout options panel does — columns, order, sort, layout, filter, nav — was click-only.
284
+
285
+ /** `--options '{"sort":"-date"}'` style flags, plus dotted `options.sort=-date` positionals. */
286
+ function parseViewValue(raw) {
287
+ if (typeof raw !== 'string') return raw;
288
+ const t = raw.trim();
289
+ if (t === 'true') return true;
290
+ if (t === 'false') return false;
291
+ if (t !== '' && !Number.isNaN(Number(t))) return Number(t);
292
+ if (t.startsWith('{') || t.startsWith('[')) {
293
+ try { return JSON.parse(t); } catch { throw new Error(`not valid JSON: ${t}`); }
294
+ }
295
+ return raw;
296
+ }
297
+
298
+ /** Assign `a.b.c` into a nested object, creating plain objects on the way down. */
299
+ function assignPath(target, dotted, value) {
300
+ const keys = dotted.split('.');
301
+ let node = target;
302
+ for (const k of keys.slice(0, -1)) {
303
+ if (node[k] == null || typeof node[k] !== 'object' || Array.isArray(node[k])) node[k] = {};
304
+ node = node[k];
305
+ }
306
+ node[keys[keys.length - 1]] = value;
307
+ }
308
+
309
+ const VIEW_META_FLAGS = new Set(['id', 'json', 'force']);
310
+
311
+ function metaUiView(ws, store, verb, flags, pos) {
312
+ if (verb === 'rm') {
313
+ const out = removeUiView(ws, store, need(pos, 0, 'ui-view id'));
314
+ flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed ui-view ${out.removed}`);
315
+ console.log('✔ compiled — the route is gone');
316
+ return 0;
317
+ }
318
+
319
+ // `set` edits what the record already says; `add` starts from nothing. Reading through the
320
+ // store means `set` works on a MODULE-shipped view too — the edit lands as a workspace source
321
+ // that shadows it, which is the same thing saving one in the UI does.
322
+ let view = {};
323
+ let id = typeof flags.id === 'string' ? flags.id : undefined;
324
+ if (verb === 'set') {
325
+ id ??= need(pos, 0, 'ui-view id');
326
+ const { fields } = store.read('ui-views', id);
327
+ view = JSON.parse(JSON.stringify(fields));
328
+ delete view.id; // the id is the filename, never a body key
329
+ }
330
+
331
+ for (const p of pos.slice(verb === 'set' ? 1 : 0)) {
332
+ if (!p.includes('=')) continue;
333
+ assignPath(view, p.slice(0, p.indexOf('=')), parseViewValue(p.slice(p.indexOf('=') + 1)));
334
+ }
335
+ for (const [k, v] of Object.entries(flags)) {
336
+ if (VIEW_META_FLAGS.has(k)) continue;
337
+ assignPath(view, k, parseViewValue(v));
338
+ }
339
+
340
+ if (!view.path) throw new Error('missing --path </route> — a view is addressed by its route');
341
+ // same id rule the descriptor declares (`{{ path | slug }}`) and the UI derives, so a view
342
+ // saved from the CLI and one saved from the panel land on the SAME record.
343
+ id ??= slug(view.path);
344
+
345
+ const out = saveUiView(ws, store, { id, view });
346
+ flags.json ? emit(JSON.stringify(out)) : console.log(`✔ ${rel(ws.root, out.file)}`);
347
+ console.log(`✔ compiled — ${view.path} is live`);
348
+ return 0;
349
+ }
350
+
351
+ function parseArgs(args) {
352
+ const flags = {};
353
+ const pos = [];
354
+ for (let i = 0; i < args.length; i++) {
355
+ const a = args[i];
356
+ if (a.startsWith('--')) {
357
+ const eq = a.indexOf('=');
358
+ if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
359
+ else if (i + 1 < args.length && !args[i + 1].startsWith('--')) flags[a.slice(2)] = args[++i];
360
+ else flags[a.slice(2)] = true;
361
+ } else pos.push(a);
362
+ }
363
+ return { flags, pos };
364
+ }
365
+
366
+ const META_FLAGS = new Set(['id', 'json', 'force', 'filter']);
367
+ const stripMeta = (flags) => Object.fromEntries(Object.entries(flags).filter(([k]) => !META_FLAGS.has(k)));
368
+
369
+ // CLI values are strings; split comma-lists for array-typed fields (ajv coerces the rest)
370
+ function coerceArrays(d, fields) {
371
+ const out = {};
372
+ for (const [k, v] of Object.entries(fields)) {
373
+ out[k] = d.schema.properties?.[k]?.type === 'array' && typeof v === 'string'
374
+ ? v.split(',').map((s) => s.trim()).filter(Boolean)
375
+ : v;
376
+ }
377
+ return out;
378
+ }
379
+
380
+ function need(pos, i, what) {
381
+ if (pos[i] === undefined) throw new Error(`missing <${what}>`);
382
+ return pos[i];
383
+ }
384
+
385
+ const fmtCell = (v) => (v === undefined ? '-' : Array.isArray(v) ? v.join(',') : String(v));
386
+
387
+ function rel(root, p) {
388
+ return p.startsWith(root) ? p.slice(root.length + 1) : p;
389
+ }
package/src/commit.js ADDED
@@ -0,0 +1,117 @@
1
+ // dt commit — publish what is already on disk. Model: GIT IS THE JOURNAL. There is no pending
2
+ // file and no cursor; the set of things to commit is sampled from `git status` over the record
3
+ // directories of every collection, which means it cannot disagree with reality.
4
+ import { execFileSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { pathToRecord } from './events.js';
8
+
9
+ const VERB = { A: 'add', M: 'set', D: 'rm', R: 'rename', '?': 'add' };
10
+
11
+ /** Record directories to watch, grouped by owning repo. System-stored collections are excluded:
12
+ * they live in the gitignored runtime and their sources are module files — the same exclusion
13
+ * pathToRecord already applies. */
14
+ function scopeByRepo(descriptors, only) {
15
+ const byRepo = new Map();
16
+ for (const d of descriptors.values()) {
17
+ const p = d.storage?.path;
18
+ // `storage.base`, not a `system/` prefix: after the flatten a runtime collection's path is a
19
+ // bare kind name (`skills`), so the old test admitted all seven — and this list becomes a
20
+ // `git add` pathspec, which fails outright on a path the workspace root does not have.
21
+ if (!p || d.storage.base === 'runtime') continue;
22
+ if (only.length && !only.includes(d.name)) continue;
23
+ const repo = d.storage.repo ?? '.';
24
+ if (!byRepo.has(repo)) byRepo.set(repo, []);
25
+ byRepo.get(repo).push(p);
26
+ }
27
+ return byRepo;
28
+ }
29
+
30
+ /** In-progress merge/rebase/cherry-pick makes a commit's meaning ambiguous. Ordinary dirtiness
31
+ * is NOT a refusal condition — committing dirty records is this verb's entire job. */
32
+ function inProgress(cwd) {
33
+ let gitDir;
34
+ try { gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], { cwd }).toString().trim(); }
35
+ catch { return 'not a git repository'; }
36
+ for (const [marker, label] of [['MERGE_HEAD', 'merge'], ['rebase-merge', 'rebase'], ['rebase-apply', 'rebase'], ['CHERRY_PICK_HEAD', 'cherry-pick'], ['REVERT_HEAD', 'revert']]) {
37
+ if (fs.existsSync(path.join(gitDir, marker))) return `a ${label} is in progress`;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** A commit on a detached HEAD is reachable only by sha. Worth saying out loud before making one;
43
+ * not worth refusing over, since it is sometimes exactly what someone means to do. */
44
+ function detached(cwd) {
45
+ try { return execFileSync('git', ['symbolic-ref', '--quiet', 'HEAD'], { cwd }).toString().trim() === ''; }
46
+ catch { return true; }
47
+ }
48
+
49
+ /** Sample one repo: porcelain status over its record dirs → [{repoRel, collection, id, verb}].
50
+ * `git status` run in a module repo returns REPO-relative paths; pathToRecord takes
51
+ * WORKSPACE-relative ones, so every path is re-prefixed before matching. Skip this and every
52
+ * module-owned path maps to null — dt commit would commit nothing and report success. */
53
+ function sample(root, repo, dirs, descriptors) {
54
+ const cwd = path.resolve(root, repo);
55
+ const prefix = repo === '.' ? '' : `${repo}/`;
56
+ const relDirs = dirs.map((d) => (prefix && d.startsWith(prefix) ? d.slice(prefix.length) : d));
57
+ // `-uall` is load-bearing: by default porcelain COLLAPSES an untracked directory to a single
58
+ // `?? data/notes/` entry, which maps to no record — so the first records of a brand-new
59
+ // collection would be invisible and dt commit would report success having committed nothing.
60
+ const out = execFileSync('git', ['status', '--porcelain', '-z', '-uall', '--', ...relDirs], { cwd }).toString();
61
+ const chunks = out.split('\0').filter((c) => c.length > 0);
62
+ const rows = [];
63
+ for (let i = 0; i < chunks.length; i++) {
64
+ const status = chunks[i].slice(0, 2).trim()[0] ?? 'M';
65
+ const repoRel = chunks[i].slice(3);
66
+ // -z emits a rename/copy as TWO chunks: the new path carries the `XY ` prefix, the old
67
+ // path follows BARE. Consume it here or the next iteration reads a path as a status code
68
+ // — and worse, the old path never gets staged, so the commit keeps half a rename.
69
+ const fromRel = (status === 'R' || status === 'C') ? chunks[++i] : null;
70
+ const rec = pathToRecord(descriptors, prefix + repoRel);
71
+ if (!rec) continue;
72
+ rows.push({ repoRel, fromRel, ...rec, verb: VERB[status] ?? 'set' });
73
+ }
74
+ return { cwd, rows };
75
+ }
76
+
77
+ /** One subject for one repo's rows. The git status letter IS the verb, which is why a
78
+ * single-record commit keeps exactly the subject it had when writes committed themselves. */
79
+ export function composeSubject(rows) {
80
+ if (rows.length === 1) return `dreamteamer: ${rows[0].collection} ${rows[0].verb} ${rows[0].id}`;
81
+ const collections = [...new Set(rows.map((r) => r.collection))].sort();
82
+ if (collections.length === 1) {
83
+ const counts = {};
84
+ for (const r of rows) counts[r.verb] = (counts[r.verb] ?? 0) + 1;
85
+ const parts = Object.entries(counts).map(([v, n]) => `${n} ${v}`).join(', ');
86
+ return `dreamteamer: ${collections[0]} ${rows.length} changes (${parts})`;
87
+ }
88
+ return `dreamteamer: ${rows.length} changes across ${collections.join(', ')}`;
89
+ }
90
+
91
+ export function commitPending(store, { only = [], message, dryRun = false } = {}) {
92
+ const byRepo = scopeByRepo(store.descriptors, only);
93
+ const results = [];
94
+ for (const [repo, dirs] of byRepo) {
95
+ const { cwd, rows } = sample(store.root, repo, dirs, store.descriptors);
96
+ if (!rows.length) continue;
97
+ const blocked = inProgress(cwd);
98
+ if (blocked) { results.push({ repo, rows, blocked }); continue; }
99
+ const warning = detached(cwd) ? 'HEAD is detached — this commit will be reachable only by sha' : null;
100
+ const subject = message ?? composeSubject(rows);
101
+ if (!dryRun) {
102
+ const paths = rows.map((r) => r.repoRel);
103
+ // A rename only reports as `R` once it is STAGED, so its old path is in neither the
104
+ // worktree nor the index and `git add` refuses it ("did not match any files"). It needs
105
+ // no adding — only naming, so the partial commit below carries the deletion half too.
106
+ const alreadyStaged = rows.filter((r) => r.fromRel).map((r) => r.fromRel);
107
+ // `--all` here is PATHSPEC-SCOPED — "including deletions of these named files", not
108
+ // "everything in the tree" (the unscoped form CLAUDE.md rule 6 forbids). Same shape
109
+ // store.js has always used.
110
+ execFileSync('git', ['add', '--all', '--', ...paths], { cwd });
111
+ execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...paths, ...alreadyStaged], { cwd });
112
+ }
113
+ const sha = dryRun ? null : execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd }).toString().trim();
114
+ results.push({ repo, rows, subject, sha, warning });
115
+ }
116
+ return results;
117
+ }