dreamteamer 0.20.0 → 0.22.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
@@ -1,6 +1,7 @@
1
1
  // dreamteamer compile — materialize (modules × workspace sources) into .dreamteamer,
2
2
  // the single runtime read surface: copies + provenance manifest, then harness adapters.
3
3
  // explicit only; nothing rebuilds implicitly.
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { createHash } from 'node:crypto';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
@@ -18,7 +19,13 @@ import {
18
19
  import { runHarnessAdapters } from './harnesses.js';
19
20
  import { satisfies } from './semver.js';
20
21
  import { parseEnvValues } from './env-vars.js';
21
- import { DERIVED_KINDS, readManifest, runtimeDir } from './runtime.js';
22
+ import { DERIVED_KINDS, readManifest, runtimeDir, engineId, engineVersion } from './runtime.js';
23
+ // ⚠ RE-EXPORTED, NOT RE-IMPLEMENTED. `engineVersion` moved to the boundary layer so `prove` can
24
+ // stamp a ledger row without importing the compiler (that edge was a real, if latent, cycle).
25
+ // Every existing caller spells it `from './compile.js'`, and a second reader of the engine's own
26
+ // package.json is exactly the drift this file's comments keep naming.
27
+ export { engineId, engineVersion };
28
+ import { artifactRefs, proofPathFor, validateProofShape, stepWarnings } from './prove.js';
22
29
 
23
30
  // re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
24
31
  // contract as `compileMod.readManifest` (engine.ts), and a removed export is the same cross-repo
@@ -382,8 +389,23 @@ function stampMirror(byName, ctx, ownerName, field, prop, holder, mirrorName, ta
382
389
  t.schema.properties = { ...t.schema.properties, [mirrorName]: generated };
383
390
  }
384
391
 
385
- export const KINDS = ['collections', 'skills', 'agents', 'commands', 'command-bindings', 'ui-views', 'collection-templates'];
392
+ export const KINDS = ['collections', 'skills', 'agents', 'commands', 'command-bindings', 'ui-views', 'collection-templates', 'proofs'];
386
393
  const FOLDER_KINDS = new Set(['skills']); // folder-shape entities: copy the whole record folder
394
+
395
+ /**
396
+ * ⚠ `proofs/fixtures/` IS RECORDS, NOT PROOFS. It holds the store a `writes` proof runs against —
397
+ * laid onto a throwaway worktree by `dt prove`, mirroring the workspace root — so it is not a
398
+ * compiled source at all.
399
+ *
400
+ * ONE predicate, because BOTH enumerations of a kind directory have to agree about it and they are
401
+ * 900 lines apart. The stager alone made compile read `data/notes/x.note.md` as a proof and refuse
402
+ * the whole module; the staleness scan alone then reported every fixture file "(new, uncompiled)"
403
+ * on every single command, for ever, with no compile able to clear it.
404
+ *
405
+ * @param {string} kind the source kind being enumerated
406
+ * @param {string} relFromKind the entry's path RELATIVE to the kind directory, '/'-separated
407
+ */
408
+ const isProofFixture = (kind, relFromKind) => kind === 'proofs' && (relFromKind === 'fixtures' || relFromKind.startsWith('fixtures/'));
387
409
  // DERIVED_KINDS (projected, not staged) lives in runtime.js — the boundary both halves read. Not in
388
410
  // KINDS on purpose: a module folder named `modules/` would be nonsense, and `isSystem` below keys
389
411
  // off KINDS to decide `storage.base`, so a `modules` collection landing on `base: workspace` would
@@ -477,6 +499,11 @@ function strayKindDirs(source, wsRoot, declaredIgnore) {
477
499
 
478
500
  const sha256 = (buf) => 'sha256:' + createHash('sha256').update(buf).digest('hex');
479
501
 
502
+ /** Is this workspace-relative source path inside an installed package? A nudge to write a file
503
+ * there would name a path the next `npm install` erases. (schema-ops has the same one-liner for the
504
+ * same reason; importing it back here would close a cycle for one regex.) */
505
+ const inNodeModules = (p) => /(^|[\\/])node_modules([\\/]|$)/.test(String(p));
506
+
480
507
  // channel -> the directory the operator knows it by (used in shadow warnings, and it IS the
481
508
  // `location` field's vocabulary — see collections/modules.collection.yaml. Keeping the export name
482
509
  // on purpose: renaming a symbol under src/ is a cross-repo change, and this one buys nothing.)
@@ -636,6 +663,7 @@ export function compile({ root, pkg }) {
636
663
  const moduleDeps = new Map(); // module name -> [module names] — HARD, must be acyclic
637
664
  const modulePeers = new Map(); // module name -> [collection names] — SOFT, cannot cycle
638
665
  const moduleNamespaces = new Map(); // module name -> [namespaces it DECLARES] (§8, option A)
666
+ const moduleLocalAssets = []; // {rel, owner, base} — validated with the workspace's own, below
639
667
  for (const source of sources) {
640
668
  let mpkg;
641
669
  try { mpkg = JSON.parse(fs.readFileSync(path.join(source.root, 'package.json'), 'utf8')); } catch { continue; }
@@ -675,6 +703,13 @@ export function compile({ root, pkg }) {
675
703
  if (!declaredEnv.has(k)) declaredEnv.set(k, []);
676
704
  declaredEnv.get(k).push(source.name);
677
705
  }
706
+ // Gathered here because mpkg is already parsed; refused below, next to the workspace's own
707
+ // declaration. The classic layout pushes the ROOT itself as an inline source, whose
708
+ // package.json IS `config` — reading it here too would report every workspace-level
709
+ // declaration twice, under the wrong owner.
710
+ if (source.root !== root) {
711
+ for (const rel of mpkg.dreamteamer?.['local-assets'] ?? []) moduleLocalAssets.push({ rel, owner: source.name, base: source.root });
712
+ }
678
713
  }
679
714
  // `dreamteamer.vars` is the WORKSPACE's own declaration (root package.json, not a module's): the
680
715
  // keys a `${env:NAME}` template is allowed to name. Same missing-key question as
@@ -714,6 +749,33 @@ export function compile({ root, pkg }) {
714
749
  }
715
750
  }
716
751
 
752
+ // ---- local-assets and postinstall: what `dt install` will do to a checkout -------
753
+ // `local-assets` are the gitignored heavy folders a checkout SHARES by symlink instead of
754
+ // duplicating — a browser profile dir, a model cache. Declared, never discovered. Every
755
+ // refusal below is something the installer would otherwise hit at RUN time, on a machine nobody
756
+ // is watching; the compiler is where a declaration is cheap to fix. (`src/checkout.js` refuses
757
+ // the escaping rel a second time when the plan is built — a runtime that writes a symlink
758
+ // outside the root must not wait for the compiler to be run.)
759
+ const ENGINE_OWNED = new Set(['.env', 'node_modules', '.dreamteamer', '.git']);
760
+ const gitQ = (args) => { try { execFileSync('git', args, { cwd: root, stdio: 'ignore' }); return true; } catch { return false; } };
761
+ for (const a of [...(config['local-assets'] ?? []).map((rel) => ({ rel, owner: 'workspace', base: root })), ...moduleLocalAssets]) {
762
+ const abs = path.resolve(a.base, a.rel), rel = path.relative(root, abs);
763
+ if (a.owner !== 'workspace' && path.relative(a.base, abs).startsWith('..')) fail(`local-assets: "${a.rel}" (module ${a.owner}) escapes its module with .. — declare it at the workspace level instead`);
764
+ // The workspace-level twin, and it earns its own line: a rel that climbs out of the ROOT
765
+ // otherwise reached the gitignore check below, which cannot see a path outside the repo and
766
+ // so told the operator to ignore something git will never match. `checkout.js` refuses the
767
+ // same shape when the plan is built.
768
+ if (rel.startsWith('..')) fail(`local-assets: "${a.rel}" escapes the workspace root with .. — a local asset must be a path INSIDE the workspace`);
769
+ if (ENGINE_OWNED.has(rel.split(path.sep)[0])) fail(`local-assets: "${rel}" is engine-owned — install links .env and installs node_modules itself; never declare them`);
770
+ if (gitQ(['ls-files', '--error-unmatch', rel])) fail(`local-assets: "${rel}" is tracked — a tracked path needs no link and a link would shadow it`);
771
+ // ⚠ WITHOUT a trailing slash, and the message has to say so: a dir-only pattern (`.profiles/`)
772
+ // matches neither a symlink nor a path that does not exist yet (measured, git 2.50) — and a
773
+ // shared asset is exactly those two shapes, a symlink in every non-primary checkout and
774
+ // absent before the first install.
775
+ if (!gitQ(['check-ignore', '-q', rel])) fail(`local-assets: "${rel}" is not gitignored — add it to .gitignore WITHOUT a trailing slash (a worktree holds it as a symlink, and a dir-only pattern ignores neither a symlink nor an absent path); a shared asset must never be committed`);
776
+ }
777
+ if (config.postinstall != null && typeof config.postinstall !== 'string') fail('dreamteamer.postinstall must be a single shell string');
778
+
717
779
  // ---- the module dependency graph -------------------------------------------------
718
780
  // `dependencies` names MODULES and must be acyclic. `peerDependencies` names COLLECTIONS and
719
781
  // therefore cannot cycle at all — which is the whole reason it exists: two modules that each
@@ -808,6 +870,7 @@ export function compile({ root, pkg }) {
808
870
  : fs.readdirSync(srcDir).sort();
809
871
  for (const name of names) {
810
872
  if (name.startsWith('.')) continue;
873
+ if (isProofFixture(kind, name)) continue;
811
874
  const entityId = name.replace(/\.[^.]+\.(yaml|md|json)$/, '');
812
875
  if (disabled.has(`${source.name}/${entityId}`)) { disabledHits.add(`${source.name}/${entityId}`); continue; }
813
876
  const srcPath = path.join(srcDir, name);
@@ -899,7 +962,7 @@ export function compile({ root, pkg }) {
899
962
  // NOTHING. Warn; do not fail, since a module that is temporarily source-free is the
900
963
  // operator's business, not the compiler's. Runs AFTER UI staging so a UI-only module counts.
901
964
  // ⚠ A module with a scaffolded-but-EMPTY kind folder is a module being AUTHORED, not a mistake.
902
- // `add modules` creates exactly that shape — seven empty kind folders — and a verb whose own
965
+ // `add modules` creates exactly that shape — eight empty kind folders — and a verb whose own
903
966
  // output triggers a warning reads as a broken install. The warning's remaining job is the case it
904
967
  // was written for: a module that ships nothing the engine recognises AT ALL, which is what
905
968
  // decision 156 cost two days. `kindDir` returns the flat path when neither layout exists, so this
@@ -1313,7 +1376,26 @@ export function compile({ root, pkg }) {
1313
1376
  // generated mirror (which never carries x-unique) cannot trip it.
1314
1377
  const h = (prop.items && typeof prop.items === 'object') ? prop.items : prop;
1315
1378
  if (h['x-unique'] === true && h['x-inverse'] === undefined && h['x-inverse-of'] === undefined) {
1316
- console.warn(`⚠ collection ${name}: x-unique on "${fieldName}" is inert — it is a RELATION keyword, enforced only while the store maintains a mirror, and this field declares no x-inverse. Nothing constrains the value. Declare the relation (dreamteamer update-field ${name} --name ${fieldName} --inverse) or drop x-unique.`);
1379
+ console.warn(`⚠ collection ${name}: x-unique on "${fieldName}" is inert — it is a RELATION keyword, enforced only while the store maintains a mirror, and this field declares no x-inverse. Nothing constrains the value. Declare the relation (dreamteamer set-field ${name} --name ${fieldName} --inverse) or drop x-unique.`);
1380
+ }
1381
+ // `x-choices` decorates ENUM VALUES (presentation.js#choiceRow, 0.21.0), and both ways of
1382
+ // getting it wrong are SILENT: a key that is not a value decorates nothing, and the keyword
1383
+ // on a non-enum field is read by no one at all. Either way the author sees no error and no
1384
+ // decoration — on a surface they are probably not looking at while editing the descriptor.
1385
+ // ⚠ WARNINGS, not failures, for the same reason as x-unique directly above: neither breaks
1386
+ // anything today, and a descriptor mid-edit must stay compilable.
1387
+ const choices = h['x-choices'];
1388
+ if (choices && typeof choices === 'object' && !Array.isArray(choices)) {
1389
+ const values = Array.isArray(h.enum) ? h.enum.map(String) : null;
1390
+ if (!values) {
1391
+ console.warn(`⚠ collection ${name}: x-choices on "${fieldName}" is inert — it decorates the values of an enum, and this field declares no enum. Nothing reads it.`);
1392
+ } else {
1393
+ // Named per offending key rather than "some keys are wrong", and the legal values are
1394
+ // quoted so the fix needs no second lookup — the same shape as every other warning here.
1395
+ for (const k of Object.keys(choices)) {
1396
+ if (!values.includes(k)) console.warn(`⚠ collection ${name}: x-choices on "${fieldName}" has an entry for "${k}", which is not one of its enum values (${values.join(', ')}) — it decorates nothing. Fix the spelling, or drop the entry.`);
1397
+ }
1398
+ }
1317
1399
  }
1318
1400
  }
1319
1401
  const rt = path.join('collections', `${name}.collection.yaml`);
@@ -1514,6 +1596,46 @@ export function compile({ root, pkg }) {
1514
1596
  }
1515
1597
  }
1516
1598
 
1599
+ // ---- proof validation --------------------------------------------------------------
1600
+ // A proof declares behaviour the ENGINE judges, so every key in one is a value the engine
1601
+ // interprets — which by the rule stated above the ui-view block makes all of them compile's to
1602
+ // validate. The two silent failures this refuses, both measured in the spike:
1603
+ // `about: skills/greter` — names no artifact, so the proof proves nothing and says so nowhere.
1604
+ // `where: { statuz: … }` — an unknown key fails CLOSED in `matchesFilter`, so a `count`
1605
+ // expectation over it passes or fails FOREVER for a reason no output names.
1606
+ // The judgement itself lives in `prove.js` (unit-tested against a hand-built descriptor set);
1607
+ // this block is the wiring, and it is deliberately paid for only when a proof exists.
1608
+ //
1609
+ // ⚠ `artifactRefs` is computed unconditionally, because the coverage line below prints its
1610
+ // denominators on EVERY compile — a workspace with no proofs still gets told what it has.
1611
+ const proofArtifacts = artifactRefs(entries);
1612
+ const proofEntries = [...entries].filter(([rt]) => rt.startsWith('proofs/'));
1613
+ /** every artifact ref named by at least one proof — the coverage numerators, and the nudge's silencer */
1614
+ const provenRefs = new Set();
1615
+ if (proofEntries.length) {
1616
+ // the MERGED descriptors, read back out of the entries this compile is about to write —
1617
+ // the same source of truth the ui-view block reads its own field list from, so a proof is
1618
+ // judged against the schema the workspace will actually run on (`extends` applied).
1619
+ const merged = new Map();
1620
+ for (const [rt, e] of entries) {
1621
+ if (!rt.startsWith('collections/')) continue;
1622
+ const doc = load(e.bytes.toString('utf8'));
1623
+ if (doc?.name) merged.set(doc.name, doc);
1624
+ }
1625
+ const proofCtx = { descriptors: merged, declaredVars, moduleEnv: new Set(declaredEnv.keys()), artifacts: proofArtifacts.all };
1626
+ for (const [rt, e] of proofEntries) {
1627
+ const proof = loadSource(e.bytes.toString('utf8'), e.sources[0].path);
1628
+ const errs = validateProofShape(proof, proofCtx);
1629
+ if (errs.length) fail(errs.map((m) => `${rt}: ${m}`).join('\n '));
1630
+ // ⚠ WARNINGS, not errors (R17). A step is a shell string, so a brace nobody substitutes is
1631
+ // as likely to be `awk '{print}'` as a typo'd `{recrod}` — and `substitute` refusing both
1632
+ // at run time was measured to kill four correct steps. Naming the token here is the whole
1633
+ // net that remains: it costs a line, and the proof still compiles and still runs.
1634
+ for (const w of stepWarnings(proof)) console.warn(`⚠ ${rt}: ${w}`);
1635
+ for (const ref of proof?.about ?? []) provenRefs.add(String(ref));
1636
+ }
1637
+ }
1638
+
1517
1639
  // ---- materialize .dreamteamer ------------------------------------------------
1518
1640
  // mkdir the runtime ROOT unconditionally: with zero entries nothing below created it, so the
1519
1641
  // manifest write at the end failed ENOENT — `init` followed by `compile` in a fresh workspace
@@ -1596,6 +1718,69 @@ export function compile({ root, pkg }) {
1596
1718
  : `${sources.length - 1} module(s) + workspace`;
1597
1719
  console.log(`✔ compiled ${summary || 'nothing'} from ${sourceLabel} → .dreamteamer`);
1598
1720
  for (const line of harnessSummary) console.log(`✔ harness ${line}`);
1721
+
1722
+ // ---- proof coverage, on EVERY compile ----------------------------------------
1723
+ // One line, unconditional, even at zero: coverage that is only reported when someone asks is
1724
+ // coverage nobody knows the number of. The denominators are what this compile actually produced
1725
+ // (`artifactRefs`, the one enumeration `prove --missing` also reads), the numerators what a
1726
+ // proof names.
1727
+ const covered = (list) => `${list.filter((a) => provenRefs.has(a)).length}/${list.length}`;
1728
+ console.log(`proofs: ${proofEntries.length} declared · commands ${covered(proofArtifacts.commands)} · skills ${covered(proofArtifacts.skills)} · scripts ${covered(proofArtifacts.scripts)} · bindings ${covered(proofArtifacts.bindings)}`);
1729
+
1730
+ // ---- the nudge: ONCE, per NEW command or script with no proof -----------------
1731
+ // module id → the module's workspace-relative ROOT, exactly the value the module projection
1732
+ // stores in `path`. Sorted longest-first so a nested module wins over its parent, and the root
1733
+ // layout's `.` (the empty prefix) matches last.
1734
+ const moduleRoots = sources.map((s) => ({ id: moduleId(s.name), root: rel(s.root) || '.' }))
1735
+ .sort((a, b) => b.root.length - a.root.length);
1736
+ const ownerRoot = (srcPath) => moduleRoots.find(({ root: r }) => r === '.' || srcPath === r || srcPath.startsWith(`${r}/`))?.root ?? '.';
1737
+ // ⚠ NEW, not merely uncovered. A workspace adopting proofs has forty-odd artifacts and none of
1738
+ // them proven; forty-four warnings on day one is the noise that teaches an operator to skip
1739
+ // every line this compile prints. So the nudge fires only for an artifact absent from the
1740
+ // PREVIOUS manifest, and a first-ever compile (no previous manifest at all) nudges nothing.
1741
+ //
1742
+ // Commands and scripts only — they are the artifacts that RUN, and a skill gets its nudge at the
1743
+ // moment `dt add skills` writes it. An artifact shipped from node_modules is skipped: a proof
1744
+ // written there is erased by the next `npm install`.
1745
+ //
1746
+ // ⚠ A KNOWN GAP, stated rather than hidden: a script added to an EXISTING module does not nudge.
1747
+ // The manifest records a module record's SOURCE hash (its package.json), and adding a file under
1748
+ // `bin/` changes neither the entry key nor that hash, so there is nothing to compare. The
1749
+ // coverage line still counts it.
1750
+ if (prevManifest?.entries) {
1751
+ for (const ref of [...proofArtifacts.commands, ...proofArtifacts.scripts]) {
1752
+ if (provenRefs.has(ref)) continue;
1753
+ const known = artifactSource(entries, ref);
1754
+ if (!known || known.new === false || inNodeModules(known.source)) continue;
1755
+ console.log(`no proof yet for ${ref} — ${proofPathFor(ref, known.moduleRoot)} (see using-dreamteamer › proofs)`);
1756
+ }
1757
+ }
1758
+
1759
+ /** Where an artifact's source lives, and whether the PREVIOUS manifest already knew it.
1760
+ *
1761
+ * ⚠ THE MODULE ROOT COMES OFF THE SOURCE, NEVER OFF THE FILE PATH. Slicing it out of the
1762
+ * artifact's source path (`lastIndexOf('/commands/')`, `replace(/\/package\.json$/)`) is
1763
+ * correct only in the `workspace-module` layout. In the ROOT layout (no `workspace-module`, see
1764
+ * :619) a command's source is `commands/hello.command.md` — no module segment at all — so the
1765
+ * slice returned -1 and the nudge named `commands/hello.command.m/proofs/…`, and a module
1766
+ * script named `package.json/proofs/…`. `rel(source.root)` is the same value the module record's
1767
+ * `path` field carries (see the projection near :1459), which is the value that is always right. */
1768
+ function artifactSource(all, ref) {
1769
+ if (ref.startsWith('commands/')) {
1770
+ const key = `${ref}.command.md`;
1771
+ const source = all.get(key)?.sources?.[0]?.path ?? '';
1772
+ if (!source) return null;
1773
+ return { source, moduleRoot: ownerRoot(source), new: !(key in prevManifest.entries) };
1774
+ }
1775
+ // a module script: `<module-id>/bin/<file>` — the id is in the ref, so the root is a lookup
1776
+ // rather than a guess. Its "entry" is the module record compile projected, so a whole NEW
1777
+ // module's scripts nudge and an existing module's do not (above).
1778
+ const mod = ref.slice(0, ref.indexOf('/'));
1779
+ const key = `modules/${mod}.module.yaml`;
1780
+ const entry = all.get(key);
1781
+ if (!entry) return null;
1782
+ return { source: entry.sources?.[0]?.path ?? '', moduleRoot: moduleRoots.find((m) => m.id === mod)?.root ?? '.', new: !(key in prevManifest.entries) };
1783
+ }
1599
1784
  return 0;
1600
1785
  }
1601
1786
 
@@ -1606,17 +1791,6 @@ function prevManifestNamespaces(root) {
1606
1791
  return normalizeNamespaces(readManifest(root)?.namespaces);
1607
1792
  }
1608
1793
 
1609
- function engineId() {
1610
- try {
1611
- const p = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
1612
- return `${p.name}@${p.version}`;
1613
- } catch { return 'dreamteamer@unknown'; }
1614
- }
1615
-
1616
- // bare version of the RUNNING engine (dev clone or installed copy — whichever loaded)
1617
- export function engineVersion() {
1618
- return engineId().split('@').pop();
1619
- }
1620
1794
 
1621
1795
  // staleness: does any manifest entry's SOURCE differ from what was compiled, or is a
1622
1796
  // source file missing/new? used by `status` and warned about at every tool entry.
@@ -1646,6 +1820,7 @@ export function staleness(root) {
1646
1820
  const dir = kindDir(r, kind);
1647
1821
  if (!fs.existsSync(dir)) continue;
1648
1822
  for (const f of walk(dir)) {
1823
+ if (isProofFixture(kind, path.relative(dir, f).split(path.sep).join('/'))) continue;
1649
1824
  const relPath = path.relative(root, f);
1650
1825
  if (!known.has(relPath)) stale.push(`${relPath} (new, uncompiled)`);
1651
1826
  }