dreamteamer 0.22.0 → 0.24.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
@@ -111,6 +111,19 @@ node_modules/<name>/ # published package
111
111
  Precedence runs top to bottom, so a local copy shadows a published one — which is how you develop a
112
112
  module and use it in the same workspace at the same time.
113
113
 
114
+ A published package or a git clone may carry **several** modules: when its root has a `modules/`
115
+ folder, each `modules/<name>/` with a `dreamteamer` key in its `package.json` is a module on that
116
+ channel, and the root itself is never compiled. One `npm install` then delivers a whole family, and the
117
+ workspace keeps what it wants — a bare module name in `dreamteamer.disable` drops a module before
118
+ compile looks at it (an entry with a slash, `<module>/<entity>`, still disables one entity):
119
+
120
+ ```json
121
+ "dreamteamer": { "disable": ["recordings", "introspection"] }
122
+ ```
123
+
124
+ Disabling a module another one declares in `dependencies` is refused, naming what is present. The
125
+ workspace's own `modules/*` never nest.
126
+
114
127
  Sources live **flat at a module root** — `modules/crm/skills/`, beside `package.json` — and a folder
115
128
  at a module root that isn't a known kind is a compile error rather than a silent skip.
116
129
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.22.0",
4
- "description": "A workspace compiler for coding agents \u2014 schema-validated records as plain files over git, compiled into every harness",
3
+ "version": "0.24.0",
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>",
7
7
  "homepage": "https://github.com/dreamteamer/dreamteamer#readme",
package/src/cli.js CHANGED
@@ -165,6 +165,7 @@ Every verb that MOVES records or CLEARS values takes --dry-run and prints its pl
165
165
 
166
166
  workspace verbs:
167
167
  init write the workspace skeleton into the current directory (never compiles)
168
+ [--harnesses claude-code,codex,pi,gemini-cli,cursor,notebooklm]
168
169
  --version print the engine version (works anywhere)
169
170
  install make THIS checkout ready — the engine, .env (linked from the primary when this is a
170
171
  worktree), declared local assets, git modules, compile, and a declared postinstall.
package/src/compile.js CHANGED
@@ -523,33 +523,51 @@ export const locationOf = (source, wsRoot) =>
523
523
  export function discoverModules(root, pkg) {
524
524
  const byName = new Map(); // name -> {name, root, channel}
525
525
  const shadows = []; // {name, winner, loser} — channels
526
+ // A BARE `dreamteamer.disable` entry names a whole module; `<module>/<entity>` names one entity and
527
+ // is applied per source at compile time. The bare form is what lets a workspace take a PACKAGE of
528
+ // modules and keep only the ones it wants — a disabled module is simply never discovered, so every
529
+ // caller (compile, status, install) sees the same set.
530
+ const disabledModules = new Set((pkg?.dreamteamer?.disable ?? []).filter((d) => typeof d === 'string' && !d.includes('/')));
531
+ const disabledHits = new Set();
526
532
  const tryAdd = (name, srcRoot, channel) => {
533
+ if (disabledModules.has(name)) { disabledHits.add(name); return; }
527
534
  const existing = byName.get(name);
528
535
  if (existing) { shadows.push({ name, winner: existing.channel, loser: channel }); return; }
529
536
  byName.set(name, { name, root: srcRoot, channel });
530
537
  };
531
- const scanDir = (dir, channel) => {
538
+ const readPkg = (dir) => {
539
+ try {
540
+ const mpkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
541
+ return 'dreamteamer' in mpkg ? mpkg : null;
542
+ } catch { return null; } // no package.json, or unparseable — not a module
543
+ };
544
+ // A PACKAGE OF MODULES: a dependency or a git clone whose root carries `modules/` bundles several
545
+ // modules — its sub-modules are the modules, and the root itself is never compiled. One
546
+ // `npm install` (or one clone) then delivers a whole family, and `disable` cherry-picks from it.
547
+ // Inline `modules/*` never nest: a `modules/` folder at an inline module root stays the
548
+ // unknown-folder compile error it always was, because the workspace's own tree has no reason to
549
+ // bundle.
550
+ const scanDir = (dir, channel, unpack) => {
532
551
  if (!fs.existsSync(dir)) return;
533
552
  for (const name of fs.readdirSync(dir).sort()) {
534
553
  const srcRoot = path.join(dir, name);
535
- const pkgPath = path.join(srcRoot, 'package.json');
536
- if (!fs.existsSync(pkgPath)) continue;
537
- try {
538
- const mpkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
539
- if ('dreamteamer' in mpkg) tryAdd(mpkg.name ?? name, srcRoot, channel);
540
- } catch { /* unparseable package.json — skip */ }
554
+ const mpkg = readPkg(srcRoot);
555
+ if (mpkg) (unpack ? tryAddOrUnpack : tryAdd)(mpkg.name ?? name, srcRoot, channel);
541
556
  }
542
557
  };
543
- scanDir(path.join(root, 'modules'), 'inline');
544
- scanDir(path.join(root, 'git_modules'), 'git');
558
+ const tryAddOrUnpack = (name, srcRoot, channel) => {
559
+ const bundle = path.join(srcRoot, 'modules');
560
+ if (fs.existsSync(bundle) && fs.statSync(bundle).isDirectory()) { scanDir(bundle, channel, false); return; }
561
+ tryAdd(name, srcRoot, channel);
562
+ };
563
+ scanDir(path.join(root, 'modules'), 'inline', false);
564
+ scanDir(path.join(root, 'git_modules'), 'git', true);
545
565
  for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).sort()) {
546
566
  const srcRoot = path.join(root, 'node_modules', dep);
547
- try {
548
- const mpkg = JSON.parse(fs.readFileSync(path.join(srcRoot, 'package.json'), 'utf8'));
549
- if ('dreamteamer' in mpkg) tryAdd(mpkg.name ?? dep, srcRoot, 'npm');
550
- } catch { /* dep not installed or no package.json — skip */ }
567
+ const mpkg = readPkg(srcRoot);
568
+ if (mpkg) tryAddOrUnpack(mpkg.name ?? dep, srcRoot, 'npm');
551
569
  }
552
- return { modules: [...byName.values()], shadows };
570
+ return { modules: [...byName.values()], shadows, disabledModules: [...disabledHits] };
553
571
  }
554
572
 
555
573
  // ---- module-owned data ----------------------------------------------------------
@@ -629,7 +647,7 @@ export function compile({ root, pkg }) {
629
647
  };
630
648
 
631
649
  // ---- discover sources: channel modules then the workspace's own -----------------
632
- const { modules: discovered, shadows } = discoverModules(root, pkg);
650
+ const { modules: discovered, shadows, disabledModules } = discoverModules(root, pkg);
633
651
  for (const s of shadows) console.warn(shadowWarning(s));
634
652
  const sources = [...discovered];
635
653
  // workspace-owned sources: either at the root (classic layout) or in the designated
@@ -809,7 +827,7 @@ export function compile({ root, pkg }) {
809
827
  const dataOwners = dataOwningModules(sources, fail, rel);
810
828
 
811
829
  const disabled = new Set(config.disable ?? []);
812
- const disabledHits = new Set();
830
+ const disabledHits = new Set(disabledModules); // bare entries were applied at discovery
813
831
 
814
832
  /** entries: runtime-relative path -> { sources: [workspace-relative], bytes } */
815
833
  const entries = new Map();
package/src/harnesses.js CHANGED
@@ -14,7 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { load, dump } from './yaml.js';
16
16
 
17
- export const KNOWN_HARNESSES = ['claude-code', 'codex', 'pi', 'gemini-cli', 'cursor'];
17
+ export const KNOWN_HARNESSES = ['claude-code', 'codex', 'pi', 'gemini-cli', 'cursor', 'notebooklm'];
18
18
 
19
19
  export const STAMP = '<!-- generated by dreamteamer compile — do not edit; source of truth lives in modules/<module>/<kind>/ -->';
20
20
 
@@ -99,6 +99,14 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
99
99
  summary.push('cursor → .cursor/rules/dreamteamer.mdc');
100
100
  }
101
101
 
102
+ // ---- notebooklm: NOTEBOOKLM.md carries the notebook's CONFIGURATION ---------------
103
+ // Not an orientation block: NotebookLM does not read a context file and has no skills. What it
104
+ // needs is a persona to paste, a response length to pick, and the limits that decide how the
105
+ // workspace has to be cut up — and all three go stale as collections come and go, which is why
106
+ // this is generated on compile rather than written once by hand.
107
+ block('NOTEBOOKLM.md', on('notebooklm') ? notebooklmBlock(entries, version) : null);
108
+ if (on('notebooklm')) summary.push('notebooklm → NOTEBOOKLM.md block');
109
+
102
110
  // ---- prune: anything WE stamped that this compile didn't produce ------------------
103
111
  const current = new Set(outputs);
104
112
  for (const dir of ['.claude/skills', '.claude/agents', '.claude/commands', '.agents/skills', '.cursor/rules']) {
@@ -119,6 +127,94 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
119
127
  return { outputs, blocks, summary };
120
128
  }
121
129
 
130
+ /** Sources a notebook holds, per plan — Google's published table, read 2026-09. Stated here because
131
+ * the number decides the SHAPE of the export: a workspace with more collections than the plan has
132
+ * slots has to be narrowed or packed, and that is a decision the operator makes before running
133
+ * anything. */
134
+ const NOTEBOOK_PLANS = [
135
+ ['standard', 50, 50], ['plus', 100, 200], ['pro', 300, 500], ['ultra', 600, 2500],
136
+ ];
137
+
138
+ /** The notebook's configuration, generated: what to paste as custom instructions, which settings to
139
+ * pick, and the limits that bind. Derived from the compiled DESCRIPTORS only — never from `data/`,
140
+ * for the same reason the orientation block carries no record counts: this lands in a committed file
141
+ * and a count would re-dirty it on every write. */
142
+ function notebooklmBlock(entries, version) {
143
+ const index = buildCollectionsIndex(entries);
144
+ const modules = buildModulesIndex(entries);
145
+ const data = index.filter((c) => !c.system);
146
+ const withheldCollections = data.filter((c) => c.sensitive).map((c) => c.name);
147
+ const withheldFields = data.flatMap((c) => c.sensitiveFields.map((f) => `${c.name}.${f}`));
148
+ const exported = data.filter((c) => !c.sensitive);
149
+
150
+ // grouped by module from the COLLECTIONS index, which carries each collection's owning module —
151
+ // `buildModulesIndex` deliberately reports skills, commands and bin, not collections
152
+ const title = new Map(modules.map((m) => [m.id, m.title]));
153
+ const byModule = new Map();
154
+ for (const c of data) {
155
+ const k = c.module || '';
156
+ if (!byModule.has(k)) byModule.set(k, []);
157
+ byModule.get(k).push(c.name);
158
+ }
159
+ const brief = [...byModule]
160
+ .sort((a, b) => (title.get(a[0]) || a[0]).localeCompare(title.get(b[0]) || b[0]))
161
+ .map(([mod, names]) => `- ${title.get(mod) || mod || 'the workspace'}: ${names.sort().map((c) => (withheldCollections.includes(c) ? `${c} (withheld)` : c)).join(', ')}`);
162
+
163
+ const persona = [
164
+ 'You are the reference desk for this workspace. Every source is ONE COLLECTION of it, and every row or section is one record. Answer only from the sources.',
165
+ '',
166
+ 'A record is cited as `<collection>/<id>`. A field holding a reference contains exactly that form, so `leads.company = companies/acme` means the record `acme` in the `companies` source — follow those links across sources rather than guessing. A source whose name ends `--01`, `--02` is one collection split across files purely for size; treat the parts as one collection.',
167
+ '',
168
+ 'What this workspace keeps, by module:',
169
+ ...brief,
170
+ '',
171
+ withheldCollections.length || withheldFields.length
172
+ ? `Deliberately NOT here: ${[...withheldCollections.map((c) => `the whole collection ${c}`), ...withheldFields].join('; ')}. If asked about them, say they were withheld from the export rather than inferring.`
173
+ : 'Nothing has been withheld from this export.',
174
+ '',
175
+ 'Ground every claim in a record and name it. When the question is a table question — which rows match, what is overdue, everything about one company — read down the column and give the matching records, not a summary. When the sources do not hold the answer, say so plainly: "this workspace does not record that" is a useful answer.',
176
+ ].join('\n');
177
+
178
+ const lines = [
179
+ 'NOTEBOOKLM — the configuration for a notebook over this workspace. NotebookLM reads no context',
180
+ 'file and runs no skills, so unlike the other harnesses nothing here is injected: it is applied,',
181
+ `once per notebook, and re-pasted when the schema changes. Regenerated by every \`dreamteamer compile\` (engine ${version}).`,
182
+ '',
183
+ '## settings',
184
+ '',
185
+ '| setting | value | why |',
186
+ '|---|---|---|',
187
+ '| response length | **longer** | records are terse; the useful answer quotes several and says which |',
188
+ '| chat mode | **do not set one** | `--mode` REPLACES the persona — see the warning below |',
189
+ '',
190
+ '```bash',
191
+ 'notebooklm configure -n <notebook-id> --response-length longer \\',
192
+ ' --persona "$(sed -n \'/^## custom instructions/,/^## limits/p\' NOTEBOOKLM.md | sed \'1,2d;$d\')"',
193
+ '```',
194
+ '',
195
+ '⚠ **`configure` REPLACES the whole configuration on every call, and passing `--mode` with a persona silently discards the persona.** Measured: `--persona … --response-length longer --mode default` answers `{mode: "default", configured: true}` at exit 0, with the persona and the response length gone; the identical call without `--mode` returns both. There is also no read-only inspection — a bare `configure`, the obvious way to check the current settings, CLEARS them. So send every setting you want in ONE call, never `--mode` alongside a persona, and keep the persona in a file rather than only in the notebook.',
196
+ '',
197
+ '## custom instructions',
198
+ '',
199
+ persona,
200
+ '',
201
+ '## limits',
202
+ '',
203
+ '| plan | sources per notebook | chats per day |',
204
+ '|---|---|---|',
205
+ ...NOTEBOOK_PLANS.map(([n, s, c]) => `| ${n} | ${s} | ${c} |`),
206
+ '',
207
+ `This workspace exports **${exported.length} collections**${withheldCollections.length ? ` (${withheldCollections.length} withheld)` : ''}, so one source per collection needs a plan with at least that many slots — before any collection is split.`,
208
+ '',
209
+ '- **Per source:** 500,000 words or 200 MB, per Google. ⚠ A CSV source fails well below that: measured on a large private workspace, files at or under 805,081 bytes indexed and files at or above 881,828 bytes did not, so 700,000 bytes is the working ceiling.',
210
+ '- **Persona:** 10,000 characters. The block above is generated to stay inside it; adding to it by hand can push it over, and `configure` refuses the whole call rather than truncating.',
211
+ '- **Auto-sync follows native Google Docs, Sheets and Slides only.** An uploaded file — CSV, PDF, Markdown — is a SNAPSHOT: re-export means re-adding those sources.',
212
+ '- ⚠ **`source add` reports success whether or not the file indexes.** Read `source list --json` back and require `type` to be the format you uploaded; a source that failed reads `type: unknown, status: error` with no message anywhere.',
213
+ '- ⚠ **An answer is real only with non-blank text AND at least one reference.** Exit 0 with an empty answer is a real outcome, reached by scoping a broad question with `-s`.',
214
+ ];
215
+ return lines.join('\n');
216
+ }
217
+
122
218
  // skill id → description one-liners from each SKILL.md's frontmatter; the orientation
123
219
  // block carries this index so harnesses without native skill discovery still get triggers.
124
220
  function buildSkillsIndex(entries) {
@@ -156,6 +252,10 @@ function buildCollectionsIndex(entries) {
156
252
  description: flat(d.description),
157
253
  useWhen: flat(d.use_when),
158
254
  module: d.module ?? '',
255
+ // the sensitivity marks, so a generated persona can NAME what it was not given —
256
+ // "withheld" is a better answer than "I don't know"
257
+ sensitive: d.sensitive === true,
258
+ sensitiveFields: Object.entries(d.schema?.properties ?? {}).filter(([, p]) => p?.['x-sensitive'] === true).map(([k]) => k),
159
259
  write: writeLine(d.schema),
160
260
  });
161
261
  }
package/src/land.js CHANGED
@@ -43,12 +43,19 @@ export const LAND_LOCK = 'dreamteamer-land.lock';
43
43
  * compile never touches one: matching on the basename would let a nested file that happens to carry
44
44
  * a copied block be resolved `--ours` and discarded silently (ruling R32).
45
45
  *
46
+ * ⚠ THIS LIST DRIFTS THE MOMENT A HARNESS IS ADDED ON A DIFFERENT BRANCH, and it did:
47
+ * `NOTEBOOKLM.md` was written by a harness developed in parallel with `land`, so neither branch's
48
+ * suite could see the gap and both were green. Landed together, `dt land` classified a conflict in
49
+ * that file as `other` — a foreign file that ABORTS the landing — instead of a managed block it can
50
+ * simply regenerate. The unit test below derives this list from `harnesses.js` itself for exactly
51
+ * this reason, and it is what caught it; keep it that way rather than asserting a literal.
52
+ *
46
53
  * ⚠ `.cursor/rules/dreamteamer.mdc` is deliberately NOT here (ruling R33). Cursor's output is a
47
54
  * WHOLE generated file — frontmatter, body and STAMP, no begin/end markers (harnesses.js:97) — so
48
55
  * there is no block to take `--ours` on, and `init` gitignores `.cursor/` anyway. A conflict there
49
56
  * is an ordinary non-records conflict and aborts the landing like any other.
50
57
  */
51
- export const MANAGED_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'];
58
+ export const MANAGED_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', 'NOTEBOOKLM.md'];
52
59
 
53
60
  /** Does compile write a managed block into this exact path? Task C's per-commit loop asks too. */
54
61
  export const isManaged = (filePath) => MANAGED_FILES.includes(filePath);