dreamteamer 0.16.0 → 0.17.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.
@@ -86,15 +86,26 @@ export function collectionCommand(ws, collection, verb, args) {
86
86
 
87
87
  switch (verb) {
88
88
  case 'list': {
89
- const filters = Object.entries(flags).filter(([k]) => !LIST_META_FLAGS.has(k));
90
- if (typeof flags.filter === 'string') {
91
- const eq = flags.filter.indexOf('=');
92
- filters.push([flags.filter.slice(0, eq), flags.filter.slice(eq + 1)]);
89
+ // EVERY condition, ANDed — a repeated flag composes rather than replacing. `--filter a=1
90
+ // --filter b=2` used to keep only `b=2` and return rows the caller had excluded, which is
91
+ // the one failure a narrowing verb must not have: a listing that answers a question nobody
92
+ // asked, at exit 0. Bare-field flags (`--status todo`) already composed this way; the
93
+ // meta flag now reads the same, and `[].concat` treats one and many alike.
94
+ const filters = Object.entries(flags)
95
+ .filter(([k]) => !LIST_META_FLAGS.has(k))
96
+ .flatMap(([k, v]) => [].concat(v).map((one) => [k, one]));
97
+ for (const cond of [].concat(flags.filter ?? [])) {
98
+ const eq = String(cond).indexOf('=');
99
+ // `--filter status` is not a condition. It used to slice into `statu = status`, which
100
+ // matches nothing and reads as a fact about the collection.
101
+ if (eq < 1) throw new Error(`--filter takes <field>=<value> — got "${cond}"`);
102
+ filters.push([String(cond).slice(0, eq), String(cond).slice(eq + 1)]);
93
103
  }
94
104
  // `--where` is the SAME operator set the studio's filter panel emits and saved views
95
105
  // store — one `matchesFilter`, so `--where '{"starts":{"_gte":"2026-07-01"}}'` and the
96
106
  // panel that produced that JSON cannot disagree about which records match.
97
- const where = typeof flags.where === 'string' ? load(flags.where) : null;
107
+ const whereJson = oneValue(flags, 'where');
108
+ const where = whereJson ? load(whereJson) : null;
98
109
  const resolve = where ? recordResolver(store) : null;
99
110
  const bf = bodyField(d);
100
111
  const rows = [];
@@ -107,7 +118,8 @@ export function collectionCommand(ws, collection, verb, args) {
107
118
  // sorting was studio-only until now: the browse table ordered records and no CLI
108
119
  // invocation could. Same `sortRows` the server and api.ts call, so `--sort -starts`
109
120
  // orders date-times by INSTANT across mixed offsets rather than by string.
110
- if (typeof flags.sort === 'string') sortRows(rows, flags.sort);
121
+ const sort = oneValue(flags, 'sort');
122
+ if (sort) sortRows(rows, sort);
111
123
  if (flags.json) { emit(JSON.stringify(rows, null, 2)); return 0; }
112
124
  const cols = ['id', ...(d.list_fields ?? []).filter((c) => c !== 'id')];
113
125
  for (const r of rows) console.log(cols.map((c) => fmtCell(r[c])).join(' '));
@@ -138,9 +150,7 @@ export function collectionCommand(ws, collection, verb, args) {
138
150
  }
139
151
  case 'set': {
140
152
  const id = need(pos, 0, 'id');
141
- const changes = coerceArrays(d, Object.fromEntries(
142
- pos.slice(1).filter((p) => p.includes('=')).map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
143
- ));
153
+ const changes = coerceArrays(d, pairs(pos.slice(1)));
144
154
  Object.assign(changes, coerceArrays(d, stripMeta(flags)));
145
155
  if (!Object.keys(changes).length) throw new Error('nothing to set — pass key=value pairs or --key value flags');
146
156
  store.set(collection, id, changes);
@@ -219,7 +229,7 @@ export function collectionCommand(ws, collection, verb, args) {
219
229
  }
220
230
  case 'diff': {
221
231
  const id = need(pos, 0, 'id');
222
- const out = historyDiff(store, collection, id, typeof flags.hash === 'string' ? flags.hash : 'HEAD');
232
+ const out = historyDiff(store, collection, id, oneValue(flags, 'hash') ?? 'HEAD');
223
233
  if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
224
234
  console.log(out.diff.trimEnd() || `(no change to ${out.path} in ${out.hash})`);
225
235
  return 0;
@@ -228,7 +238,7 @@ export function collectionCommand(ws, collection, verb, args) {
228
238
  const id = need(pos, 0, 'id');
229
239
  // the hash is REQUIRED and has no default: "revert" with an implied target is how you
230
240
  // destroy the wrong record. `<c> history <id>` is where you get one.
231
- const hash = typeof flags.hash === 'string' ? flags.hash : pos[1];
241
+ const hash = oneValue(flags, 'hash') ?? pos[1];
232
242
  if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer history ${collection}/${id}\` to pick one`);
233
243
  const out = store.revert(collection, id, hash);
234
244
  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`);
@@ -252,7 +262,7 @@ export function collectionCommand(ws, collection, verb, args) {
252
262
  // the only caller, so a second, weaker splitter here could only ever disagree with it.
253
263
  function metaCommandsFor(ws, store, flags, pos) {
254
264
  const collection = need(pos, 0, 'collection');
255
- const ids = typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
265
+ const ids = (oneValue(flags, 'ids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
256
266
  const out = commandsFor(store, collection, ids);
257
267
  if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
258
268
  if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
@@ -280,6 +290,7 @@ function metaReposEnsure(ws, flags, pos) {
280
290
 
281
291
  // `dreamteamer collections add --name research-docs --template docs`
282
292
  function metaCollectionsAdd(ws, store, flags) {
293
+ refuseRepeats(flags);
283
294
  const { file } = createCollection(ws, store, { name: flags.name, template: flags.template, namespace: flags.namespace });
284
295
  console.log(`✔ ${rel(ws.root, file)}`);
285
296
  console.log('✔ compiled — the collection is live (schema ops prove sources with a real compile)');
@@ -290,6 +301,7 @@ function metaCollectionsAdd(ws, store, flags) {
290
301
  // The whole point is that namespacing EXISTING data is one command instead of a six-step hand
291
302
  // migration whose last step (rewriting references) dangles everything when forgotten.
292
303
  function metaCollectionsRename(ws, store, flags, pos) {
304
+ refuseRepeats(flags);
293
305
  const [oldName, explicitNew] = pos;
294
306
  if (!oldName) throw new Error('usage: dreamteamer schema rename-collection <old> <new> | <old> --namespace <ns>');
295
307
  // `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
@@ -313,6 +325,7 @@ function metaCollectionsRename(ws, store, flags, pos) {
313
325
  // `dreamteamer collections rm widgets [--force]` — --force is required to drop a collection
314
326
  // that still has records (removeCollection refuses otherwise, and says so).
315
327
  function metaCollectionsRm(ws, store, flags, pos) {
328
+ refuseRepeats(flags);
316
329
  const name = need(pos, 0, 'collection name');
317
330
  const out = removeCollection(ws, store, name, { force: !!flags.force });
318
331
  flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed collection ${out.removed}`);
@@ -322,6 +335,7 @@ function metaCollectionsRm(ws, store, flags, pos) {
322
335
 
323
336
  // `dreamteamer tasks add-field --name urgent --type boolean --default-value false`
324
337
  function metaAddField(ws, store, collection, flags) {
338
+ refuseRepeats(flags);
325
339
  const prop = fieldDef(store, flags, collection);
326
340
  // fieldDef DEFERS every relation flag it has no reference to attach to, because on update-field
327
341
  // the target is carried in afterwards. add-field has nothing to carry, so a relation flag that
@@ -372,6 +386,7 @@ function reportMirror(store, collection, fieldName, prop) {
372
386
  // Same flag vocabulary as add-field (one `fieldDef`), so the two read as one operation with two
373
387
  // preconditions rather than two dialects.
374
388
  function metaUpdateField(ws, store, collection, flags) {
389
+ refuseRepeats(flags);
375
390
  if (!flags.name) throw new Error('missing --name <field>');
376
391
  const prop = fieldDef(store, flags, collection);
377
392
  // tri-state: omitting --required leaves requiredness ALONE, rather than silently clearing it
@@ -392,6 +407,7 @@ function metaUpdateField(ws, store, collection, flags) {
392
407
 
393
408
  // `dreamteamer tasks remove-field --name urgent`
394
409
  function metaRemoveField(ws, store, collection, flags) {
410
+ refuseRepeats(flags);
395
411
  const name = flags.name ?? flags.field;
396
412
  if (!name) throw new Error('missing --name <field>');
397
413
  const out = removeField(ws, store, collection, name);
@@ -402,6 +418,10 @@ function metaRemoveField(ws, store, collection, flags) {
402
418
  // half of a destructive verb, and a silent deletion is a different act from a reported one.
403
419
  if (out.cleared) console.log(` cleared its values from ${out.cleared} ${collection} record${out.cleared === 1 ? '' : 's'} (git holds them: git show HEAD~1)`);
404
420
  reportDropped(out.dropped);
421
+ // This descriptor's own `list_fields`/`sort_field` were pruned with the field; a ui-view is a
422
+ // source this verb does not own, so it is NAMED rather than edited — and naming it is the whole
423
+ // point, since a column of a field that no longer exists renders as an empty one.
424
+ if (out.staleViews?.length) console.warn(`⚠ still listing ${collection}.${name} as a column: ${out.staleViews.join(', ')} — edit with \`dreamteamer schema set-view <id> options.columns=…\``);
405
425
  }
406
426
  return 0;
407
427
  }
@@ -411,8 +431,26 @@ function metaRemoveField(ws, store, collection, flags) {
411
431
  // through saveUiView's compile gate rather than the record store. Without these verbs everything
412
432
  // the Layout options panel does — columns, order, sort, layout, filter, nav — was click-only.
413
433
 
414
- /** `--options '{"sort":"-date"}'` style flags, plus dotted `options.sort=-date` positionals. */
415
- function parseViewValue(raw) {
434
+ /**
435
+ * Layout options whose value is a LIST, so `options.columns=title,status` means what it says.
436
+ *
437
+ * ⚠ NAMED, not inferred, and that is the whole design: `options` is an open bag with no schema —
438
+ * each layout declares its own keys and unknown ones ride through untouched — so nothing in the
439
+ * runtime can tell a list key from a scalar one. Without this set the comma spelling every other
440
+ * verb accepts for an array (`--tags a,b`) stored the literal string `"title,status"`, which the
441
+ * surface reads with `Array.isArray` and therefore ignores: a view that looked configured, drew
442
+ * the descriptor's `list_fields` instead, and reported nothing anywhere.
443
+ *
444
+ * `columns` is honoured by every layout; `ref_fields` and `value_fields` are the diagram's
445
+ * link-by pickers. `arrangement` is deliberately ABSENT — its elements are objects, so the JSON
446
+ * form is its only honest spelling, and a comma split would quietly produce garbage.
447
+ */
448
+ const VIEW_LIST_OPTIONS = new Set(['options.columns', 'options.ref_fields', 'options.value_fields']);
449
+
450
+ /** `--options '{"sort":"-date"}'` style flags, plus dotted `options.sort=-date` positionals. The
451
+ * KEY is passed because the value's shape depends on it: only the key says whether a comma is a
452
+ * separator or a character. */
453
+ function parseViewValue(raw, key) {
416
454
  if (typeof raw !== 'string') return raw;
417
455
  const t = raw.trim();
418
456
  if (t === 'true') return true;
@@ -421,6 +459,22 @@ function parseViewValue(raw) {
421
459
  if (t.startsWith('{') || t.startsWith('[')) {
422
460
  try { return JSON.parse(t); } catch { throw new Error(`not valid JSON: ${t}`); }
423
461
  }
462
+ // ⚠ A QUOTED value is a LITERAL string, and `options.sort='""'` is the whole reason the branch
463
+ // exists: `sort: ''` is what the surface requires to round-trip "unsorted" (omit it and the
464
+ // ordering silently reverts to a fallback on the next load), and the dotted grammar could not
465
+ // express it — an empty value UNSETS, by the same convention `dt set` has for a record field.
466
+ // So the quoting is the operator saying "the empty string IS the value"; `assignViewValue`
467
+ // carries that through to `assignPath`.
468
+ if (t.startsWith('"')) {
469
+ try {
470
+ const s = JSON.parse(t);
471
+ if (typeof s === 'string') return s;
472
+ } catch { /* one message for every malformed quoting, below */ }
473
+ throw new Error(`not a quoted string: ${t} — a literal is written like '"-date"' (and '""' for the empty one)`);
474
+ }
475
+ // An empty value falls through to assignPath, which UNSETS the key — a `columns=` that wrote
476
+ // `columns: []` would be a configured-to-show-nothing view, not a removed setting.
477
+ if (t !== '' && VIEW_LIST_OPTIONS.has(key)) return t.split(',').map((s) => s.trim()).filter(Boolean);
424
478
  return raw;
425
479
  }
426
480
 
@@ -435,11 +489,15 @@ function parseViewValue(raw) {
435
489
  *
436
490
  * Intermediate objects are not created on the way to a delete: unsetting `a.b` on a record with no
437
491
  * `a` should leave the record alone, not grow an empty `a: {}`.
492
+ *
493
+ * `explicit` is the escape hatch, and it has exactly one caller: a value the operator QUOTED
494
+ * (`options.sort='""'`) is a value, empty or not. Without it the convention above has no exception
495
+ * and `sort: ''` — which the surface needs to mean "unsorted" — is unwritable from the CLI.
438
496
  */
439
- function assignPath(target, dotted, value) {
497
+ function assignPath(target, dotted, value, explicit = false) {
440
498
  const keys = dotted.split('.');
441
499
  const leaf = keys[keys.length - 1];
442
- const unset = value === null || value === '';
500
+ const unset = !explicit && (value === null || value === '');
443
501
  let node = target;
444
502
  for (const k of keys.slice(0, -1)) {
445
503
  if (node[k] == null || typeof node[k] !== 'object' || Array.isArray(node[k])) {
@@ -452,6 +510,12 @@ function assignPath(target, dotted, value) {
452
510
  else node[leaf] = value;
453
511
  }
454
512
 
513
+ /** One dotted `key=value` write into a view: the value grammar, plus the one signal `assignPath`
514
+ * cannot recover from the parsed value — whether the operator quoted it. */
515
+ function assignViewValue(view, key, raw) {
516
+ assignPath(view, key, parseViewValue(raw, key), typeof raw === 'string' && raw.trim().startsWith('"'));
517
+ }
518
+
455
519
  const VIEW_META_FLAGS = new Set(['id', 'json', 'force']);
456
520
 
457
521
  function metaUiView(ws, store, verb, flags, pos) {
@@ -466,7 +530,7 @@ function metaUiView(ws, store, verb, flags, pos) {
466
530
  // store means `set` works on a MODULE-shipped view too — the edit lands as a workspace source
467
531
  // that shadows it, which is the same thing saving one in the UI does.
468
532
  let view = {};
469
- let id = typeof flags.id === 'string' ? flags.id : undefined;
533
+ let id = oneValue(flags, 'id');
470
534
  if (verb === 'set') {
471
535
  id ??= need(pos, 0, 'ui-view id');
472
536
  const { fields } = store.read('ui-views', id);
@@ -476,11 +540,16 @@ function metaUiView(ws, store, verb, flags, pos) {
476
540
 
477
541
  for (const p of pos.slice(verb === 'set' ? 1 : 0)) {
478
542
  if (!p.includes('=')) continue;
479
- assignPath(view, p.slice(0, p.indexOf('=')), parseViewValue(p.slice(p.indexOf('=') + 1)));
543
+ assignViewValue(view, p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1));
480
544
  }
481
545
  for (const [k, v] of Object.entries(flags)) {
482
546
  if (VIEW_META_FLAGS.has(k)) continue;
483
- assignPath(view, k, parseViewValue(v));
547
+ // A view key holds ONE value, and a list IS one value — so a repeated flag is a mistake with
548
+ // two spellings to correct it, rather than an array nobody asked for.
549
+ if (Array.isArray(v)) {
550
+ throw new Error(`--${k} was given ${v.length} times — a view key takes one value, and a list is written as one: --${k} ${v.join(',')} (or the JSON form '${JSON.stringify(v)}')`);
551
+ }
552
+ assignViewValue(view, k, v);
484
553
  }
485
554
 
486
555
  if (!view.path) throw new Error('missing --path </route> — a view is addressed by its route');
@@ -621,21 +690,75 @@ function* parseEach(store, collection, onError) {
621
690
  }
622
691
  }
623
692
 
624
- function parseArgs(args) {
693
+ /**
694
+ * `--flag value`, `--flag=value`, a bare `--flag` (true), and positionals.
695
+ *
696
+ * ⚠ A REPEATED flag PROMOTES TO AN ARRAY; it does not overwrite. `dt add c --tags a --tags b`
697
+ * assigned twice and kept `b`, so the first value never reached disk and nothing said so — the
698
+ * shape of every silent-wrong-answer bug this CLI has had. The promotion is the only honest parse,
699
+ * because "the operator typed it twice" is a fact the parser cannot interpret on its own.
700
+ *
701
+ * Every consumer of a flag therefore owes an answer to "what does a repeat MEAN here", and there
702
+ * are exactly three: it is one ELEMENT of an array field (`coerceArrays`), it is one more
703
+ * conjunctive CONDITION (`--filter`, in list), or the flag holds one value and a repeat is a
704
+ * MISTAKE (`oneValue`, and the view writer). Silence is not one of them.
705
+ */
706
+ export function parseArgs(args) {
625
707
  const flags = {};
626
708
  const pos = [];
709
+ const put = (k, v) => { flags[k] = k in flags ? [].concat(flags[k], v) : v; };
627
710
  for (let i = 0; i < args.length; i++) {
628
711
  const a = args[i];
629
712
  if (a.startsWith('--')) {
630
713
  const eq = a.indexOf('=');
631
- if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
632
- else if (i + 1 < args.length && !args[i + 1].startsWith('--')) flags[a.slice(2)] = args[++i];
633
- else flags[a.slice(2)] = true;
714
+ if (eq > -1) put(a.slice(2, eq), a.slice(eq + 1));
715
+ else if (i + 1 < args.length && !args[i + 1].startsWith('--')) put(a.slice(2), args[++i]);
716
+ else put(a.slice(2), true);
634
717
  } else pos.push(a);
635
718
  }
636
719
  return { flags, pos };
637
720
  }
638
721
 
722
+ /** Read a flag that takes exactly ONE value. A repeat arrives as an array, and dropping it on the
723
+ * floor (`typeof v === 'string'` reads an array as absent) would answer the wrong question at exit
724
+ * 0 — so it is named instead, with the line that was typed. */
725
+ function oneValue(flags, key) {
726
+ const v = flags[key];
727
+ if (Array.isArray(v)) {
728
+ throw new Error(`--${key} was given ${v.length} times and takes ONE value: ${v.map((x) => `--${key} ${x}`).join(' ')}`);
729
+ }
730
+ return typeof v === 'string' ? v : undefined;
731
+ }
732
+
733
+ /**
734
+ * A schema verb takes ONE value per flag, and a repeat is refused before anything is written.
735
+ *
736
+ * None of them means a list by repetition — `--options a,b` is one value, and `--name` names the
737
+ * single thing being written — so every repeat is a mistake. It has to be caught here because the
738
+ * ones that matter are IDENTITY: `--name x --name y` wrote a field called `y` before the promotion
739
+ * and one called `x,y` after it, and both of those are a source file the operator then has to find
740
+ * and edit by hand.
741
+ */
742
+ function refuseRepeats(flags) {
743
+ const dup = Object.entries(flags).find(([, v]) => Array.isArray(v));
744
+ if (!dup) return;
745
+ const [k, v] = dup;
746
+ throw new Error(`--${k} was given ${v.length} times, and a schema verb takes ONE value per flag: ${v.map((x) => `--${k} ${x}`).join(' ')}`);
747
+ }
748
+
749
+ /** `field=value` positionals, with the same promote-on-repeat rule the flags have. It was
750
+ * `Object.fromEntries`, which keeps the LAST pair — `dt set c/id tags=a tags=b` wrote `[b]`. */
751
+ function pairs(list) {
752
+ const out = {};
753
+ for (const p of list) {
754
+ if (!p.includes('=')) continue;
755
+ const k = p.slice(0, p.indexOf('='));
756
+ const v = p.slice(p.indexOf('=') + 1);
757
+ out[k] = k in out ? [].concat(out[k], v) : v;
758
+ }
759
+ return out;
760
+ }
761
+
639
762
  const META_FLAGS = new Set(['id', 'json', 'force', 'filter']);
640
763
  const stripMeta = (flags) => Object.fromEntries(Object.entries(flags).filter(([k]) => !META_FLAGS.has(k)));
641
764
 
@@ -643,9 +766,18 @@ const stripMeta = (flags) => Object.fromEntries(Object.entries(flags).filter(([k
643
766
  function coerceArrays(d, fields) {
644
767
  const out = {};
645
768
  for (const [k, v] of Object.entries(fields)) {
646
- out[k] = d.schema.properties?.[k]?.type === 'array' && typeof v === 'string'
647
- ? v.split(',').map((s) => s.trim()).filter(Boolean)
648
- : v;
769
+ const isList = d.schema.properties?.[k]?.type === 'array';
770
+ // An array here means the operator SPELLED the key twice (`--tags a --tags b`, or
771
+ // `tags=a tags=b`). On a list field each sighting is one element — deliberately NOT
772
+ // comma-split again, so a value that contains a comma can be written by repeating the flag.
773
+ if (Array.isArray(v)) {
774
+ if (!isList) {
775
+ throw new Error(`${k} was given ${v.length} times (--${k} <value> / ${k}=<value>) and ${k} is not an array field — pass it once`);
776
+ }
777
+ out[k] = v;
778
+ continue;
779
+ }
780
+ out[k] = isList && typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : v;
649
781
  }
650
782
  return out;
651
783
  }
package/src/commit.js CHANGED
@@ -377,7 +377,14 @@ function sample(root, repo, dirs, descriptors) {
377
377
  const fromRel = (status === 'R' || status === 'C') ? chunks[++i] : null;
378
378
  const rec = pathToRecord(descriptors, prefix + repoRel);
379
379
  if (!rec) continue;
380
- rows.push({ repoRel, fromRel, ...rec, verb: VERB[status] ?? 'set' });
380
+ // ⚠ `M` MEANS `set` FOR EVERY CODEC BUT ONE. `set` is the single verb the store refuses on a
381
+ // `codec: file` record — its bytes ARE the record, and the refusal's own message says to use
382
+ // `add --from … --force` instead — so a forced replacement was published under the name of
383
+ // the one thing that cannot be done to it. The status letter still decides; the descriptor
384
+ // only says what the letter is called here.
385
+ let verb = VERB[status] ?? 'set';
386
+ if (verb === 'set' && (descriptors.get(rec.collection)?.storage?.codec ?? 'md') === 'file') verb = 'replace';
387
+ rows.push({ repoRel, fromRel, ...rec, verb });
381
388
  }
382
389
  return { cwd, rows };
383
390
  }
@@ -485,6 +492,7 @@ export function commitPending(store, { only = [], message, dryRun = false } = {}
485
492
  // store.js has always used.
486
493
  execFileSync('git', ['add', '--all', '--', ...paths], { cwd });
487
494
  execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...paths, ...alreadyStaged], { cwd });
495
+ store.headMoved(); // the verb that exists to move HEAD — see store.gitHead
488
496
  }
489
497
  const sha = dryRun ? null : execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd }).toString().trim();
490
498
  results.push({ repo, rows, subject, sha, warning, leftPending });
package/src/compile.js CHANGED
@@ -12,7 +12,7 @@ import { walk, patternRe } from './records.js';
12
12
  import { unknownOperators } from './filter.js';
13
13
  import {
14
14
  normalizeNamespaces, namespaceProblems, unqualifiedProblems, defaultStoragePath, storageOverlaps,
15
- baseNameOf,
15
+ baseNameOf, singular,
16
16
  } from './namespace.js';
17
17
  // circular on paper in earlier versions — safe: both sides only
18
18
  // call at run time, same pattern as store.js ↔ compile.js.
@@ -104,7 +104,7 @@ function normalizeRelationKeywords(schema, name, prefix = '') {
104
104
  // every per-relation keyword, not just `x-inverse` — `x-unique` decides the mirror's
105
105
  // cardinality, `x-on-delete` its removal rule and `x-inverse-of` names the owner from the
106
106
  // other spelling, so all four have to reach the same node the relation is read from.
107
- for (const kw of ['x-inverse', 'x-title-template', 'x-unique', 'x-on-delete', 'x-inverse-of']) {
107
+ for (const kw of ['x-inverse', 'x-title-template', 'x-unique', 'x-on-delete', 'x-inverse-of', 'x-reference-soft']) {
108
108
  if (!(kw in prop)) continue;
109
109
  if (kw in prop.items && prop.items[kw] !== prop[kw]) {
110
110
  fail(`collection "${name}": field "${at}" declares conflicting ${kw} on the property and its items — keep one.`);
@@ -423,14 +423,29 @@ const NON_SOURCE_DIRS = new Set([
423
423
  ]);
424
424
 
425
425
  /** Unrecognised source-root folders in a module, or [] for the workspace root (a vault legitimately
426
- * holds arbitrary directories — this gate is about PACKAGES, whose folders all mean something). */
426
+ * holds arbitrary directories — this gate is about PACKAGES, whose folders all mean something).
427
+ *
428
+ * ⚠ `system/` is enumerated TOO, under the same rule. It is in NON_SOURCE_DIRS so the root pass
429
+ * waves it through, and that waved through everything BELOW it — so `system/gizmos/probe.gizmo.yaml`
430
+ * compiled ✔ in silence while the identical folder at the module root hard-errored. That is exactly
431
+ * the decision-156 shape the root gate exists to prevent, surviving one level down: `kindDir` still
432
+ * reads `system/<kind>` as the pre-flatten fallback, so a kind the engine stopped knowing is just as
433
+ * invisible there as it ever was at the root, and the half-migrated module is the likeliest place
434
+ * for one to be. Known kinds under `system/` keep compiling — the fallback is deliberate and stays
435
+ * (CLAUDE.md); only UNKNOWN folders become errors, and `dreamteamer.ignore` excuses them in both
436
+ * places with one entry. */
427
437
  function strayKindDirs(source, wsRoot, declaredIgnore) {
428
438
  if (path.resolve(source.root) === path.resolve(wsRoot)) return [];
429
439
  const allow = new Set([...KINDS, ...NON_SOURCE_DIRS, ...declaredIgnore]);
430
- return fs.readdirSync(source.root, { withFileTypes: true })
440
+ const dirsIn = (dir, prefix = '') => (fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }) : [])
431
441
  .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !allow.has(e.name))
432
- .map((e) => e.name)
433
- .sort();
442
+ .map((e) => `${prefix}${e.name}`);
443
+ return [
444
+ ...dirsIn(source.root),
445
+ // The SAME allow set one level down — a generic package folder reads the same wherever the
446
+ // module put it, and one `ignore` entry covers both spellings rather than two.
447
+ ...dirsIn(path.join(source.root, 'system'), 'system/'),
448
+ ].sort();
434
449
  }
435
450
 
436
451
  const sha256 = (buf) => 'sha256:' + createHash('sha256').update(buf).digest('hex');
@@ -527,6 +542,30 @@ export function compile({ root, pkg }) {
527
542
  const harnesses = config.harnesses ?? ['claude-code'];
528
543
  const rel = (p) => path.relative(root, p);
529
544
 
545
+ /** ⚠ YAML, WITH THE FILE IN THE MESSAGE. js-yaml reports `line:column` and a snippet and never a
546
+ * path, so one stray tab in a workspace of a hundred sources was an mtime bisect. Every parse of
547
+ * a MODULE SOURCE goes through here; the projected records compile dumps itself do not need it. */
548
+ const loadSource = (text, srcPath) => {
549
+ try {
550
+ return load(text);
551
+ } catch (e) {
552
+ return fail(`${srcPath}: ${e.message}`);
553
+ }
554
+ };
555
+
556
+ /** An EMPTY source is never intentional, and every reader indexes straight into what it parses.
557
+ * The collection case died with a bare "Cannot read properties of undefined" naming no file; a
558
+ * 0-byte ui-view was worse — compile said ✔ and `check` caught it one gate later, naming the
559
+ * COMPILED artifact under `.dreamteamer/`, which nobody can edit and the next compile overwrites.
560
+ * Refuse here, the last layer that still knows which module file the bytes came from.
561
+ * ⚠ ENTITY FILES ONLY. A skill is a FOLDER: its `SKILL.md` is the entity and is covered, while a
562
+ * placeholder under `references/` is the author's business, not the compiler's. */
563
+ const refuseEmptySource = (srcPath) => {
564
+ if (fs.readFileSync(srcPath, 'utf8').trim() === '') {
565
+ fail(`${rel(srcPath)}: source is empty — there is nothing to compile. Delete the file, or write one.`);
566
+ }
567
+ };
568
+
530
569
  // ---- discover sources: channel modules then the workspace's own -----------------
531
570
  const { modules: discovered, shadows } = discoverModules(root, pkg);
532
571
  for (const s of shadows) console.warn(shadowWarning(s));
@@ -688,9 +727,13 @@ export function compile({ root, pkg }) {
688
727
  // both of which used to compile ✔ and contribute nothing (see NON_SOURCE_DIRS)
689
728
  const strays = strayKindDirs(source, root, moduleIgnores.get(source.name) ?? []);
690
729
  if (strays.length) {
730
+ // `ignore` matches a FOLDER NAME, at the root and under `system/` alike, so the remedy has
731
+ // to quote the bare name — suggesting "system/gizmos" would print an entry that matches
732
+ // nothing and send the reader back for a second compile to find that out.
733
+ const ignorable = [...new Set(strays.map((s) => s.replace(/^system\//, '')))];
691
734
  fail(`module "${source.name}" (${rel(source.root)}) has folder(s) that are not a known kind: ${strays.join(', ')}
692
735
  known kinds: ${KINDS.join(', ')}
693
- if these are not sources, declare them: "dreamteamer": { "ignore": [${strays.map((s) => `"${s}"`).join(', ')}] } in ${rel(path.join(source.root, 'package.json'))}`);
736
+ if these are not sources, declare them: "dreamteamer": { "ignore": [${ignorable.map((s) => `"${s}"`).join(', ')}] } in ${rel(path.join(source.root, 'package.json'))}`);
694
737
  }
695
738
  for (const kind of KINDS) {
696
739
  // a half-moved module compiles its flat half and drops the rest — say so rather than
@@ -721,7 +764,16 @@ export function compile({ root, pkg }) {
721
764
  if (kind === 'collections' && !isDir) {
722
765
  // descriptors merge via 'extends' — collect per collection name
723
766
  const bytes = fs.readFileSync(srcPath);
724
- const doc = load(bytes.toString('utf8'));
767
+ // A `touch`ed file on the way to writing one is the ordinary way to arrive here. The
768
+ // descriptor keeps its own wording — it is the one kind that can say what is missing.
769
+ if (bytes.toString('utf8').trim() === '') {
770
+ fail(`${rel(srcPath)}: collection source is empty — a descriptor needs at least 'name' and 'schema' (or 'extends'). Delete the file, or write one.`);
771
+ }
772
+ const doc = loadSource(bytes.toString('utf8'), rel(srcPath));
773
+ // a scalar or a list parses fine and then reads as undefined on every key below
774
+ if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) {
775
+ fail(`${rel(srcPath)}: collection source is not a mapping — a descriptor needs at least 'name' and 'schema' (or 'extends').`);
776
+ }
725
777
  // `codec: file` records are opaque bytes: there are no fields, so there is no schema to
726
778
  // require and none to honour. Every other codec parses text into fields and must declare
727
779
  // what they are.
@@ -733,18 +785,23 @@ export function compile({ root, pkg }) {
733
785
  descriptorGroups.get(doc.name).push({ src: { path: rel(srcPath), hash: sha256(bytes) }, doc, moduleName: source.name });
734
786
  contributed.add(source.name);
735
787
  } else if (FOLDER_KINDS.has(kind) && isDir) {
788
+ // the folder's ENTITY file, not its payload — see refuseEmptySource
789
+ const entityFile = path.join(srcPath, 'SKILL.md');
790
+ if (fs.existsSync(entityFile)) refuseEmptySource(entityFile);
736
791
  for (const file of walk(srcPath)) {
737
792
  addEntry(path.join(kind, name, path.relative(srcPath, file)), file);
738
793
  contributed.add(source.name);
739
794
  }
740
795
  counts[kind]++;
741
796
  } else if (!isDir) {
797
+ refuseEmptySource(srcPath);
742
798
  addEntry(path.join(kind, name), srcPath);
743
799
  contributed.add(source.name);
744
800
  counts[kind]++;
745
801
  } else {
746
802
  // nested dirs for file-shape kinds (e.g. date-partitioned) — recurse
747
803
  for (const file of walk(srcPath)) {
804
+ refuseEmptySource(file);
748
805
  addEntry(path.join(kind, path.relative(srcDir, file)), file);
749
806
  contributed.add(source.name);
750
807
  counts[kind]++;
@@ -805,8 +862,8 @@ export function compile({ root, pkg }) {
805
862
  for (const [rt, entry] of entries) {
806
863
  const m = /^collection-templates\/(.+)\.collection-template\.yaml$/.exec(rt);
807
864
  if (!m) continue;
808
- const doc = load(entry.bytes.toString('utf8'));
809
- templateDocs.set(m[1], { template: doc.template ?? {}, src: entry.sources[0] });
865
+ const doc = loadSource(entry.bytes.toString('utf8'), entry.sources[0].path);
866
+ templateDocs.set(m[1], { template: doc?.template ?? {}, src: entry.sources[0] });
810
867
  }
811
868
 
812
869
  // ---- namespaces: the declared list, validated against what actually compiled ----------
@@ -916,6 +973,21 @@ export function compile({ root, pkg }) {
916
973
  // An authored `storage.path` still wins — registering an existing folder is a first-class case
917
974
  // (skills/using-dreamteamer/references/collections.md).
918
975
  merged.storage.path ??= defaultStoragePath(name, namespaces, config['data-path'] ?? 'data');
976
+ // The middle segment of every record filename, `<id>.<suffix>.<ext>`. DERIVED here, exactly as
977
+ // `path` is above, so the runtime always carries an explicit one.
978
+ //
979
+ // ⚠ Absent, it was interpolated raw and every record landed as `<id>.undefined.md` — silent at
980
+ // compile, at `add` and at `check`, because `idFromRecordPath` reads the same undefined back
981
+ // and the round trip agrees with itself. On a `codec: file` collection it is not even silent
982
+ // for long: the next verb dies inside that function on `undefined.replace`.
983
+ //
984
+ // The rule is the singular of the BARE name (`health/doctors` records are `<id>.doctor.md`,
985
+ // not `<id>.health/doctor.md`) — the same rule `collections add` writes into a new descriptor,
986
+ // and the one `rename-collection` already ASSUMES when it tests `oldSuffix === singular(oldBase)`
987
+ // to decide whether a suffix may be re-derived. Deriving here makes that assumption true rather
988
+ // than coincidental. Kinder than refusing, and no existing workspace changes: a descriptor that
989
+ // authors a suffix keeps it, and one that does not was writing `.undefined.` files.
990
+ merged.storage.suffix ??= singular(baseNameOf(name, namespaces));
919
991
  const storagePath = String(merged.storage.path ?? '');
920
992
  const systemKinds = [...KINDS, ...DERIVED_KINDS];
921
993
  const isSystem = systemKinds.includes(storagePath) || systemKinds.includes(storagePath.replace(/^system\//, ''));
@@ -1001,6 +1073,8 @@ export function compile({ root, pkg }) {
1001
1073
  const declaredDeps = new Set(groupModules.flatMap((m) => moduleDeps.get(m) ?? []));
1002
1074
  const declaredPeers = new Set(groupModules.flatMap((m) => modulePeers.get(m) ?? []));
1003
1075
  const owns = (t) => groupModules.includes(collOwner.get(t));
1076
+ // what THIS collection actually points at — see `unresolved_peers` below
1077
+ const referenced = new Set();
1004
1078
  normalizeRelationKeywords(merged.schema, name);
1005
1079
  for (const [at, raw] of refTargets(merged.schema)) {
1006
1080
  if (raw === '*') {
@@ -1021,6 +1095,7 @@ export function compile({ root, pkg }) {
1021
1095
  fail(`collection "${name}": field "${at}" has an invalid x-reference ${JSON.stringify(raw)} — expected a collection name, a non-empty list of collection names, or '*'.`);
1022
1096
  }
1023
1097
  for (const target of targets) {
1098
+ referenced.add(target);
1024
1099
  if (CORE_COLLECTIONS.has(target) || owns(target)) continue;
1025
1100
  const owner = collOwner.get(target);
1026
1101
  if (owner && declaredDeps.has(owner)) continue;
@@ -1034,7 +1109,13 @@ export function compile({ root, pkg }) {
1034
1109
  // Declared peers that nothing provides, stated as DATA on the descriptor so `check` can
1035
1110
  // excuse their references without learning what a module is (the `storage.base` precedent —
1036
1111
  // check.js is in the record layer and must not know modules exist).
1037
- const unresolved = [...declaredPeers].filter((p) => !collOwner.has(p)).sort();
1112
+ //
1113
+ // ⚠ Only the peers THIS collection references. It used to be every peer the module declared,
1114
+ // stamped onto every collection in it — which made the field a module-level fact wearing a
1115
+ // collection-level key, and handed a collection that references nothing an excuse it can
1116
+ // never legitimately use. A peer declared for one collection would then have silently excused
1117
+ // a typo'd reference in a sibling.
1118
+ const unresolved = [...declaredPeers].filter((p) => referenced.has(p) && !collOwner.has(p)).sort();
1038
1119
  if (unresolved.length) merged.unresolved_peers = unresolved;
1039
1120
 
1040
1121
  // ONE BODY PER COLLECTION. A record's prose is the text after the frontmatter — there is only
@@ -1187,7 +1268,7 @@ export function compile({ root, pkg }) {
1187
1268
  for (const [rt, e] of entries) {
1188
1269
  if (rt.startsWith('agents/')) {
1189
1270
  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(e.bytes.toString('utf8'));
1190
- const doc = fm ? load(fm[1]) : {};
1271
+ const doc = fm ? loadSource(fm[1], e.sources[0].path) : {};
1191
1272
  for (const sk of doc.skills ?? []) {
1192
1273
  if (!skillIds.has(String(sk).replace(/^skills\//, ''))) fail(`${rt}: references unknown skill "${sk}"`);
1193
1274
  }
@@ -1221,7 +1302,7 @@ export function compile({ root, pkg }) {
1221
1302
  const ownFields = load(entries.get(path.join('collections', 'ui-views.collection.yaml'))?.bytes?.toString('utf8') ?? '')?.schema?.properties ?? {};
1222
1303
  for (const [rt, e] of entries) {
1223
1304
  if (!rt.startsWith('ui-views/')) continue;
1224
- const view = load(e.bytes.toString('utf8'));
1305
+ const view = loadSource(e.bytes.toString('utf8'), e.sources[0].path);
1225
1306
  // filters are load-bearing (they narrow what the operator SEES) — typo'd operators
1226
1307
  // fail at compile, not silently at render (review finding 5)
1227
1308
  const badOps = view?.filter ? [...unknownOperators(view.filter)] : [];
@@ -1258,7 +1339,7 @@ export function compile({ root, pkg }) {
1258
1339
  const commandIds = new Set([...entries.keys()].filter((k) => k.startsWith('commands/')).map((k) => path.basename(k).replace(/\.command\.md$/, '')));
1259
1340
  for (const [rt, e] of entries) {
1260
1341
  if (!rt.startsWith('command-bindings/')) continue;
1261
- const b = load(e.bytes.toString('utf8'));
1342
+ const b = loadSource(e.bytes.toString('utf8'), e.sources[0].path);
1262
1343
  const cmd = String(b?.command ?? '').replace(/^commands\//, '');
1263
1344
  if (!cmd || !commandIds.has(cmd)) fail(`${rt}: references unknown command "${b?.command ?? ''}"`);
1264
1345
  const coll = String(b?.collection ?? '').replace(/^collections\//, '');
package/src/env-vars.js CHANGED
@@ -7,7 +7,9 @@
7
7
  // Two accepted non-goals, unchanged on purpose: `KEY =value` (space before `=`) doesn't match the
8
8
  // key pattern and is silently dropped, no diagnostic; an unquoted value keeps a trailing inline
9
9
  // `# comment` as part of its text (quote it to strip one).
10
+ import fs from 'node:fs';
10
11
  import os from 'node:os';
12
+ import path from 'node:path';
11
13
 
12
14
  export function parseEnvValues(text) {
13
15
  const out = new Map();
@@ -22,6 +24,19 @@ export function parseEnvValues(text) {
22
24
  return out;
23
25
  }
24
26
 
27
+ /** What `renderTemplate` renders AGAINST, for one workspace: the names `dreamteamer.vars` declares,
28
+ * the values this machine's `.env` carries, and the root. One builder rather than three lines at
29
+ * each call site — `dt resolve` and `repos ensure` disagreeing about which vars are declared would
30
+ * be a path that renders in one verb and refuses in the other. */
31
+ export function envContext(ws) {
32
+ const envFile = path.join(ws.root, '.env');
33
+ return {
34
+ env: parseEnvValues(fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : ''),
35
+ workspaceFolder: ws.root,
36
+ declared: ws.pkg.dreamteamer?.vars ?? [],
37
+ };
38
+ }
39
+
25
40
  const SUPPORTED = 'supported: ${env:NAME}, ${workspaceFolder}, ${userHome}';
26
41
 
27
42
  export function renderTemplate(str, { env, workspaceFolder, declared }) {