dreamteamer 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gilad Khen <giladkhen@gmail.com>",
package/src/cli.js CHANGED
@@ -29,7 +29,7 @@ import { envContext, renderTemplate } from './env-vars.js';
29
29
  const QUIET = ['ignore', 'pipe', 'ignore'];
30
30
 
31
31
 
32
- const USAGE = `usage: dreamteamer <verb> [<target>] [flags]
32
+ export const USAGE = `usage: dreamteamer <verb> [<target>] [flags]
33
33
 
34
34
  record verbs (hard validation — invalid writes are rejected before disk).
35
35
  A <target> is either a collection name or a <collection>/<id> reference; the reference splits at
@@ -89,8 +89,10 @@ The commit lands in the repo that holds the source, so a write into a git module
89
89
  health/doctors; a module declaring exactly ONE
90
90
  namespace infers it, and the resolved name is echoed.
91
91
  --namespace '' means no namespace)
92
- add modules --name <id> [--description "…"]
92
+ add modules --name <id> [--description "…"] [--namespace <ns>]
93
93
  (modules/<id>/ + every kind folder + package.json.
94
+ --namespace DECLARES it in the module (§8), so every
95
+ later \`add collections --module <id>\` infers it.
94
96
  folder = package name = id, so a module never forks.
95
97
  the git shape is \`install --clone <url> [name]\`)
96
98
  add skills --name <id> --description "…" (skills/<id>/SKILL.md — --description is required,
@@ -192,9 +194,23 @@ const EITHER_VERBS = new Set(['move', 'commands']);
192
194
  // verbs, and `collectionCommand`'s interceptors are the whole dispatch (§4).
193
195
  const FIELD_VERBS = ['add-field', 'update-field', 'remove-field', 'rename-field'];
194
196
 
197
+ // WORKSPACE VERBS take a closed set of options, and nothing downstream of here would notice a
198
+ // misspelling — `dt commit --dryrun` COMMITTED, because `rest.includes('--dry-run')` is false for a
199
+ // flag nobody typed correctly. The record/system/field verbs are checked in `collections-cli.js`,
200
+ // beside the parser they share; these nine have no shared parser, so the table is here.
201
+ export const WORKSPACE_FLAGS = {
202
+ init: ['name', 'data-path', 'harnesses', 'workspace-module'], install: ['clone'], update: [],
203
+ start: ['port'], compile: ['watch'], check: [], status: [],
204
+ changes: ['since', 'json'], commit: ['dry-run', 'json'],
205
+ };
206
+
195
207
  export function run(argv) {
196
208
  const [cmd, ...rest] = argv;
197
209
  try {
210
+ if (cmd in WORKSPACE_FLAGS) {
211
+ const bad = rest.filter((a) => a.startsWith('--')).map((a) => a.slice(2).split('=')[0]).find((f) => !WORKSPACE_FLAGS[cmd].includes(f));
212
+ if (bad) throw new Error(`unknown flag "--${bad}" on \`dt ${cmd}\`\n known: ${WORKSPACE_FLAGS[cmd].map((f) => `--${f}`).join(', ') || '(none — this verb takes no flags)'}`);
213
+ }
198
214
  if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
199
215
  // works OUTSIDE a workspace — the post-install "did it land?" affordance
200
216
  const p = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -78,6 +78,7 @@ const LIST_META_FLAGS = new Set(['json', 'filter', 'where', 'sort']);
78
78
  export function collectionCommand(ws, collection, verb, args) {
79
79
  const store = new Store(ws);
80
80
  const { flags, pos } = parseArgs(args);
81
+ refuseUnknownFlags(store, collection, verb, flags);
81
82
 
82
83
  // ---- system verbs: source writes, never the runtime ----------
83
84
  // These MUST come before the generic switch: their collections are system-stored, so the
@@ -137,6 +138,17 @@ export function collectionCommand(ws, collection, verb, args) {
137
138
  // panel that produced that JSON cannot disagree about which records match.
138
139
  const whereJson = oneValue(flags, 'where');
139
140
  const where = whereJson ? load(whereJson) : null;
141
+ // ⚠ A FLAG NAME IS NOT THE ONLY THING THAT CAN BE MISSPELLED. `--filter nmae=Ada` and
142
+ // `--sort nmae` are correctly spelled FLAGS whose VALUE names a field that does not exist,
143
+ // and both answer at exit 0 — an empty listing and an unsorted one. Same silent-empty class,
144
+ // worse than a bad write because there is nothing to notice. `--where` gets the type check
145
+ // for the same reason: `--where 'name _eq Ada'` yaml-parses to a STRING, and matchesFilter
146
+ // then matched every row.
147
+ if (whereJson && (typeof where !== 'object' || where === null)) throw new Error(`--where takes ONE filter OBJECT and got a ${where === null ? 'null' : typeof where}: ${whereJson}\n a condition is {"<field>":{"_eq":"<value>"}} — the shorthand for one equality is --filter <field>=<value>`);
148
+ const sort = oneValue(flags, 'sort');
149
+ const vocab = ['id', ...Object.keys(d.schema?.properties ?? {})];
150
+ const stray = [...filters.map(([k]) => k), ...(sort ? [String(sort).replace(/^-/, '')] : [])].find((f) => !vocab.includes(f));
151
+ if (stray) throw new Error(`${collection} has no field "${stray}"${nearest(stray, vocab) ? ` — did you mean "${nearest(stray, vocab)}"?` : ''} (dt get collections/${collection} lists them)`);
140
152
  const resolve = where ? recordResolver(store) : null;
141
153
  const bf = bodyField(d);
142
154
  const rows = [];
@@ -149,7 +161,6 @@ export function collectionCommand(ws, collection, verb, args) {
149
161
  // sorting was studio-only until now: the browse table ordered records and no CLI
150
162
  // invocation could. Same `sortRows` the server and api.ts call, so `--sort -starts`
151
163
  // orders date-times by INSTANT across mixed offsets rather than by string.
152
- const sort = oneValue(flags, 'sort');
153
164
  if (sort) sortRows(rows, sort);
154
165
  if (flags.json) { emit(JSON.stringify(rows, null, 2)); return 0; }
155
166
  const cols = ['id', ...(d.list_fields ?? []).filter((c) => c !== 'id')];
@@ -224,6 +235,7 @@ export function collectionCommand(ws, collection, verb, args) {
224
235
  }
225
236
  case 'rm': {
226
237
  const id = need(pos, 0, 'id');
238
+ if (flags['dry-run']) return dryRunPlan(`rm ${collection}/${id}`, { records: store.ids(collection).has(id) ? 1 : 0, refs: store.findInboundRefs(`${collection}/${id}`).length });
227
239
  const { inboundIgnored } = store.rm(collection, id, { force: !!flags.force });
228
240
  flags.json ? emit(JSON.stringify({ id, removed: true, inboundIgnored })) : console.log(`✔ removed${inboundIgnored ? ` (${inboundIgnored} inbound reference(s) left dangling — run \`dreamteamer check\`)` : ''}`);
229
241
  return 0;
@@ -240,9 +252,11 @@ export function collectionCommand(ws, collection, verb, args) {
240
252
  // no enum describes (operator: "still no dropdown for many things, visibility, status").
241
253
  case 'values': {
242
254
  const field = need(pos, 0, 'field');
243
- const out = distinctValues(store, collection, field, {
244
- limit: flags.limit === undefined ? undefined : Number(flags.limit),
245
- });
255
+ // A BARE `--limit` parses as the boolean `true`, and `Number(true)` is 1 — so it
256
+ // truncated the vocabulary to one value while the footer claimed "showing 1", which reads
257
+ // as a fact about the collection. Refused, in `--drop`'s words.
258
+ if ('limit' in flags && !Number.isFinite(Number(oneValue(flags, 'limit')))) throw new Error(`--limit needs a number: dreamteamer values ${collection} ${field} --limit 20`);
259
+ const out = distinctValues(store, collection, field, { limit: flags.limit === undefined ? undefined : Number(flags.limit) });
246
260
  if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
247
261
  if (out.skipped) { console.log(`(${collection}.${field} is a ${out.skipped} field — no value vocabulary)`); return 0; }
248
262
  if (!out.values.length) { console.log(`(no values set on ${collection}.${field})`); return 0; }
@@ -326,10 +340,20 @@ function metaReposEnsure(ws, flags, pos) {
326
340
 
327
341
  // `dreamteamer add modules --name core [--description "…"]`
328
342
  function metaModulesAdd(ws, store, flags) {
329
- refuseRepeats(flags);
330
- const out = createModule(ws, store, { name: oneValue(flags, 'name'), description: oneValue(flags, 'description') });
343
+ // ⚠ BEFORE the write, as `add collections` does it and for the same reason: naming the mistake
344
+ // after the call lands a committed module and then exits 1.
345
+ if (flags.namespace === true) throw new Error("--namespace takes a value: dreamteamer add modules --name <id> --namespace <ns>");
346
+ const out = createModule(ws, store, {
347
+ name: oneValue(flags, 'name'),
348
+ description: oneValue(flags, 'description'),
349
+ namespace: oneValue(flags, 'namespace'),
350
+ });
331
351
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
332
352
  console.log(`✔ ${out.root}/ — package.json + ${KIND_COUNT} kind folder(s)`);
353
+ // §6.2 promises the namespace is DECLARED IN THE MODULE, and §8 makes every later
354
+ // `add collections --module <id>` infer it — so the declaration is echoed, because an inferred
355
+ // identity the operator did not type is one they must be able to read back.
356
+ if (out.namespace) console.log(`✔ declared namespace "${out.namespace}" in modules/${out.id}`);
333
357
  console.log('✔ compiled — the module is live (add a collection with `dreamteamer add collections --name <c> --module ' + out.id + '`)');
334
358
  reportCommits(out.commits);
335
359
  return 0;
@@ -337,18 +361,14 @@ function metaModulesAdd(ws, store, flags) {
337
361
 
338
362
  // `dreamteamer rm modules/core [--force] [--dry-run]`
339
363
  function metaModulesRm(ws, store, flags, pos) {
340
- refuseRepeats(flags);
341
364
  const id = need(pos, 0, 'module id');
342
365
  const out = removeModule(ws, store, id, { force: !!flags.force, dryRun: !!flags['dry-run'] });
343
366
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
344
- if (out.dryRun) {
345
- console.log(`dry run dreamteamer rm modules/${id} --force would:`);
346
- console.log(planLine(out));
347
- if (out.collections.length) console.log(` sources removed for: ${out.collections.join(', ')}`);
348
- if (out.withRecords.length) console.log(` records left in place and UNINDEXED: ${out.withRecords.join(', ')}`);
349
- if (out.dependents.length) console.log(` dependencies entry dropped from: ${out.dependents.join(', ')}`);
350
- return 0;
351
- }
367
+ if (out.dryRun) return dryRunPlan(`rm modules/${id} --force`, out, [
368
+ out.collections.length ? `sources removed for: ${out.collections.join(', ')}` : null,
369
+ out.withRecords.length ? `records left in place and UNINDEXED: ${out.withRecords.join(', ')}` : null,
370
+ out.dependents.length ? `dependencies entry dropped from: ${out.dependents.join(', ')}` : null,
371
+ ]);
352
372
  console.log(`✔ removed module ${out.removed}`);
353
373
  if (out.withRecords.length) console.log(` ⚠ records remain and are now unindexed: ${out.withRecords.join(', ')}`);
354
374
  if (out.dependents.length) console.log(` dropped it from ${out.dependents.join(', ')}'s dependencies`);
@@ -359,7 +379,6 @@ function metaModulesRm(ws, store, flags, pos) {
359
379
 
360
380
  // `dreamteamer rename modules/core shared`
361
381
  function metaModulesRename(ws, store, flags, pos) {
362
- refuseRepeats(flags);
363
382
  const oldId = need(pos, 0, 'module id');
364
383
  const out = renameModule(ws, store, oldId, need(pos, 1, 'new module id'));
365
384
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
@@ -373,12 +392,12 @@ function metaModulesRename(ws, store, flags, pos) {
373
392
 
374
393
  // `dreamteamer set modules/hr description="…" dependencies=modules/core`
375
394
  function metaModulesSet(ws, store, flags, pos) {
376
- refuseRepeats(flags);
377
395
  const id = need(pos, 0, 'module id');
378
396
  const changes = { ...pairs(pos.slice(1)), ...stripMeta(flags) };
379
397
  if (!Object.keys(changes).length) throw new Error('nothing to set — pass key=value pairs (description, dependencies, peerDependencies)');
380
398
  const out = setModule(ws, store, id, changes);
381
399
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
400
+ if (out.unchanged) return alreadyThat(`modules/${id} ${out.changed.join(', ')}`);
382
401
  console.log(`✔ ${rel(ws.root, out.file)} — ${out.changed.join(', ')}`);
383
402
  console.log('✔ compiled — the module record is up to date');
384
403
  reportCommits(out.commits);
@@ -389,11 +408,26 @@ function metaModulesSet(ws, store, flags, pos) {
389
408
  * KINDS so the sentence cannot go stale against the list it describes. */
390
409
  const KIND_COUNT = KINDS.length;
391
410
 
392
- /** The plan line every destructive verb prints, one shape: `records N · refs M · descriptors K ·
393
- * values cleared V`. ONE format, because a reader comparing two dry runs must not have to work out
394
- * whether a missing term means zero or means "this verb does not count that". */
395
- function planLine(plan) {
396
- return ` records ${plan.records ?? 0} · refs ${plan.refs ?? 0} · descriptors ${plan.descriptors ?? 0} · values cleared ${plan.cleared ?? 0}`;
411
+ /**
412
+ * THE ONE PLAN PRINTER, for every verb that moves records or clears values one shape, `records N ·
413
+ * refs M · descriptors K · values cleared V`, because a reader comparing two dry runs must not have
414
+ * to work out whether a missing term means zero or means "this verb does not count that".
415
+ *
416
+ * ⚠ FOUR OF THE SIX SPELLINGS DOCUMENTED `--dry-run` AND EXECUTED ANYWAY, and the self-commit made
417
+ * it durable: `dt rm collections/widgets --dry-run --force` printed "✔ removed collection widgets",
418
+ * deleted the source and committed it, at exit 0, against a `dt help` that spells `rm <system>/<id>
419
+ * [--force] [--dry-run]` verbatim. `rm modules/<id>` was the ONE that honoured it. A flag a verb
420
+ * advertises and ignores is worse than one it does not have — the operator's whole reason for typing
421
+ * it is that they are not sure yet.
422
+ *
423
+ * It counts only what it can count WITHOUT doing the op; an unmeasured term is stated as such in
424
+ * `extra` rather than guessed at, per `rename collections`' dry run.
425
+ */
426
+ function dryRunPlan(what, plan, extra) {
427
+ console.log(`dry run — dreamteamer ${what} would:`);
428
+ console.log(` records ${plan.records ?? 0} · refs ${plan.refs ?? 0} · descriptors ${plan.descriptors ?? 0} · values cleared ${plan.cleared ?? 0}`);
429
+ for (const line of extra ?? []) if (line) console.log(` ${line}`);
430
+ return 0;
397
431
  }
398
432
 
399
433
  /**
@@ -417,7 +451,6 @@ function reportCommits(commits) {
417
451
 
418
452
  // `dreamteamer collections add --name research-docs --template docs`
419
453
  function metaCollectionsAdd(ws, store, flags) {
420
- refuseRepeats(flags);
421
454
  // ⚠ BEFORE the write, not after. `--namespace=` is the empty STRING (clear it); a bare
422
455
  // `--namespace` parses as `true`, which is a mistake worth naming — and naming it AFTER the call
423
456
  // lands a committed collection and then exits 1, which is the one report shape that lies twice.
@@ -452,7 +485,6 @@ function metaCollectionsAdd(ws, store, flags) {
452
485
  * half-applies or has to be explained, and the operator gets no signal about which happened.
453
486
  */
454
487
  function metaCollectionsSet(ws, store, flags, pos) {
455
- refuseRepeats(flags);
456
488
  const name = need(pos, 0, 'collection name');
457
489
  const positional = pairs(pos.slice(1));
458
490
  const changes = { ...positional, ...stripMeta(flags) };
@@ -465,12 +497,7 @@ function metaCollectionsSet(ws, store, flags, pos) {
465
497
  if (moveTo !== undefined) {
466
498
  const out = moveCollection(ws, store, name, String(moveTo), { dryRun: !!flags['dry-run'] });
467
499
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
468
- if (out.dryRun) {
469
- console.log(`dry run — dreamteamer set collections/${name} module=${moveTo} would:`);
470
- console.log(planLine(out));
471
- console.log(` descriptor ${out.from} → ${out.to} (records stay where they are)`);
472
- return 0;
473
- }
500
+ if (out.dryRun) return dryRunPlan(`set collections/${name} module=${moveTo}`, out, [`descriptor ${out.from} → ${out.to} (records stay where they are)`]);
474
501
  if (!out.moved) { console.log(`✔ ${name} — already owned by ${out.from}, nothing to do`); return 0; }
475
502
  console.log(`✔ ${name}: ${out.from} → ${out.to}`);
476
503
  console.log(` records ${out.records} left in place — a move never changes an id`);
@@ -482,6 +509,7 @@ function metaCollectionsSet(ws, store, flags, pos) {
482
509
  if (!Object.keys(changes).length) throw new Error('nothing to set — pass key=value pairs, or module=<id> to move it');
483
510
  const out = setCollectionScalars(ws, store, name, changes, { moduleId: undefined });
484
511
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
512
+ if (out.unchanged) return alreadyThat(`collections/${name} ${out.changed.join(', ')}`);
485
513
  console.log(`✔ ${rel(ws.root, out.file)} — ${out.changed.join(', ')}`);
486
514
  console.log('✔ compiled — the descriptor is up to date');
487
515
  reportCommits(out.commits);
@@ -500,7 +528,6 @@ function metaCollectionsSet(ws, store, flags, pos) {
500
528
  * it writes ONE descriptor and keeps the numbers legible.
501
529
  */
502
530
  function metaCollectionsMove(ws, store, flags, pos) {
503
- refuseRepeats(flags);
504
531
  const name = need(pos, 0, 'collection name');
505
532
  store.descriptor(name);
506
533
  const rows = [...store.readAll('collections')]
@@ -541,7 +568,6 @@ function metaCollectionsMove(ws, store, flags, pos) {
541
568
  * this is the only way to see what a given module actually wrote, which is the question an overlay
542
569
  * makes unanswerable from the runtime alone. */
543
570
  function metaCollectionsGet(ws, store, flags, pos) {
544
- refuseRepeats(flags);
545
571
  const name = need(pos, 0, 'collection name');
546
572
  const moduleId = oneValue(flags, 'module');
547
573
  const { file } = collectionSourceFileFor(ws, store, name, moduleId);
@@ -554,7 +580,6 @@ function metaCollectionsGet(ws, store, flags, pos) {
554
580
  // The whole point is that namespacing EXISTING data is one command instead of a six-step hand
555
581
  // migration whose last step (rewriting references) dangles everything when forgotten.
556
582
  function metaCollectionsRename(ws, store, flags, pos) {
557
- refuseRepeats(flags);
558
583
  const [oldName, explicitNew] = pos;
559
584
  if (!oldName) throw new Error('usage: dreamteamer rename collections/<old> <new> | collections/<old> --namespace <ns>');
560
585
  // `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
@@ -566,15 +591,14 @@ function metaCollectionsRename(ws, store, flags, pos) {
566
591
  if (flags['dry-run']) {
567
592
  const d = store.descriptor(oldName);
568
593
  const records = store.ids(oldName).size;
569
- console.log(`dry run — dreamteamer rename collections/${oldName} ${newName} would:`);
570
- console.log(planLine({ records, refs: 0, descriptors: 1, cleared: 0 }));
571
- console.log(` records ${d.storage.path} → ${defaultStoragePath(newName, store.namespaces, ws.pkg.dreamteamer?.['data-path'] ?? 'data')}`);
572
594
  // ⚠ `refs` is honestly 0 here and the line says so. Counting them would mean running the batch
573
595
  // rewrite to find out, which IS the op — and a number the plan cannot know is worse than a
574
596
  // stated gap: the plan line has a fixed shape precisely so a reader never has to guess whether
575
597
  // a term is zero or unmeasured.
576
- console.log(' refs are counted only by the real run — the rewrite is what discovers them');
577
- return 0;
598
+ return dryRunPlan(`rename collections/${oldName} ${newName}`, { records, descriptors: 1 }, [
599
+ `records ${d.storage.path} → ${defaultStoragePath(newName, store.namespaces, ws.pkg.dreamteamer?.['data-path'] ?? 'data')}`,
600
+ 'refs are counted only by the real run — the rewrite is what discovers them',
601
+ ]);
578
602
  }
579
603
  const out = renameCollection(ws, store, oldName, newName);
580
604
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
@@ -592,8 +616,8 @@ function metaCollectionsRename(ws, store, flags, pos) {
592
616
  // `dreamteamer collections rm widgets [--force]` — --force is required to drop a collection
593
617
  // that still has records (removeCollection refuses otherwise, and says so).
594
618
  function metaCollectionsRm(ws, store, flags, pos) {
595
- refuseRepeats(flags);
596
619
  const name = need(pos, 0, 'collection name');
620
+ if (flags['dry-run']) return dryRunPlan(`rm collections/${name}`, { records: store.ids(name).size, descriptors: 1 }, [`descriptor removed; records under ${store.descriptor(name).storage.path} stay in place and become unindexed`]);
597
621
  const out = removeCollection(ws, store, name, { force: !!flags.force });
598
622
  flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed collection ${out.removed}`);
599
623
  console.log('✔ compiled — the collection is gone');
@@ -603,7 +627,6 @@ function metaCollectionsRm(ws, store, flags, pos) {
603
627
 
604
628
  // `dreamteamer tasks add-field --name urgent --type boolean --default-value false`
605
629
  function metaAddField(ws, store, collection, flags) {
606
- refuseRepeats(flags);
607
630
  const prop = fieldDef(store, flags, collection);
608
631
  // fieldDef DEFERS every relation flag it has no reference to attach to, because on update-field
609
632
  // the target is carried in afterwards. add-field has nothing to carry, so a relation flag that
@@ -611,7 +634,7 @@ function metaAddField(ws, store, collection, flags) {
611
634
  const stray = (prop.items ?? prop)['x-reference'] === undefined && relationFlagsStated(flags);
612
635
  if (stray) throw new Error(`--${stray} needs a --type <collection> reference.`);
613
636
  const out = addField(ws, store, collection, { name: flags.name, prop, required: flags.required === 'true', moduleId: oneValue(flags, 'module') });
614
- if (out.unchanged) return alreadyThat(collection, flags.name);
637
+ if (out.unchanged) return alreadyThat(`${collection}.${flags.name}`);
615
638
  console.log(`✔ ${rel(ws.root, out.file)}${out.extends ? ` (extends ${out.extends})` : ''}`);
616
639
  console.log('✔ compiled — the field is live');
617
640
  reportCommits(out.commits);
@@ -622,8 +645,8 @@ function metaAddField(ws, store, collection, flags) {
622
645
 
623
646
  /** The idempotent answer, in `rename-collection`'s words — a command that asks for what is already
624
647
  * there succeeded, and the operator needs to know which field it was talking about. */
625
- function alreadyThat(collection, field) {
626
- console.log(`✔ ${collection}.${field} — already exactly that, nothing to do`);
648
+ function alreadyThat(subject) {
649
+ console.log(`✔ ${subject} — already exactly that, nothing to do`);
627
650
  return 0;
628
651
  }
629
652
 
@@ -655,7 +678,6 @@ function reportMirror(store, collection, fieldName, prop) {
655
678
  // Same flag vocabulary as add-field (one `fieldDef`), so the two read as one operation with two
656
679
  // preconditions rather than two dialects.
657
680
  function metaUpdateField(ws, store, collection, flags) {
658
- refuseRepeats(flags);
659
681
  if (!flags.name) throw new Error('missing --name <field>');
660
682
  const prop = fieldDef(store, flags, collection);
661
683
  // tri-state: omitting --required leaves requiredness ALONE, rather than silently clearing it
@@ -663,7 +685,7 @@ function metaUpdateField(ws, store, collection, flags) {
663
685
  // `flags` for the VALUES and `stated` for what the caller meant to restate: updateField carries
664
686
  // every unstated relation keyword forward from the previous prop.
665
687
  const out = updateField(ws, store, collection, flags.name, { prop, required, flags, stated: statedKeywords(flags), moduleId: oneValue(flags, 'module') });
666
- if (out.unchanged) return alreadyThat(collection, flags.name);
688
+ if (out.unchanged) return alreadyThat(`${collection}.${flags.name}`);
667
689
  console.log(`✔ ${rel(ws.root, out.file)}${out.extends ? ` (extends ${out.extends})` : ''}`);
668
690
  console.log('✔ compiled — the field is updated');
669
691
  reportCommits(out.commits);
@@ -677,16 +699,12 @@ function metaUpdateField(ws, store, collection, flags) {
677
699
 
678
700
  // `dreamteamer tasks remove-field --name urgent`
679
701
  function metaRemoveField(ws, store, collection, flags) {
680
- refuseRepeats(flags);
681
702
  const name = flags.name ?? flags.field;
682
703
  if (!name) throw new Error('missing --name <field>');
683
704
  const moduleId = oneValue(flags, 'module');
684
705
  if (flags['dry-run']) {
685
706
  const plan = removeFieldPlan(store, collection, name);
686
- console.log(`dry run — dreamteamer remove-field ${collection} --name ${name} would:`);
687
- console.log(planLine(plan));
688
- if (plan.staleViews.length) console.log(` ui-views still listing it as a column: ${plan.staleViews.join(', ')}`);
689
- return 0;
707
+ return dryRunPlan(`remove-field ${collection} --name ${name}`, plan, [plan.staleViews.length ? `ui-views still listing it as a column: ${plan.staleViews.join(', ')}` : null]);
690
708
  }
691
709
  const out = removeField(ws, store, collection, name, { moduleId });
692
710
  flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed field ${collection}.${out.removed}`);
@@ -709,7 +727,6 @@ const ENTITY_KINDS = new Set(['skills', 'agents', 'commands', 'command-bindings'
709
727
  const SCAFFOLDABLE = new Set(['skills']);
710
728
 
711
729
  function metaEntityVerb(ws, store, kind, verb, flags, pos) {
712
- refuseRepeats(flags);
713
730
  const one = kind.replace(/s$/, '');
714
731
  if (verb === 'add') {
715
732
  const name = oneValue(flags, 'name');
@@ -726,7 +743,9 @@ function metaEntityVerb(ws, store, kind, verb, flags, pos) {
726
743
  return 0;
727
744
  }
728
745
  if (verb === 'rm') {
729
- const out = removeEntity(ws, store, kind, need(pos, 0, `${one} id`));
746
+ const id0 = need(pos, 0, `${one} id`);
747
+ if (flags['dry-run']) return dryRunPlan(`rm ${kind}/${id0}`, { descriptors: 1 }, [`the ${one} source is removed; nothing else moves`]);
748
+ const out = removeEntity(ws, store, kind, id0);
730
749
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
731
750
  console.log(`✔ removed ${one} ${out.removed}`);
732
751
  console.log('✔ compiled — it is gone');
@@ -748,6 +767,7 @@ function metaEntityVerb(ws, store, kind, verb, flags, pos) {
748
767
  if (!Object.keys(changes).length) throw new Error(`nothing to set — pass key=value pairs (a ${one}'s frontmatter keys)`);
749
768
  const out = setEntityFrontmatter(ws, store, kind, id, changes);
750
769
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
770
+ if (out.unchanged) return alreadyThat(`${kind}/${id} ${out.changed.join(', ')}`);
751
771
  console.log(`✔ ${rel(ws.root, out.file)} — ${out.changed.join(', ')}`);
752
772
  console.log('✔ compiled — the change is live');
753
773
  reportCommits(out.commits);
@@ -756,25 +776,27 @@ function metaEntityVerb(ws, store, kind, verb, flags, pos) {
756
776
 
757
777
  /** The path `revert`'s refusal names — where a human edits this compiled entity. */
758
778
  function sourceHintFor(store, collection) {
759
- const d = store.descriptors.get(collection);
760
- return d?.storage?.path ? `modules/*/${d.storage.path}/` : `modules/*/${collection}/`;
779
+ // `modules` is the one system collection that is PROJECTED rather than stored as a kind folder:
780
+ // its source is each module's package.json. Deriving the hint from `storage.path` gave
781
+ // `modules/*/modules/`, which `git ls-files` matches nothing at all — a correct refusal handing
782
+ // over an unusable remedy.
783
+ if (collection === 'modules') return 'modules/*/package.json';
784
+ return `modules/*/${store.descriptors.get(collection)?.storage?.path ?? collection}/`;
761
785
  }
762
786
 
763
787
  // `dreamteamer rename-field people --name employer --to company`
764
788
  function metaRenameField(ws, store, collection, flags) {
765
- refuseRepeats(flags);
766
789
  const from = oneValue(flags, 'name') ?? oneValue(flags, 'field');
767
790
  if (!from) throw new Error(`missing --name <field>: dreamteamer rename-field ${collection} --name <field> --to <new-name>`);
768
791
  const to = oneValue(flags, 'to');
769
792
  if (flags['dry-run']) {
770
793
  const plan = renameFieldPlan(store, collection, from);
771
- console.log(`dry run — dreamteamer rename-field ${collection} --name ${from} --to ${to ?? '<new-name>'} would:`);
772
- console.log(planLine(plan));
773
794
  // ⚠ The same honesty the `rename collections` dry run needs, for the same reason: a number the
774
795
  // plan cannot know is worse than a stated gap.
775
- console.log(' descriptors, ui-views and command-bindings naming it are counted by the real run —');
776
- console.log(' the rewrite is what discovers which of them carry the name');
777
- return 0;
796
+ return dryRunPlan(`rename-field ${collection} --name ${from} --to ${to ?? '<new-name>'}`, plan, [
797
+ 'descriptors, ui-views and command-bindings naming it are counted by the real run —',
798
+ 'the rewrite is what discovers which of them carry the name',
799
+ ]);
778
800
  }
779
801
  const out = renameField(ws, store, collection, from, to, { moduleId: oneValue(flags, 'module') });
780
802
  if (flags.json) { emit(JSON.stringify(out)); return 0; }
@@ -877,11 +899,16 @@ function assignViewValue(view, key, raw) {
877
899
  assignPath(view, key, parseViewValue(raw, key), typeof raw === 'string' && raw.trim().startsWith('"'));
878
900
  }
879
901
 
880
- const VIEW_META_FLAGS = new Set(['id', 'json', 'force']);
902
+ // `module` is a VERB OPTION, exactly as it is on every other verb that takes one (see META_FLAGS)
903
+ // — it says WHERE the source lands and is never a key of the view. Omitting it here is what wrote
904
+ // `module: core` into the yaml as a field.
905
+ const VIEW_META_FLAGS = new Set(['id', 'json', 'force', 'module']);
881
906
 
882
907
  function metaUiView(ws, store, verb, flags, pos) {
883
908
  if (verb === 'rm') {
884
- const out = removeUiView(ws, store, need(pos, 0, 'ui-view id'));
909
+ const viewId = need(pos, 0, 'ui-view id');
910
+ if (flags['dry-run']) return dryRunPlan(`rm ui-views/${viewId}`, { descriptors: 1 }, ['the view source is removed; its route stops resolving']);
911
+ const out = removeUiView(ws, store, viewId);
885
912
  flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed ui-view ${out.removed}`);
886
913
  console.log('✔ compiled — the route is gone');
887
914
  reportCommits(out.commits);
@@ -919,8 +946,10 @@ function metaUiView(ws, store, verb, flags, pos) {
919
946
  // saved from the CLI and one saved from the panel land on the SAME record.
920
947
  id ??= slug(view.path);
921
948
 
922
- const out = saveUiView(ws, store, { id, view });
923
- flags.json ? emit(JSON.stringify(out)) : console.log(`✔ ${rel(ws.root, out.file)}`);
949
+ const out = saveUiView(ws, store, { id, view, moduleId: oneValue(flags, 'module') });
950
+ if (flags.json) { emit(JSON.stringify(out)); return 0; }
951
+ if (out.unchanged) return alreadyThat(`ui-views/${id}`);
952
+ console.log(`✔ ${rel(ws.root, out.file)}`);
924
953
  console.log(`✔ compiled — ${view.path} is live`);
925
954
  reportCommits(out.commits);
926
955
  return 0;
@@ -938,7 +967,9 @@ function metaUiView(ws, store, verb, flags, pos) {
938
967
  export function relationsCommand(ws, args) {
939
968
  const store = new Store(ws);
940
969
  const { flags, pos } = parseArgs(args);
941
- if (pos[0] === 'rebuild') return relationsRebuild(store, flags, pos);
970
+ const rebuilding = pos[0] === 'rebuild';
971
+ refuseUnknownFlags(store, (rebuilding ? pos[1] : pos[0]) ?? '', rebuilding ? 'rebuild' : 'relations', flags);
972
+ if (rebuilding) return relationsRebuild(store, flags, pos);
942
973
 
943
974
  // `store.relations()` is `relationsOf(this.descriptors)` memoized per Store — going through it
944
975
  // rather than calling relationsOf here keeps one decoder for the whole process.
@@ -1093,22 +1124,6 @@ function oneValue(flags, key) {
1093
1124
  return typeof v === 'string' ? v : undefined;
1094
1125
  }
1095
1126
 
1096
- /**
1097
- * A schema verb takes ONE value per flag, and a repeat is refused before anything is written.
1098
- *
1099
- * None of them means a list by repetition — `--options a,b` is one value, and `--name` names the
1100
- * single thing being written — so every repeat is a mistake. It has to be caught here because the
1101
- * ones that matter are IDENTITY: `--name x --name y` wrote a field called `y` before the promotion
1102
- * and one called `x,y` after it, and both of those are a source file the operator then has to find
1103
- * and edit by hand.
1104
- */
1105
- function refuseRepeats(flags) {
1106
- const dup = Object.entries(flags).find(([, v]) => Array.isArray(v));
1107
- if (!dup) return;
1108
- const [k, v] = dup;
1109
- throw new Error(`--${k} was given ${v.length} times, and a schema verb takes ONE value per flag: ${v.map((x) => `--${k} ${x}`).join(' ')}`);
1110
- }
1111
-
1112
1127
  /** `field=value` positionals, with the same promote-on-repeat rule the flags have. It was
1113
1128
  * `Object.fromEntries`, which keeps the LAST pair — `dt set c/id tags=a tags=b` wrote `[b]`. */
1114
1129
  function pairs(list) {
@@ -1158,3 +1173,98 @@ const fmtCell = (v) => (v === undefined ? '-' : Array.isArray(v) ? v.join(',') :
1158
1173
  function rel(root, p) {
1159
1174
  return p.startsWith(root) ? p.slice(root.length + 1) : p;
1160
1175
  }
1176
+
1177
+ /**
1178
+ * THE ONE PLACE A FLAG NAME IS VALIDATED — every record, system and field verb goes through
1179
+ * `collectionCommand`, so the check lives beside the parser they share rather than being
1180
+ * re-invented per verb.
1181
+ *
1182
+ * ⚠ IT EXISTS BECAUSE THE PARSER USED TO SWALLOW EVERYTHING. `dt add modules --name hr --bogusflag x`
1183
+ * succeeded silently, so a misspelled flag and a supported one were indistinguishable — and that is
1184
+ * what let `add modules --namespace hr` be accepted-and-ignored for a whole release with nobody
1185
+ * noticing. The worst instance was `--dryrun`, where the swallowed flag is the one standing between
1186
+ * a plan and a self-committed delete.
1187
+ *
1188
+ * TWO VOCABULARIES, and the split is what keeps the refusal honest. A verb's OPTIONS are closed and
1189
+ * enumerated here; an entity's own KEYS are open and cannot be — `dt list people --status todo` is a
1190
+ * shorthand filter on a declared field, `dt add people --name Ada` writes one, and a ui-view's
1191
+ * `options.*` is an open bag by design. So the allowlist is the table PLUS the target's declared
1192
+ * properties, and nothing is refused that either half can name.
1193
+ *
1194
+ * ⚠ A REFUSAL THAT REJECTS A VALID FLAG IS WORSE THAN THE SILENCE IT REPLACES, which is why a verb
1195
+ * with no table entry is left exactly as it was rather than being guessed at — and why the field
1196
+ * verbs' row is the whole `fieldDef` vocabulary, read off nothing, so a flag added there has to be
1197
+ * added here too or its own test fails.
1198
+ *
1199
+ * A key is `<collection>:<verb>` where the system entity has its own interceptor, `<verb>` otherwise.
1200
+ */
1201
+ export const FIELD_FLAGS = ['json', 'module', 'name', 'field', 'type', 'options', 'default-value', 'default', 'required', 'description', 'many', 'inverse', 'inverse-description', 'unique', 'body', 'on-delete', 'mirror-of', 'target'];
1202
+ const JSON_ONLY = ['json'];
1203
+ const FORCE_RM = ['json', 'force', 'dry-run'];
1204
+ const NAV_MOVE = ['json', 'after', 'before', 'top', 'bottom'];
1205
+
1206
+ export const VERB_FLAGS = {
1207
+ list: ['json', 'filter', 'where', 'sort'], get: JSON_ONLY, add: ['json', 'id', 'from', 'force'], set: JSON_ONLY,
1208
+ rm: FORCE_RM, rename: JSON_ONLY, move: [...NAV_MOVE, 'init'], values: ['json', 'limit'],
1209
+ history: JSON_ONLY, diff: ['json', 'hash'], revert: ['json', 'hash'],
1210
+ ensure: ['json', 'all'], for: ['json', 'ids'], relations: JSON_ONLY, rebuild: ['json', 'drop'],
1211
+ 'add-field': FIELD_FLAGS, 'update-field': FIELD_FLAGS,
1212
+ 'remove-field': ['json', 'module', 'name', 'field', 'dry-run'],
1213
+ 'rename-field': ['json', 'module', 'name', 'field', 'to', 'dry-run'],
1214
+ 'collections:add': ['json', 'module', 'name', 'namespace', 'template', 'description', 'suffix', 'id-shape'],
1215
+ 'collections:get': ['json', 'module'], 'collections:set': ['json', 'module', 'dry-run'],
1216
+ 'collections:rm': FORCE_RM, 'collections:rename': ['json', 'namespace', 'dry-run'], 'collections:move': NAV_MOVE,
1217
+ 'modules:add': ['json', 'name', 'description', 'namespace'], 'modules:rename': JSON_ONLY, 'modules:rm': FORCE_RM,
1218
+ 'modules:set': ['json', 'description', 'namespaces', 'dependencies', 'peerDependencies'],
1219
+ // the identity kinds: `add` scaffolds, `rm`/`rename` fall through to the generic rows, and `set`
1220
+ // is deliberately UNCHECKED — a skill's frontmatter is an open document (`allowed-tools`, `model`,
1221
+ // whatever a harness reads), so there is no closed set to check it against.
1222
+ 'skills:add': ['json', 'module', 'name', 'description'],
1223
+ 'ui-views:add': ['json', 'module', 'id', 'force'], 'ui-views:set': ['json', 'module', 'id', 'force'], 'ui-views:rm': FORCE_RM,
1224
+ };
1225
+
1226
+ /** Edit distance, capped — enough to turn `--fliter` into "did you mean --filter?", and to refuse to
1227
+ * guess at anything further away than a typo. */
1228
+ function nearest(word, candidates) {
1229
+ const distance = (c) => {
1230
+ let prev = [...Array(word.length + 1).keys()];
1231
+ for (let i = 1; i <= c.length; i++) {
1232
+ const row = [i];
1233
+ for (let j = 1; j <= word.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (c[i - 1] === word[j - 1] ? 0 : 1));
1234
+ prev = row;
1235
+ }
1236
+ return prev[word.length];
1237
+ };
1238
+ const cap = Math.min(3, Math.ceil(word.length / 2));
1239
+ return candidates.map((c) => [c, distance(c)]).filter(([, n]) => n <= cap).sort((a, b) => a[1] - b[1])[0]?.[0] ?? null;
1240
+ }
1241
+
1242
+ function refuseUnknownFlags(store, collection, verb, flags) {
1243
+ const d = store.descriptors.get(collection);
1244
+ const known = VERB_FLAGS[`${collection}:${verb}`]
1245
+ ?? (ENTITY_KINDS.has(collection) && verb === 'add' ? VERB_FLAGS['skills:add'] : VERB_FLAGS[verb]);
1246
+ if (!known) return; // no declared vocabulary — left exactly as it was rather than guessed at
1247
+ // The OPEN half: a data collection's own fields (shorthand filters and field writes), and the
1248
+ // declared keys of an entity `set` writes (`--layout` on a view, and dotted `options.sort`).
1249
+ const system = d?.storage?.base === 'runtime';
1250
+ const openOf = !system ? (['list', 'add', 'set'].includes(verb) ? `field of ${collection}` : null)
1251
+ : (collection === 'ui-views' && verb !== 'rm') || (ENTITY_KINDS.has(collection) && verb === 'set') ? `declared key of ${collection}` : null;
1252
+ const open = openOf ? Object.keys(d?.schema?.properties ?? {}) : [];
1253
+ const allowed = new Set([...known, ...open]);
1254
+ for (const f of Object.keys(flags)) {
1255
+ if (allowed.has(f) || (openOf && allowed.has(f.split('.')[0]))) continue;
1256
+ const near = nearest(f, [...allowed]);
1257
+ throw new Error(`unknown flag "--${f}" on \`dt ${verb} ${collection}\`${near ? ` — did you mean --${near}?` : ''}\n known: ${[...known].sort().map((k) => `--${k}`).join(', ')}${openOf ? `, plus any ${openOf}` : ''}`);
1258
+ }
1259
+ // ⚠ AND THE REPEAT REFUSAL, which used to be 15 hand-written `refuseRepeats(flags)` lines — one
1260
+ // per meta verb, and a new verb had to remember it. A SCHEMA verb takes one value per flag (none
1261
+ // of them means a list by repetition), so the same table that says which flags exist says which
1262
+ // verbs the rule applies to: everything with a closed vocabulary EXCEPT the open halves, where a
1263
+ // repeat legitimately composes (`--filter a=1 --filter b=2`, `--tags a --tags b`). `ui-views`
1264
+ // keeps its own message, which names both spellings of the fix. ⚠ THE FIELD VERBS COUNT AS SCHEMA
1265
+ // VERBS EVEN THOUGH THEIR TARGET IS A DATA COLLECTION — `add-field notes --name x --name y` is the
1266
+ // case the old helper was written for, and keying the rule on the target alone excused it.
1267
+ if (collection === 'ui-views' || (!system && !verb.endsWith('-field'))) return;
1268
+ const dup = Object.entries(flags).find(([, v]) => Array.isArray(v));
1269
+ if (dup) throw new Error(`--${dup[0]} was given ${dup[1].length} times, and a schema verb takes ONE value per flag: ${dup[1].map((x) => `--${dup[0]} ${x}`).join(' ')}`);
1270
+ }
package/src/schema-ops.js CHANGED
@@ -98,6 +98,22 @@ function writeGated(ws, store, files, subject, mutate, after, { commentsMayDecre
98
98
  throw e;
99
99
  }
100
100
  }
101
+ // ⚠ A WRITE THAT CHANGED NO BYTES MUST NOT REACH `git commit`. `git commit` on an empty index
102
+ // exits NON-ZERO, so the catch below read a successful no-op as a failed op and reported
103
+ // `✖ git commit failed — the schema change was rolled back` with the raw git command appended,
104
+ // at exit 1 — for `dt set collections/people description=<the value it already has>`, which
105
+ // asked for nothing and got nothing. Every sibling verb already has the graceful spelling
106
+ // ("already named that, nothing to do"), and the documented namespace cleanup path
107
+ // (`dt set modules/<m> namespaces=<ns>` where it is already declared) ran straight into it.
108
+ //
109
+ // Compared as BYTES, after the gate compile: an op whose mutation is a re-serialization can
110
+ // produce an identical file, and "identical" is the only definition of no-op the caller can
111
+ // act on.
112
+ const moved = snapshots.some(({ f, prev }) => {
113
+ const now = fs.existsSync(f) ? fs.readFileSync(f) : null;
114
+ return now === null ? prev !== null : prev === null || !now.equals(prev);
115
+ });
116
+ if (!moved && !extra.files.length) return { ...extra, commits: [], unchanged: true };
101
117
  const rels = [...files, ...extra.files].map((f) => path.relative(ws.root, f));
102
118
  // Schema ops commit UNCONDITIONALLY — `auto-commit` governs RECORD writes only. A source
103
119
  // change is inseparable from the compile that validated it, and `dt commit` scopes itself
@@ -240,7 +256,7 @@ function editWorkspacePkg(ws, mutate) {
240
256
  return file;
241
257
  }
242
258
 
243
- export function createModule(ws, store, { name, description }) {
259
+ export function createModule(ws, store, { name, description, namespace }) {
244
260
  if (!name || name === true) throw new Error('missing module name — dreamteamer add modules --name <id>');
245
261
  if (!MODULE_ID.test(name)) {
246
262
  throw new Error(`invalid module id "${name}" — lowercase alphanumeric with single hyphens. It becomes a folder name, a package name and a record id at once, so there is only one spelling.`);
@@ -253,6 +269,23 @@ export function createModule(ws, store, { name, description }) {
253
269
 
254
270
  const dt = {};
255
271
  if (typeof description === 'string' && description) dt.description = description;
272
+ // §6.2: `--namespace hr` DECLARES THE NAMESPACE IN THE MODULE, which is the whole point of §8 —
273
+ // the workspace's effective set is the union of what its modules declare, so a module that owns a
274
+ // namespace can be copied alone into a bare workspace and still compile.
275
+ //
276
+ // ⚠ It used to be dropped on the floor. `dt add modules --name hr --namespace hr` reported ✔,
277
+ // shipped `"dreamteamer": {}`, left the manifest's `namespaces: []`, and the next
278
+ // `add collections --module hr` then created an UNPREFIXED collection — four steps of silence
279
+ // behind one accepted-and-ignored flag. Normalized here rather than trusted: a leading or
280
+ // trailing slash in a declaration re-splits every reference under it.
281
+ const ns = typeof namespace === 'string' ? namespace.replace(/^\/+|\/+$/g, '') : '';
282
+ if (ns) {
283
+ const owner = moduleRows(store).find((r) => normalizeNamespaces(r.fields.namespaces).includes(ns));
284
+ // compile refuses two declarations of one namespace, and refusing it HERE names the verb that
285
+ // fixes it instead of rolling a created module back over a compile error.
286
+ if (owner) throw new Error(`namespace "${ns}" is already declared by ${owner.id} — one owner. Remove it there first (dt set modules/${owner.id} namespaces=…), or pick another namespace.`);
287
+ dt.namespaces = [ns];
288
+ }
256
289
  // `files` is the npm publish surface: every kind a module CAN ship, so a kind added to the engine
257
290
  // does not silently stop being packaged.
258
291
  const mpkg = { name, private: true, version: '0.0.1', files: [...KINDS], dreamteamer: dt };
@@ -272,7 +305,7 @@ export function createModule(ws, store, { name, description }) {
272
305
  },
273
306
  undo: () => fs.rmSync(root, { recursive: true, force: true }),
274
307
  });
275
- return { id: name, root: path.relative(ws.root, root), file: pkgFile, commits: out.commits };
308
+ return { id: name, root: path.relative(ws.root, root), file: pkgFile, namespace: ns || null, commits: out.commits };
276
309
  }
277
310
 
278
311
  /** The settable fields of a `modules` record, and how each translates from the record-shaped value
@@ -338,7 +371,7 @@ export function setModule(ws, store, id, changes) {
338
371
  });
339
372
  }
340
373
  }, undefined, { commentsMayDecrease: true });
341
- return { id, file, changed, commits: gate.commits };
374
+ return { id, file, changed, commits: gate.commits, unchanged: gate.unchanged };
342
375
  }
343
376
 
344
377
  export function removeModule(ws, store, id, { force = false, dryRun = false } = {}) {
@@ -755,6 +788,15 @@ export function setCollectionScalars(ws, store, name, changes, { moduleId } = {}
755
788
  throw new Error(`${key}: ${collectionMissingFields(name, missing)} — declare it first (dreamteamer add-field ${name} --name ${missing[0]} --type <t>).`);
756
789
  }
757
790
  }
791
+ // ⚠ NAMED, not resolved to an overlay. `collectionSourceFile` falls back to a workspace-module
792
+ // path for a base it cannot write, which is right for `add-field` (an overlay IS the remedy) and
793
+ // wrong here: a collection-level scalar has no overlay spelling, so the fallback produced
794
+ // `modules/default/collections/repos.collection.yaml is not on disk — run compile and re-run`,
795
+ // and compiling will never help. `rm` and `rename` already say the true sentence.
796
+ const owned = baseDescriptorSource(ws, name).base;
797
+ if (owned && IN_NODE_MODULES(owned)) {
798
+ throw new Error(`"${name}" ships from node_modules (${owned}) — a write there is erased by the next \`npm install\`, and a collection-level scalar has no overlay spelling. Add "<module>/${name}" to dreamteamer.disable and declare your own instead.`);
799
+ }
758
800
  const { file } = collectionSourceFile(ws, store, name, moduleId, { subject: name });
759
801
  if (!fs.existsSync(file)) throw new Error(`${path.relative(ws.root, file)} is not on disk — run \`dreamteamer compile\` and re-run.`);
760
802
  const previousText = fs.readFileSync(file, 'utf8');
@@ -770,7 +812,7 @@ export function setCollectionScalars(ws, store, name, changes, { moduleId } = {}
770
812
  }
771
813
  fs.writeFileSync(file, writeSource(previousText, doc));
772
814
  });
773
- return { name, file, changed, commits: gate.commits };
815
+ return { name, file, changed, commits: gate.commits, unchanged: gate.unchanged };
774
816
  }
775
817
 
776
818
  // ---- rename-field ------------------------------------------------------------------------------
@@ -965,10 +1007,19 @@ export function renameField(ws, store, collection, from, to, { moduleId, dryRun
965
1007
  if (String(doc.collection ?? '') !== `collections/${collection}`) continue;
966
1008
  const keys = kind === 'ui-views' ? ['filter'] : ['can-enter', 'can-exit'];
967
1009
  let changed = false;
968
- if (Array.isArray(doc.options?.columns) && doc.options.columns.includes(from)) {
969
- doc.options.columns = doc.options.columns.map((c) => (c === from ? to : c));
1010
+ // The field-name LISTS: `columns` is honoured by every layout, `ref_fields` and
1011
+ // `value_fields` are the diagram's link-by pickers. Same vocabulary `list_fields` uses.
1012
+ for (const key of ['columns', 'ref_fields', 'value_fields']) {
1013
+ if (!Array.isArray(doc.options?.[key]) || !doc.options[key].includes(from)) continue;
1014
+ doc.options[key] = doc.options[key].map((c) => (c === from ? to : c));
970
1015
  changed = true;
971
1016
  }
1017
+ // ⚠ AND `options.sort`, which is a field name with an optional `-` in front of it. It
1018
+ // was the one §3.2 surface the rename missed, and the miss is invisible: `dt check`
1019
+ // reports 0 violations for a view sorting on a field that no longer exists, so the
1020
+ // listing silently falls back to an arbitrary order.
1021
+ const sorted = /^(-?)(.+)$/.exec(typeof doc.options?.sort === 'string' ? doc.options.sort : '');
1022
+ if (sorted && sorted[2] === from) { doc.options.sort = sorted[1] + to; changed = true; }
972
1023
  for (const key of keys) if (rewriteFilterField(doc[key], from, to)) changed = true;
973
1024
  if (!changed) continue;
974
1025
  const after = writeSource(before, doc);
@@ -2309,9 +2360,17 @@ function uiViewSourceFile(ws, id) {
2309
2360
  // saved views (M3): a studio-saved view IS a ui-view record — but ui-views are
2310
2361
  // system-stored (sources + compile), so the write goes through the same gate as any
2311
2362
  // other schema op. the studio "save view" button lands here.
2312
- export function saveUiView(ws, store, { id, view }) {
2363
+ export function saveUiView(ws, store, { id, view, moduleId }) {
2313
2364
  if (!id || !/^[a-z0-9][a-z0-9-/]*$/.test(id)) throw new Error(`invalid ui-view id "${id}" — lowercase slug required`);
2314
- const { file: dest, shipped } = uiViewSourceFile(ws, id);
2365
+ // §5: `add` on a system collection takes `--module`. It used to be dropped by the parser and then
2366
+ // assigned into the VIEW as a field called `module` — `dt add ui-views … --module core` wrote
2367
+ // `module: core` into the yaml, compiled clean, and self-committed a workspace only `dt check`
2368
+ // would later object to. An explicit target module resolves the destination; without one the
2369
+ // manifest answers (an existing view is edited where it lives), and the workspace module is the
2370
+ // fallback for a new one.
2371
+ const { file: dest, shipped } = moduleId
2372
+ ? { file: path.join(kindDir(path.join(ws.root, moduleRecord(store, moduleId).fields.path), 'ui-views'), `${id}.ui-view.yaml`), shipped: null }
2373
+ : uiViewSourceFile(ws, id);
2315
2374
  if (shipped && /(^|\/)node_modules\//.test(shipped))
2316
2375
  throw new Error(`ui-view "${id}" is shipped by an installed package (${shipped}) — a write there is erased by the next npm install.\n save it under a different name, or disable it (dreamteamer.disable) and re-create it.`);
2317
2376
  const existed = fs.existsSync(dest);
@@ -2325,7 +2384,7 @@ export function saveUiView(ws, store, { id, view }) {
2325
2384
  fs.mkdirSync(path.dirname(dest), { recursive: true });
2326
2385
  fs.writeFileSync(dest, writeSource(previous, view));
2327
2386
  }, undefined, { commentsMayDecrease: true });
2328
- return { id, file: dest, updated: existed, commits: gate.commits };
2387
+ return { id, file: dest, updated: existed, commits: gate.commits, unchanged: gate.unchanged };
2329
2388
  }
2330
2389
 
2331
2390
  export function removeUiView(ws, store, id) {
@@ -2716,6 +2775,16 @@ export function setEntityFrontmatter(ws, store, kind, id, changes) {
2716
2775
  const { dir, file, shipped } = entitySource(ws, kind, id);
2717
2776
  if (!shipped) throw new Error(`${kind.replace(/s$/, '')} "${id}" does not exist — dt list ${kind}`);
2718
2777
  refuseNpmEntity(kind, id, shipped);
2778
+ // ⚠ THE DESCRIPTOR IS THE AUTHORITY, and the engine was disagreeing with itself: `dt set
2779
+ // skills/<id> descripton="oops"` wrote the typo, compiled, SELF-COMMITTED — and then `dt check`
2780
+ // failed on the very workspace the self-commit exists to keep valid, because the `skills`
2781
+ // descriptor declares a closed set of properties and check validates against it. "Frontmatter is
2782
+ // an open document" was the wrong half to believe: if a key is not in the descriptor, `check`
2783
+ // will reject it, so `set` refuses it first. Its two siblings already read this way
2784
+ // (`setCollectionScalars`, `setModule`), and a body field is not settable from the CLI at all.
2785
+ const props = store.descriptors.get(kind)?.schema?.properties ?? {};
2786
+ const unknown = Object.keys(changes).find((k) => !(k in props) || props[k]?.['x-body'] === true);
2787
+ if (unknown) throw new Error(`"${unknown}" is not a settable key of ${kind} — declared: ${Object.keys(props).filter((k) => props[k]?.['x-body'] !== true).join(', ')}. \`dreamteamer check\` rejects anything else, so this is refused before it is committed.`);
2719
2788
  const target = shape.folder ? path.join(dir, 'SKILL.md') : file;
2720
2789
  // A YAML source (a binding, a template) is a whole document; a markdown one has frontmatter and
2721
2790
  // prose. `writeSource` round-trips both — the difference is only which text it is handed.
@@ -2740,7 +2809,7 @@ export function setEntityFrontmatter(ws, store, kind, id, changes) {
2740
2809
  fs.writeFileSync(target, text);
2741
2810
  }
2742
2811
  }, undefined, { commentsMayDecrease: true });
2743
- return { id, file: target, changed, commits: gate.commits };
2812
+ return { id, file: target, changed, commits: gate.commits, unchanged: gate.unchanged };
2744
2813
  }
2745
2814
 
2746
2815
  /**