dreamteamer 0.24.1 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.24.1",
3
+ "version": "0.25.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>",
@@ -172,8 +172,20 @@ export function collectionCommand(ws, collection, verb, args) {
172
172
  }
173
173
  if (flags.from) throw new Error(`--from imports a file as a record, and "${collection}" is not a \`codec: file\` collection`);
174
174
  const fields = coerceArrays(d, stripMeta(flags));
175
- const { id, file } = store.add(collection, fields, { id: flags.id });
176
- flags.json ? emit(JSON.stringify({ id, path: rel(ws.root, file) })) : console.log(`✔ ${rel(ws.root, file)}`);
175
+ const { id, file, idFallback } = store.add(collection, fields, { id: flags.id });
176
+ flags.json
177
+ ? emit(JSON.stringify({ id, path: rel(ws.root, file), ...(idFallback ? { idFallback } : {}) }))
178
+ : console.log(`✔ ${rel(ws.root, file)}`);
179
+ // ⚠ SAY IT, EVERY TIME. The id was derived from a hash because the value it is generated
180
+ // from carries no a-z0-9 — the write succeeded and the record is fine, but the id is
181
+ // unreadable and unguessable, and nobody finds that out until they try to type it. By
182
+ // then other records reference it and renaming is a migration.
183
+ if (idFallback && !flags.json) {
184
+ console.warn(`⚠ id "${idFallback.id}" is a hash, not a name — "${idFallback.field}" (${idFallback.value}) has no latin characters to slug.`);
185
+ console.warn(' give this collection a latin handle: id.generate accepts an ORDERED LIST and takes the first that renders —');
186
+ console.warn(" id: { generate: ['{{ code }}', '{{ name | slug }}'] }");
187
+ console.warn(' or pass --id on this write. Renaming later rewrites every reference.');
188
+ }
177
189
  return 0;
178
190
  }
179
191
  case 'set': {
package/src/commit.js CHANGED
@@ -415,7 +415,27 @@ function assertResolvable(store, records, matched) {
415
415
  }
416
416
  }
417
417
 
418
- export function commitPending(store, { only = [], message, dryRun = false } = {}) {
418
+ export function commitPending(store, opts = {}) {
419
+ // ⚠ THE WHOLE VERB TAKES THE WRITE LOCK, PLANNING INCLUDED — not just the two git calls at the
420
+ // end. `dt commit` was the one write path that took no lock at all: it sampled `git status`,
421
+ // planned a sweep from what it saw, then ran `git add` and `git commit`. Two sessions doing that
422
+ // at once collide on `.git/index.lock` and `HEAD`, which are repository-wide and do not care
423
+ // that the records are unrelated — and the loser is not told, so its records stay on disk,
424
+ // uncommitted, for the next unscoped commit to sweep under someone else's subject.
425
+ //
426
+ // The lock has to cover the PLAN as well as the write, because a plan built from a `git status`
427
+ // taken before a sibling's commit describes a tree that no longer exists by the time it is
428
+ // applied. Serialising only the git calls would trade a lock collision for a stale plan, which
429
+ // is the same loss wearing a quieter failure.
430
+ //
431
+ // This matters more than it reads: since auto-commit was turned off, a write does not commit, so
432
+ // the window between writing a record and publishing it is a whole session rather than
433
+ // milliseconds — and this vault family is routinely operated by several concurrent sessions.
434
+ if (opts.dryRun) return commitPlan(store, opts); // reads only; nothing to serialize
435
+ return store.withWriteLock(() => commitPlan(store, opts));
436
+ }
437
+
438
+ function commitPlan(store, { only = [], message, dryRun = false } = {}) {
419
439
  // Targets are resolved BEFORE anything is committed, so one bad target in a list of good ones
420
440
  // leaves the whole tree untouched rather than committing a prefix of what was asked for.
421
441
  const targets = parseTargets(store.descriptors, only);
package/src/compile.js CHANGED
@@ -1832,13 +1832,46 @@ export function staleness(root) {
1832
1832
  let pkg = {};
1833
1833
  try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* no pkg */ }
1834
1834
  const wm = pkg.dreamteamer?.['workspace-module'];
1835
- const roots = [...(wm ? [] : [root]), ...discoverModules(root, pkg).modules.map((m) => m.root)];
1836
- for (const r of roots) {
1835
+ const found = discoverModules(root, pkg);
1836
+ // A DISABLED ENTITY IS NOT A NEW ONE, AND THIS IS THE DIFFERENCE BETWEEN A WARNING AND A LIE.
1837
+ // `compile` skips every source named by an ENTITY-LEVEL `dreamteamer.disable` entry
1838
+ // (`<module>/<entity>`) before `addEntry`, so that file's path never becomes a manifest source.
1839
+ // This scan used to have no knowledge of that filter, so it found the file on disk, found it
1840
+ // absent from `known`, and reported it `(new, uncompiled)` — permanently, because every future
1841
+ // compile skips it exactly the same way. The workspace was told to run a compile that could not
1842
+ // possibly clear the warning, at every tool entry, for as long as the disable stood.
1843
+ //
1844
+ // Two consuming workspaces were sitting in that state when this was found, one of them with a
1845
+ // clean compile seconds earlier. The MODULE-level form (a bare name) needs no handling here:
1846
+ // `discoverModules` drops the whole module, so its files are never walked at all.
1847
+ //
1848
+ // The root itself, when a workspace declares no `workspace-module`, is not a named module, so no
1849
+ // `<module>/<entity>` entry can address its sources — it is walked unfiltered, as before.
1850
+ const disabledEntities = new Set((pkg.dreamteamer?.disable ?? []).filter((d) => typeof d === 'string' && d.includes('/')));
1851
+ const roots = [...(wm ? [] : [{ name: null, root }]), ...found.modules.map((m) => ({ name: m.name, root: m.root }))];
1852
+ for (const { name: moduleName, root: r } of roots) {
1837
1853
  for (const kind of KINDS) {
1838
1854
  const dir = kindDir(r, kind);
1839
1855
  if (!fs.existsSync(dir)) continue;
1840
1856
  for (const f of walk(dir)) {
1841
- if (isProofFixture(kind, path.relative(dir, f).split(path.sep).join('/'))) continue;
1857
+ const rel = path.relative(dir, f).split(path.sep).join('/');
1858
+ if (isProofFixture(kind, rel)) continue;
1859
+ // The SAME id derivation `compile` uses, so the two can never disagree about which
1860
+ // file a disable entry names.
1861
+ //
1862
+ // ⚠ AND THE TWO KINDS DERIVE IT DIFFERENTLY. `compile` walks collections recursively,
1863
+ // so a collection's id is its whole relative path (it may carry a namespace segment,
1864
+ // `<ns>/<name>`); every other kind it reads with a flat `readdirSync`, so the entity
1865
+ // is the TOP-LEVEL entry and a folder-shaped one — a skill is a directory holding
1866
+ // `SKILL.md` — is named by the folder alone. Matching the full path for those meant a
1867
+ // disabled SKILL still counted as stale, because `working-with-tasks/SKILL.md` is not
1868
+ // `working-with-tasks`. Caught against a real workspace whose disable list held one
1869
+ // of each: the ui-view cleared and the skill did not.
1870
+ if (moduleName) {
1871
+ const relEntity = kind === 'collections' ? rel : rel.split('/')[0];
1872
+ const entityId = relEntity.replace(/\.[^.]+\.(yaml|md|json)$/, '');
1873
+ if (disabledEntities.has(`${moduleName}/${entityId}`)) continue;
1874
+ }
1842
1875
  const relPath = path.relative(root, f);
1843
1876
  if (!known.has(relPath)) stale.push(`${relPath} (new, uncompiled)`);
1844
1877
  }
package/src/server.js CHANGED
@@ -131,7 +131,7 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
131
131
  // branch is here, at the surface, exactly as the CLI's interceptor is.
132
132
  if (store.descriptors.get(req.params.name)?.storage?.base === 'runtime') {
133
133
  const out = systemWrite(ws, store, req);
134
- reload();
134
+ if (!out?.dryRun) reload();
135
135
  return res.json(out);
136
136
  }
137
137
  const { id: explicitId, ...fields } = req.body ?? {};
@@ -142,7 +142,7 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
142
142
  api.patch('/collections/:name/records/*id', (req, res) => {
143
143
  if (store.descriptors.get(req.params.name)?.storage?.base === 'runtime') {
144
144
  const out = systemWrite(ws, store, req);
145
- reload();
145
+ if (!out?.dryRun) reload();
146
146
  return res.json(out);
147
147
  }
148
148
  // clients may echo synthetic response keys back on save (id/path/last-modified/the two
@@ -182,7 +182,7 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
182
182
  api.delete('/collections/:name/records/*id', (req, res) => {
183
183
  if (store.descriptors.get(req.params.name)?.storage?.base === 'runtime') {
184
184
  const out = systemWrite(ws, store, req);
185
- reload();
185
+ if (!out?.dryRun) reload();
186
186
  return res.json(out);
187
187
  }
188
188
  store.rm(req.params.name, idParam(req), { force: req.query.force === 'true' });
@@ -224,7 +224,9 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
224
224
  const schemaOp = (fn) => (req, res, next) => {
225
225
  try {
226
226
  const out = fn(req);
227
- reload();
227
+ // A dry run wrote nothing, so there is nothing to reload — and reloading would imply to
228
+ // every reader that something changed.
229
+ if (!out?.dryRun) reload();
228
230
  res.json(out);
229
231
  } catch (e) { next(e); }
230
232
  };
@@ -250,9 +252,14 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
250
252
  // `…/name` rather than a body key, because renaming a field is a DIFFERENT act from editing one:
251
253
  // it rewrites the key in every record and in every descriptor, view and binding that names it.
252
254
  api.patch('/collections/:name/fields/:field/name', schemaOp((req) =>
253
- renameField(ws, store, req.params.name, req.params.field, String(req.body?.to ?? ''), { moduleId: moduleParam(req) })));
254
- api.delete('/collections/:name/fields/:field', schemaOp((req) =>
255
- removeField(ws, store, req.params.name, req.params.field, { moduleId: moduleParam(req) })));
255
+ renameField(ws, store, req.params.name, req.params.field, String(req.body?.to ?? ''),
256
+ { moduleId: moduleParam(req), dryRun: wantsDryRun(req) })));
257
+ api.delete('/collections/:name/fields/:field', schemaOp((req) => {
258
+ // `removeField` computes no plan, so a dry-run request is refused rather than performed —
259
+ // clearing a value out of every record is the last thing to guess at.
260
+ if (wantsDryRun(req)) throw new DryRunUnsupported('fields:rm');
261
+ return removeField(ws, store, req.params.name, req.params.field, { moduleId: moduleParam(req) });
262
+ }));
256
263
 
257
264
  // per-record revision diff + revert (M3: git already has the data; this exposes it)
258
265
  api.get('/history-diff/:name/*id', (req, res) => {
@@ -273,6 +280,7 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
273
280
  // error contract: store errors are 400 (validation) / 404 (missing) / 409 (referenced)
274
281
  app.use((err, req, res, next) => {
275
282
  const msg = err.message ?? String(err);
283
+ if (err instanceof DryRunUnsupported) return res.status(400).json({ error: msg, 'dry-run': 'unsupported' });
276
284
  const code = err instanceof CompileError ? 400
277
285
  : /no such record/.test(msg) ? 404
278
286
  : /referenced by|already exists/.test(msg) ? 409 : 400;
@@ -333,11 +341,53 @@ function moduleParam(req) {
333
341
  * name; renaming any of them is a cross-repo activation failure, so the new operations are new
334
342
  * exports beside them.
335
343
  */
344
+ // ⚠ A CLIENT ASKING FOR A PLAN MUST NEVER GET A WRITE. `?dry-run=true` was read by nothing here:
345
+ // the query string was parsed for `force` and nothing else, so every request asking what a
346
+ // destructive verb WOULD do performed it instead — a module removal, a collection moved between
347
+ // modules, a field renamed across every record and descriptor that names it. The CLI has taken
348
+ // `--dry-run` on those verbs since the plan/apply split, which is exactly what makes the omission
349
+ // dangerous: the two surfaces are documented as the same operation, so a client has every reason to
350
+ // believe the flag is honoured.
351
+ //
352
+ // Three ops can produce a plan (`removeModule`, `moveCollection`, `renameField` — each returns
353
+ // `{…plan, dryRun: true}` and touches nothing). For anything else the answer is a REFUSAL, never a
354
+ // write: an op that cannot describe itself must not be guessed at, and returning a 200 with an empty
355
+ // plan would read as "this would change nothing", which is the opposite of the truth.
356
+ export function wantsDryRun(req) {
357
+ const v = req?.query?.['dry-run'];
358
+ return v === true || v === 'true' || v === '1';
359
+ }
360
+
361
+ /** The `<kind>:<verb>` pairs whose op accepts `dryRun` and returns a plan instead of writing. */
362
+ export const DRY_RUNNABLE = new Set(['modules:rm', 'collections:move', 'fields:rename']);
363
+
364
+ export class DryRunUnsupported extends Error {
365
+ constructor(what) {
366
+ super(`dry-run is not supported for ${what} — supported: ${[...DRY_RUNNABLE].join(', ')}. `
367
+ + 'Nothing was written; re-send without dry-run to perform it.');
368
+ this.status = 400;
369
+ this.dryRunUnsupported = what;
370
+ }
371
+ }
372
+
336
373
  function systemWrite(ws, store, req) {
337
374
  const kind = req.params.name;
338
375
  const id = req.params.id ? idParam(req) : undefined;
339
376
  const moduleId = moduleParam(req);
340
377
  const b = req.body ?? {};
378
+ const dryRun = wantsDryRun(req);
379
+ if (dryRun) {
380
+ // Decide from the SAME shape the dispatch below uses, so the two can never disagree about
381
+ // which op a request reaches — a refusal that names a different verb than the one that would
382
+ // have run is worse than no refusal.
383
+ if (req.method === 'DELETE' && kind === 'modules') {
384
+ return removeModule(ws, store, id, { force: req.query.force === 'true', dryRun: true });
385
+ }
386
+ if (req.method === 'PATCH' && kind === 'collections' && typeof b.module === 'string') {
387
+ return moveCollection(ws, store, id, b.module, { dryRun: true });
388
+ }
389
+ throw new DryRunUnsupported(`${kind}:${req.method.toLowerCase()}`);
390
+ }
341
391
  if (req.method === 'POST') {
342
392
  if (kind === 'collections') return createCollection(ws, store, { ...b, moduleId });
343
393
  if (kind === 'modules') return createModule(ws, store, b);
package/src/store.js CHANGED
@@ -502,7 +502,15 @@ export class Store {
502
502
  // the KEYS, not a copy of them: `generateId` iterates this once and only for a `{{ seq }}`
503
503
  // template, so materializing the whole id list was an O(N) allocation per add that almost
504
504
  // every collection threw away unread.
505
- const id = explicitId ?? generateId(d.id?.generate ?? '{{ name | slug }}', fields, this.ids(collection).keys());
505
+ // An id derived from a hash rather than from readable text is reported back to the caller, not
506
+ // swallowed — see the `slug` filter's note. The write still happens: an id must be produced,
507
+ // and refusing here would break every workspace whose values are not latin. What must not
508
+ // happen is that nobody is told.
509
+ let idFallback = null;
510
+ const id = explicitId ?? generateId(
511
+ d.id?.generate ?? '{{ name | slug }}', fields, this.ids(collection).keys(),
512
+ { onFallback: (f) => { idFallback = f; } },
513
+ );
506
514
  if (d.id?.pattern && !patternRe(d.id.pattern).test(id)) {
507
515
  throw new Error(`id "${id}" does not match pattern ${d.id.pattern} — nothing was written.`);
508
516
  }
@@ -545,7 +553,7 @@ export class Store {
545
553
  }, d.storage.repo ?? '.');
546
554
  // LAST, after the commit: the key it is re-stated under carries the sha, and `commit` moves it
547
555
  this._indexAdd(collection, memo, id, file);
548
- return { id, file };
556
+ return { id, file, idFallback };
549
557
  });
550
558
  }
551
559
 
@@ -1003,13 +1011,35 @@ export class Store {
1003
1011
  return { touched, rewrites, skipped, ambiguous, restore };
1004
1012
  }
1005
1013
 
1014
+ /** Where the cross-process write lock lives.
1015
+ *
1016
+ * ⚠ IT BELONGS TO THE REPOSITORY, NOT TO THE CHECKOUT. What it protects is `.git/index.lock`
1017
+ * and `HEAD` — both of which are SHARED by every worktree of a repo, while `.dreamteamer/` is
1018
+ * gitignored build output that each checkout has its own copy of. A lock in the runtime folder
1019
+ * therefore serialized a single checkout against itself and left two worktrees of one repo free
1020
+ * to collide on exactly the files it exists to guard. `--git-common-dir` is the one path that
1021
+ * resolves to the same place from the primary and from every linked worktree.
1022
+ *
1023
+ * The runtime folder stays the fallback, because a workspace need not be a git repo at all and
1024
+ * a store that cannot lock is worse than one that locks narrowly. */
1025
+ writeLockPath() {
1026
+ if (this._lockPath) return this._lockPath;
1027
+ let dir = null;
1028
+ try {
1029
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: this.root, stdio: QUIET }).toString().trim();
1030
+ if (common) dir = path.resolve(this.root, common);
1031
+ } catch { /* not a git repo — fall back to the runtime folder */ }
1032
+ this._lockPath = path.join(dir ?? this.runtime, '.dreamteamer-write-lock');
1033
+ return this._lockPath;
1034
+ }
1035
+
1006
1036
  // ---- write serialization + rollback (review finding 3; reinstates the v2 commit
1007
1037
  // queue idea in sync form). within ONE process Node's sync fs/exec already serializes;
1008
1038
  // the lock guards CLI-beside-server cross-process races on .git/index.lock. a commit
1009
1039
  // failure UNDOES the write, so "one mutation = one commit" fails CLOSED and
1010
1040
  // "nothing was written" stays true.
1011
1041
  withWriteLock(fn) {
1012
- const lock = path.join(this.runtime, '.write-lock');
1042
+ const lock = this.writeLockPath();
1013
1043
  fs.mkdirSync(path.dirname(lock), { recursive: true });
1014
1044
  const deadline = Date.now() + 5000;
1015
1045
  for (;;) {
package/src/template.js CHANGED
@@ -5,7 +5,21 @@
5
5
  // mutable fields.
6
6
  const SEQ = '__DT_SEQ__';
7
7
 
8
- export function generateId(tpl, fields, existingIds = []) {
8
+ /** Render ONE template, or throw if a field it names is missing. The list form loops over this. */
9
+ export function generateId(tpl, fields, existingIds = [], opts = {}) {
10
+ // ⚠ AN ORDERED LIST IS "USE THIS, ELSE THAT" — the only way a descriptor can express a readable
11
+ // latin handle WITHOUT forcing a required field onto every record. `id.generate` takes a string
12
+ // or a list of them, and the FIRST template whose fields are all present wins; a template that
13
+ // names a missing field is skipped, not fatal, until the last one, whose error is the one the
14
+ // writer sees. Before this, a missing field threw before any fallback could run and an unknown
15
+ // `default` filter threw too, so `{{ code }} else {{ name | slug }}` was inexpressible and the
16
+ // only workaround was passing --id on every single add.
17
+ if (Array.isArray(tpl)) {
18
+ for (let i = 0; i < tpl.length; i++) {
19
+ try { return generateId(tpl[i], fields, existingIds, opts); }
20
+ catch (e) { if (i === tpl.length - 1) throw e; }
21
+ }
22
+ }
9
23
  const created = new Date();
10
24
  let sawSeq = false;
11
25
  let seqPad = 0;
@@ -25,7 +39,7 @@ export function generateId(tpl, fields, existingIds = []) {
25
39
  if (name === 'pad') seqPad = Number(arg) || 0;
26
40
  continue; // filters never transform the seq placeholder itself
27
41
  }
28
- value = applyFilter(name, arg, value);
42
+ value = applyFilter(name, arg, value, { field: head, onFallback: opts.onFallback });
29
43
  }
30
44
  if (value instanceof Date) value = fmtDate(value, 'YYYY-MM-DD');
31
45
  return String(value);
@@ -45,12 +59,25 @@ export function generateId(tpl, fields, existingIds = []) {
45
59
  return prefix + (seqPad ? n.padStart(seqPad, '0') : n) + suffix;
46
60
  }
47
61
 
48
- function applyFilter(name, arg, value) {
62
+ function applyFilter(name, arg, value, ctx = {}) {
49
63
  switch (name) {
50
64
  case 'date': return fmtDate(asDate(value), arg || 'YYYY-MM-DD');
51
65
  // ids are paths: no colons (windows-hostile, ungreppable) — 2026-07-25T13-39-17
52
66
  case 'datetime': return asDate(value).toISOString().slice(0, 19).replace(/:/g, '-');
53
- case 'slug': return slugOrHash(String(value));
67
+ // THE SILENCE IS THE DEFECT, NOT THE ERGONOMICS. A value with no a-z0-9 in it — any Hebrew,
68
+ // Arabic, Cyrillic or CJK name — has nothing to slug, so this falls back to a deterministic
69
+ // hash and THE WRITE SUCCEEDS. That is worse than a refusal: `x1a2b3c4` lands, passes
70
+ // `check`, gets referenced by other records, and is discovered only when a person reads the
71
+ // tree, by which point renaming it is a migration. The fallback still happens (an id must be
72
+ // produced, and existing records keep theirs), but it is no longer silent: the caller is
73
+ // handed the field, the value and the id so a writer can say so.
74
+ case 'slug': {
75
+ const out = slugOrHash(String(value));
76
+ if (ctx.onFallback && out !== slug(String(value))) {
77
+ ctx.onFallback({ field: ctx.field, value: String(value), id: out });
78
+ }
79
+ return out;
80
+ }
54
81
  case 'pad': return String(value).padStart(Number(arg) || 0, '0');
55
82
  case 'basename': return String(value).split('/').pop();
56
83
  default: throw new Error(`unknown id-template filter "${name}"`);