dreamteamer 0.26.0 → 0.28.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/README.md CHANGED
@@ -150,6 +150,9 @@ adapters: Claude Code, Codex, Pi, Gemini CLI, Cursor. Author a skill once; every
150
150
 
151
151
  ## The editor
152
152
 
153
+ Extension id `dreamteamer.dreamteamer-vscode` (Marketplace · Open VSX). `init` and `compile` write the
154
+ `.vscode/extensions.json` recommendation, and `dt status` reports whether it is active.
155
+
153
156
  [dreamteamer-vscode](https://github.com/dreamteamer/dreamteamer-vscode) gives you tables, boards,
154
157
  calendars, maps, forms and a data-model designer over the same files — and it loads **the engine your
155
158
  workspace pins**, so the editor, the CLI and any agent session are provably running the same code.
@@ -4,10 +4,24 @@ id: { generate: "{{ name | slug }}" }
4
4
  schema:
5
5
  type: object
6
6
  required: [name, schema]
7
+ # The fields fall into five groups, in this order: IDENTITY (name · singular · title ·
8
+ # title_template — what the collection and a record of it are called), RETRIEVAL (description ·
9
+ # use_when — what brings a session here), SHAPE (extends · schema · id · sensitive), PLACEMENT
10
+ # (storage · module · group — where records live and who owns them), PRESENTATION (order ·
11
+ # list_fields · sort_field · icon · ui — how the surfaces show it). A new field joins one of
12
+ # these; a field that fits none is a sign it belongs on a record, not on the collection.
7
13
  properties:
8
14
  name:
9
15
  type: string
10
16
  description: The collection id — must equal the filename, and must be unique across every installed module.
17
+ singular:
18
+ type: string
19
+ description: >-
20
+ The word the CLI accepts beside `name` — `dt add task …` for `tasks`. DERIVED by inflection
21
+ when absent (`tasks` → `task`, `companies` → `company`, `rnd/projects` → `rnd/project`) and
22
+ authored only where inflection is wrong (`people` → `person`, `meeting-analyses` →
23
+ `meeting-analysis`). Typed input only: a REFERENCE inside a record still spells the full
24
+ name. compile refuses two collections whose name or singular coincide.
11
25
  description:
12
26
  type: string
13
27
  description: >-
@@ -172,10 +186,19 @@ schema:
172
186
  group:
173
187
  type: string
174
188
  description: >-
175
- DEPRECATED as a nav axis since 2026-08-11 — the nav groups by `owner` (a module, which has a
176
- title of its own) instead of by this string with a display-name map maintained in a surface.
177
- Still read by nothing; kept so the change is a code revert rather than a data migration, and
178
- because a workspace may yet want a partition that deliberately DIFFERS from its modules.
189
+ The collection's PARTITION — which family of nouns it belongs to, authored freely (`crm`,
190
+ `finance`, `family`, `content`…) and set with `dt set collections/<c> group=<g>`. One value
191
+ is reserved and load-bearing: `group: system` says the collection is the workspace's own
192
+ MACHINERY rather than one of its domain nouns, so it is left out of the generated block's
193
+ domain listing, named on the system-collections line instead, and drawn on a surface's
194
+ schema surface rather than in the record tree. ⚠ It does NOT change where records live or
195
+ whether they can be written — that is `storage.base`, asked separately, and `repos` is the
196
+ collection where the two answers split: `group: system` and workspace-stored records the
197
+ operator edits by hand. Nearly removed on 2026-08-11, when the nav stopped grouping by it in
198
+ favour of `owner` (a module, which has a title of its own) and nothing else read it; kept
199
+ then so the change would be a code revert rather than a data migration, and because a
200
+ workspace may want a partition that deliberately DIFFERS from its modules. That is what it
201
+ became.
179
202
  order: 10
180
203
  list_fields: [name, last-modified]
181
204
  icon: schema
@@ -52,4 +52,10 @@ schema:
52
52
  order: 145
53
53
  list_fields: [name, identity, ref, url]
54
54
  icon: source
55
+ # MACHINERY, NOT A DOMAIN NOUN, and `group: system` is the whole statement of it. `repos` is the
56
+ # engine's only collection whose records live in `data/`, which used to make every workspace render
57
+ # a **System** module group with one collection under it — the group the block's own renderer says
58
+ # should not exist (harnesses.js). Being in the `system` partition folds it out of the block's
59
+ # domain listing and onto the schema surface instead. The records stay real, writable and committed;
60
+ # only the presentation changes.
55
61
  group: system
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
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>",
@@ -29,7 +29,8 @@
29
29
  "node": ">=20"
30
30
  },
31
31
  "bin": {
32
- "dreamteamer": "bin/dreamteamer.js"
32
+ "dreamteamer": "bin/dreamteamer.js",
33
+ "dt": "bin/dreamteamer.js"
33
34
  },
34
35
  "files": [
35
36
  "NOTICE",
@@ -49,7 +50,7 @@
49
50
  "express": "^5.2.1",
50
51
  "fractional-indexing": "^4.0.0",
51
52
  "js-yaml": "^4.1.0",
52
- "yaml": "2.8.1"
53
+ "yaml": "2.8.4"
53
54
  },
54
55
  "dreamteamer": {
55
56
  "title": "System"
@@ -59,6 +59,7 @@ their flags, on one page (there is no per-verb `--help`).
59
59
  the verb names, as a map (semantics and flags live in `help`; a test holds this list to the
60
60
  dispatch, so it cannot drift):
61
61
 
62
+ - a collection may be spelled in the SINGULAR on any of these (`dt add task "call the bank"` — one bare positional is the title); references inside records still spell the full name
62
63
  - read & measure — `list` `get` `values` `history` `diff` `next` `relations` `resolve`
63
64
  - write & publish — `add` `set` `rm` `rename` `move` `revert` `commit`
64
65
  - fields (sources, through the compile gate) — `add-field` `set-field` `rm-field` `rename-field` (system entities — modules, collections, skills, ui-views… — take the RECORD verbs above)
@@ -112,3 +112,24 @@ prefers. Nothing about the code moves; `check` and `compile` read only what desc
112
112
  | hand-writing the first descriptor | `dt add collections` is compile-gated and publishes itself; hand-written sources owe `dt compile` |
113
113
  | rewriting existing files to fit a guessed schema | describe reality, compile, `check` — then decide which violations are worth fixing in the data |
114
114
  | waiting for a UI before starting | the CLI and the records are the complete system; any surface renders them later, unchanged |
115
+
116
+ ## The editor
117
+
118
+ The VS Code-family extension is **`dreamteamer.dreamteamer-vscode`** (Marketplace and Open VSX). It
119
+ loads the engine the workspace pins, so the editor, the CLI and an agent session run the same code.
120
+ `init` writes `.vscode/extensions.json` recommending it and `compile` keeps that file current, so a
121
+ VS Code, Cursor or code-server window opened on the workspace offers to install it — that prompt is
122
+ the intended path. From a terminal:
123
+
124
+ ```bash
125
+ code --install-extension dreamteamer.dreamteamer-vscode # VS Code (on some machines `code` is Cursor)
126
+ code-server --install-extension dreamteamer.dreamteamer-vscode \
127
+ --extensions-dir <the dir the running server was started with> # match its --extensions-dir, or the window never sees it
128
+ ```
129
+
130
+ ⚠ Inside code-server's own terminal (or an agent it started) that command fails with `error not spawned
131
+ with IPC`: the shell inherits code-server's `VSCODE_*` / `CODE_SERVER_PARENT_PID` variables and the
132
+ CLI thinks it is a forked child. Prefix it with `env -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_SIGPIPE
133
+ -u VSCODE_HANDLES_UNCAUGHT_ERRORS -u VSCODE_NLS_CONFIG -u VSCODE_CWD -u VSCODE_RECONNECTION_GRACE_TIME
134
+ -u CODE_SERVER_PARENT_PID`, or let the recommendation prompt do the install. Whether the extension is
135
+ ACTIVE is `dt status`'s `editor:` line — it reads the marker the extension writes on activation.
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ import { findWorkspace } from './workspace.js';
16
16
  import { compile, staleness, warnIfStale, discoverModules, CHANNEL_LABEL, locationOf, KINDS } from './compile.js';
17
17
  import { check } from './check.js';
18
18
  import { collectionCommand, emit, relationsCommand, parseArgs, refuseUnknownFlags } from './collections-cli.js';
19
+ import { driverTarget, driverCommand, setup as hostSetup, parseFlags as hostFlags, DRIVER_VERBS, LIFECYCLE_VERBS, CONTAINER_FLAGS } from './containers.js';
19
20
  import { init, installClone, update, listRepos } from './init.js';
20
21
  import { installCommand, describeCheckout, listWorktrees, worktreeCommand } from './checkout.js';
21
22
  import { proveCommand, readLedger, flagEnabled } from './prove.js';
@@ -23,7 +24,7 @@ import { landCommand } from './land.js';
23
24
  import { deriveEvents } from './events.js';
24
25
  import { commitPending } from './commit.js';
25
26
  import { Store } from './store.js';
26
- import { splitRef } from './ref.js';
27
+ import { splitRef, canonicalCollection } from './ref.js';
27
28
  import { envContext, renderTemplate } from './env-vars.js';
28
29
  import { exportCommand, EXPORT_FLAGS } from './export-notebooklm.js';
29
30
 
@@ -57,8 +58,12 @@ the longest DECLARED collection prefix, so finance/transactions/2026/03/coffee i
57
58
  case-insensitive variants; date-times sort and
58
59
  compare as instants, across offsets)
59
60
  get <collection>/<id> [--json]
60
- add <collection> --<field> <value> … [--id <explicit-id>]
61
- (a codec-file collection takes --from <path>
61
+ add <collection> ["<title>"] --<field> <value> … [--id <explicit-id>]
62
+ (ONE bare positional fills the collection's title
63
+ field — the one its title_template names — so
64
+ dt add task "call the bank" is
65
+ dt add tasks --name "call the bank".
66
+ a codec-file collection takes --from <path>
62
67
  instead — the file IS the record, fields derive;
63
68
  --force replaces an existing file record.
64
69
  A repeated --<field> is one ELEMENT of an array
@@ -164,6 +169,12 @@ collection: the ENGINE does not read one, and \`rename-field\` was the only capa
164
169
  title_template, id.generate, a ui-view's options.columns and filter,
165
170
  and a command-binding's can-enter/can-exit. ONE commit)
166
171
 
172
+ Every <collection> above may be spelled in the SINGULAR: dt add task …, dt get task/<id>,
173
+ dt list meeting-analysis, dt add-field task …. The singular is derived from the descriptor
174
+ (tasks → task, companies → company) and authored on it as singular: where inflection is
175
+ wrong (people → person); compile refuses two collections whose words collide. A REFERENCE
176
+ inside a record still names the collection in full — tasks/kickoff, never task/kickoff.
177
+
167
178
  Every verb that MOVES records or CLEARS values takes --dry-run and prints its plan first:
168
179
  records N · refs M · descriptors K · values cleared V
169
180
 
@@ -232,6 +243,24 @@ workspace verbs:
232
243
  and one \`proofs:\` line counting each proof's LAST verdict on this machine
233
244
  [--strict] exit 1 when any proof's ledger tail is a FAIL
234
245
  start serve the clean REST api at /api [--port <n>]
246
+
247
+ containers — a workspace as a running container (Docker Engine API over its socket, no dependency;
248
+ these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop are enough):
249
+ setup make THIS MACHINE ready: checks Docker, writes ~/.dreamteamer/.env with its defaults
250
+ (DT_PORT_BASE 8100 · DT_BIND 127.0.0.1 · DT_REGISTRY · DT_TEMPLATE_TAG), lists the
251
+ templates present, pulls one on request [--template <t>] [--json]
252
+ start container <name> --template <t> create-if-absent and start: a code-server editor at
253
+ http://localhost:<port>/?folder=/workspace over a compiled workspace, three named volumes
254
+ (workspace · home · files), image <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>.
255
+ Idempotent. NO token is ever injected — log in INSIDE, once; the home volume keeps it.
256
+ [--name <git name>] [--email <git email>] [--no-open] [--json]
257
+ stop container <name> stop it; every volume kept [--json]
258
+ open container <name> print (and open) its editor URL [--no-open]
259
+ list containers | images the record verbs, answered over Docker instead of a
260
+ get container <name> | image <ref> folder — singular or plural, either spelling.
261
+ rm container <name> [--force] plain rm keeps the volumes; --force removes them too
262
+ add image --template <t> pull a template's image; rm image <ref> removes one
263
+
235
264
  changes what changed in every repo that holds records, as record events
236
265
  [--since <sha|YYYY-MM-DD>] (default: HEAD~1 — the last commit's own changes) [--json]
237
266
  commit publish records already written to disk: samples git status over every
@@ -286,7 +315,10 @@ export const GLOBAL_FLAGS = ['vault'];
286
315
  export const WORKSPACE_FLAGS = {
287
316
  init: ['name', 'data-path', 'harnesses', 'workspace-module'], update: [],
288
317
  install: ['clone', 'dry-run', 'json', 'link-env', 'all', 'hook', 'print-adapters'],
289
- start: ['port'], compile: ['watch'], check: [], status: ['strict'],
318
+ // `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
319
+ // lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
320
+ start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
321
+ setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open'],
290
322
  changes: ['since', 'json'], commit: ['dry-run', 'json'],
291
323
  export: EXPORT_FLAGS,
292
324
  // the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
@@ -299,6 +331,15 @@ export const WORKSPACE_FLAGS = {
299
331
  export function run(argv) {
300
332
  const [cmd, ...rest] = argv;
301
333
  try {
334
+ // HOST VERBS resolve BEFORE workspace discovery: `setup`, and any verb whose target is a
335
+ // driver collection (`containers`, `images`, singular or plural). They answer identically on a
336
+ // bare machine — `npm i -g dreamteamer` and Docker Desktop, nothing else — and inside a
337
+ // workspace, because the thing they make IS the workspace (src/containers.js).
338
+ const host = hostDispatch(cmd, rest);
339
+ if (host) {
340
+ host.then((code) => process.exit(code)).catch((e) => { console.error(`✖ ${e.message}`); process.exit(1); });
341
+ return;
342
+ }
302
343
  if (cmd in WORKSPACE_FLAGS) {
303
344
  const bad = rest.filter((a) => a.startsWith('--')).map((a) => a.slice(2).split('=')[0]).find((f) => !WORKSPACE_FLAGS[cmd].includes(f));
304
345
  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)'}`);
@@ -315,7 +356,9 @@ export function run(argv) {
315
356
  for (let i = 0; i < rest.length; i++) if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1];
316
357
  process.exit(init({ flags }));
317
358
  }
318
- if (!cmd) {
359
+ if (!cmd || cmd === 'help') {
360
+ // `help` works OUTSIDE a workspace too — the host verbs above do, and a person who just ran
361
+ // `npm i -g dreamteamer` on a bare machine has nothing else to read.
319
362
  emit(USAGE);
320
363
  process.exit(0);
321
364
  }
@@ -507,6 +550,16 @@ export function run(argv) {
507
550
  process.exit(1);
508
551
  }
509
552
  console.log(`compiled: ${s.manifest.compiled}`);
553
+ // THE EDITOR, from the marker the extension writes on activation (`.dreamteamer/editor.json`).
554
+ // Without it an agent working inside the editor could not tell whether the extension was
555
+ // installed, active, or refusing the engine — the operator had to report "no icon".
556
+ try {
557
+ const marker = path.join(ws.root, '.dreamteamer', 'editor.json');
558
+ if (fs.existsSync(marker)) {
559
+ const e = JSON.parse(fs.readFileSync(marker, 'utf8'));
560
+ console.log(`editor: ${e.extension ?? 'dreamteamer-vscode'} ${e.version ?? '?'} · ${e.state ?? 'active'} ${e.activated ?? ''} · engine ${e.engine ?? '?'}${e.host ? ` · ${e.host}` : ''}`);
561
+ } else console.log('editor: not detected — the extension dreamteamer.dreamteamer-vscode writes .dreamteamer/editor.json when it activates on this workspace');
562
+ } catch { console.log('editor: marker unreadable — .dreamteamer/editor.json is not JSON'); }
510
563
  // provenance is LIVE discovery (not the manifest) — shows what the next compile would use
511
564
  const { modules, shadows } = discoverModules(ws.root, ws.pkg);
512
565
  const shadowed = new Map(shadows.map((sh) => [sh.name, sh]));
@@ -661,7 +714,8 @@ export function run(argv) {
661
714
  if (!target || target.startsWith('--')) {
662
715
  throw new Error(`dt ${cmd} needs a collection: dreamteamer ${cmd} <collection> --name <field> …`);
663
716
  }
664
- process.exit(collectionCommand(ws, target, cmd, flagArgs));
717
+ // the singular is legal here too: `dt add-field task --name due …`
718
+ process.exit(collectionCommand(ws, canonicalCollection(new Store(ws).descriptors, target) ?? target, cmd, flagArgs));
665
719
  }
666
720
  case 'relations':
667
721
  warnIfStale(ws.root);
@@ -728,6 +782,25 @@ export function run(argv) {
728
782
  }
729
783
  }
730
784
 
785
+ /** The verbs that run with no workspace. Returns a promise of an exit code, or null when the
786
+ * command is not ours and the ordinary workspace dispatch should take it. */
787
+ function hostDispatch(cmd, rest) {
788
+ if (cmd === 'setup') {
789
+ const bad = rest.filter((a) => a.startsWith('--')).map((a) => a.slice(2).split('=')[0]).find((f) => !WORKSPACE_FLAGS.setup.includes(f));
790
+ if (bad) throw new Error(`unknown flag "--${bad}" on \`dt setup\`\n known: ${WORKSPACE_FLAGS.setup.map((f) => `--${f}`).join(', ')}`);
791
+ return hostSetup(hostFlags(rest).flags);
792
+ }
793
+ const target = driverTarget(rest[0]);
794
+ if (target && DRIVER_VERBS.has(cmd)) return driverCommand(cmd, target, rest.slice(1));
795
+ // A lifecycle verb aimed at anything else is refused by name: `dt start tasks` is not a
796
+ // server and not a container, and "unknown collection" would send the reader the wrong way.
797
+ if (LIFECYCLE_VERBS.has(cmd) && rest[0] && !rest[0].startsWith('--')) {
798
+ return Promise.reject(new Error(`\`${cmd}\` is a container lifecycle verb — "${rest[0]}" is not a container. dt ${cmd} container <name>${cmd === 'start' ? ' --template <t>' : ''}${cmd === 'start' ? '; a bare `dt start` serves the REST api' : ''}`));
799
+ }
800
+ if (cmd === 'stop' || cmd === 'open') return Promise.reject(new Error(`dt ${cmd} container <name> — see \`dreamteamer help\``));
801
+ return null;
802
+ }
803
+
731
804
  /** Translate `dt <verb> <target> …` into the noun-verb call the implementation layer takes. */
732
805
  function dispatchRecordVerb(ws, verb, args) {
733
806
  const [target, ...rest] = args;
@@ -735,18 +808,22 @@ function dispatchRecordVerb(ws, verb, args) {
735
808
  // A flag in the target slot is a word-order mistake, not a collection: without this,
736
809
  // `dt list --json contacts` reported `unknown collection "--json"` and dumped every name.
737
810
  if (target.startsWith('--')) throw new Error(`dt ${verb} takes its target BEFORE the flags: dreamteamer ${verb} <target> ${target} …`);
738
- if (COLLECTION_VERBS.has(verb)) return collectionCommand(ws, target, verb, rest);
811
+ // A collection may be named by its declared name OR its singular (`dt add task …`); the
812
+ // canonical name is what every layer below sees. An unknown word passes through unchanged so
813
+ // the store's own "unknown collection" sentence, which lists what exists, is the one printed.
814
+ const { descriptors } = new Store(ws);
815
+ const canonical = canonicalCollection(descriptors, target) ?? target;
816
+ if (COLLECTION_VERBS.has(verb)) return collectionCommand(ws, canonical, verb, rest);
739
817
  if (REF_VERBS.has(verb)) {
740
- const { collection, id } = splitRef(new Store(ws).descriptors, target);
818
+ const { collection, id } = splitRef(descriptors, target);
741
819
  return collectionCommand(ws, collection, verb, [id, ...rest]);
742
820
  }
743
821
  // EITHER_VERBS from here: a bare collection is legal for both — `move <collection> --init`,
744
822
  // `next <collection>`.
745
- const { descriptors } = new Store(ws);
746
- if (descriptors.has(target)) {
823
+ if (descriptors.has(canonical)) {
747
824
  return verb === 'move'
748
- ? collectionCommand(ws, target, 'move', rest)
749
- : collectionCommand(ws, 'commands', 'for', [target, ...rest]);
825
+ ? collectionCommand(ws, canonical, 'move', rest)
826
+ : collectionCommand(ws, 'commands', 'for', [canonical, ...rest]);
750
827
  }
751
828
  const { collection, id } = splitRef(descriptors, target);
752
829
  if (verb === 'move') return collectionCommand(ws, collection, 'move', [id, ...rest]);
@@ -179,6 +179,16 @@ export function collectionCommand(ws, collection, verb, args) {
179
179
  return 0;
180
180
  }
181
181
  if (flags.from) throw new Error(`--from imports a file as a record, and "${collection}" is not a \`codec: file\` collection`);
182
+ // ONE bare positional is the record's title — the field `title_template` names — so
183
+ // `dt add task "call the bank"` reads as a sentence. Two positionals is a mistake (a flag
184
+ // value that lost its flag), and so is giving the title twice; both are refused by name.
185
+ if (pos.length > 1) throw new Error(`dt add ${collection} takes ONE positional (the title) and flags for the rest — got ${pos.length}: ${pos.map((p) => `"${p}"`).join(' ')}`);
186
+ if (pos.length === 1) {
187
+ const titleField = /\{\{\s*([A-Za-z_][\w]*)/.exec(d.title_template ?? '')?.[1];
188
+ if (!titleField || titleField === 'id') throw new Error(`"${collection}" labels its records by id, so there is no title field for "${pos[0]}" to fill — pass fields as --<field> <value>`);
189
+ if (titleField in flags) throw new Error(`the title was given twice — "${pos[0]}" and --${titleField} ${JSON.stringify(flags[titleField])}`);
190
+ flags[titleField] = pos[0];
191
+ }
182
192
  const fields = coerceArrays(d, stripMeta(flags));
183
193
  const { id, file, idFallback } = store.add(collection, fields, { id: flags.id });
184
194
  flags.json
package/src/commit.js CHANGED
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import { pathToRecord } from './events.js';
8
8
  import { parseRecordText } from './records.js';
9
9
  import { relationsOf } from './relations.js';
10
- import { splitRef } from './ref.js';
10
+ import { splitRef, canonicalCollection } from './ref.js';
11
11
 
12
12
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
13
13
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -42,7 +42,8 @@ function parseTargets(descriptors, only) {
42
42
  const whole = new Set();
43
43
  const records = new Map();
44
44
  for (const target of only) {
45
- if (descriptors.has(target)) { whole.add(target); scope.add(target); continue; }
45
+ const asCollection = canonicalCollection(descriptors, target);
46
+ if (asCollection) { whole.add(asCollection); scope.add(asCollection); continue; }
46
47
  const { collection, id } = splitRef(descriptors, target);
47
48
  records.set(`${collection}/${id}`, { collection, id });
48
49
  scope.add(collection);
package/src/compile.js CHANGED
@@ -16,7 +16,8 @@ import {
16
16
  baseNameOf, singular, namespaceOf } from './namespace.js';
17
17
  // circular on paper in earlier versions — safe: both sides only
18
18
  // call at run time, same pattern as store.js ↔ compile.js.
19
- import { runHarnessAdapters } from './harnesses.js';
19
+ import { runHarnessAdapters, BEGIN, END, INSTRUCTIONS_BEGIN, INSTRUCTIONS_END } from './harnesses.js';
20
+ import { ensureEditorRecommendation, ensureEnvExample } from './workspace.js';
20
21
  import { satisfies } from './semver.js';
21
22
  import { parseEnvValues } from './env-vars.js';
22
23
  import { DERIVED_KINDS, readManifest, runtimeDir, engineId, engineVersion } from './runtime.js';
@@ -676,7 +677,9 @@ export function compile({ root, pkg }) {
676
677
  // what every message in this engine already calls it. Defined HERE, above the namespace pass,
677
678
  // because a namespace error has to name the module by the id the fix is typed with.
678
679
  const moduleId = (n) => slug(String(n).replace(/^@[^/]+\//, ''));
680
+ const channelOf = new Map(sources.map((s) => [s.name, s.channel]));
679
681
  const declaredEnv = new Map(); // env key -> [module names]
682
+ const envMeta = new Map(); // env key -> { description, example } — the first module to say wins
680
683
  const moduleIgnores = new Map(); // module name -> non-source folders it declares (strayKindDirs)
681
684
  const moduleDeps = new Map(); // module name -> [module names] — HARD, must be acyclic
682
685
  const modulePeers = new Map(); // module name -> [collection names] — SOFT, cannot cycle
@@ -717,9 +720,17 @@ export function compile({ root, pkg }) {
717
720
  if (ok === false) console.warn(`⚠ module ${source.name} declares engine "${range}" — running engine is ${engineVer} (out of range; compile continues)`);
718
721
  else if (ok === null) console.warn(`⚠ module ${source.name}: engine range "${range}" not understood by the built-in checker (see src/semver.js) — not verified`);
719
722
  }
720
- for (const k of mpkg.dreamteamer?.env ?? []) {
723
+ // `dreamteamer.env`: a bare key name, or `{ name, description, example }` so the warning and
724
+ // `.env.example` can say what the key IS and what a value looks like — a bare `WORK_CALENDARS`
725
+ // told a first-run operator nothing about ids, addresses or display names (2026-09-24).
726
+ const envDecl = mpkg.dreamteamer?.env ?? [];
727
+ if (!Array.isArray(envDecl)) fail(`module "${source.name}": dreamteamer.env must be a list of key names or { name, description, example } objects (got ${JSON.stringify(envDecl)})`);
728
+ for (const entry of envDecl) {
729
+ const k = typeof entry === 'string' ? entry : entry?.name;
730
+ if (typeof k !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) fail(`module "${source.name}": dreamteamer.env entry ${JSON.stringify(entry)} — a key is an identifier (A-Z, 0-9, _) as a string or as { name, description, example }`);
721
731
  if (!declaredEnv.has(k)) declaredEnv.set(k, []);
722
732
  declaredEnv.get(k).push(source.name);
733
+ if (typeof entry === 'object' && !envMeta.has(k)) envMeta.set(k, { description: entry.description ? String(entry.description) : undefined, example: entry.example !== undefined ? String(entry.example) : undefined });
723
734
  }
724
735
  // Gathered here because mpkg is already parsed; refused below, next to the workspace's own
725
736
  // declaration. The classic layout pushes the ROOT itself as an inline source, whose
@@ -758,7 +769,8 @@ export function compile({ root, pkg }) {
758
769
  const present = new Set([...parsedEnv].filter(([, v]) => v.trim() !== '').map(([k]) => k));
759
770
  for (const [k, mods] of declaredEnv) {
760
771
  if (present.has(k)) continue;
761
- for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k} — missing from .env (see .env.example)`);
772
+ const about = envMeta.get(k)?.description ? ` (${envMeta.get(k).description})` : '';
773
+ for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k}${about} — missing from .env (see .env.example)`);
762
774
  }
763
775
  for (const k of declaredVars) {
764
776
  if (present.has(k)) continue;
@@ -767,6 +779,17 @@ export function compile({ root, pkg }) {
767
779
  }
768
780
  }
769
781
 
782
+ // Two root files kept current on every compile, both cheap and both about the FIRST run of a
783
+ // stranger: `.env.example` lists every declared key with its description, so the warning above
784
+ // points at a file that actually names them; `.vscode/extensions.json` recommends the editor
785
+ // extension, so the first window offers it. Both are append/merge-only — nothing authored moves.
786
+ {
787
+ const added = ensureEnvExample(root, [...declaredEnv].map(([key, mods]) => ({ key, modules: mods, ...(envMeta.get(key) ?? {}) })),
788
+ '# secrets for skills and modules go here (copy to .env; .env is never committed).\n# modules declare the env keys they require in their package.json dreamteamer.env list.\n');
789
+ if (added.length) console.log(`✔ .env.example now names ${added.join(', ')}`);
790
+ ensureEditorRecommendation(root);
791
+ }
792
+
770
793
  // ---- local-assets and postinstall: what `dt install` will do to a checkout -------
771
794
  // `local-assets` are the gitignored heavy folders a checkout SHARES by symlink instead of
772
795
  // duplicating — a browser profile dir, a model cache. Declared, never discovered. Every
@@ -1093,6 +1116,7 @@ export function compile({ root, pkg }) {
1093
1116
  let mergedCount = 0;
1094
1117
  let templatedCount = 0;
1095
1118
  const storageEntries = []; // {name, path, base} per collection — checked for overlap after the loop
1119
+ const wordEntries = []; // {name, word: singular} per collection — checked for collisions after the loop
1096
1120
  // Merged descriptors are held, NOT dumped, until every one of them exists: a relation spans two
1097
1121
  // collections, and the second is not merged yet when the first is reached. So this loop resolves
1098
1122
  // and validates each descriptor on its own, `materializeRelations` runs over the whole set, and
@@ -1285,8 +1309,14 @@ export function compile({ root, pkg }) {
1285
1309
  if (raw === '*') {
1286
1310
  // The workspace module is the orchestrating parent and may reference anything —
1287
1311
  // including modules that do not exist yet, which is what `tasks.item` means.
1288
- // Anywhere else a wildcard is a cross-module surface no declaration can cover.
1289
- if (!groupModules.includes(wsModuleName)) {
1312
+ // Anywhere else a wildcard is a cross-module surface no declaration can cover — and
1313
+ // it is the MODULE AUTHOR's to cover, so the warning is raised only where the author
1314
+ // is: a module in this tree (inline). A module installed from npm or a clone is
1315
+ // somebody else's source; warning its consumers about it on every compile told a
1316
+ // first-run operator four things they could not fix (2026-09-24). The module's own
1317
+ // CI, compiling it alone, still sees them.
1318
+ const authoredHere = groupModules.some((m) => (channelOf.get(m) ?? 'inline') === 'inline');
1319
+ if (!groupModules.includes(wsModuleName) && authoredHere) {
1290
1320
  console.warn(`⚠ collection ${name}: field "${at}" uses x-reference: '*' outside the workspace module — an unverifiable cross-module surface; name the collections it may target`);
1291
1321
  }
1292
1322
  continue;
@@ -1380,6 +1410,16 @@ export function compile({ root, pkg }) {
1380
1410
  // for `meta.title_field`, promoted to an authorable field. Reference fields pointing here
1381
1411
  // inherit it (presentation.js), which is what replaces 51 hand-written `x-display` lines.
1382
1412
  merged.title_template ??= `{{ ${['title', 'name', 'subject'].find((f) => f in labelProps) ?? 'id'} }}`;
1413
+ // The word the CLI accepts beside the name (`dt add task …`). DERIVED by the same inflection
1414
+ // the storage suffix already uses, with the namespace kept (`rnd/projects` → `rnd/project`),
1415
+ // so the two never disagree; AUTHORED where inflection is wrong (`people` → `person`).
1416
+ // Collisions are refused after the loop, once every descriptor has one.
1417
+ if (merged.singular !== undefined && (typeof merged.singular !== 'string' || !merged.singular.trim())) fail(`collection "${name}": \`singular\` must be a non-empty string`);
1418
+ if (merged.singular === undefined) {
1419
+ const ns = namespaceOf(name, namespaces);
1420
+ merged.singular = ns ? `${ns}/${singular(baseNameOf(name, namespaces))}` : singular(name);
1421
+ }
1422
+ wordEntries.push({ name, word: merged.singular });
1383
1423
  for (const [fieldName, prop] of Object.entries(labelProps)) {
1384
1424
  if (!prop || typeof prop !== 'object' || Array.isArray(prop)) continue;
1385
1425
  prop.title ??= titleCase(fieldName);
@@ -1445,6 +1485,19 @@ export function compile({ root, pkg }) {
1445
1485
  // `owns-data` module prefix and any authored override all already applied). See
1446
1486
  // namespace.storageOverlaps for what this silently did before it was checked.
1447
1487
  for (const p of storageOverlaps(storageEntries)) fail(p);
1488
+ // Two collections that answer to one word would make `dt add <word>` a coin toss, so the set of
1489
+ // words — every name and every singular — must be injective. Refused with both names, because
1490
+ // the fix is an authored `singular:` on one of them and the author needs to know which two.
1491
+ {
1492
+ const owners = new Map(); // word -> name
1493
+ for (const { name } of wordEntries) owners.set(name, name);
1494
+ for (const { name, word } of wordEntries) {
1495
+ if (word === name) continue;
1496
+ const other = owners.get(word);
1497
+ if (other && other !== name) fail(`collections "${name}" and "${other}" both answer to the word "${word}" (a name or a singular) — author \`singular:\` on one of them so \`dt add ${word}\` names exactly one collection`);
1498
+ owners.set(word, name);
1499
+ }
1500
+ }
1448
1501
 
1449
1502
  // ---- modules, projected ---------------------------------------------------------
1450
1503
  // One record per discovered module, written from what discovery and the package pass already
@@ -1526,6 +1579,19 @@ export function compile({ root, pkg }) {
1526
1579
  counts.modules = (counts.modules ?? 0) + 1;
1527
1580
  }
1528
1581
 
1582
+ // ---- the workspace's own hand-written instructions -------------------------------
1583
+ // ONE source, rendered verbatim into every harness's instruction file. It is registered as a
1584
+ // manifest entry for exactly one reason: `staleness` walks manifest sources, so a file that is
1585
+ // not one can be edited forever without `dt status` ever saying the harness files lag it — and a
1586
+ // silent lag on the file carrying the operator's rules is the worst possible thing to be silent
1587
+ // about. The runtime copy is never read by anything; the manifest ENTRY is the whole point.
1588
+ const instructionsPath = path.join(root, INSTRUCTIONS_SOURCE);
1589
+ if (fs.existsSync(instructionsPath)) {
1590
+ const bytes = fs.readFileSync(instructionsPath);
1591
+ refuseManagedMarkers(bytes.toString('utf8'), rel(instructionsPath));
1592
+ entries.set('instructions.md', { sources: [{ path: rel(instructionsPath), hash: sha256(bytes) }], bytes });
1593
+ }
1594
+
1529
1595
  // ---- unresolved references are compile errors (an agent's declared skills)
1530
1596
  const skillIds = new Set([...entries.keys()].filter((k) => k.startsWith('skills/')).map((k) => k.split('/')[1]));
1531
1597
  for (const [rt, e] of entries) {
@@ -1877,6 +1943,14 @@ export function staleness(root) {
1877
1943
  }
1878
1944
  }
1879
1945
  }
1946
+ // ⚠ `dreamteamer.md` is a compile source that is NOT under a KIND directory, so the walk above
1947
+ // cannot reach it — and its CREATION is the one moment that matters most: day one in an adopting
1948
+ // workspace, when no harness file carries an instructions block yet. Every later EDIT was already
1949
+ // caught by the manifest-source walk at the top of this function; only the first write was silent,
1950
+ // and it reported `.dreamteamer is fresh` while the rules reached no agent at all.
1951
+ if (fs.existsSync(path.join(root, INSTRUCTIONS_SOURCE)) && !known.has(INSTRUCTIONS_SOURCE)) {
1952
+ stale.push(`${INSTRUCTIONS_SOURCE} (new, uncompiled)`);
1953
+ }
1880
1954
  return { compiled: true, stale, manifest };
1881
1955
  }
1882
1956
 
@@ -1965,6 +2039,42 @@ function descriptorAjv() {
1965
2039
  return _descriptorAjv;
1966
2040
  }
1967
2041
 
2042
+ // ⚠ A MANAGED MARKER INSIDE `dreamteamer.md` IS A REFUSAL, not something to escape around.
2043
+ // The file is rendered VERBATIM into a managed block, and `writeBlock` finds that block by the FIRST
2044
+ // occurrence of its begin marker anywhere in the file — so a marker quoted inside the rendered text
2045
+ // is found before the real delimiter. Both directions were measured on a fixture:
2046
+ //
2047
+ // - quoting the ORIENTATION pair: the orientation pass rewrites the quoted region, the instructions
2048
+ // pass that runs immediately after restores it from source, and the real orientation block is
2049
+ // never touched again. It silently keeps describing the schema of the day it was written, while
2050
+ // `compile` exits 0 and `status` reports the runtime fresh.
2051
+ // - quoting the INSTRUCTIONS end marker: the block is closed at the quote and a second end line is
2052
+ // appended, so all three committed root files grow by ~40 bytes and one duplicated line per
2053
+ // compile, without ever reaching a fixed point.
2054
+ //
2055
+ // Escaping the markers on the way out is the alternative, and it is not one: the whole promise of
2056
+ // this file is that what was written is what every agent reads, and an escaped marker is not that.
2057
+ // A rule ABOUT the block describes it instead of quoting it.
2058
+ /** The one hand-written root source. Named once: `compile` reads it and `staleness` looks for it. */
2059
+ export const INSTRUCTIONS_SOURCE = 'dreamteamer.md';
2060
+
2061
+ const MANAGED_MARKERS = [
2062
+ ['the orientation block', BEGIN],
2063
+ ['the orientation block', END],
2064
+ ['the instructions block', INSTRUCTIONS_BEGIN],
2065
+ ['the instructions block', INSTRUCTIONS_END],
2066
+ ];
2067
+
2068
+ function refuseManagedMarkers(text, srcPath) {
2069
+ const lines = text.split('\n');
2070
+ for (const [i, line] of lines.entries()) {
2071
+ for (const [which, marker] of MANAGED_MARKERS) {
2072
+ if (!line.includes(marker)) continue;
2073
+ fail(`${srcPath}:${i + 1}: contains the managed marker ${marker}, which delimits ${which} in the harness files. This source is rendered verbatim into that block, so the quoted copy is found before the real delimiter and the block is rewritten around the wrong place. Describe the block instead of quoting its marker.`);
2074
+ }
2075
+ }
2076
+ }
2077
+
1968
2078
  function fail(msg) {
1969
2079
  throw new CompileError(`compile error: ${msg}`);
1970
2080
  }