dreamteamer 0.9.1 → 0.12.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.
package/src/cli.js CHANGED
@@ -1,5 +1,14 @@
1
- // dreamteamer CLI — noun-verb grammar over the same primitives every surface uses.
2
- // this phase ships: compile, check, status. collection verbs land next.
1
+ // dreamteamer CLI — VERB-FIRST: `dt <verb> [<target>]`, over the same primitives every surface uses.
2
+ //
3
+ // The verb set is CLOSED. `run()` switches on it and anything unrecognised is an error, because the
4
+ // predecessor grammar (`dt <collection> <verb>`) made the FALLBACK the collection path: a typo
5
+ // dispatched to a collection lookup and answered "unknown collection", which is a true sentence
6
+ // about the wrong thing. It also meant no verb could ever be named without a noun in front of it
7
+ // — `dt resolve '<string>'` had nowhere to live — and a namespaced reference had to be typed as two
8
+ // arguments that only the caller knew belonged together.
9
+ //
10
+ // This file TRANSLATES; it does not implement. Every record and schema verb lands on
11
+ // `collectionCommand(ws, collection, verb, args)`, whose signature is unchanged.
3
12
  import fs from 'node:fs';
4
13
  import path from 'node:path';
5
14
  import { execFileSync } from 'node:child_process';
@@ -11,6 +20,8 @@ import { init, install, installClone, update, listRepos } from './init.js';
11
20
  import { deriveEvents } from './events.js';
12
21
  import { commitPending } from './commit.js';
13
22
  import { Store } from './store.js';
23
+ import { splitRef } from './ref.js';
24
+ import { parseEnvValues, renderTemplate } from './env-vars.js';
14
25
 
15
26
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
16
27
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -18,9 +29,64 @@ import { Store } from './store.js';
18
29
  const QUIET = ['ignore', 'pipe', 'ignore'];
19
30
 
20
31
 
21
- const USAGE = `usage: dreamteamer <command> | dreamteamer <collection> <verb> …
32
+ const USAGE = `usage: dreamteamer <verb> [<target>] [flags]
22
33
 
23
- commands:
34
+ record verbs (hard validation — invalid writes are rejected before disk).
35
+ A <target> is either a collection name or a <collection>/<id> reference; the reference splits at
36
+ the longest DECLARED collection prefix, so finance/transactions/2026/03/coffee is ONE argument:
37
+ list <collection> [--filter k=v] [--where <json>] [--sort [-]<field>] [--json]
38
+ (--where takes the studio's operator set, e.g.
39
+ '{"starts":{"_gte":"2026-07-01"}}'; date-times
40
+ sort and compare as instants, across offsets)
41
+ get <collection>/<id> [--json]
42
+ add <collection> --<field> <value> … [--id <explicit-id>]
43
+ set <collection>/<id> <field>=<value> …
44
+ rm <collection>/<id> [--force]
45
+ rename <collection>/<id> <new-id> (rewrites all inbound refs, ONE commit)
46
+ move <collection>/<id> --after|--before <id> | --top | --bottom
47
+ move <collection> --init (place every record that has no sort value yet)
48
+ values <collection> <field> [--limit n] (the vocabulary a field actually uses —
49
+ what a filter/validator offers as choices)
50
+ history <collection>/<id> [--json] (git revisions of this record, newest first)
51
+ diff <collection>/<id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
52
+ revert <collection>/<id> --hash <sha> (restore the content at <sha>, as a NEW commit)
53
+ commands <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
54
+ available / done / not-applicable)
55
+ ensure <repos-id> | --all [--json] (materialize an attached repo's working tree ON
56
+ DEMAND — never at install; --all is the explicit
57
+ opt-in, e.g. before going offline)
58
+ resolve '<string>' | <collection>/<id> <field>
59
+ (render \${env:NAME} · \${workspaceFolder} ·
60
+ \${userHome} — the ONLY substitution point; a
61
+ record keeps the template verbatim. An array
62
+ field prints one item per line)
63
+
64
+ schema verbs (write SOURCES through a compile gate, never the runtime — a different act, so a
65
+ different word in front of it):
66
+ schema add-collection --name <name> [--namespace <ns>] [--template docs|entity]
67
+ (--namespace health --name doctors === --name health/doctors; the
68
+ namespace must already be declared in dreamteamer.namespaces, and
69
+ records land in data/<ns>/<name>/)
70
+ schema rm-collection <name> [--force] (--force required if it still has records)
71
+ schema rename-collection <old> <new> (or <old> --namespace <ns> to move it into one)
72
+ moves the descriptor AND the records, re-suffixes files when the
73
+ suffix was derived, rewrites every inbound reference, ONE commit
74
+ schema add-field <collection> --name <field> --type <type> [--options a,b] [--default-value v]
75
+ [--required true] [--description "what this field means"]
76
+ types: string text markdown boolean number integer date datetime
77
+ enum tags <collection> — a date-time may be written as
78
+ "2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
79
+ stamped on for you (2026-07-28T12:00:00+03:00)
80
+ schema update-field <collection> --name <field> --type <type> [--options a,b] [--default-value v]
81
+ [--required true|false] [--description "…"]
82
+ (an existing description survives a retype)
83
+ schema remove-field <collection> --name <field>
84
+ schema add-view --path </route> --target list --collection collections/<c> --layout <id>
85
+ [--id <id>] [k.v=…]
86
+ schema set-view <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
87
+ schema rm-view <id>
88
+
89
+ workspace verbs:
24
90
  init write the workspace skeleton into the current directory (never compiles)
25
91
  --version print the engine version (works anywhere)
26
92
  install restore git_modules/ from the lockfile map; --clone <url> [name] adds one
@@ -35,51 +101,30 @@ commands:
35
101
  commit publish records already written to disk: samples git status over every
36
102
  collection's record dirs, one commit PER REPO, subject composed from the
37
103
  status letters. [<collection> …] to scope, [-m <subject>], [--dry-run]
104
+ help this text
105
+ `;
38
106
 
39
- collection verbs (hard validation — invalid writes are rejected before disk):
40
- <collection> list [--filter k=v] [--where <json>] [--sort [-]<field>] [--json]
41
- (--where takes the studio's operator set, e.g.
42
- '{"starts":{"_gte":"2026-07-01"}}'; date-times
43
- sort and compare as instants, across offsets)
44
- <collection> get <id> [--json]
45
- <collection> add --<field> <value> … [--id <explicit-id>]
46
- <collection> set <id> <field>=<value> …
47
- <collection> rm <id> [--force]
48
- <collection> rename <old-id> <new-id> (rewrites all inbound refs, ONE commit)
49
- <collection> history <id> [--json] (git revisions of this record, newest first)
50
- <collection> diff <id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
51
- <collection> revert <id> --hash <sha> (restore the content at <sha>, as a NEW commit)
107
+ // Record verbs, split by what their <target> means. `move` and `commands` are in NEITHER set: both
108
+ // accept either shape, and which one it is has to be decided against the declared collections.
109
+ const REF_VERBS = new Set(['get', 'set', 'rm', 'rename', 'history', 'diff', 'revert']);
110
+ const COLLECTION_VERBS = new Set(['list', 'add', 'values']);
111
+ const EITHER_VERBS = new Set(['move', 'commands']);
52
112
 
53
- repo attachment (working trees are materialized ON DEMAND, never at install):
54
- repos ensure <id> [--json] (clone if missing, then print the path; idempotent)
55
- repos ensure --all [--json] (explicit opt-in: everything, e.g. before going offline)
56
-
57
- meta verbs (schema operations — write SOURCES through a compile gate, never the runtime):
58
- collections add --name <name> [--namespace <ns>] [--template docs|entity]
59
- (--namespace health --name doctors === --name health/doctors; the
60
- namespace must already be declared in dreamteamer.namespaces, and
61
- records land in data/<ns>/<name>/)
62
- collections rm <name> [--force] (--force required if it still has records)
63
- collections rename <old> <new> (or <old> --namespace <ns> to move it into one)
64
- moves the descriptor AND the records, re-suffixes files when the
65
- suffix was derived, rewrites every inbound reference, ONE commit
66
- <collection> add-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true]
67
- [--description "what this field means"]
68
- types: string text markdown boolean number integer date datetime
69
- enum tags <collection> — a date-time may be written as
70
- "2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
71
- stamped on for you (2026-07-28T12:00:00+03:00)
72
- <collection> update-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true|false]
73
- [--description "…"] (an existing description survives a retype)
74
- <collection> remove-field --name <field>
75
- ui-views add --path </route> --target list --collection collections/<c> --layout <id> [--id <id>] [k.v=…]
76
- ui-views set <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
77
- ui-views rm <id>
78
- commands for <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
79
- available / done / not-applicable)
80
- <collection> values <field> [--limit n] (the vocabulary a field actually uses —
81
- what a filter/validator offers as choices)
82
- `;
113
+ // `schema <op>` → the (collection, verb) pair the implementation layer already answers to. The
114
+ // collections are literals: `collections` and `ui-views` are SYSTEM-stored, which is precisely what
115
+ // makes these a separate group in the grammar rather than records like any other.
116
+ const SCHEMA_OPS = {
117
+ 'add-collection': ['collections', 'add'],
118
+ 'rm-collection': ['collections', 'rm'],
119
+ 'rename-collection': ['collections', 'rename'],
120
+ 'add-view': ['ui-views', 'add'],
121
+ 'set-view': ['ui-views', 'set'],
122
+ 'rm-view': ['ui-views', 'rm'],
123
+ };
124
+ // These three name their collection POSITIONALLY (`schema add-field contacts --name phone`) and keep
125
+ // their existing verb spelling on it — the schema group is a prefix here, not a rename.
126
+ const SCHEMA_FIELD_OPS = new Set(['add-field', 'update-field', 'remove-field']);
127
+ const SCHEMA_OP_LIST = [...Object.keys(SCHEMA_OPS), ...SCHEMA_FIELD_OPS].join(' | ');
83
128
 
84
129
  export function run(argv) {
85
130
  const [cmd, ...rest] = argv;
@@ -96,6 +141,10 @@ export function run(argv) {
96
141
  for (let i = 0; i < rest.length; i++) if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1];
97
142
  process.exit(init({ flags }));
98
143
  }
144
+ if (!cmd) {
145
+ console.log(USAGE);
146
+ process.exit(0);
147
+ }
99
148
  const ws = findWorkspace();
100
149
  switch (cmd) {
101
150
  case 'install': {
@@ -202,7 +251,7 @@ export function run(argv) {
202
251
  if (repos.length) {
203
252
  const here = repos.filter((r) => r.present).length;
204
253
  console.log(`repos: ${here}/${repos.length} materialized`);
205
- for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer repos ensure ${r.id})`);
254
+ for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer ensure ${r.id})`);
206
255
  }
207
256
  } catch { /* no repos descriptor compiled — nothing to report */ }
208
257
  // Uncommitted records are invisible to `dt changes` (it diffs commits), so the
@@ -226,14 +275,25 @@ export function run(argv) {
226
275
  case 'help':
227
276
  console.log(USAGE);
228
277
  process.exit(0);
229
- default: {
230
- if (!cmd || rest.length === 0) {
231
- console.log(USAGE);
232
- process.exit(cmd ? 1 : 0);
233
- }
278
+ case 'list': case 'add': case 'values':
279
+ case 'get': case 'set': case 'rm': case 'rename': case 'history': case 'diff': case 'revert':
280
+ case 'move': case 'commands':
234
281
  warnIfStale(ws.root);
235
- process.exit(collectionCommand(ws, cmd, rest[0], rest.slice(1)));
236
- }
282
+ process.exit(dispatchRecordVerb(ws, cmd, rest));
283
+ // `repos ensure` lost its noun: the repos collection is still where the declaration lives,
284
+ // but materializing one is a verb the operator types, not a record write.
285
+ case 'ensure':
286
+ warnIfStale(ws.root);
287
+ process.exit(collectionCommand(ws, 'repos', 'ensure', rest));
288
+ case 'schema':
289
+ warnIfStale(ws.root);
290
+ process.exit(dispatchSchemaVerb(ws, rest));
291
+ case 'resolve':
292
+ process.exit(resolveVariables(ws, rest));
293
+ default:
294
+ console.error(`✖ unknown verb "${cmd}" — dreamteamer is verb-first since 0.12.0: dt <verb> [<target>]`);
295
+ console.error(USAGE);
296
+ process.exit(1);
237
297
  }
238
298
  } catch (e) {
239
299
  console.error(`✖ ${e.message}`);
@@ -241,6 +301,110 @@ export function run(argv) {
241
301
  }
242
302
  }
243
303
 
304
+ /** Translate `dt <verb> <target> …` into the noun-verb call the implementation layer takes. */
305
+ function dispatchRecordVerb(ws, verb, args) {
306
+ const [target, ...rest] = args;
307
+ if (!target) throw new Error(`dt ${verb} needs a target — see \`dreamteamer help\``);
308
+ // A flag in the target slot is a word-order mistake, not a collection: without this,
309
+ // `dt list --json contacts` reported `unknown collection "--json"` and dumped every name.
310
+ if (target.startsWith('--')) throw new Error(`dt ${verb} takes its target BEFORE the flags: dreamteamer ${verb} <target> ${target} …`);
311
+ if (COLLECTION_VERBS.has(verb)) return collectionCommand(ws, target, verb, rest);
312
+ if (REF_VERBS.has(verb)) {
313
+ const { collection, id } = splitRef(new Store(ws).descriptors, target);
314
+ return collectionCommand(ws, collection, verb, [id, ...rest]);
315
+ }
316
+ // EITHER_VERBS from here: a bare collection is legal for both — `move <collection> --init`,
317
+ // `commands <collection>`.
318
+ const { descriptors } = new Store(ws);
319
+ if (descriptors.has(target)) {
320
+ return verb === 'move'
321
+ ? collectionCommand(ws, target, 'move', rest)
322
+ : collectionCommand(ws, 'commands', 'for', [target, ...rest]);
323
+ }
324
+ const { collection, id } = splitRef(descriptors, target);
325
+ if (verb === 'move') return collectionCommand(ws, collection, 'move', [id, ...rest]);
326
+ // `commands for <c>/<id>` split its own target at the FIRST slash, which cannot name a
327
+ // namespaced collection. splitRef can, so the id is handed over as `--ids` — the same
328
+ // `commandsFor(store, collection, ids)` call, reached without re-encoding the reference.
329
+ // Ours goes FIRST so an explicit `--ids` from the caller still wins (last flag parsed wins).
330
+ return collectionCommand(ws, 'commands', 'for', [collection, '--ids', id, ...rest]);
331
+ }
332
+
333
+ /**
334
+ * `dt resolve '<string>'` | `dt resolve <collection>/<id> <field>` — the ONLY place a `${env:…}`
335
+ * template becomes a value. Records hold the template verbatim; no read path substitutes anything,
336
+ * so a reference means the same thing on every machine and the file says which is which.
337
+ *
338
+ * THE HEURISTIC: the first argument is a REFERENCE iff it contains no `${` AND splits against a
339
+ * declared collection. Both halves are needed. `${` first, because `docs/${env:X}` is ref-SHAPED
340
+ * and must not be split as one — a reference can never contain a template, so the marker decides it
341
+ * outright. Then the split, because a plain path (`/tmp/x`, `nope/q3`) must render to itself rather
342
+ * than report an unknown collection it was never naming. Two accepted consequences: a bare
343
+ * collection name resolves to itself (splitRef refuses it, so it is "just a string"), and in an
344
+ * UNCOMPILED workspace every argument is a string, because there are no descriptors to split
345
+ * against — which keeps `dt resolve '${env:K}'` working before the first compile.
346
+ */
347
+ function resolveVariables(ws, args) {
348
+ const [target, field] = args;
349
+ if (!target) throw new Error("dt resolve takes a string template or a <collection>/<id> and a field: dreamteamer resolve '${env:FILES_FOLDER}/x'");
350
+ // resolve has no flags, so a flag-shaped target is a mistake — and the one that costs is
351
+ // `dt resolve --help`, which would otherwise print `--help` back and exit 0.
352
+ if (target.startsWith('--')) throw new Error(`dt resolve takes a string or a <collection>/<id>, not a flag ("${target}") — see \`dreamteamer help\``);
353
+ const declared = ws.pkg.dreamteamer?.vars ?? [];
354
+ const envFile = path.join(ws.root, '.env');
355
+ const env = parseEnvValues(fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : '');
356
+ const ctx = { env, workspaceFolder: ws.root, declared };
357
+
358
+ let ref = null;
359
+ let store = null;
360
+ if (!target.includes('${')) {
361
+ try {
362
+ store = new Store(ws);
363
+ ref = splitRef(store.descriptors, target);
364
+ } catch { ref = null; }
365
+ }
366
+ // An argument nobody reads is a silent wrong answer: `dt resolve docs/q3 source_file garbage`
367
+ // exited 0 on the field it happened to recognise.
368
+ const takes = ref ? 2 : 1;
369
+ if (args.length > takes) {
370
+ throw new Error(`dt resolve takes ${takes === 1 ? 'one string template' : 'a reference and ONE field'} — ${args.length - takes} extra argument(s): ${args.slice(takes).join(' ')}`);
371
+ }
372
+ if (!ref) {
373
+ console.log(renderTemplate(target, ctx));
374
+ return 0;
375
+ }
376
+ if (!field) throw new Error(`dt resolve ${target} needs a field name: dreamteamer resolve ${target} <field>`);
377
+ const { fields } = store.read(ref.collection, ref.id);
378
+ const value = fields[field];
379
+ if (value === undefined) throw new Error(`${target} has no field "${field}"`);
380
+ // RENDER EVERY ITEM BEFORE PRINTING ANY. An array prints one item per line so the output pipes
381
+ // into a shell loop unchanged — and a loop that does not check $? cannot tell a truncated list
382
+ // from a short one, so a failure on item n must not leave items 1..n-1 on stdout. Anything that
383
+ // is not text is refused rather than stringified: a number cannot hold a template.
384
+ const items = Array.isArray(value) ? value : [value];
385
+ const bad = items.findIndex((v) => typeof v !== 'string'); // index, not the value: an item may be null
386
+ if (bad > -1) throw new Error(`${target} field "${field}" holds a ${typeof items[bad]} — resolve renders text (a string, or a list of them)`);
387
+ const rendered = items.map((v) => renderTemplate(v, ctx));
388
+ if (rendered.length) console.log(rendered.join('\n'));
389
+ return 0;
390
+ }
391
+
392
+ /** Translate `dt schema <op> …` onto the same meta verbs `collectionCommand` already routes. */
393
+ function dispatchSchemaVerb(ws, args) {
394
+ const [op, ...rest] = args;
395
+ if (!op) throw new Error(`dt schema needs an operation — use ${SCHEMA_OP_LIST}`);
396
+ if (SCHEMA_FIELD_OPS.has(op)) {
397
+ const [collection, ...flags] = rest;
398
+ if (!collection || collection.startsWith('--')) {
399
+ throw new Error(`dt schema ${op} needs a collection: dreamteamer schema ${op} <collection> --name <field> …`);
400
+ }
401
+ return collectionCommand(ws, collection, op, flags);
402
+ }
403
+ const pair = SCHEMA_OPS[op];
404
+ if (!pair) throw new Error(`unknown schema operation "${op}" — use ${SCHEMA_OP_LIST}`);
405
+ return collectionCommand(ws, pair[0], pair[1], rest);
406
+ }
407
+
244
408
  function tryGit(cwd, args) {
245
409
  try { return execFileSync('git', args, { cwd, stdio: QUIET }).toString().trim() || null; } catch { return null; }
246
410
  }
@@ -1,6 +1,7 @@
1
- // noun-verb collection commands: dreamteamer <collection> list|get|add|set|rm|rename|history|diff|revert
2
- // + meta verbs: `collections add|rm`, `<collection> add-field|update-field|remove-field`,
3
- // `ui-views add|set|rm`
1
+ // The IMPLEMENTATION layer behind the verb-first CLI: `collectionCommand(ws, collection, verb, args)`
2
+ // is (collection, verb) shaped and stays that way — `cli.js` translates `dt <verb> <target>` onto it.
3
+ // So a spelling here (`collections add`, `<collection> add-field`, `commands for`, `repos ensure`) is
4
+ // an INTERNAL pair, not what the operator types; `dt schema add-collection` is what they type.
4
5
  import { execFileSync } from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
@@ -19,6 +20,7 @@ import { distinctValues } from './field-values.js';
19
20
  import { matchesFilter } from './filter.js';
20
21
  import { baseNameOf, normalizeNamespaces } from './namespace.js';
21
22
  import { sortRows } from './temporal.js';
23
+ import { keyBetween, placementKey } from './fractional-index.js';
22
24
  import { ensureRepo, ensureAllRepos } from './init.js';
23
25
 
24
26
  /**
@@ -26,7 +28,7 @@ import { ensureRepo, ensureAllRepos } from './init.js';
26
28
  *
27
29
  * `console.log` to a pipe is asynchronous, and every CLI path ends in `process.exit()`, which
28
30
  * discards whatever is still buffered. A shell pipeline hides the bug completely — the reader
29
- * drains concurrently, so `dreamteamer contacts list --json | wc -c` reports all 32381 bytes — but
31
+ * drains concurrently, so `dreamteamer list contacts --json | wc -c` reports all 32381 bytes — but
30
32
  * the way a script or a coding agent actually calls this is execFileSync/spawnSync, and there the
31
33
  * child exits with the pipe still full. Measured before this fix: the same command captured with
32
34
  * execFileSync returned exactly **8190 bytes** (one pipe buffer) of that 32381-byte document, i.e.
@@ -133,6 +135,40 @@ export function collectionCommand(ws, collection, verb, args) {
133
135
  flags.json ? emit(JSON.stringify({ id })) : console.log('✔ updated');
134
136
  return 0;
135
137
  }
138
+ // Manual ordering. ONE record is written per move — that is the entire feature; a dense
139
+ // integer would renumber everything below the insertion point and bury the change. The field is
140
+ // named by the descriptor (`sort_field`), never here, so a workspace may call it anything.
141
+ case 'move': {
142
+ const field = d.sort_field;
143
+ if (!field) throw new Error(`collection "${collection}" declares no sort_field — add one to its descriptor before ordering it by hand.`);
144
+
145
+ // Blanks sort FIRST (compareValues, via `?? ''`), so unplaced records surface at the top
146
+ // rather than hiding at the bottom, and ties fall back to id order: `walk` reads name-sorted
147
+ // and Array.sort is stable. That is the tiebreak two agents landing on the same key rely on.
148
+ const rows = [];
149
+ for (const { id: rid, fields } of store.readAll(collection)) rows.push({ id: rid, key: fields[field] ?? '' });
150
+ sortRows(rows, 'key');
151
+
152
+ if (flags.init) {
153
+ // Idempotent: a record that already carries a key keeps it, so --init can be re-run after
154
+ // adding records without disturbing an order the operator set by hand.
155
+ let prev = rows.filter((r) => r.key).pop()?.key ?? null;
156
+ let written = 0;
157
+ for (const r of rows) {
158
+ if (r.key) continue;
159
+ prev = keyBetween(prev, null);
160
+ store.set(collection, r.id, { [field]: prev });
161
+ written++;
162
+ }
163
+ flags.json ? emit(JSON.stringify({ placed: written })) : console.log(written ? `✔ placed ${written} record(s) in ${field}` : '✔ nothing to place');
164
+ return 0;
165
+ }
166
+
167
+ const id = need(pos, 0, 'id');
168
+ store.set(collection, id, { [field]: placementKey(rows, id, flags, collection) });
169
+ flags.json ? emit(JSON.stringify({ id })) : console.log('✔ moved');
170
+ return 0;
171
+ }
136
172
  case 'rm': {
137
173
  const id = need(pos, 0, 'id');
138
174
  const { inboundIgnored } = store.rm(collection, id, { force: !!flags.force });
@@ -146,7 +182,7 @@ export function collectionCommand(ws, collection, verb, args) {
146
182
  if (out.touched) console.log(`✔ rewrote ${out.rewrites} inbound reference(s) across ${out.touched} file(s)`);
147
183
  return 0;
148
184
  }
149
- // `dreamteamer meetings values status` — the vocabulary a field ACTUALLY uses, so a filter
185
+ // `dreamteamer values meetings status` — the vocabulary a field ACTUALLY uses, so a filter
150
186
  // or a command-binding validator can offer a dropdown for a plain `type: string` field that
151
187
  // no enum describes (operator: "still no dropdown for many things, visibility, status").
152
188
  case 'values': {
@@ -181,7 +217,7 @@ export function collectionCommand(ws, collection, verb, args) {
181
217
  // the hash is REQUIRED and has no default: "revert" with an implied target is how you
182
218
  // destroy the wrong record. `<c> history <id>` is where you get one.
183
219
  const hash = typeof flags.hash === 'string' ? flags.hash : pos[1];
184
- if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer ${collection} history ${id}\` to pick one`);
220
+ if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer history ${collection}/${id}\` to pick one`);
185
221
  const out = store.revert(collection, id, hash);
186
222
  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`);
187
223
  return 0;
@@ -193,16 +229,18 @@ export function collectionCommand(ws, collection, verb, args) {
193
229
 
194
230
 
195
231
 
196
- // `dreamteamer commands for <collection>[/<id>] [--ids <id>[,…]] [--json]` — which bound
197
- // commands apply, in which state (available / done / not-applicable). THE engine surface
198
- // behind the studio's Commands tab (engine/UI parity: the verb lands first, the button second).
232
+ // Which bound commands apply, in which state (available / done / not-applicable). THE engine
233
+ // surface behind the studio's Commands tab (engine/UI parity: the verb lands first, the button
234
+ // second).
235
+ //
236
+ // ⚠ The COLLECTION arrives already resolved, and the ids only ever through `--ids`. This used to
237
+ // split its own target at the first slash — which cannot name a namespaced collection, so
238
+ // `commands finance/transactions` asked for a collection called "finance" and every namespaced
239
+ // target failed. `cli.js` owns reference resolution now (splitRef, longest declared prefix) and is
240
+ // the only caller, so a second, weaker splitter here could only ever disagree with it.
199
241
  function metaCommandsFor(ws, store, flags, pos) {
200
- const target = need(pos, 0, 'collection[/id]');
201
- const slash = target.indexOf('/');
202
- const collection = slash > 0 ? target.slice(0, slash) : target;
203
- const ids = slash > 0
204
- ? [target.slice(slash + 1)]
205
- : typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
242
+ const collection = need(pos, 0, 'collection');
243
+ const ids = typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
206
244
  const out = commandsFor(store, collection, ids);
207
245
  if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
208
246
  if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
@@ -241,7 +279,7 @@ function metaCollectionsAdd(ws, store, flags) {
241
279
  // migration whose last step (rewriting references) dangles everything when forgotten.
242
280
  function metaCollectionsRename(ws, store, flags, pos) {
243
281
  const [oldName, explicitNew] = pos;
244
- if (!oldName) throw new Error('usage: collections rename <old-name> <new-name> | <old-name> --namespace <ns>');
282
+ if (!oldName) throw new Error('usage: dreamteamer schema rename-collection <old> <new> | <old> --namespace <ns>');
245
283
  // `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
246
284
  // which is the common case and saves retyping it.
247
285
  const newName = explicitNew
package/src/compile.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  // call at run time, same pattern as store.js ↔ compile.js.
18
18
  import { runHarnessAdapters } from './harnesses.js';
19
19
  import { satisfies } from './semver.js';
20
+ import { parseEnvValues } from './env-vars.js';
20
21
  import { DERIVED_KINDS, readManifest, runtimeDir } from './runtime.js';
21
22
 
22
23
  // re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
@@ -295,21 +296,37 @@ export function compile({ root, pkg }) {
295
296
  declaredEnv.get(k).push(source.name);
296
297
  }
297
298
  }
298
- if (declaredEnv.size) {
299
+ // `dreamteamer.vars` is the WORKSPACE's own declaration (root package.json, not a module's): the
300
+ // keys a `${env:NAME}` template is allowed to name. Same missing-key question as
301
+ // `dreamteamer.env`, one .env parse, two warnings — a module needs its key to RUN, a var is
302
+ // needed the moment someone calls `dt resolve`, and only the workspace can declare one.
303
+ if (config.vars !== undefined && (!Array.isArray(config.vars) || config.vars.some((v) => typeof v !== 'string'))) {
304
+ fail(`dreamteamer.vars must be a list of env key names (got ${JSON.stringify(config.vars)})`);
305
+ }
306
+ const declaredVars = config.vars ?? [];
307
+ if (declaredEnv.size || declaredVars.length) {
299
308
  // .env is parsed for KEY names ONLY — values never reach any output or the manifest
300
309
  const envPath = path.join(root, '.env');
301
310
  if (!fs.existsSync(envPath)) {
302
- console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
311
+ if (declaredEnv.size) console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
312
+ if (declaredVars.length) console.warn(`⚠ no .env — dreamteamer.vars declares ${declaredVars.join(', ')}, so no \${env:…} template can render here (see .env.example)`);
303
313
  } else {
304
- const present = new Set();
305
- for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
306
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
307
- if (m) present.add(m[1]);
308
- }
314
+ // ⚠ THE ONE PARSER, not a key regex of our own. This used to hand-roll
315
+ // `/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/`, which accepted two lines
316
+ // `parseEnvValues` deliberately drops (`KEY =value`, and an indented key) and scanned a
317
+ // quoted value's continuation lines for keys. Either disagreement produces the worst
318
+ // pairing there is: compile says nothing and then `dt resolve` answers "no value in
319
+ // .env" about a line the operator is looking straight at. Values are read here and
320
+ // never printed — the warnings below name keys only.
321
+ const present = new Set(parseEnvValues(fs.readFileSync(envPath, 'utf8')).keys());
309
322
  for (const [k, mods] of declaredEnv) {
310
323
  if (present.has(k)) continue;
311
324
  for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k} — missing from .env (see .env.example)`);
312
325
  }
326
+ for (const k of declaredVars) {
327
+ if (present.has(k)) continue;
328
+ console.warn(`⚠ dreamteamer.vars declares ${k} — missing from .env, so \${env:${k}} cannot render on this machine`);
329
+ }
313
330
  }
314
331
  }
315
332
 
@@ -630,6 +647,15 @@ export function compile({ root, pkg }) {
630
647
  fail(`collection "${name}": id.pattern is not a valid regular expression — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
631
648
  }
632
649
  }
650
+ // `sort_field` names a field of this collection's OWN schema. Without this gate a typo
651
+ // surfaces as "dragging does nothing" while the drag handle is still offered — a silent lie,
652
+ // and the ordering it writes would land in a field no reader sorts by.
653
+ if (merged.sort_field !== undefined) {
654
+ if (typeof merged.sort_field !== 'string') fail(`collection "${name}": sort_field must be a string (got ${JSON.stringify(merged.sort_field)})`);
655
+ if (!(merged.schema?.properties ?? {})[merged.sort_field]) {
656
+ fail(`collection "${name}": sort_field "${merged.sort_field}" is not a field of its schema — declare it, or point sort_field at one that exists (${group.map((g) => g.src.path).join(', ')}).`);
657
+ }
658
+ }
633
659
  // ---- the reference contract: every target is owned, depended on, or declared a peer ----
634
660
  // Attribution is unioned across the whole group rather than taken from the base, because the
635
661
  // merge keeps no per-field provenance — an overlay that adds a ref field would otherwise be
@@ -696,6 +722,17 @@ export function compile({ root, pkg }) {
696
722
  }
697
723
  const rt = path.join('collections', `${name}.collection.yaml`);
698
724
  entries.set(rt, { sources: [...group.map((g) => g.src), ...templateSources], bytes: Buffer.from(dump(merged)) });
725
+ // A descriptor with no `description:` renders in the orientation block as a bare NAME — an
726
+ // agent learns the noun exists and nothing about when it is the right one. Derived pressure
727
+ // rather than a heroic backfill pass, and the same shape as the per-missing-env-key warning:
728
+ // non-blocking, named per offender, so the gap converges instead of being rediscovered.
729
+ //
730
+ // ⚠ Deliberately NO equivalent warning for `use_when`. That field is optional and correct to
731
+ // omit on most collections — warning on it would invert its authoring test and manufacture a
732
+ // restatement of the description on every collection that does not need one.
733
+ if (merged.storage.base !== 'runtime' && !String(merged.description ?? '').trim()) {
734
+ console.warn(`⚠ collection ${name} has no description — it renders as a bare name in the orientation block every session loads`);
735
+ }
699
736
  counts.collections++;
700
737
  if (extenders.length) mergedCount++;
701
738
  }
@@ -0,0 +1,40 @@
1
+ // ${env:KEY} / ${workspaceFolder} / ${userHome} — VS Code's variable grammar, three variables.
2
+ // Values render ONLY on explicit request (dt resolve); records are never auto-substituted.
3
+ // parseEnvValues reads .env TEXT only (never shell-evaluates). A QUOTED value (single or double)
4
+ // may span multiple lines — the closing quote just has to end some later line, not the one it
5
+ // opened on — because `[^"\\]`/`[^']` match newlines in JS regex same as any other character.
6
+ // An UNQUOTED value is line-bound: it runs to end of line, so it can't span lines.
7
+ // Two accepted non-goals, unchanged on purpose: `KEY =value` (space before `=`) doesn't match the
8
+ // key pattern and is silently dropped, no diagnostic; an unquoted value keeps a trailing inline
9
+ // `# comment` as part of its text (quote it to strip one).
10
+ import os from 'node:os';
11
+
12
+ export function parseEnvValues(text) {
13
+ const out = new Map();
14
+ // KEY=value with optional `export `, optional quotes; value is whatever follows on the SAME line
15
+ const re = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'[^']*'|[^\n]*)$/gm;
16
+ let m;
17
+ while ((m = re.exec(text))) {
18
+ let v = m[2].trim();
19
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
20
+ out.set(m[1], v);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ const SUPPORTED = 'supported: ${env:NAME}, ${workspaceFolder}, ${userHome}';
26
+
27
+ export function renderTemplate(str, { env, workspaceFolder, declared }) {
28
+ return str.replace(/\$\{([A-Za-z]+)(?::([A-Za-z0-9_]*))?\}/g, (whole, ns, arg) => {
29
+ if (arg === undefined) {
30
+ if (ns === 'workspaceFolder') return workspaceFolder;
31
+ if (ns === 'userHome') return os.homedir();
32
+ return whole; // un-namespaced ${VAR}: not ours, inert — prose mentions ${…} freely
33
+ }
34
+ if (ns !== 'env') throw new Error(`\${${ns}:${arg}} is not a dreamteamer variable — ${SUPPORTED}`);
35
+ if (!arg) throw new Error(`\${env:} needs a key name — ${SUPPORTED}`);
36
+ if (!declared.includes(arg)) throw new Error(`\${env:${arg}}: "${arg}" is not declared in dreamteamer.vars (workspace package.json) — declared: ${declared.join(', ') || '(none)'}`);
37
+ if (!env.has(arg)) throw new Error(`\${env:${arg}} is declared but has no value in .env on this machine`);
38
+ return env.get(arg);
39
+ });
40
+ }
@@ -0,0 +1,44 @@
1
+ // Manual ordering keys. Named for the ALGORITHM: the FIELD is named per collection via
2
+ // `sort_field`, and nothing here may assume what it is called.
3
+ //
4
+ // ⚠ THE ALPHABET IS LOAD-BEARING. `compareValues` (temporal.js) ends in `localeCompare`, which is
5
+ // locale-aware, so the library's DEFAULT base-62 keys mis-sort here: prepending three times gives
6
+ // `Zy Zz a0`, and `sortRows` returns `a0 Zy Zz`. Same trap that breaks fractional indexing on
7
+ // Postgres under `en_US.utf8` instead of `C`. a-z is the intersection where three things hold at
8
+ // once — locale order agrees with codepoint order, and no key can parse as a number (the numeric
9
+ // branch of `compareValues` would sort "9" after "10") or as a temporal.
10
+ //
11
+ // test/unit/fractional-index.test.js asserts the base-62 failure directly, so this argument cannot
12
+ // be deleted as noise.
13
+ //
14
+ // Why a dependency rather than 75 lines of our own: the naive midpoint (halve toward the open end)
15
+ // degenerates on APPEND, which is the commonest write — measured at 200-character keys after 1000
16
+ // appends. The library's integer-part-with-magnitude-head keeps that at 4.
17
+ import { generateKeyBetween } from 'fractional-indexing';
18
+
19
+ const DIGITS = 'abcdefghijklmnopqrstuvwxyz';
20
+
21
+ /** A key strictly between `a` and `b`. Either may be null for an open end. */
22
+ export const keyBetween = (a, b) => generateKeyBetween(a ?? null, b ?? null, DIGITS);
23
+
24
+ /**
25
+ * The key that puts `id` where `dest` asks, given the collection's records in current sort order
26
+ * (`[{ id, key }]`, blanks first). PURE — the caller does the reading and the writing, which is what
27
+ * lets the CLI and the HTTP surface share one placement rule instead of two that drift.
28
+ *
29
+ * Fails closed on a destination that is not placed yet: with no key on the target there is nothing to
30
+ * compute against, and guessing would silently put the record somewhere the operator did not ask for.
31
+ */
32
+ export function placementKey(rows, id, dest, collection = '<collection>') {
33
+ const placed = rows.filter((r) => r.key && r.id !== id);
34
+ const at = (t) => {
35
+ const i = placed.findIndex((r) => r.id === t);
36
+ if (i < 0) throw new Error(`"${t}" has no sort value yet — run \`dreamteamer move ${collection} --init\` first. nothing was written.`);
37
+ return i;
38
+ };
39
+ if (dest.top) return keyBetween(null, placed[0]?.key ?? null);
40
+ if (dest.bottom) return keyBetween(placed[placed.length - 1]?.key ?? null, null);
41
+ if (typeof dest.after === 'string') { const i = at(dest.after); return keyBetween(placed[i].key, placed[i + 1]?.key ?? null); }
42
+ if (typeof dest.before === 'string') { const i = at(dest.before); return keyBetween(placed[i - 1]?.key ?? null, placed[i].key); }
43
+ throw new Error('say where to put it — --after <id>, --before <id>, --top or --bottom. nothing was written.');
44
+ }