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 +2 -2
- package/collections/collections.collection.yaml +1 -1
- package/collections/modules.collection.yaml +1 -1
- package/package.json +2 -1
- package/skills/using-dreamteamer/SKILL.md +6 -2
- package/src/cli.js +7 -2
- package/src/commit.js +58 -2
- package/src/compile.js +12 -5
- package/src/env-vars.js +5 -1
- package/src/init.js +4 -4
- package/src/schema-ops.js +17 -8
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`
|
|
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
|
|
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`, `
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
212
|
+
// bare args are TARGETS — a 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
|
-
|
|
102
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
// (
|
|
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
|
-
|
|
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
|
|
79
|
-
// one repo (
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
|
223
|
-
* 3,391-file workspace
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|