dreamteamer 0.12.0 → 0.13.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
@@ -73,8 +73,8 @@ source_file: ${env:FILES_FOLDER}/2026/q3.pdf
73
73
  ```
74
74
 
75
75
  Three variables, borrowing VS Code's grammar: `${env:NAME}` — declared in `dreamteamer.vars` in
76
- `package.json`, valued in the gitignored `.env` plus `${workspaceFolder}` and `${userHome}`.
77
- One verb renders them:
76
+ `package.json`, valued in the gitignored `.env` (an empty or whitespace-only value counts as no
77
+ value at all) — plus `${workspaceFolder}` and `${userHome}`. One verb renders them:
78
78
 
79
79
  ```bash
80
80
  npx dreamteamer resolve '${env:FILES_FOLDER}/x' # → /Volumes/annex/x
@@ -93,7 +93,7 @@ schema:
93
93
  description: >-
94
94
  The module that OWNS this concept — DERIVED by compile from the base source, never authored.
95
95
  An overlay adds fields to somebody else's collection and does not take it over, so `meetings`
96
- stays owned by crm even though hq3 overlays it. This is the workspace's real partition, and
96
+ stays owned by crm even though the workspace module overlays it. This is the workspace's real partition, and
97
97
  what the nav groups by.
98
98
  group:
99
99
  type: string
@@ -30,7 +30,7 @@ schema:
30
30
  properties:
31
31
  name:
32
32
  type: string
33
- description: The module's package name, verbatim — `@dreamteamer/crm`, `hq3-workspace`.
33
+ description: The module's package name, verbatim — `@dreamteamer/crm`, `acme-workspace`.
34
34
  title:
35
35
  type: string
36
36
  description: >-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.12.0",
3
+ "version": "0.13.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>",
@@ -57,6 +57,7 @@
57
57
  "test": "node scripts/test.mjs",
58
58
  "test:unit": "node scripts/test.mjs --unit",
59
59
  "test:watch": "node --watch scripts/test.mjs --unit",
60
+ "perf": "node test/perf/run.mjs",
60
61
  "metrics": "node scripts/metrics.mjs",
61
62
  "metrics:check": "node scripts/metrics.mjs --check",
62
63
  "layers": "node scripts/layers.mjs",
@@ -75,7 +75,11 @@ and deliberately nothing else — including nothing about people. There is no `u
75
75
  from git's own status letters), and a multi-record commit says what it swept.
76
76
  - **one commit per REPO.** a module can own its records (`owns-data` in its package.json), and git
77
77
  has no cross-repo commit — so a rename whose inbound refs live in another repo is TWO commits.
78
- `dt commit` prints both. `dt commit <collection> …` scopes it; `--dry-run` shows the set first.
78
+ `dt commit` prints both. `--dry-run` shows the set first.
79
+ - **scope the commit to what YOU wrote: `dt commit <collection>/<id>`.** any number of targets, each
80
+ either a whole `<collection>` or one record — bare `dt commit` publishes everything pending, and
81
+ `dt commit <collection>` publishes every dirty record under it *whoever wrote it*, which is the
82
+ same sweep as the blanket add below when a second session shares the tree.
79
83
  - **never `git add -A`, `git add .`, or `git commit -a`.** stage explicit paths. more than one agent
80
84
  can be working in a tree, and a blanket add silently commits whatever another session has
81
85
  uncommitted right now — under your subject, leaving `git status` clean and the damage invisible.
@@ -100,7 +104,7 @@ source_file: ${env:FILES_FOLDER}/2026/q3.pdf
100
104
 
101
105
  | variable | renders to |
102
106
  |---|---|
103
- | `${env:NAME}` | `NAME`'s value in the workspace's `.env` — and only if `NAME` is listed in `dreamteamer.vars` in `package.json` |
107
+ | `${env:NAME}` | `NAME`'s value in the workspace's `.env` — and only if `NAME` is listed in `dreamteamer.vars` in `package.json` AND has a non-empty value there (an empty or whitespace-only value fails exactly like an unset key) |
104
108
  | `${workspaceFolder}` | the workspace root, absolute |
105
109
  | `${userHome}` | the current user's home directory |
106
110
 
package/src/cli.js CHANGED
@@ -100,7 +100,10 @@ workspace verbs:
100
100
  [--since <sha|YYYY-MM-DD>] (default: the last commit) [--json]
101
101
  commit publish records already written to disk: samples git status over every
102
102
  collection's record dirs, one commit PER REPO, subject composed from the
103
- status letters. [<collection> …] to scope, [-m <subject>], [--dry-run]
103
+ status letters. Scope it with any number of targets, each either a whole
104
+ <collection> or one <collection>/<id> — the record form is what keeps a
105
+ concurrent session's pending records out of your commit.
106
+ [<collection>|<collection>/<id> …] [-m <subject>] [--dry-run] [--json]
104
107
  help this text
105
108
  `;
106
109
 
@@ -206,7 +209,9 @@ export function run(argv) {
206
209
  const store = new Store(ws);
207
210
  const mi = rest.indexOf('-m');
208
211
  const message = mi > -1 ? rest[mi + 1] : undefined;
209
- // bare args are collection names minus the token `-m` consumed as its subject
212
+ // bare args are TARGETSa whole collection or a `<collection>/<id>` reference,
213
+ // told apart in commit.js against the declared collections — minus the token `-m`
214
+ // consumed as its subject
210
215
  const only = rest.filter((a, i) => !a.startsWith('-') && (mi === -1 || i !== mi + 1));
211
216
  const results = commitPending(store, { only, message, dryRun: rest.includes('--dry-run') });
212
217
  if (rest.includes('--json')) { emit(JSON.stringify(results, null, 2)); process.exit(0); }
package/src/commit.js CHANGED
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { pathToRecord } from './events.js';
8
+ import { splitRef } from './ref.js';
8
9
 
9
10
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
10
11
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -14,6 +15,28 @@ const QUIET = ['ignore', 'pipe', 'ignore'];
14
15
 
15
16
  const VERB = { A: 'add', M: 'set', D: 'rm', R: 'rename', '?': 'add' };
16
17
 
18
+ /** What the caller asked to publish. A target is EITHER a collection name or a `<collection>/<id>`
19
+ * reference — the same either-shape `move` and `commands` accept, and after 0.12.0 the shape every
20
+ * other verb's target has. Which one it is cannot be guessed from the string (an id may contain
21
+ * slashes and so may a namespaced collection name), so it is decided against the DECLARED
22
+ * collections: a key of `descriptors` is a collection, anything else goes to splitRef — which
23
+ * throws, naming the known collections, rather than letting a typo mean "no scope".
24
+ *
25
+ * Returns `scope` (collections to sample, so the git pathspec stays as narrow as it was), `whole`
26
+ * (collections asked for entire) and `records` (ref → {collection, id}). */
27
+ function parseTargets(descriptors, only) {
28
+ const scope = new Set();
29
+ const whole = new Set();
30
+ const records = new Map();
31
+ for (const target of only) {
32
+ if (descriptors.has(target)) { whole.add(target); scope.add(target); continue; }
33
+ const { collection, id } = splitRef(descriptors, target);
34
+ records.set(`${collection}/${id}`, { collection, id });
35
+ scope.add(collection);
36
+ }
37
+ return { scoped: only.length > 0, scope: [...scope], whole, records };
38
+ }
39
+
17
40
  /** Record directories to watch, grouped by owning repo. System-stored collections are excluded:
18
41
  * they live in the gitignored runtime and their sources are module files — the same exclusion
19
42
  * pathToRecord already applies. */
@@ -97,11 +120,44 @@ export function composeSubject(rows) {
97
120
  return `dreamteamer: ${rows.length} changes across ${collections.join(', ')}`;
98
121
  }
99
122
 
123
+ /** A requested reference that matched no pending row is one of two very different things: a record
124
+ * that is already published (nothing to do, and fine) or a MISTYPED id — which must not pass as
125
+ * "nothing pending", the one report that looks like success. Only the store can tell them apart,
126
+ * and it is only asked in this branch: a pending DELETION matched a row above, so a record whose
127
+ * file is legitimately gone never reaches here. */
128
+ function assertResolvable(store, records, matched) {
129
+ for (const [ref, { collection, id }] of records) {
130
+ if (matched.has(ref) || store.ids(collection).has(id)) continue;
131
+ throw new Error(`${ref}: no such record — nothing pending under that reference`);
132
+ }
133
+ }
134
+
100
135
  export function commitPending(store, { only = [], message, dryRun = false } = {}) {
101
- const byRepo = scopeByRepo(store.descriptors, only);
102
- const results = [];
136
+ // Targets are resolved BEFORE anything is committed, so one bad target in a list of good ones
137
+ // leaves the whole tree untouched rather than committing a prefix of what was asked for.
138
+ const targets = parseTargets(store.descriptors, only);
139
+ const byRepo = scopeByRepo(store.descriptors, targets.scope);
140
+ // ⚠ Sample every repo FIRST, then check the references, then commit. The unknown-reference test
141
+ // below can only be answered once every repo has been sampled — a record lives in exactly one
142
+ // repo, and which one is not known in advance.
143
+ const sampled = [];
144
+ const matched = new Set();
103
145
  for (const [repo, dirs] of byRepo) {
104
146
  const { cwd, rows } = sample(store.root, repo, dirs, store.descriptors);
147
+ // The SAMPLER is deliberately left alone — it is what makes a hand-edited record
148
+ // indistinguishable from one the store wrote. Narrowing happens here, on the sampled rows,
149
+ // so `dt commit <collection>/<id>` publishes that record and leaves a sibling written by
150
+ // another session exactly as pending as it found it.
151
+ const wanted = !targets.scoped ? rows : rows.filter((r) => {
152
+ const ref = `${r.collection}/${r.id}`;
153
+ if (targets.records.has(ref)) { matched.add(ref); return true; }
154
+ return targets.whole.has(r.collection);
155
+ });
156
+ sampled.push({ repo, cwd, rows: wanted });
157
+ }
158
+ assertResolvable(store, targets.records, matched);
159
+ const results = [];
160
+ for (const { repo, cwd, rows } of sampled) {
105
161
  if (!rows.length) continue;
106
162
  const blocked = inProgress(cwd);
107
163
  if (blocked) { results.push({ repo, rows, blocked }); continue; }
package/src/compile.js CHANGED
@@ -49,7 +49,7 @@ export function titleCase(id) {
49
49
  * Every `x-display` left in a schema, as `[fieldPath, template, referenceTarget|null]`.
50
50
  *
51
51
  * The keyword was renamed to `x-title-template` and mostly DELETED — its value is inherited from
52
- * the target collection's `title_template`. There is deliberately no alias: recipes and dt-hq pin
52
+ * the target collection's `title_template`. There is deliberately no alias: real workspaces pin
53
53
  * this engine by SHA, so nothing breaks until someone bumps a pin, and that person needs a message
54
54
  * rather than silence. JSON Schema IGNORES unknown keywords, so the alternative to failing here is
55
55
  * a label that quietly stops working and regresses to a raw id.
@@ -318,7 +318,11 @@ export function compile({ root, pkg }) {
318
318
  // pairing there is: compile says nothing and then `dt resolve` answers "no value in
319
319
  // .env" about a line the operator is looking straight at. Values are read here and
320
320
  // never printed — the warnings below name keys only.
321
- const present = new Set(parseEnvValues(fs.readFileSync(envPath, 'utf8')).keys());
321
+ // A key present with an EMPTY (or whitespace-only) value is treated as absent, same as
322
+ // resolve's renderTemplate — `FILES_FOLDER=` must warn here exactly as `FILES_FOLDER`
323
+ // missing entirely would, or compile says nothing and resolve fails on the same line.
324
+ const parsedEnv = parseEnvValues(fs.readFileSync(envPath, 'utf8'));
325
+ const present = new Set([...parsedEnv].filter(([, v]) => v.trim() !== '').map(([k]) => k));
322
326
  for (const [k, mods] of declaredEnv) {
323
327
  if (present.has(k)) continue;
324
328
  for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k} — missing from .env (see .env.example)`);
@@ -662,7 +666,8 @@ export function compile({ root, pkg }) {
662
666
  // judged against the BASE module's declarations, which it never wrote.
663
667
  const groupModules = [...new Set(group.map((g) => g.moduleName))];
664
668
  // WHO OWNS the concept — the module whose source is the base, not the ones overlaying it.
665
- // An overlay adds fields to somebody else's collection (hq3 adds `tags` to crm's contacts);
669
+ // An overlay adds fields to somebody else's collection (a workspace module adding its own
670
+ // `tags` to the `crm` module's `contacts`);
666
671
  // it does not take the concept over. Measured 2026-08-11: letting the overlay win moves
667
672
  // `contacts` and `meetings` out of CRM, and a CRM without contacts reads as broken.
668
673
  //
@@ -672,7 +677,8 @@ export function compile({ root, pkg }) {
672
677
  // IS the answer, and it exists to REPLACE `group:` as the workspace's partition rather than
673
678
  // to sit beside it.
674
679
  merged.owner = `modules/${moduleId(base?.moduleName ?? groupModules[0])}`;
675
- // EVERY contributing module, not just the base — a collection merged from crm + hq3 belongs
680
+ // EVERY contributing module, not just the base — a collection merged from `crm` and the
681
+ // workspace module that overlays it belongs
676
682
  // to both, and saying otherwise is what made a flat "which module owns this" field wrong.
677
683
  for (const m of groupModules) {
678
684
  if (!moduleColls.has(m)) moduleColls.set(m, new Set());
@@ -881,7 +887,8 @@ export function compile({ root, pkg }) {
881
887
  // in this loop and never got cleared — a module that was RENAMED or REMOVED left its old record
882
888
  // behind forever, listing collections that no longer exist. `check` reads those records like any
883
889
  // other, so it surfaced as a dangling reference in a file nobody had touched, twice in one day
884
- // (`hq3-workspace` after the workspace-module rename, and again after `crm` was folded in). The
890
+ // (the workspace module's own record after it was renamed, and a domain module's after it was
891
+ // folded into another). The
885
892
  // runtime is build output; stale build output is the compiler's problem, not the reader's.
886
893
  for (const kind of [...KINDS, ...DERIVED_KINDS]) fs.rmSync(path.join(RUNTIME, kind), { recursive: true, force: true });
887
894
  fs.rmSync(path.join(RUNTIME, 'system'), { recursive: true, force: true });
package/src/env-vars.js CHANGED
@@ -34,7 +34,11 @@ export function renderTemplate(str, { env, workspaceFolder, declared }) {
34
34
  if (ns !== 'env') throw new Error(`\${${ns}:${arg}} is not a dreamteamer variable — ${SUPPORTED}`);
35
35
  if (!arg) throw new Error(`\${env:} needs a key name — ${SUPPORTED}`);
36
36
  if (!declared.includes(arg)) throw new Error(`\${env:${arg}}: "${arg}" is not declared in dreamteamer.vars (workspace package.json) — declared: ${declared.join(', ') || '(none)'}`);
37
- if (!env.has(arg)) throw new Error(`\${env:${arg}} is declared but has no value in .env on this machine`);
37
+ // An empty or whitespace-only value is indistinguishable from unset to anyone reading the
38
+ // rendered output — `FILES_FOLDER=` passes `env.has()` and silently renders to '', producing
39
+ // a plausible-looking but wrong path. Same failure, same message: the operator can't tell
40
+ // the two states apart from outside and doesn't care which one it is.
41
+ if (!env.has(arg) || env.get(arg).trim() === '') throw new Error(`\${env:${arg}} is declared but has no value in .env on this machine`);
38
42
  return env.get(arg);
39
43
  });
40
44
  }
package/src/init.js CHANGED
@@ -75,10 +75,10 @@ export function init({ flags = {} } = {}) {
75
75
  harnesses,
76
76
  'gitignore-runtime-folder': true,
77
77
  // The workspace's own sources live in `modules/default/`, and the folder is named for its ROLE,
78
- // not for the vault. It used to be named after the workspace, and that name went stale twice in
79
- // one repo (`hq3` → `gk`, decision 213 reversed by 224) — each rename rewriting every path that
80
- // RESOLVES while the historical documents deliberately kept the old spelling, so a stale-looking
81
- // `modules/hq3` was correct in prose and a bug in a path. A role name cannot go stale.
78
+ // not for the vault. It used to be named after the workspace, and that name went stale TWICE in
79
+ // one repo (decision 213, reversed by 224) — each rename rewriting every path that RESOLVES
80
+ // while the historical documents deliberately kept the old spelling, so the old `modules/<vault>`
81
+ // was correct in prose and a bug in a path. A role name cannot go stale.
82
82
  //
83
83
  // `default` is deliberately the same word `RESERVED_NAMESPACES` holds (namespace.js): this module
84
84
  // owns the DEFAULT-namespace collections, and the default namespace is the empty prefix. The one
package/src/schema-ops.js CHANGED
@@ -219,14 +219,23 @@ export function removeCollection(ws, store, name, { force = false } = {}) {
219
219
  * relearn both, and would corrupt `data/tasks/` in a path or a URL on its first outing. N passes over
220
220
  * the record files is the price, and at human scale it is worth paying for reusing the correct code.
221
221
  *
222
- * ⚠ MEASURED 2026-08-17, so the cost is a number rather than a hope: a 2,291-record collection in a
223
- * 3,391-file workspace — gk-brain's `finance-transactions` — takes **3 minutes**, of which 142s is
224
- * system time. That is 7.7M file reads to rewrite ZERO references, because the pass runs per id
225
- * whether or not anything points at the collection. Tolerable for a one-time migration and left
226
- * alone on that basis; it is O(records x files), so a workspace 3x larger pays 27 minutes. The fix
227
- * when it is needed is a batch entry point on the store that reads each file ONCE and loops the ref
228
- * set in memory, with `text.includes(oldName + '/')` as a cheap NEGATIVE filter only — never as the
229
- * matcher, for the reason above.
222
+ * ⚠ MEASURED 2026-08-17, so the cost is a number rather than a hope: a real 2,291-record collection
223
+ * in a 3,391-file workspace takes **3 minutes**, of which 142s is system time — 7.7M file reads to
224
+ * rewrite ZERO references, because the pass runs per id whether or not anything points at the
225
+ * collection. Tolerable for a one-time migration and left alone on that basis; it is
226
+ * O(records x files), so a workspace 3x larger pays 27 minutes.
227
+ *
228
+ * REPRODUCED 2026-08-22 by `npm run perf -- --records=2291 --filler=1100`, which generates a
229
+ * workspace that shape — 271s wall, 203s of it system, and **15.6M reads, not 7.7M**. The original
230
+ * figure counted ONE pass per id; there are TWO, because `captureRefs` walks every record file for
231
+ * the rollback snapshot before `rewriteRefs` walks them all again. `7.7M` is the per-pass number.
232
+ * That is what a generated fixture is for: the finding was right about the shape and off by 2x on
233
+ * the count, and no comment could have told you.
234
+ *
235
+ * The fix when it is needed is a batch entry point on the store that reads each file ONCE and loops
236
+ * the ref set in memory, with `text.includes(oldName + '/')` as a cheap NEGATIVE filter only — never
237
+ * as the matcher, for the reason above. Halving it is cheaper still: the snapshot pass and the
238
+ * rewrite pass read the same bytes.
230
239
  */
231
240
  export function renameCollection(ws, store, oldName, newName) {
232
241
  const d = store.descriptor(oldName); // throws with the known-collection list if absent