dreamteamer 0.9.0 → 0.10.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 +11 -0
- package/package.json +1 -1
- package/src/compile.js +18 -1
- package/src/harnesses.js +167 -7
- package/src/schema-ops.js +125 -21
- package/src/store.js +19 -1
|
@@ -11,6 +11,17 @@ schema:
|
|
|
11
11
|
description:
|
|
12
12
|
type: string
|
|
13
13
|
description: What kind of thing this collection holds, in one line.
|
|
14
|
+
use_when:
|
|
15
|
+
type: string
|
|
16
|
+
description: >-
|
|
17
|
+
WHEN to reach for this collection, in one clause — rendered into the orientation block every
|
|
18
|
+
agent session loads. OPTIONAL and usually absent: for most collections the trigger IS the
|
|
19
|
+
description (`contacts` = a person; you use it when you have a person), and a restatement
|
|
20
|
+
there costs every session tokens while diluting the few that carry real information. The
|
|
21
|
+
test: author this only when an agent that FULLY UNDERSTANDS the description would still not
|
|
22
|
+
know to reach for the collection. It spans both acts — read ("you are about to diagnose a
|
|
23
|
+
defect — search here first") and write ("a day needs planning"). NOT a place for procedure:
|
|
24
|
+
a `how` belongs in the module's skill, this field holds a `when`.
|
|
14
25
|
title:
|
|
15
26
|
type: string
|
|
16
27
|
description: What to call this collection in the nav and page headers. DERIVED from `name` by title-casing when absent — author it only when that is wrong (`ui-views` → `UI Views`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dreamteamer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A workspace compiler for coding agents \u2014 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>",
|
package/src/compile.js
CHANGED
|
@@ -696,6 +696,17 @@ export function compile({ root, pkg }) {
|
|
|
696
696
|
}
|
|
697
697
|
const rt = path.join('collections', `${name}.collection.yaml`);
|
|
698
698
|
entries.set(rt, { sources: [...group.map((g) => g.src), ...templateSources], bytes: Buffer.from(dump(merged)) });
|
|
699
|
+
// A descriptor with no `description:` renders in the orientation block as a bare NAME — an
|
|
700
|
+
// agent learns the noun exists and nothing about when it is the right one. Derived pressure
|
|
701
|
+
// rather than a heroic backfill pass, and the same shape as the per-missing-env-key warning:
|
|
702
|
+
// non-blocking, named per offender, so the gap converges instead of being rediscovered.
|
|
703
|
+
//
|
|
704
|
+
// ⚠ Deliberately NO equivalent warning for `use_when`. That field is optional and correct to
|
|
705
|
+
// omit on most collections — warning on it would invert its authoring test and manufacture a
|
|
706
|
+
// restatement of the description on every collection that does not need one.
|
|
707
|
+
if (merged.storage.base !== 'runtime' && !String(merged.description ?? '').trim()) {
|
|
708
|
+
console.warn(`⚠ collection ${name} has no description — it renders as a bare name in the orientation block every session loads`);
|
|
709
|
+
}
|
|
699
710
|
counts.collections++;
|
|
700
711
|
if (extenders.length) mergedCount++;
|
|
701
712
|
}
|
|
@@ -840,7 +851,13 @@ export function compile({ root, pkg }) {
|
|
|
840
851
|
// clear each kind's folder, plus `system/` — a runtime compiled by a pre-flatten engine has the
|
|
841
852
|
// whole tree under there, and leaving it would keep stale descriptors on disk beside the fresh
|
|
842
853
|
// ones. Never `rm -rf` the runtime root itself: it also holds the write lock.
|
|
843
|
-
|
|
854
|
+
// ⚠ DERIVED_KINDS too, not just KINDS. `modules/` is projected rather than staged, so it was not
|
|
855
|
+
// in this loop and never got cleared — a module that was RENAMED or REMOVED left its old record
|
|
856
|
+
// behind forever, listing collections that no longer exist. `check` reads those records like any
|
|
857
|
+
// other, so it surfaced as a dangling reference in a file nobody had touched, twice in one day
|
|
858
|
+
// (`hq3-workspace` after the workspace-module rename, and again after `crm` was folded in). The
|
|
859
|
+
// runtime is build output; stale build output is the compiler's problem, not the reader's.
|
|
860
|
+
for (const kind of [...KINDS, ...DERIVED_KINDS]) fs.rmSync(path.join(RUNTIME, kind), { recursive: true, force: true });
|
|
844
861
|
fs.rmSync(path.join(RUNTIME, 'system'), { recursive: true, force: true });
|
|
845
862
|
fs.rmSync(path.join(RUNTIME, 'ui'), { recursive: true, force: true });
|
|
846
863
|
for (const [rt, e] of entries) {
|
package/src/harnesses.js
CHANGED
|
@@ -56,7 +56,7 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
|
|
|
56
56
|
}
|
|
57
57
|
summary.push(`claude-code → .claude (${n} files)`);
|
|
58
58
|
}
|
|
59
|
-
writeBlock(root, 'CLAUDE.md', on('claude-code') ? orientationBlock('claude-code', skillsIndex, sourceLayout, namespaces, version) : null);
|
|
59
|
+
writeBlock(root, 'CLAUDE.md', on('claude-code') ? orientationBlock('claude-code', skillsIndex, sourceLayout, namespaces, version, entries) : null);
|
|
60
60
|
|
|
61
61
|
// ---- shared cross-agent skills mirror (.agents/skills) — codex/pi discover it,
|
|
62
62
|
// cursor/gemini blocks point at it. written once no matter how many harnesses use it.
|
|
@@ -73,17 +73,17 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// ---- codex + pi: both read root AGENTS.md; one block serves both ----------------
|
|
76
|
-
writeBlock(root, 'AGENTS.md', on('codex') || on('pi') ? orientationBlock('agents-md', skillsIndex, sourceLayout, namespaces, version) : null);
|
|
76
|
+
writeBlock(root, 'AGENTS.md', on('codex') || on('pi') ? orientationBlock('agents-md', skillsIndex, sourceLayout, namespaces, version, entries) : null);
|
|
77
77
|
if (on('codex')) summary.push('codex → AGENTS.md block');
|
|
78
78
|
if (on('pi')) summary.push('pi → AGENTS.md block + .agents/skills');
|
|
79
79
|
|
|
80
80
|
// ---- gemini-cli: GEMINI.md is its context file -----------------------------------
|
|
81
|
-
writeBlock(root, 'GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout, namespaces, version) : null);
|
|
81
|
+
writeBlock(root, 'GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout, namespaces, version, entries) : null);
|
|
82
82
|
if (on('gemini-cli')) summary.push('gemini-cli → GEMINI.md block');
|
|
83
83
|
|
|
84
84
|
// ---- cursor: native .mdc rule (alwaysApply) ---------------------------------------
|
|
85
85
|
if (on('cursor')) {
|
|
86
|
-
const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version)}\n\n${STAMP}\n`;
|
|
86
|
+
const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version, entries)}\n\n${STAMP}\n`;
|
|
87
87
|
write('.cursor/rules/dreamteamer.mdc', Buffer.from(mdc));
|
|
88
88
|
summary.push('cursor → .cursor/rules/dreamteamer.mdc');
|
|
89
89
|
}
|
|
@@ -123,6 +123,161 @@ function buildSkillsIndex(entries) {
|
|
|
123
123
|
return index.sort((a, b) => a.id.localeCompare(b.id));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** One flattened line of prose from a possibly-folded YAML scalar. */
|
|
127
|
+
const flat = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
128
|
+
|
|
129
|
+
/** collection name -> description / use_when / whether it is a schema-ops collection, read from the
|
|
130
|
+
* compiled descriptor BYTES compile already holds. Same pattern as buildSkillsIndex: no filesystem
|
|
131
|
+
* read, and deliberately nothing that touches `data/` — see collectionsSection for why. */
|
|
132
|
+
function buildCollectionsIndex(entries) {
|
|
133
|
+
const index = [];
|
|
134
|
+
for (const [rt, e] of entries) {
|
|
135
|
+
const m = /^collections\/(.+)\.collection\.yaml$/.exec(rt);
|
|
136
|
+
if (!m) continue;
|
|
137
|
+
let d = {};
|
|
138
|
+
try { d = load(e.bytes.toString('utf8')) ?? {}; } catch { /* unparseable descriptor */ }
|
|
139
|
+
index.push({
|
|
140
|
+
name: d.name ?? m[1],
|
|
141
|
+
// DERIVED, never a hardcoded name list: `runtime` is exactly the schema-ops set
|
|
142
|
+
// (collections, commands, skills, agents, ui-views, command-bindings,
|
|
143
|
+
// collection-templates, modules) and stays right in a workspace shipping others.
|
|
144
|
+
system: d.storage?.base === 'runtime',
|
|
145
|
+
description: flat(d.description),
|
|
146
|
+
useWhen: flat(d.use_when),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return index.sort((a, b) => a.name.localeCompare(b.name));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Commands, for the harnesses that do NOT discover them natively. claude-code writes every command
|
|
153
|
+
* into `.claude/commands` and its harness injects name+description at session start, so an index in
|
|
154
|
+
* CLAUDE.md would be a second copy of what is already there. codex/pi/gemini-cli/cursor got one
|
|
155
|
+
* pointer line and nothing else — which is why this is conditional, like the skills index. */
|
|
156
|
+
function buildCommandsIndex(entries) {
|
|
157
|
+
const index = [];
|
|
158
|
+
for (const [rt, e] of entries) {
|
|
159
|
+
const m = /^commands\/(.+)\.command\.md$/.exec(rt);
|
|
160
|
+
if (!m) continue;
|
|
161
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(e.bytes.toString('utf8'));
|
|
162
|
+
let desc = '';
|
|
163
|
+
try { desc = (fm ? load(fm[1]) : {})?.description ?? ''; } catch { /* unparseable frontmatter */ }
|
|
164
|
+
index.push({ id: m[1], desc: flat(desc) });
|
|
165
|
+
}
|
|
166
|
+
return index.sort((a, b) => a.id.localeCompare(b.id));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** THE LEXICON — the half of the DSL that was never at t=0. The grammar (record shape, reference
|
|
170
|
+
* shape, namespaces) has been in this block since 0.6; an agent could form a well-shaped reference
|
|
171
|
+
* and not name one thing it could be about. The dogfood vault's hand-written list in CLAUDE.md is
|
|
172
|
+
* the argument for deriving it: that list named three collections for 48 hours after they were
|
|
173
|
+
* deleted, because a human had to remember to edit it.
|
|
174
|
+
*
|
|
175
|
+
* ⚠ NO RECORD COUNTS, and that is not an omission. This block lands in CLAUDE.md / AGENTS.md /
|
|
176
|
+
* GEMINI.md, which are COMMITTED files. A count changes on every write, so every compile after
|
|
177
|
+
* ordinary data work would re-dirty three tracked files — in trees where more than one agent has
|
|
178
|
+
* uncommitted work — and the number would be stale the moment it was printed. Ordering is
|
|
179
|
+
* namespace-grouped then alphabetical precisely so the block diffs ONLY when the schema changed. */
|
|
180
|
+
function collectionsSection(index, namespaces) {
|
|
181
|
+
if (!index.length) return [];
|
|
182
|
+
const nsOf = (name) => {
|
|
183
|
+
const i = name.indexOf('/');
|
|
184
|
+
const p = i < 0 ? '' : name.slice(0, i);
|
|
185
|
+
return namespaces.includes(p) ? p : '';
|
|
186
|
+
};
|
|
187
|
+
const lines = [
|
|
188
|
+
'',
|
|
189
|
+
'COLLECTIONS — the nouns of this workspace. The descriptor at',
|
|
190
|
+
'`.dreamteamer/collections/<name>.collection.yaml` is the authority on fields, id shape and',
|
|
191
|
+
'defaults — read it before writing a kind you have not written this session. Create records',
|
|
192
|
+
'with `dt <collection> add`: it generates the id and rejects invalid writes before disk.',
|
|
193
|
+
];
|
|
194
|
+
const data = index.filter((c) => !c.system);
|
|
195
|
+
for (const group of ['', ...namespaces]) {
|
|
196
|
+
for (const c of data.filter((c) => nsOf(c.name) === group)) {
|
|
197
|
+
lines.push(`- ${c.name}${c.description ? ` — ${c.description}` : ''}`);
|
|
198
|
+
if (c.useWhen) lines.push(` use when: ${c.useWhen}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const system = index.filter((c) => c.system).map((c) => c.name);
|
|
202
|
+
if (system.length) lines.push(`- schema-ops only (write with the meta verbs, never by hand): ${system.join(' · ')}`);
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Collection-templates are field sets modules stamp onto their collections — the provenance quad
|
|
207
|
+
* reaches ~40 collections in the dogfood vault and was stated nowhere an agent reads.
|
|
208
|
+
*
|
|
209
|
+
* ⚠ RENDERED, NEVER HARDCODED. An earlier draft wrote the four provenance field names straight
|
|
210
|
+
* into this file. That is recipe knowledge in core: `provenance` is shipped by a MODULE, and
|
|
211
|
+
* deliberately triplicated (`provenance`, `crm-provenance`, `rnd-provenance`) so each module stays
|
|
212
|
+
* copyable on its own. A workspace without them would have been handed a block that lies. Core
|
|
213
|
+
* renders the line; the module owns the sentence. */
|
|
214
|
+
function templatesSection(entries) {
|
|
215
|
+
const rows = [];
|
|
216
|
+
for (const [rt, e] of entries) {
|
|
217
|
+
const m = /^collection-templates\/(.+)\.collection-template\.yaml$/.exec(rt);
|
|
218
|
+
if (!m) continue;
|
|
219
|
+
let d = {};
|
|
220
|
+
try { d = load(e.bytes.toString('utf8')) ?? {}; } catch { /* unparseable template */ }
|
|
221
|
+
rows.push({ name: d.name ?? m[1], description: flat(d.description) });
|
|
222
|
+
}
|
|
223
|
+
if (!rows.length) return [];
|
|
224
|
+
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
225
|
+
return ['', 'CROSS-CUTTING TEMPLATES — field sets modules stamp onto their collections:',
|
|
226
|
+
...rows.map((r) => `- ${r.name}${r.description ? ` — ${r.description}` : ''}`)];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `can-enter` / `can-exit` rendered LITERALLY. Translating a filter into English would be a
|
|
230
|
+
* generator that has to grow with every operator the filter grammar gains, for prose the binding's
|
|
231
|
+
* own `description:` already carries. Real shapes are a nested map terminating in one `_op`:
|
|
232
|
+
* {status: {_eq: draft}} -> status=draft
|
|
233
|
+
* {status: {_in: [enriched, x]}} -> status in enriched|x
|
|
234
|
+
* {file: {_nempty: true}} -> file set
|
|
235
|
+
* {meeting: {_empty: true}} -> meeting empty */
|
|
236
|
+
const GATE_OPS = {
|
|
237
|
+
_eq: (p, v) => `${p}=${v}`,
|
|
238
|
+
_in: (p, v) => `${p} in ${[].concat(v).join('|')}`,
|
|
239
|
+
_nempty: (p) => `${p} set`,
|
|
240
|
+
_empty: (p) => `${p} empty`,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function renderGate(label, filter) {
|
|
244
|
+
const parts = [];
|
|
245
|
+
const walkFilter = (obj, at) => {
|
|
246
|
+
for (const [k, v] of Object.entries(obj ?? {})) {
|
|
247
|
+
if (k.startsWith('_')) parts.push((GATE_OPS[k] ?? ((p, x) => `${p} ${k.slice(1)} ${x}`))(at, v));
|
|
248
|
+
else if (v && typeof v === 'object') walkFilter(v, at ? `${at}.${k}` : k);
|
|
249
|
+
else parts.push(`${at ? `${at}.` : ''}${k}=${v}`);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
if (filter && typeof filter === 'object') walkFilter(filter, '');
|
|
253
|
+
return parts.length ? `${label}: ${parts.join(', ')}` : '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The METHODS on a type. A binding says which collection a command applies to and the record state
|
|
257
|
+
* that makes it available — invisible on EVERY harness, claude-code included, whose native command
|
|
258
|
+
* discovery gives the command's description and nothing about what it acts on. The cost of the
|
|
259
|
+
* absence is on disk: a contact record in the dogfood vault carries the hand-written sentence
|
|
260
|
+
* "`status: draft` keeps this in the /scrape-contact queue" — an agent re-deriving a can-enter
|
|
261
|
+
* filter into prose because nothing surfaced it. */
|
|
262
|
+
function bindingsSection(entries) {
|
|
263
|
+
const byCollection = new Map();
|
|
264
|
+
for (const [rt, e] of entries) {
|
|
265
|
+
if (!/^command-bindings\/.+\.command-binding\.yaml$/.test(rt)) continue;
|
|
266
|
+
let d = {};
|
|
267
|
+
try { d = load(e.bytes.toString('utf8')) ?? {}; } catch { continue; }
|
|
268
|
+
const coll = String(d.collection ?? '').replace(/^collections\//, '');
|
|
269
|
+
const cmd = String(d.command ?? '').replace(/^commands\//, '');
|
|
270
|
+
if (!coll || !cmd) continue;
|
|
271
|
+
const gate = [renderGate('enter', d['can-enter']), renderGate('exit', d['can-exit'])].filter(Boolean).join(' · ');
|
|
272
|
+
if (!byCollection.has(coll)) byCollection.set(coll, []);
|
|
273
|
+
byCollection.get(coll).push(`/${cmd}${gate ? ` (${gate})` : ''}`);
|
|
274
|
+
}
|
|
275
|
+
if (!byCollection.size) return [];
|
|
276
|
+
const lines = ['', 'VERBS BOUND TO COLLECTIONS (`dt commands for <collection>[/<id>]` answers per record):'];
|
|
277
|
+
for (const coll of [...byCollection.keys()].sort()) lines.push(`- ${coll} — ${byCollection.get(coll).sort().join(' · ')}`);
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
|
|
126
281
|
/** `sourceLayout` describes what THIS workspace actually looks like — 'flat' (`<module>/skills/`),
|
|
127
282
|
* 'nested' (the pre-2026-08-05 `<module>/system/skills/`), or 'mixed'. It is passed in rather than
|
|
128
283
|
* assumed because generated prose that contradicts the workspace is worse than no prose: this block
|
|
@@ -134,7 +289,7 @@ function buildSkillsIndex(entries) {
|
|
|
134
289
|
* agent that splits `health/doctors/dana-levi` at the first slash reads a collection that does not
|
|
135
290
|
* exist. Naming the declared list is what makes the grammar decidable from this block alone, without
|
|
136
291
|
* the agent having to go read the manifest. A workspace with no namespaces gets no extra sentence. */
|
|
137
|
-
function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces = [], version = 'unknown') {
|
|
292
|
+
function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces = [], version = 'unknown', entries = new Map()) {
|
|
138
293
|
const sourcesLine = {
|
|
139
294
|
flat: '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
|
|
140
295
|
nested: '`modules/<module>/system/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
|
|
@@ -145,7 +300,8 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces
|
|
|
145
300
|
// exactly one minor release and then quietly wrong in every workspace it had been written into.
|
|
146
301
|
// Passed rather than imported because compile.js already computes it and imports THIS module —
|
|
147
302
|
// reaching back for `engineVersion` would close a cycle for the sake of one string.
|
|
148
|
-
`this workspace is operated by dreamteamer v${version}
|
|
303
|
+
`this workspace is a typed record DSL operated by dreamteamer v${version} — collections are the`,
|
|
304
|
+
'nouns. **read the `using-dreamteamer` skill before',
|
|
149
305
|
'working with data.** schemas (read): `.dreamteamer/collections/` (provenance:',
|
|
150
306
|
'`.dreamteamer/manifest.yaml`). sources (write): ' + sourcesLine,
|
|
151
307
|
'`command-bindings/`, `ui-views/`, `collection-templates/`',
|
|
@@ -166,6 +322,9 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces
|
|
|
166
322
|
'DECLARED prefix, not at the first slash. collections with no prefix are unaffected.',
|
|
167
323
|
);
|
|
168
324
|
}
|
|
325
|
+
lines.push(...collectionsSection(buildCollectionsIndex(entries), namespaces));
|
|
326
|
+
lines.push(...templatesSection(entries));
|
|
327
|
+
lines.push(...bindingsSection(entries));
|
|
169
328
|
// claude-code discovers skills natively (Skill tool) — an index in CLAUDE.md is pure
|
|
170
329
|
// context bloat there. every other harness gets the trigger index + discovery pointers.
|
|
171
330
|
if (flavor !== 'claude-code') {
|
|
@@ -177,7 +336,8 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces
|
|
|
177
336
|
...skillsIndex.map((s) => `- \`${s.id}\` — ${s.desc}`),
|
|
178
337
|
'',
|
|
179
338
|
'agent personas live at `.dreamteamer/agents/*.agent.md`; commands at',
|
|
180
|
-
'`.dreamteamer/commands
|
|
339
|
+
'`.dreamteamer/commands/`, invoked as `/<name>` by the harness:',
|
|
340
|
+
...buildCommandsIndex(entries).map((c) => `- \`/${c.id}\` — ${c.desc}`),
|
|
181
341
|
);
|
|
182
342
|
}
|
|
183
343
|
return lines.join('\n');
|
package/src/schema-ops.js
CHANGED
|
@@ -95,6 +95,50 @@ function descriptorSourceDir(ws, name) {
|
|
|
95
95
|
return { dir: kindDir(moduleRoot, 'collections'), sources };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Set one scalar in a YAML document TEXTUALLY, so comments and key order survive.
|
|
100
|
+
*
|
|
101
|
+
* This exists because `load` → mutate → `dump` is lossy in the one way that matters here: it drops
|
|
102
|
+
* every comment. That is fine for a generated artifact and wrong for a module SOURCE, which is where
|
|
103
|
+
* this project writes down why a collection exists. Only `renameCollection` uses it, and only for the
|
|
104
|
+
* three scalars a rename changes; anything more ambitious belongs in a real round-trip YAML library,
|
|
105
|
+
* not in a regex.
|
|
106
|
+
*
|
|
107
|
+
* Handles both spellings the descriptors actually use — a top-level key, a nested block mapping, and
|
|
108
|
+
* the inline `storage: { path: x, suffix: y }` flow form. Callers MUST re-parse and assert, because a
|
|
109
|
+
* shape not covered here fails by changing nothing rather than by throwing.
|
|
110
|
+
*/
|
|
111
|
+
function setScalar(text, keyPath, value) {
|
|
112
|
+
const [head, child] = keyPath;
|
|
113
|
+
if (!child) return text.replace(new RegExp(`^${head}:.*$`, 'm'), `${head}: ${value}`);
|
|
114
|
+
|
|
115
|
+
// inline flow mapping: `storage: { path: data/x, suffix: y }`
|
|
116
|
+
const flow = new RegExp(`^${head}:\\s*\\{([^}]*)\\}\\s*$`, 'm').exec(text);
|
|
117
|
+
if (flow) {
|
|
118
|
+
let body = flow[1];
|
|
119
|
+
body = new RegExp(`\\b${child}:\\s*[^,}]+`).test(body)
|
|
120
|
+
? body.replace(new RegExp(`(\\b${child}:\\s*)[^,}]+`), `$1${value}`)
|
|
121
|
+
: `${body.trimEnd()}, ${child}: ${value}`;
|
|
122
|
+
return text.slice(0, flow.index) + `${head}: {${body}}` + text.slice(flow.index + flow[0].length);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// block mapping: `storage:\n path: data/x`
|
|
126
|
+
const block = new RegExp(`^${head}:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${child}:.*$`, 'm').exec(text);
|
|
127
|
+
if (block) {
|
|
128
|
+
return text.slice(0, block.index)
|
|
129
|
+
+ block[0].replace(new RegExp(`([ \\t]+${child}:).*$`, 'm'), `$1 ${value}`)
|
|
130
|
+
+ text.slice(block.index + block[0].length);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// the key is absent under an existing block — insert it directly after the parent
|
|
134
|
+
const parent = new RegExp(`^${head}:\\s*$`, 'm').exec(text);
|
|
135
|
+
if (parent) {
|
|
136
|
+
const at = parent.index + parent[0].length + 1;
|
|
137
|
+
return text.slice(0, at) + ` ${child}: ${value}\n` + text.slice(at);
|
|
138
|
+
}
|
|
139
|
+
return text;
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
// ---- ops ------------------------------------------------------------------------
|
|
99
143
|
|
|
100
144
|
export function createCollection(ws, store, { name, template, namespace }) {
|
|
@@ -263,21 +307,66 @@ export function renameCollection(ws, store, oldName, newName) {
|
|
|
263
307
|
};
|
|
264
308
|
for (const id of ids) captureRefs(`${oldName}/${id}`);
|
|
265
309
|
captureRefs(`collections/${oldName}`);
|
|
266
|
-
const restoreRefs = () => {
|
|
310
|
+
const restoreRefs = () => {
|
|
311
|
+
for (const [f, bytes] of refFiles) {
|
|
312
|
+
fs.mkdirSync(path.dirname(f), { recursive: true }); // pruneEmpty may have taken the parent
|
|
313
|
+
fs.writeFileSync(f, bytes);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
267
316
|
|
|
268
317
|
const touched = new Set();
|
|
269
318
|
let rewrites = 0;
|
|
270
319
|
try {
|
|
271
|
-
// 1. the descriptor source, at its new path
|
|
320
|
+
// 1. the descriptor source, at its new path — EDITED TEXTUALLY, never re-dumped.
|
|
321
|
+
//
|
|
322
|
+
// ⚠ `fs.writeFileSync(dest, dump(doc))` destroyed every comment in the descriptor, and a
|
|
323
|
+
// descriptor's comments are where this project keeps its reasoning: 194 lines across 24
|
|
324
|
+
// files in one real migration, including 22-line headers stating what belongs in a
|
|
325
|
+
// collection and which failure mode it guards against. The record survived; the thinking
|
|
326
|
+
// did not, and nothing said so.
|
|
327
|
+
//
|
|
328
|
+
// A rename changes exactly three scalars. Rewriting those three in place keeps the
|
|
329
|
+
// comments, the key order and the author's formatting — and the parse afterwards proves
|
|
330
|
+
// the edit landed rather than trusting the regex.
|
|
331
|
+
const edited = setScalar(setScalar(setScalar(srcBytes.toString('utf8'),
|
|
332
|
+
['name'], newName),
|
|
333
|
+
['storage', 'path'], newPath),
|
|
334
|
+
['storage', 'suffix'], newSuffix);
|
|
335
|
+
const parsed = load(edited);
|
|
336
|
+
if (parsed?.name !== newName || parsed?.storage?.path !== newPath || parsed?.storage?.suffix !== newSuffix) {
|
|
337
|
+
throw new Error(`could not rewrite ${path.relative(ws.root, src)} in place — name/storage.path/storage.suffix did not take. nothing was changed.`);
|
|
338
|
+
}
|
|
272
339
|
doc.name = newName;
|
|
273
340
|
doc.storage = { ...doc.storage, path: newPath, suffix: newSuffix };
|
|
274
341
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
275
|
-
fs.writeFileSync(dest,
|
|
342
|
+
fs.writeFileSync(dest, edited);
|
|
276
343
|
if (dest !== src) fs.rmSync(src);
|
|
277
344
|
touched.add(src);
|
|
278
345
|
touched.add(dest);
|
|
279
346
|
|
|
280
|
-
// 2.
|
|
347
|
+
// 2. INBOUND REFERENCES FIRST, while the records are still where the store thinks they are.
|
|
348
|
+
//
|
|
349
|
+
// ⚠ This used to run AFTER the folder move and it silently missed every SELF-reference.
|
|
350
|
+
// `store.rewriteRefs` walks `recordFiles()`, which resolves each collection's directory
|
|
351
|
+
// from the descriptor loaded when the Store was built — i.e. the OLD `storage.path`. Move
|
|
352
|
+
// the records first and that walk finds an empty directory, so a record pointing at its
|
|
353
|
+
// own collection is never rewritten and dangles the moment compile catches up.
|
|
354
|
+
//
|
|
355
|
+
// It is not a corner case: it hit `finance/accounts`, where every card and loan carries
|
|
356
|
+
// `settled_by: <the account that settles it>` — 5 dangling refs out of 11 records, found
|
|
357
|
+
// only because `check` ran afterwards. Doing the rewrite first needs no descriptor reload
|
|
358
|
+
// and no second code path: the files are still at the old path, which is exactly what the
|
|
359
|
+
// old refs say.
|
|
360
|
+
for (const id of ids) {
|
|
361
|
+
const out = store.rewriteRefs(`${oldName}/${id}`, `${newName}/${id}`);
|
|
362
|
+
rewrites += out.rewrites;
|
|
363
|
+
for (const f of out.touched) touched.add(f);
|
|
364
|
+
}
|
|
365
|
+
const collOut = store.rewriteRefs(`collections/${oldName}`, `collections/${newName}`);
|
|
366
|
+
rewrites += collOut.rewrites;
|
|
367
|
+
for (const f of collOut.touched) touched.add(f);
|
|
368
|
+
|
|
369
|
+
// 3. the record folder, then the per-file suffix if it was derived
|
|
281
370
|
if (newDir !== oldDir && fs.existsSync(oldDir)) {
|
|
282
371
|
fs.mkdirSync(path.dirname(newDir), { recursive: true });
|
|
283
372
|
fs.renameSync(oldDir, newDir);
|
|
@@ -295,33 +384,48 @@ export function renameCollection(ws, store, oldName, newName) {
|
|
|
295
384
|
}
|
|
296
385
|
if (movedData) { touched.add(oldDir); touched.add(newDir); }
|
|
297
386
|
|
|
298
|
-
// 3. inbound references: per record id, plus the collection's own id in `collections`
|
|
299
|
-
// (which is what ui-views and command-bindings point at).
|
|
300
|
-
for (const id of ids) {
|
|
301
|
-
const out = store.rewriteRefs(`${oldName}/${id}`, `${newName}/${id}`);
|
|
302
|
-
rewrites += out.rewrites;
|
|
303
|
-
for (const f of out.touched) touched.add(f);
|
|
304
|
-
}
|
|
305
|
-
const collOut = store.rewriteRefs(`collections/${oldName}`, `collections/${newName}`);
|
|
306
|
-
rewrites += collOut.rewrites;
|
|
307
|
-
for (const f of collOut.touched) touched.add(f);
|
|
308
|
-
|
|
309
387
|
// 4. bare `x-reference: <oldName>` in every descriptor SOURCE. Not a `<collection>/<id>`
|
|
310
|
-
// ref, so step
|
|
388
|
+
// ref, so step 2 cannot see it — and leaving it makes compile fail on an unknown target.
|
|
389
|
+
//
|
|
390
|
+
// ⚠ TEXTUAL, for the same reason step 1 is. This used to `load` → mutate → `dump`, which
|
|
391
|
+
// meant that ANY descriptor needing a retarget lost every comment in it — including the
|
|
392
|
+
// renamed one itself when it self-references, which is how step 1's careful preservation
|
|
393
|
+
// was undone one step later. 17 of the 24 descriptors stripped in the migration that
|
|
394
|
+
// found this were stripped HERE, not there.
|
|
395
|
+
//
|
|
396
|
+
// `retargetRefs` still decides WHETHER a file is affected — it walks the parsed schema and
|
|
397
|
+
// knows about nested properties and `items` — but the write is a line edit, and the parse
|
|
398
|
+
// afterwards proves it landed.
|
|
311
399
|
for (const f of descriptorSources(ws, store)) {
|
|
312
400
|
const before = fs.readFileSync(f, 'utf8');
|
|
313
|
-
const
|
|
314
|
-
if (!
|
|
401
|
+
const probe = load(before);
|
|
402
|
+
if (!probe || !retargetRefs(probe.schema, oldName, newName)) continue;
|
|
403
|
+
// ⚠ the boundary must cover BOTH spellings. A descriptor may write the block form
|
|
404
|
+
// (`x-reference: accounts` to end of line) or the inline flow form
|
|
405
|
+
// (`{ type: string, x-reference: accounts }`), where the value ends at `,` or `}`.
|
|
406
|
+
// Anchoring on `$` alone silently matched nothing in the flow form — and the assert
|
|
407
|
+
// below turned that silence into a refusal, which is how it was found.
|
|
408
|
+
const after = before.replace(
|
|
409
|
+
new RegExp(`(x-reference:\\s*)(['"]?)${oldName.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}\\2(?=\\s*(?:[,}]|#|$))`, 'gm'),
|
|
410
|
+
(_m, lead) => `${lead}${newName.includes('/') ? `'${newName}'` : newName}`);
|
|
411
|
+
const reparsed = load(after);
|
|
412
|
+
if (!reparsed || retargetRefs(reparsed.schema, oldName, newName)) {
|
|
413
|
+
throw new Error(`could not retarget x-reference "${oldName}" in ${path.relative(ws.root, f)} without reformatting it — nothing was changed.`);
|
|
414
|
+
}
|
|
315
415
|
if (!refFiles.has(f)) refFiles.set(f, Buffer.from(before));
|
|
316
|
-
fs.writeFileSync(f,
|
|
416
|
+
fs.writeFileSync(f, after);
|
|
317
417
|
touched.add(f);
|
|
318
418
|
rewrites++;
|
|
319
419
|
}
|
|
320
420
|
|
|
321
421
|
compile(ws); // the gate: an uncompilable rename never reaches history
|
|
322
422
|
} catch (e) {
|
|
323
|
-
|
|
423
|
+
// ⚠ undo() FIRST. A captured file can be a SELF-reference — a record of the collection being
|
|
424
|
+
// renamed — so its path only exists again once undo() has moved the folder back. Restoring
|
|
425
|
+
// before that wrote into a directory that was no longer there, and the ENOENT masked the
|
|
426
|
+
// error actually being rolled back from.
|
|
324
427
|
undo();
|
|
428
|
+
restoreRefs();
|
|
325
429
|
try { compile(ws); } catch { /* pre-rename sources were compilable */ }
|
|
326
430
|
throw e;
|
|
327
431
|
}
|
|
@@ -338,8 +442,8 @@ export function renameCollection(ws, store, oldName, newName) {
|
|
|
338
442
|
execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: collections rename ${oldName} → ${newName}`, '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
|
|
339
443
|
} catch (e) {
|
|
340
444
|
try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET }); } catch { /* nothing staged */ }
|
|
341
|
-
restoreRefs();
|
|
342
445
|
undo();
|
|
446
|
+
restoreRefs();
|
|
343
447
|
try { compile(ws); } catch { /* pre-rename sources were compilable */ }
|
|
344
448
|
throw new Error(`git commit failed — the rename was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
|
|
345
449
|
}
|
package/src/store.js
CHANGED
|
@@ -299,13 +299,31 @@ export class Store {
|
|
|
299
299
|
return new RegExp(`${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/-])`, 'g');
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
// ⚠ EACH FILE EXACTLY ONCE. The `modules` collection's storage.path is `modules` and
|
|
303
|
+
// `sourceRoots()` includes the workspace root, so walking it RE-YIELDS every module source that
|
|
304
|
+
// its own kind's walk already produced — 173 files in one real vault, every module source
|
|
305
|
+
// among them.
|
|
306
|
+
//
|
|
307
|
+
// That was harmless while every rewrite was idempotent, and stopped being harmless the day
|
|
308
|
+
// namespaces arrived: replacing `draft-docs/x` with `rnd/draft-docs/x` is NOT idempotent,
|
|
309
|
+
// because the result still contains the pattern. A second pass wrote
|
|
310
|
+
// `data/rnd/rnd/draft-docs/x` into module-source comments during a real migration. Dedupe HERE
|
|
311
|
+
// rather than making each caller idempotent — `findInboundRefs` is a caller too, and its counts
|
|
312
|
+
// were quietly doubled by the same walk.
|
|
302
313
|
*recordFiles() {
|
|
314
|
+
const seen = new Set();
|
|
303
315
|
for (const d of this.descriptors.values()) {
|
|
304
316
|
// for runtime-based collections, inbound-ref surgery targets SOURCES, not the runtime
|
|
305
317
|
const roots = d.storage.base === 'runtime' ? this.sourceRoots() : [this.root];
|
|
306
318
|
for (const srcRoot of roots) {
|
|
307
319
|
const dir = path.join(srcRoot, d.storage.path);
|
|
308
|
-
if (fs.existsSync(dir))
|
|
320
|
+
if (!fs.existsSync(dir)) continue;
|
|
321
|
+
for (const f of walk(dir)) {
|
|
322
|
+
const key = path.resolve(f);
|
|
323
|
+
if (seen.has(key)) continue;
|
|
324
|
+
seen.add(key);
|
|
325
|
+
yield f;
|
|
326
|
+
}
|
|
309
327
|
}
|
|
310
328
|
}
|
|
311
329
|
}
|