dreamteamer 0.31.0 → 0.32.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.
@@ -91,6 +91,32 @@ schema:
91
91
  entry:
92
92
  type: string
93
93
  description: Folder shapes only — the file inside the folder that IS the record (e.g. SKILL.md).
94
+ under:
95
+ type: object
96
+ description: >-
97
+ RELATIONSHIP-BASED STORAGE — records live INSIDE the folder of the record they belong
98
+ to. `{ field: company, path: meetings }` puts a meeting whose `company` is
99
+ `companies/northwind` at `<companies root>/northwind/meetings/<id>.meeting.md`; one with
100
+ no `company` stays in this collection's own `path`, which remains its fallback root.
101
+ Still ONE logical collection: `list` is the union across every parent folder, a
102
+ reference is `<collection>/<id>` wherever the file sits, and the id never changes when
103
+ the owner does — `set <field>=…` MOVES the file. `field` must be a scalar `x-reference`
104
+ to exactly one collection, that collection must be `shape: folder`, and one level is
105
+ supported (a placed collection cannot be a parent). The field is the intended owner and
106
+ the folder is observed placement: `check` reports a disagreement, `dreamteamer relocate`
107
+ reconciles it, nothing infers an owner from where a file was found. Not for a record
108
+ many parents share equally — that is a plain reference.
109
+ required: [field, path]
110
+ properties:
111
+ field:
112
+ type: string
113
+ description: The scalar reference field on THIS collection that names the parent record.
114
+ path:
115
+ type: string
116
+ description: The folder INSIDE each parent record's folder that holds these records — relative, e.g. `meetings`.
117
+ collection:
118
+ type: string
119
+ description: DERIVED by compile, never authored — the parent collection, read off `field`'s x-reference so the record layer never opens a schema to find it.
94
120
  repo:
95
121
  type: string
96
122
  description: DERIVED by compile, never authored — the workspace-relative root of the git repo holding these records ('.' is the workspace). Set from the owning module's `owns-data`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.31.0",
3
+ "version": "0.32.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>",
@@ -30,7 +30,10 @@ unsure which skill owns the job in front of you.
30
30
 
31
31
  - a record is a `<id>.<suffix>.<ext>` file (or a folder, for folder-shape collections). **the id
32
32
  is the path** inside the collection folder minus suffix and extension — nested folders join in:
33
- `data/meetings/2026/07/standup.meeting.md` ⇒ id `2026/07/standup`.
33
+ `data/meetings/2026/07/standup.meeting.md` ⇒ id `2026/07/standup`. a collection may instead keep
34
+ its records INSIDE the folder of the record they belong to (`storage.under` — a company's
35
+ meetings in `data/companies/<company>/meetings/`): still ONE collection, the same id, the same
36
+ `meetings/<id>` reference; only the folder follows the owner field (`references/collections.md`).
34
37
  - **references are `<collection>/<id>`** strings — always qualified, greppable, never a bare name
35
38
  and never a file path.
36
39
 
@@ -61,7 +64,7 @@ dispatch, so it cannot drift):
61
64
 
62
65
  - a collection may be spelled in the SINGULAR on any of these (`dt add task "call the bank"` — one bare positional is the title); references inside records still spell the full name
63
66
  - read & measure — `list` `get` `values` `history` `diff` `next` `relations` `resolve`
64
- - write & publish — `add` `set` `rm` `rename` `move` `revert` `commit`
67
+ - write & publish — `add` `set` `rm` `rename` `move` `revert` `commit` `relocate`
65
68
  - fields (sources, through the compile gate) — `add-field` `set-field` `rm-field` `rename-field` (system entities — modules, collections, skills, ui-views… — take the RECORD verbs above)
66
69
  - workspace — `init` `install` `update` `compile` `check` `status` `changes` `help`
67
70
  - an EXTENSION (a workspace module or a dependency declaring `dreamteamer.extension`) adds verbs of
@@ -105,6 +108,7 @@ Load by the map; nothing here is loaded "just in case".
105
108
  | "what changed while I was away" | `references/changes.md` |
106
109
  | the workspace seems unable to do something — a new kind of thing, a missing capability, "don't we already have this?" | `references/before-you-build.md` (look first); a new model then continues `references/data-modeling.md` (decide) → `references/collections.md` (write it) |
107
110
  | a collection or field, mechanically — the descriptor, the system and field verbs, `templates:`/`extends:`, a compile or check message | `references/collections.md` |
111
+ | "keep a company's meetings in the company's folder" — records stored beside the record they belong to, a `placed … but` check report, `dt relocate` | `references/collections.md` (declaring it) · `references/records.md` (working with it) |
108
112
  | knowledge a session should find on its own | `references/skills.md` |
109
113
  | "let me type one word and have this done" | `references/commands.md` |
110
114
  | "which command applies to this record?" — a binding, a gate | `references/commands.md` |
@@ -172,7 +172,77 @@ is copied.
172
172
  - Nested namespaces work (`work/clients`); the longest declared prefix wins.
173
173
  - ⚠ **No collection may store records inside another's folder** — a namespace folder cannot
174
174
  itself be a collection root. Compile refuses it, because the outer collection would index the
175
- inner one's records as its own.
175
+ inner one's records as its own. The one DECLARED exception is the next section: a collection
176
+ stored under the records of a folder-shape parent, where compile knows exactly which files
177
+ belong to whom.
178
+
179
+ ## relationship-based storage — records beside the record they belong to
180
+
181
+ A collection can keep each record INSIDE the folder of the record it belongs to, so a company's
182
+ folder holds the company's meetings and a browse of `data/companies/northwind/` shows the whole
183
+ account. It is declared on the CHILD's `storage`, in one line, and changes nothing about what the
184
+ collection IS:
185
+
186
+ ```yaml
187
+ # modules/default/collections/meetings.collection.yaml
188
+ storage: { path: data/meetings, suffix: meeting, under: { field: company, path: meetings } }
189
+ ```
190
+
191
+ ```text
192
+ data/companies/northwind/company.md ← the parent: shape: folder, entry: company.md
193
+ data/companies/northwind/meetings/2026/10/kickoff.meeting.md ← meetings/2026/10/kickoff, company: companies/northwind
194
+ data/meetings/2026/10/offsite.meeting.md ← meetings/2026/10/offsite, no company: the FALLBACK root
195
+ ```
196
+
197
+ - **Still one logical collection.** `dt list meetings` is the union across every company folder
198
+ and the fallback root, ordered by id; `dt get meetings/2026/10/kickoff` finds the file wherever it
199
+ sits; a reference is `meetings/<id>` everywhere. Nothing is spelled per company — no descriptor,
200
+ no skill, no view.
201
+ - **The id is independent of placement.** `dt set meetings/<id> company=companies/harbor` MOVES
202
+ the file into Harbor's folder and changes nothing else: not the id, not one inbound reference.
203
+ Clearing the field moves it back to the fallback root. An id is unique across every root, and a
204
+ second file claiming one is a `check` violation and a write refusal, never last-one-wins.
205
+ - **`field`** is a scalar `x-reference` to exactly ONE collection (a list has no single folder; a
206
+ union has no single parent). **`path`** is a relative folder inside each parent record's folder.
207
+ compile derives `under.collection` from the field; nothing else is authored.
208
+ - **The parent must be `shape: folder`** (`storage: { shape: folder, entry: company.md }`) — only
209
+ a folder can hold anything beside the record. A file-shape collection that should become a
210
+ parent changes its descriptor to folder shape, and `dt relocate <collection>` then moves each
211
+ `<id>.<suffix>.md` into `<id>/<entry>` with its id unchanged (`check` names them until it runs).
212
+ - **One level.** A placed collection cannot itself be a parent; the child is a text record
213
+ (`md` · `yaml` · `json`, file shape) — an opaque or folder-shape child is refused for now. Two
214
+ children of one parent need two different paths, and a path never equals the parent's entry.
215
+ - **The field is the owner; the folder is observed placement.** A file found under the wrong
216
+ company — moved by hand, or sitting in the fallback root from before the declaration existed — is
217
+ `placed under … but <field> is …` in `check`, which changes nothing. `dt relocate <collection>`
218
+ (or `<collection>/<id>`, `--dry-run` first) moves files to where the compiled descriptor puts
219
+ them and refuses a source with unpublished changes or an occupied destination. Editing some OTHER
220
+ field never relocates as a side effect; nothing ever infers an owner from where a file was found.
221
+ - **A parent with records inside its folder cannot be removed** — not with `--force` either;
222
+ reassign or clear their owner first. Renaming the parent carries the folder with everything in
223
+ it and rewrites the children's owner field; their ids do not change.
224
+ - **Adopting it on existing data is three explicit steps**, each reviewable: make the parent folder
225
+ shape and `relocate` it; add `under` to the child and compile (no file moves at compile — `check`
226
+ reports every mismatch); `relocate` the child, `--dry-run` first. `dt commit` stages a moved
227
+ record's old and new path together; `dt revert` of an owner change moves the file back.
228
+ - **Removing or changing `under` is the same walk backwards, and compile holds the door.** While
229
+ records still sit inside parent folders, a compile that drops the declaration, changes its
230
+ `path`, or moves the PARENT collection's `storage.path` is REFUSED — the new descriptor would stop
231
+ every reader seeing them. The order is
232
+ `dt relocate <collection> --to-root` (every placed record back into the collection's own folder,
233
+ ids unchanged, under the still-current declaration) → edit the descriptor → compile → `dt relocate
234
+ <collection>` to place them under the new path. The same order renames a placed collection.
235
+ - **`relocate` refuses before it moves anything**: a dangling or malformed owner field (fix the
236
+ field first — it never makes a folder for a parent that does not exist), an occupied destination,
237
+ a source with unpublished changes, or a destination behind a symlink. One problem refuses the
238
+ whole plan; `--dry-run` reports it.
239
+ - **Inside a parent's folder, only real entries count.** A symlink at a child root, on the way to
240
+ one, or anywhere beneath one — a folder or a file — is never written through and never read as a
241
+ record; `check` names it. The collection's own roots (`data/`, `storage.path`) are not subject to
242
+ this; the rule is about what a record folder may contain.
243
+ - **When NOT to use it.** A record several parents share equally, a record whose owner is usually
244
+ unknown, or a collection nobody browses as a folder — keep conventional storage and a plain
245
+ reference. Folder grouping is a browsing convenience, never a permission boundary.
176
246
 
177
247
  ## `templates:` — a live shared field set
178
248
 
@@ -300,7 +300,15 @@ roadmap, not a model; wait for the second consumer.
300
300
  `max_bytes` (default 200 KB). A big binary is not a record: it lives outside the vault under a
301
301
  declared var, with an ordinary record carrying the `${env:...}` template that points at it.
302
302
  - **`shape: folder` when a record is intrinsically several files** (a skill with references beside
303
- it). Rare; prefer one file until the record itself demands companions.
303
+ it), or when OTHER collections' records should live inside it — see the next bullet. Otherwise
304
+ prefer one file until the record itself demands companions.
305
+ - **`storage.under` when people browse a parent as a unit** — a company folder holding that
306
+ company's meetings, a case folder holding its documents. It is one line on the CHILD
307
+ (`under: { field: company, path: meetings }`), the collection stays ONE collection with the same
308
+ ids and references, and the owner field is what moves a file (`collections.md`). Choose ONE
309
+ physical owner and leave every other relationship a plain reference; keep conventional storage
310
+ when no single owner is sensible, when the owner is usually unknown, or when nobody would open
311
+ the folder. It organises files; it grants nothing.
304
312
  - **Machine-specific paths are templates, never absolute paths.** `${env:FILES_FOLDER}/…` is inert
305
313
  data rendered per machine by `dt resolve`; an absolute path in a record is wrong on every other
306
314
  machine, silently. **A files folder is named after the collection or field that indexes it, and
@@ -111,6 +111,23 @@ first slash. the default namespace has no prefix (`tasks/kickoff`, exactly as al
111
111
  undeclared prefix reads as a nested id and dangles — `dt check` says so. declaring one is
112
112
  `collections.md`.
113
113
 
114
+ ## records stored under another record — the folder follows the owner
115
+
116
+ a collection may declare `storage.under` (`collections.md`): a meeting with a `company` lives in
117
+ that company's folder, one without stays in `data/meetings/`. working with it is unchanged in
118
+ every way that names a record — `list` is the whole collection, `get`/`set`/`rm`/`rename` take
119
+ `meetings/<id>` wherever the file sits, references never carry a folder — and different in one:
120
+ **the owner field moves the file.** `dt set meetings/<id> company=companies/harbor` relocates the
121
+ record into Harbor's folder with the same id and every inbound reference intact; `company=`
122
+ moves it back to the fallback root. so never `mv` one by hand, exactly as for a rename — a file
123
+ under the wrong company is what `check` reports as `placed under … but`, and `dt relocate
124
+ <collection>[/<id>]` (`--dry-run` first) is what moves it to where its field says — refusing whole
125
+ when an owner field dangles, a destination is taken or a source is unpublished. a parent
126
+ holding records in its folder refuses `rm` until they are reassigned; `dt commit <collection>/<id>`
127
+ after a move publishes both paths; `dt revert` of an owner change moves the file back too. before
128
+ the declaration is removed or its path changed: `dt relocate <collection> --to-root`
129
+ (`collections.md` has the order — compile refuses the edit while records would be stranded).
130
+
114
131
  ## two-way relations — the mirror is generated, and read-only
115
132
 
116
133
  a reference field may declare `x-inverse`: compile GENERATES the field it names on the TARGET
package/src/check.js CHANGED
@@ -10,6 +10,7 @@ import { NO_RUNTIME, loadDescriptors, runtimeDir, namespaces as compiledNamespac
10
10
  import { parseRef } from './namespace.js';
11
11
  import { refTargetsOf, refIsSoft } from './ref.js';
12
12
  import { relationsOf, expectedMirrors } from './relations.js';
13
+ import { placementOf, placedRecords, ownerIdOf, symlinkedChildRoots } from './placement.js';
13
14
 
14
15
  export function check({ root }) {
15
16
  const RUNTIME = runtimeDir(root);
@@ -34,16 +35,19 @@ export function check({ root }) {
34
35
 
35
36
  // ---- index all records: collection -> Map<id, filePath> ------------------------
36
37
  const index = new Map();
38
+ // placed collections only: id -> the parent id its file was FOUND under (null = the fallback root)
39
+ const observed = new Map();
37
40
  const strays = [];
38
41
  // declared here rather than beside the validation pass: indexing can itself produce a
39
42
  // finding (an unreachable data root, below) before a single record is read.
40
43
  const violations = [];
44
+ const dirOf = (d) => path.join(d.storage.base === 'runtime' ? RUNTIME : root, d.storage.path);
41
45
  for (const [name, d] of descriptors) {
42
46
  const ids = new Map();
43
47
  index.set(name, ids);
44
48
  // runtime-based (knowhow/meta) collections are read from the COMPILED runtime —
45
49
  // their sources may live in any module; .dreamteamer is the merged read surface
46
- const dir = path.join(d.storage.base === 'runtime' ? RUNTIME : root, d.storage.path);
50
+ const dir = dirOf(d);
47
51
  // An unreachable data ROOT is a finding, not a skip: a collection whose module clone is
48
52
  // missing otherwise reports zero records and a clean check — a silent success. An EMPTY
49
53
  // directory stays fine (a module with no records yet is normal); only a missing owning
@@ -53,13 +57,46 @@ export function check({ root }) {
53
57
  violations.push({ file: d.storage.path, msg: `collection "${name}" is owned by ${d.storage.repo}, which is not present — every record in it is unreadable` });
54
58
  continue;
55
59
  }
60
+ const under = placementOf(d);
61
+ if (under) {
62
+ // ONE logical collection across the fallback root and every parent folder — the same walk
63
+ // the store indexes with (src/placement.js), so the two cannot disagree about which files
64
+ // are records. The first file to claim an id keeps it; every later one is a violation,
65
+ // because a `get` that silently answered from whichever folder sorted later is the
66
+ // failure this report exists to make visible.
67
+ const seen = new Map();
68
+ observed.set(name, seen);
69
+ const parentDir = dirOf(descriptors.get(under.collection));
70
+ // a child root that is (or sits behind) a symlink is not read — whatever it points at is not
71
+ // this parent's folder — and it is named here rather than silently skipped
72
+ for (const link of symlinkedChildRoots(under, parentDir)) {
73
+ violations.push({ file: rel(link), msg: `is a symlink — ${name} records are read only from real folders inside ${under.collection} records; whatever this points at is not indexed. Replace it with a real folder.` });
74
+ }
75
+ const onLink = (p) => violations.push({ file: rel(p), msg: `is a symlink inside a ${under.collection} record's folder — nothing behind it is read as a ${name} record, and nothing is written through it. Replace it with a real folder or file.` });
76
+ for (const r of placedRecords(d, dir, parentDir, onLink)) {
77
+ if (ids.has(r.id)) {
78
+ violations.push({ file: rel(r.file), msg: `collection "${name}" holds the id "${r.id}" twice — ${rel(ids.get(r.id))} and ${rel(r.file)}. Remove one.` });
79
+ continue;
80
+ }
81
+ ids.set(r.id, r.file);
82
+ seen.set(r.id, r.parentId);
83
+ }
84
+ continue;
85
+ }
56
86
  if (!fs.existsSync(dir)) continue;
57
87
  const shape = d.storage.shape ?? 'file';
58
88
  if (shape === 'folder') {
89
+ // a FILE record at the root of a folder-shape collection is the state a collection is in
90
+ // right after its shape changed — named as such, with the verb that finishes the change
91
+ const asFile = { ...d, storage: { ...d.storage, shape: 'file' } };
59
92
  for (const entry of fs.readdirSync(dir).sort()) {
60
93
  if (entry.startsWith('.')) continue;
61
94
  const p = path.join(dir, entry);
62
- if (!fs.statSync(p).isDirectory()) { strays.push({ collection: name, file: rel(p) }); continue; }
95
+ if (!fs.statSync(p).isDirectory()) {
96
+ const legacy = idFromRecordPath(asFile, entry) !== null;
97
+ strays.push({ collection: name, file: rel(p), note: legacy ? `a file-shape record in a folder-shape collection — dreamteamer relocate ${name} moves it to ${entry.split('.')[0]}/${d.storage.entry}` : undefined });
98
+ continue;
99
+ }
63
100
  const main = path.join(p, d.storage.entry ?? 'SKILL.md');
64
101
  if (fs.existsSync(main)) ids.set(entry, main);
65
102
  else strays.push({ collection: name, file: rel(p), note: `missing entry file ${d.storage.entry}` });
@@ -127,6 +164,24 @@ export function check({ root }) {
127
164
  checkRef(file, fieldPath, value, target, softTargets, soft);
128
165
  }
129
166
  }
167
+ // ---- placement: the FIELD is the intended owner, the FOLDER is observed placement ---
168
+ // Reported, never repaired: a record found under the wrong company is either a hand move
169
+ // (the field is right, run relocate) or a hand edit of the field (the folder is right, set
170
+ // it back) and only a person knows which. A malformed owner value is skipped here — the
171
+ // reference check above has already named it, and "placed under X but owner is empty"
172
+ // on top of that would be a second report of one typo.
173
+ const under = placementOf(d);
174
+ if (under) {
175
+ const raw = fields[under.field];
176
+ const wellFormed = raw == null || raw === '' || parseRef(raw, namespaces);
177
+ const want = ownerIdOf(fields, under, (v) => parseRef(v, namespaces));
178
+ const got = observed.get(name).get(id);
179
+ if (wellFormed && want !== got) {
180
+ const where = got ? `under ${under.collection}/${got}` : `in its own root (${d.storage.path})`;
181
+ const should = want ? `${under.field} is ${under.collection}/${want}` : `${under.field} is empty`;
182
+ flag(file, `placed ${where} but ${should} — the file is not where its owner puts it. Run: dreamteamer relocate ${name}/${id}`);
183
+ }
184
+ }
130
185
  parsed.get(name).set(id, fields);
131
186
  }
132
187
  }
package/src/checkout.js CHANGED
@@ -55,7 +55,7 @@ export function planInstall(state, opts = {}) {
55
55
  const { checkout: c } = state;
56
56
  const steps = [];
57
57
  // ⚠ EVERY declared direct dependency, not just the engine. A worktree whose engine is a mirrored dev
58
- // LINK used to read as ready while an installed extension (@dreamteamer/workflows) was missing — so
58
+ // LINK used to read as ready while an installed extension was missing — so
59
59
  // npm never ran, and compile then refused the extension's source folder as an unknown kind.
60
60
  const missing = state.missingDeps ?? [];
61
61
  steps.push(missing.length
@@ -336,7 +336,7 @@ export function readHookInput(stdinText) {
336
336
 
337
337
  // The hook events and the verb each one runs. Core owns ONE — making the checkout a session opens in
338
338
  // ready — and an installed extension adds its own (`hooks:` in its contribution; the worktree
339
- // lifecycle lives in @dreamteamer/workflows). NO MATCHER on any of them (spec §13.9): bootstrap is
339
+ // lifecycle belongs to a worktree extension). NO MATCHER on any of them (spec §13.9): bootstrap is
340
340
  // idempotent precisely so the session-start hook may fire on every event — `startup` alone would
341
341
  // silence it on resume, clear, compact and fork, which is most of what a long session actually does.
342
342
  const CLAUDE_HOOKS = { SessionStart: 'install --hook' };
package/src/cli.js CHANGED
@@ -79,6 +79,17 @@ the longest DECLARED collection prefix, so finance/transactions/2026/03/coffee i
79
79
  relations [<collection>] (every two-way pair: owner.field → target.mirror)
80
80
  relations rebuild <collection> [--drop <f>] (regenerate mirror VALUES from the owning side;
81
81
  --drop removes a stale ex-mirror key from records)
82
+ relocate <target> [--dry-run] [--json] (move record FILES to where the compiled descriptor
83
+ puts them — a record stored under another
84
+ collection (storage.under) whose folder disagrees
85
+ with its owner field, or a file record in a
86
+ collection that became shape: folder. Ids and
87
+ references never change; a pending edit on a
88
+ moved file, or a dangling owner, refuses the whole
89
+ plan — commit, or fix the field, first)
90
+ relocate <collection> --to-root [--dry-run] (the REVERSE: every placed record back into the
91
+ collection's own folder, ids unchanged — the step
92
+ before removing or changing storage.under)
82
93
  resolve '<string>' | <collection>/<id> <field>
83
94
  (render \${env:NAME} · \${workspaceFolder} ·
84
95
  \${userHome} — the ONLY substitution point; a
@@ -245,7 +256,7 @@ export const WORKSPACE_FLAGS = {
245
256
  init: ['name', 'data-path', 'harnesses', 'workspace-module'], update: [],
246
257
  install: ['clone', 'dry-run', 'json', 'link-env', 'all', 'hook', 'print-adapters'],
247
258
  compile: ['watch'], check: [], status: [],
248
- changes: ['since', 'json'], commit: ['dry-run', 'json'],
259
+ changes: ['since', 'json'], commit: ['dry-run', 'json'], relocate: ['dry-run', 'json', 'to-root'],
249
260
  };
250
261
 
251
262
  /** Every verb this CLI answers itself — the set an extension's `commands` may not claim. The retired
@@ -254,7 +265,7 @@ export const WORKSPACE_FLAGS = {
254
265
  export const CORE_VERBS = [
255
266
  'init', 'install', 'update', 'compile', 'check', 'status', 'changes', 'commit', 'help', 'version', '--version', '-v',
256
267
  'list', 'add', 'values', 'get', 'set', 'rm', 'rename', 'history', 'diff', 'revert', 'move', 'next',
257
- 'add-field', 'set-field', 'rm-field', 'rename-field', 'relations', 'resolve',
268
+ 'add-field', 'set-field', 'rm-field', 'rename-field', 'relations', 'resolve', 'relocate',
258
269
  'schema', 'ensure', 'update-field', 'remove-field', 'commands',
259
270
  ];
260
271
 
@@ -547,6 +558,24 @@ export async function run(argv) {
547
558
  case 'relations':
548
559
  warnIfStale(ws.root);
549
560
  process.exit(relationsCommand(ws, rest));
561
+ case 'relocate': {
562
+ warnIfStale(ws.root);
563
+ const store = new Store(ws);
564
+ const target = rest.find((a) => !a.startsWith('--'));
565
+ if (!target) throw new Error('dt relocate needs a target: dreamteamer relocate <collection> | <collection>/<id> [--dry-run]');
566
+ // a collection, or one record of it — the either-shape every other target has
567
+ const asCollection = canonicalCollection(store.descriptors, target);
568
+ const { collection, id } = asCollection ? { collection: asCollection, id: null } : splitRef(store.descriptors, target);
569
+ const out = store.relocate(collection, { only: id ? [id] : null, dryRun: rest.includes('--dry-run'), toRoot: rest.includes('--to-root') });
570
+ if (rest.includes('--json')) { emit(JSON.stringify(out, null, 2)); process.exit(out.problems.length ? 1 : 0); }
571
+ const rel = (p) => path.relative(ws.root, p);
572
+ for (const m of out.moves) console.log(`${out.applied ? '✔' : '→'} ${collection}/${m.id} ${rel(m.from)} → ${rel(m.to)}`);
573
+ for (const p of out.problems) console.error(`✖ ${p}`);
574
+ if (!out.moves.length && !out.problems.length) console.log(`nothing to relocate — every ${collection} record is where its descriptor puts it`);
575
+ else if (!out.applied && !out.problems.length) console.log(`${out.moves.length} move(s) planned (dry run) — nothing was moved`);
576
+ else if (out.applied) console.log(`${out.moves.length} record(s) relocated — ids and references unchanged; \`dreamteamer commit ${collection}\` publishes the moves`);
577
+ process.exit(out.problems.length ? 1 : 0);
578
+ }
550
579
  case 'resolve':
551
580
  process.exit(resolveVariables(ws, rest));
552
581
  default:
package/src/commit.js CHANGED
@@ -329,6 +329,13 @@ function scopeByRepo(descriptors, only) {
329
329
  const repo = d.storage.repo ?? '.';
330
330
  if (!byRepo.has(repo)) byRepo.set(repo, []);
331
331
  byRepo.get(repo).push(p);
332
+ // A collection stored UNDER another keeps most of its files inside the parent's folder, so
333
+ // scoping `git status` to its own path alone would sample only the fallback root and report
334
+ // the rest as "nothing pending" — the one report that looks like success. The parent's path
335
+ // joins the pathspec; pathToRecord then attributes each file to the collection it belongs to,
336
+ // and the row filter in commitPlan keeps the parent's own records out of a scoped commit.
337
+ const parent = d.storage.under && descriptors.get(d.storage.under.collection);
338
+ if (parent?.storage?.path && !byRepo.get(repo).includes(parent.storage.path)) byRepo.get(repo).push(parent.storage.path);
332
339
  }
333
340
  return byRepo;
334
341
  }
package/src/compile.js CHANGED
@@ -10,6 +10,8 @@ import addFormats from 'ajv-formats';
10
10
  import { load, dump } from './yaml.js';
11
11
  import { slug } from './template.js';
12
12
  import { walk, patternRe } from './records.js';
13
+ import { refTargetsOf } from './ref.js';
14
+ import { subpathProblem, placedRecords } from './placement.js';
13
15
  import { unknownOperators } from './filter.js';
14
16
  import {
15
17
  normalizeNamespaces, namespaceProblems, unqualifiedProblems, defaultStoragePath, storageOverlaps,
@@ -20,7 +22,7 @@ import { runHarnessAdapters, renderContributions, BEGIN, END, INSTRUCTIONS_BEGIN
20
22
  import { ensureEditorRecommendation, ensureEnvExample } from './workspace.js';
21
23
  import { satisfies } from './semver.js';
22
24
  import { parseEnvValues } from './env-vars.js';
23
- import { DERIVED_KINDS, readManifest, runtimeDir, engineId, engineVersion } from './runtime.js';
25
+ import { DERIVED_KINDS, readManifest, runtimeDir, engineId, engineVersion, loadDescriptors as loadCompiledDescriptors } from './runtime.js';
24
26
  import { excludedFromKind, disablesPackage, isPackageEntry } from './extensions.js';
25
27
  export { engineId, engineVersion, readManifest };
26
28
 
@@ -381,6 +383,101 @@ function stampMirror(byName, ctx, ownerName, field, prop, holder, mirrorName, ta
381
383
  t.schema.properties = { ...t.schema.properties, [mirrorName]: generated };
382
384
  }
383
385
 
386
+ /**
387
+ * `storage.under` — RELATIONSHIP-BASED STORAGE, validated and derived (see src/placement.js for the
388
+ * contract the record layer holds). Authored as `{ field, path }` on the CHILD; compiled with the
389
+ * parent `collection` stamped on, read off the field's `x-reference`, so Store, check and events
390
+ * never open a schema to find the parent.
391
+ *
392
+ * Authored on the child rather than on the parent's inverse field on purpose: the parent's side of
393
+ * the relation is a GENERATED mirror (or absent — no inverse is required), and `storage` is the
394
+ * block that already answers "where do THIS collection's records live". One authored spelling, one
395
+ * compiled spelling, no second copy.
396
+ *
397
+ * Every refusal below is a shape the record layer could not make safe at runtime: a list owner has
398
+ * no single folder, a file-shape parent has no folder at all, an opaque or folder-shape child needs
399
+ * code the store does not carry yet, a second level of nesting has no reader, and two children on
400
+ * one path would index each other's files. Compile is where a descriptor is read, so compile says no.
401
+ */
402
+ function resolvePlacement(byName) {
403
+ const claims = new Map(); // parent collection -> [{ path, name }]
404
+ for (const [name, d] of byName) {
405
+ const under = d.storage?.under;
406
+ if (under === undefined) continue;
407
+ const where = `collection "${name}": storage.under`;
408
+ if (!under || typeof under !== 'object' || Array.isArray(under)) fail(`${where} must be an object { field: <reference field>, path: <folder inside the parent record> }`);
409
+ const unknown = Object.keys(under).filter((k) => k !== 'field' && k !== 'path');
410
+ if (unknown.length) fail(`${where} has unknown key(s) ${unknown.join(', ')} — it takes \`field\` and \`path\`, nothing else`);
411
+ if (typeof under.field !== 'string' || !under.field) fail(`${where}.field must name the scalar reference field that holds the parent`);
412
+ const bad = subpathProblem(under.path);
413
+ if (bad) fail(`${where}.path ${bad}`);
414
+ // the CHILD's own shape first: an opaque collection has no authored fields at all (compile
415
+ // replaces its schema with the derived ones), so judged later this would read as "no such field"
416
+ if ((d.storage.codec ?? 'md') === 'file') fail(`${where}: this collection is codec: file — an opaque record is not placed under a parent yet; keep it in its own folder`);
417
+ if ((d.storage.shape ?? 'file') === 'folder') fail(`${where}: this collection is shape: folder — a folder record is not placed under a parent yet; keep it in its own folder`);
418
+ const prop = d.schema?.properties?.[under.field];
419
+ if (!prop || typeof prop !== 'object') fail(`${where}.field "${under.field}" — no such field in ${name}'s schema`);
420
+ if (prop.type === 'array' || prop.items) fail(`${where}.field "${under.field}" is a list — a record lives in ONE place, so its owner is a scalar reference`);
421
+ const targets = refTargetsOf(prop);
422
+ if (!targets) fail(`${where}.field "${under.field}" is not a reference — the owner field needs \`x-reference: <parent collection>\``);
423
+ if (targets === '*' || targets.length !== 1) fail(`${where}.field "${under.field}" must reference exactly one collection — a record can live under one kind of parent`);
424
+ const parentName = targets[0];
425
+ const parent = byName.get(parentName);
426
+ if (!parent) fail(`${where}: parent collection "${parentName}" is not installed — a record cannot live inside a folder nothing provides`);
427
+ // nesting before shape: a placed collection is file-shape by the rule two lines up, so judged
428
+ // the other way round every nesting attempt would be told to make its parent a folder
429
+ if (parent.storage?.under !== undefined) fail(`${where}: "${parentName}" is itself stored under another collection — one level is supported; a placed collection cannot be a parent`);
430
+ if ((parent.storage?.shape ?? 'file') !== 'folder') fail(`${where}: "${parentName}" is not shape: folder — a record can only live INSIDE a parent that is a folder (storage: { shape: folder, entry: <file> } on ${parentName})`);
431
+ if ((parent.storage?.repo ?? '.') !== (d.storage?.repo ?? '.')) fail(`${where}: "${parentName}" lives in another git repo (storage.repo) — a record and the folder it sits in must share one`);
432
+ const entry = parent.storage.entry;
433
+ if (entry && under.path.split('/')[0] === entry) fail(`${where}.path "${under.path}" collides with ${parentName}'s entry file "${entry}" — pick a folder name`);
434
+ const siblings = claims.get(parentName) ?? [];
435
+ for (const s of siblings) {
436
+ if (s.path === under.path || s.path.startsWith(under.path + '/') || under.path.startsWith(s.path + '/')) {
437
+ fail(`collections "${s.name}" and "${name}" both store records under ${parentName}/<id>/${s.path === under.path ? s.path : `${s.path} · ${under.path}`} — one would index the other's files; give each its own folder`);
438
+ }
439
+ }
440
+ claims.set(parentName, [...siblings, { path: under.path, name }]);
441
+ d.storage.under = { field: under.field, path: under.path, collection: parentName };
442
+ }
443
+ }
444
+
445
+ /**
446
+ * A `storage.under` that is REMOVED or CHANGED while records still sit under the old declaration is
447
+ * refused (R3). The compiled descriptor is the only thing that knows where those records are: the
448
+ * moment it is rewritten, listing, check and relocate all read the new layout, the old child folders
449
+ * fall out of every walk, and a workspace with records on disk reports ✔ 0 violations over fewer
450
+ * records than it holds — the quietest data loss there is. So the runtime about to be replaced is
451
+ * read first, and a transition with records in the way names the two-step that is safe:
452
+ * `relocate --to-root` under the OLD declaration (ids unchanged), then compile, then `relocate`.
453
+ * Adding `under` to a conventional collection moves nothing out of sight and is not refused.
454
+ */
455
+ function refusePlacementTransitions(root, byName) {
456
+ const previous = loadCompiledDescriptors(root);
457
+ if (!previous) return;
458
+ for (const [name, d] of byName) {
459
+ const prev = previous.get(name);
460
+ const was = prev?.storage?.under;
461
+ if (!was?.collection || !was.path) continue;
462
+ const now = d.storage?.under ?? null;
463
+ const parent = previous.get(was.collection);
464
+ if (!parent?.storage?.path || !prev.storage?.path) continue;
465
+ // The EFFECTIVE root, not only the annotation: the parent collection's own `storage.path` is
466
+ // part of where every child record is, so moving the parent's folder in its descriptor strands
467
+ // the children exactly as dropping `under` does (R3b).
468
+ const newParentPath = now ? byName.get(now.collection)?.storage?.path : null;
469
+ const same = now && now.collection === was.collection && now.path === was.path && newParentPath === parent.storage.path;
470
+ if (same) continue;
471
+ let n = 0;
472
+ for (const r of placedRecords(prev, path.join(root, prev.storage.path), path.join(root, parent.storage.path))) if (r.parentId !== null) n++;
473
+ if (!n) continue;
474
+ const what = !now ? 'storage.under was removed'
475
+ : now.collection !== was.collection || now.path !== was.path ? `storage.under changed (${was.path} → ${now.path})`
476
+ : `${was.collection}'s storage.path changed (${parent.storage.path} → ${newParentPath})`;
477
+ fail(`collection "${name}": ${what}, but ${n} ${name} record(s) still sit inside ${was.collection} folders (${parent.storage.path}/<id>/${was.path}/) — compiling would stop every reader seeing them. First move them out under the CURRENT declaration: dreamteamer relocate ${name} --to-root (to ${prev.storage.path}, ids unchanged), then compile${now ? `, then dreamteamer relocate ${name} to place them again` : ''}.`);
478
+ }
479
+ }
480
+
384
481
  /** The source kinds the compiler itself stages. An installed extension may add more
385
482
  * (`sourceKinds`, src/extensions.js) — every enumeration below reads `kindsOf(ws)`, never this alone. */
386
483
  export const KINDS = ['collections', 'skills', 'agents', 'commands', 'command-bindings', 'ui-views', 'collection-templates'];
@@ -1386,6 +1483,11 @@ export function compile(ws) {
1386
1483
  moduleDeps, wsModuleName,
1387
1484
  moduleOf: (n) => collOwner.get(n),
1388
1485
  });
1486
+ // ---- placement: a collection stored UNDER another ---------------------------------
1487
+ // Here for the same reason relations are: `storage.under` names a field of this collection AND
1488
+ // the shape of ANOTHER collection, so it can only be judged once every descriptor exists.
1489
+ resolvePlacement(new Map([...mergedGroups].map(([n, g]) => [n, g.merged])));
1490
+ refusePlacementTransitions(root, new Map([...mergedGroups].map(([n, g]) => [n, g.merged])));
1389
1491
 
1390
1492
  // ---- resolved labels, then bytes -------------------------------------------------
1391
1493
  // A second loop rather than a tail of the first: generated mirror fields do not exist until the
package/src/events.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import path from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { idFromRecordPath } from './records.js';
7
+ import { placedChildAt } from './placement.js';
7
8
 
8
9
  /** Record events between two points, across EVERY repo that holds records. `from` is a sha or a
9
10
  * date — a sha is meaningless in another repo, so it is resolved to its commit DATE and each
@@ -107,8 +108,12 @@ export function pathToRecord(descriptors, relPath) {
107
108
  const rest = relPath.slice(best.storage.path.length + 1);
108
109
  if (best.storage.shape === 'folder') {
109
110
  const entry = best.storage.entry ?? 'SKILL.md';
110
- if (!rest.endsWith('/' + entry)) return null;
111
- return { collection: best.name, id: rest.slice(0, -(entry.length + 1)) };
111
+ // `<id>/<entry>` is the parent's own record; anything deeper may be a record of a collection
112
+ // stored UNDER it (`<id>/meetings/2026/10/kickoff.meeting.md`), which the longest-prefix match
113
+ // above can never see because its own storage.path is elsewhere. One place decides, so a
114
+ // commit, an event and a surface agree on whose record a path is.
115
+ if (rest.endsWith('/' + entry) && rest.indexOf('/') === rest.length - entry.length - 1) return { collection: best.name, id: rest.slice(0, -(entry.length + 1)) };
116
+ return placedChildAt(descriptors, best.name, rest);
112
117
  }
113
118
  const id = idFromRecordPath(best, rest);
114
119
  return id === null ? null : { collection: best.name, id };
package/src/extensions.js CHANGED
@@ -58,8 +58,8 @@ export function declaredExtensions(ws) {
58
58
 
59
59
  /**
60
60
  * Does a `dreamteamer.disable` list switch off the WHOLE package `name`? An entry names a package by
61
- * its full name (`@dreamteamer/workflows`, `probe-kit`) or by its module id — the name with the npm
62
- * scope stripped (`workflows`), which is what every engine message calls a module. Anything else with
61
+ * its full name (`@scope/kit`, `probe-kit`) or by its module id — the name with the npm
62
+ * scope stripped (`kit`), which is what every engine message calls a module. Anything else with
63
63
  * a slash is `<module>/<entity>`, one entity of a module, and never the package.
64
64
  *
65
65
  * ⚠ The scoped full name used to be read as `<module>/<entity>` because it contains a slash, and the
package/src/harnesses.js CHANGED
@@ -19,8 +19,8 @@ export const KNOWN_HARNESSES = ['claude-code', 'codex', 'pi', 'gemini-cli', 'cur
19
19
  export const STAMP = '<!-- generated by dreamteamer compile — do not edit; source of truth lives in modules/<module>/<kind>/ -->';
20
20
 
21
21
  // ⚠ THE VALUES ARE A CONTRACT, not an implementation detail: every managed root file on every disk
22
- // already carries these two exact strings, and a tool that merges branches (@dreamteamer/workflows'
23
- // `land`) classifies a conflict by asking whether its hunks lie between them. Changing a byte orphans
22
+ // already carries these two exact strings, and a tool that merges branches (a worktree
23
+ // extension's `land`) classifies a conflict by asking whether its hunks lie between them. Changing a byte orphans
24
24
  // every block ever written and silently reclassifies a generated conflict as the operator's own prose.
25
25
  export const BEGIN = '<!-- dreamteamer:begin (generated — do not edit inside this block) -->';
26
26
  export const END = '<!-- dreamteamer:end -->';