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.
- package/collections/collections.collection.yaml +26 -0
- package/package.json +1 -1
- package/skills/using-dreamteamer/SKILL.md +6 -2
- package/skills/using-dreamteamer/references/collections.md +71 -1
- package/skills/using-dreamteamer/references/data-modeling.md +9 -1
- package/skills/using-dreamteamer/references/records.md +17 -0
- package/src/check.js +57 -2
- package/src/checkout.js +2 -2
- package/src/cli.js +31 -2
- package/src/commit.js +7 -0
- package/src/compile.js +103 -1
- package/src/events.js +7 -2
- package/src/extensions.js +2 -2
- package/src/harnesses.js +2 -2
- package/src/placement.js +209 -0
- package/src/records-api.d.ts +10 -2
- package/src/schema-ops.js +10 -2
- package/src/store.js +396 -35
|
@@ -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.
|
|
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)
|
|
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 =
|
|
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()) {
|
|
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
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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 (`@
|
|
62
|
-
* scope stripped (`
|
|
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 (
|
|
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 -->';
|