dreamteamer 0.27.0 → 0.29.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/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
  }