dreamteamer 0.13.2 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
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>",
@@ -5,6 +5,7 @@
5
5
  // ref traversal (tier 1). serves `dreamteamer commands for`, GET /api/commands/:name,
6
6
  // and (through the extension's api.ts port) the studio's Commands tab.
7
7
  import { matchesFilter } from './filter.js';
8
+ import { parseRef } from './namespace.js';
8
9
 
9
10
  // memoized `<collection>/<id>` → parsed fields (or null) for ONE evaluation pass:
10
11
  // overlapping refs across a 50-record selection parse once, and filter.js stays
@@ -14,11 +15,20 @@ export function recordResolver(store) {
14
15
  return (ref) => {
15
16
  if (memo.has(ref)) return memo.get(ref);
16
17
  let target = null;
17
- const slash = typeof ref === 'string' ? ref.indexOf('/') : -1;
18
- if (slash > 0) {
18
+ // `parseRef`, NEVER `ref.indexOf('/')`. This split at the first slash until 0.13.3, which
19
+ // made `family/people/gilad` the collection `family` (a NAMESPACE, not a collection) holding
20
+ // the id `people/gilad`. `store.read` threw, the catch below swallowed it, and the caller got
21
+ // null — which filter.js is documented to treat as NARROWING. So every one-hop relational
22
+ // filter over a namespaced collection matched ZERO records, with no error anywhere, while the
23
+ // identical filter over a default-namespace ref worked. Measured on a real vault: 151 rows via
24
+ // `companies/<id>`, 0 rows via `family/people/<id>`, 273 for the same records addressed flat.
25
+ // The blast radius was not only filtering — this resolver also evaluates `can-enter`/`can-exit`
26
+ // below, so a binding predicate hopping a namespaced ref reported "not available".
27
+ const parsed = parseRef(ref, store.namespaces);
28
+ if (parsed) {
19
29
  try {
20
- const { fields } = store.read(ref.slice(0, slash), ref.slice(slash + 1));
21
- target = { ...fields, id: ref.slice(slash + 1) };
30
+ const { fields } = store.read(parsed.collection, parsed.id);
31
+ target = { ...fields, id: parsed.id };
22
32
  } catch { /* dangling ref or unknown collection — narrows, never widens */ }
23
33
  }
24
34
  memo.set(ref, target);
package/src/schema-ops.js CHANGED
@@ -597,23 +597,95 @@ function upsertField(ws, store, collection, fieldName, prop, required, verb) {
597
597
  return { collection, field: fieldName, file: dest, extends: doc.extends };
598
598
  }
599
599
 
600
+ /**
601
+ * WHERE A UI-VIEW'S SOURCE ACTUALLY LIVES — asked of the manifest, exactly as `descriptorSourceDir`
602
+ * asks it for a collection, and for the same reason: the guard that matters is "will `npm install`
603
+ * erase this write", not "which module owns it".
604
+ *
605
+ * ⚠ This used to be `workspaceSystemDir` unconditionally, which silently meant a view could only be
606
+ * saved if the WORKSPACE MODULE happened to ship it. Saving one shipped by any other inline module
607
+ * wrote a SECOND file carrying the same id, and compile refuses that by name — so the whole write
608
+ * rolled back and the surface reported `name collision on ui-view "…"` instead of saving. Measured
609
+ * on gk-brain 2026-08-28: every one of its module-shipped views (`modules/family`, `modules/rnd`,
610
+ * `modules/services`) was unsaveable, and the failure said nothing about why.
611
+ *
612
+ * Returns `{ file, shipped }` — where to write, and the workspace-relative source that already
613
+ * exists (null for a new view, which lands in the workspace module as before).
614
+ */
615
+ function uiViewSourceFile(ws, id) {
616
+ const src = readManifest(ws.root)?.entries?.[`ui-views/${id}.ui-view.yaml`]?.sources?.[0];
617
+ // sources are `{path, hash}`; tolerate the pre-0.10 string form, same as compile's staleness check
618
+ const shipped = typeof src === 'string' ? src : src?.path;
619
+ if (!shipped) return { file: path.join(workspaceSystemDir(ws, 'ui-views'), `${id}.ui-view.yaml`), shipped: null };
620
+ return { file: path.join(ws.root, shipped), shipped };
621
+ }
622
+
623
+ /**
624
+ * Re-attach a rewritten YAML source's COMMENTS — the part `dump` cannot round-trip.
625
+ *
626
+ * js-yaml drops every comment on `load` → `dump`. That is fine for a generated artifact and wrong
627
+ * for a module SOURCE, which is where this project writes down why something exists (`setScalar`
628
+ * above exists for the same reason). A real round-trip needs a different YAML library and core is
629
+ * not taking one on for this, so this does the narrow thing that is actually safe: a comment block
630
+ * sitting directly above a TOP-LEVEL key is carried back above that same key, if the key survived.
631
+ * The file header comes along for free — it is the block above the first key.
632
+ *
633
+ * ⚠ Deliberately top-level only. A comment above a NESTED key cannot be re-placed without knowing
634
+ * where that key ended up, and a misplaced comment is worse than an absent one: it would attach an
635
+ * explanation to something it does not explain. Those are still lost. Measured against
636
+ * `modules/family/ui-views/health-labs-abnormal.ui-view.yaml`, whose two blocks — the file header
637
+ * and the ⚠ above `filter:` — are both top-level and both survive.
638
+ */
639
+ function reattachComments(oldText, newText) {
640
+ const TOP_KEY = /^([A-Za-z_][\w-]*):/;
641
+ const blocks = new Map(); // surviving key -> the comment lines that sat above it
642
+ let pending = [];
643
+ for (const line of oldText.split('\n')) {
644
+ if (line.startsWith('#') || line.trim() === '') { pending.push(line); continue; }
645
+ const key = TOP_KEY.exec(line)?.[1];
646
+ if (key && pending.some((l) => l.startsWith('#'))) {
647
+ while (pending.length && pending[pending.length - 1].trim() === '') pending.pop();
648
+ blocks.set(key, pending);
649
+ }
650
+ pending = [];
651
+ }
652
+ if (!blocks.size) return newText;
653
+
654
+ const out = [];
655
+ for (const line of newText.split('\n')) {
656
+ const block = blocks.get(TOP_KEY.exec(line)?.[1]);
657
+ if (block) out.push(...block);
658
+ out.push(line);
659
+ }
660
+ return out.join('\n');
661
+ }
662
+
600
663
  // saved views (M3): a studio-saved view IS a ui-view record — but ui-views are
601
664
  // system-stored (sources + compile), so the write goes through the same gate as any
602
665
  // other schema op. the studio "save view" button lands here.
603
666
  export function saveUiView(ws, store, { id, view }) {
604
667
  if (!id || !/^[a-z0-9][a-z0-9-/]*$/.test(id)) throw new Error(`invalid ui-view id "${id}" — lowercase slug required`);
605
- const dest = path.join(workspaceSystemDir(ws, 'ui-views'), `${id}.ui-view.yaml`);
668
+ const { file: dest, shipped } = uiViewSourceFile(ws, id);
669
+ if (shipped && /(^|\/)node_modules\//.test(shipped))
670
+ throw new Error(`ui-view "${id}" is shipped by an installed package (${shipped}) — a write there is erased by the next npm install.\n save it under a different name, or disable it (dreamteamer.disable) and re-create it.`);
606
671
  const existed = fs.existsSync(dest);
672
+ // A module source is where this project writes down WHY a view exists; `dump` cannot keep that.
673
+ const previous = existed ? fs.readFileSync(dest, 'utf8') : null;
607
674
  writeGated(ws, store, [dest], `dreamteamer: ui-views ${existed ? 'update' : 'add'} ${id}`, () => {
608
675
  fs.mkdirSync(path.dirname(dest), { recursive: true });
609
- fs.writeFileSync(dest, dump(view));
676
+ fs.writeFileSync(dest, previous === null ? dump(view) : reattachComments(previous, dump(view)));
610
677
  });
611
678
  return { id, file: dest, updated: existed };
612
679
  }
613
680
 
614
681
  export function removeUiView(ws, store, id) {
615
- const dest = path.join(workspaceSystemDir(ws, 'ui-views'), `${id}.ui-view.yaml`);
616
- if (!fs.existsSync(dest)) throw new Error(`ui-view "${id}" is not workspace-owned (module-shipped views are removed via dreamteamer.disable)`);
682
+ // Same source resolution as the save above — an inline module's view is under this repo's git
683
+ // history like everything else, so deleting it is one revertable commit. Refusing it while
684
+ // ALLOWING a save to the same file would be an asymmetry with nothing behind it.
685
+ const { file: dest, shipped } = uiViewSourceFile(ws, id);
686
+ if (shipped && /(^|\/)node_modules\//.test(shipped))
687
+ throw new Error(`ui-view "${id}" is shipped by an installed package (${shipped}) — removing the file would be undone by the next npm install.\n disable it instead: add "<module>/${id}" to dreamteamer.disable in package.json.`);
688
+ if (!fs.existsSync(dest)) throw new Error(`ui-view "${id}" does not exist`);
617
689
  writeGated(ws, store, [dest], `dreamteamer: ui-views rm ${id}`, () => fs.rmSync(dest));
618
690
  return { removed: id };
619
691
  }