dreamteamer 0.6.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -51
- package/package.json +62 -58
- package/skills/building-dreamteamer/SKILL.md +10 -7
- package/skills/building-dreamteamer/references/collections.md +32 -2
- package/skills/building-dreamteamer/references/commands.md +1 -1
- package/skills/building-dreamteamer/references/ui-views.md +10 -5
- package/skills/using-dreamteamer/SKILL.md +7 -6
- package/skills/using-dreamteamer/references/records.md +18 -1
- package/src/check.js +12 -6
- package/src/cli.js +7 -1
- package/src/collections-cli.js +28 -2
- package/src/compile.js +74 -7
- package/src/harnesses.js +29 -8
- package/src/init.js +20 -19
- package/src/namespace.js +181 -0
- package/src/runtime.js +29 -3
- package/src/schema-ops.js +260 -11
- package/src/server.js +13 -14
- package/src/store.js +56 -20
- package/collections/users.collection.yaml +0 -21
package/src/collections-cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Store, bodyField } from './store.js';
|
|
|
8
8
|
import { load, dump } from './yaml.js';
|
|
9
9
|
import { slug } from './template.js';
|
|
10
10
|
import {
|
|
11
|
-
createCollection, removeCollection, addField, updateField, removeField, fieldDef, saveUiView, removeUiView,
|
|
11
|
+
createCollection, removeCollection, renameCollection, addField, updateField, removeField, fieldDef, saveUiView, removeUiView,
|
|
12
12
|
// was copy-pasted here, and the copy went stale the moment the source layout gained a second
|
|
13
13
|
// spelling — one implementation, two callers
|
|
14
14
|
workspaceSystemDir,
|
|
@@ -17,6 +17,7 @@ import { history, historyDiff } from './history.js';
|
|
|
17
17
|
import { commandsFor, recordResolver } from './record-commands.js';
|
|
18
18
|
import { distinctValues } from './field-values.js';
|
|
19
19
|
import { matchesFilter } from './filter.js';
|
|
20
|
+
import { baseNameOf, normalizeNamespaces } from './namespace.js';
|
|
20
21
|
import { sortRows } from './temporal.js';
|
|
21
22
|
import { ensureRepo, ensureAllRepos } from './init.js';
|
|
22
23
|
|
|
@@ -69,6 +70,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
69
70
|
// ordinary record path refuses them ("… are system sources") and always would.
|
|
70
71
|
if (collection === 'collections' && verb === 'add') return metaCollectionsAdd(ws, store, flags);
|
|
71
72
|
if (collection === 'collections' && verb === 'rm') return metaCollectionsRm(ws, store, flags, pos);
|
|
73
|
+
if (collection === 'collections' && verb === 'rename') return metaCollectionsRename(ws, store, flags, pos);
|
|
72
74
|
if (collection === 'commands' && verb === 'for') return metaCommandsFor(ws, store, flags, pos);
|
|
73
75
|
if (collection === 'ui-views' && ['add', 'set', 'rm'].includes(verb)) return metaUiView(ws, store, verb, flags, pos);
|
|
74
76
|
if (collection === 'repos' && verb === 'ensure') return metaReposEnsure(ws, flags, pos);
|
|
@@ -228,12 +230,36 @@ function metaReposEnsure(ws, flags, pos) {
|
|
|
228
230
|
|
|
229
231
|
// `dreamteamer collections add --name research-docs --template docs`
|
|
230
232
|
function metaCollectionsAdd(ws, store, flags) {
|
|
231
|
-
const { file } = createCollection(ws, store, { name: flags.name, template: flags.template });
|
|
233
|
+
const { file } = createCollection(ws, store, { name: flags.name, template: flags.template, namespace: flags.namespace });
|
|
232
234
|
console.log(`✔ ${rel(ws.root, file)}`);
|
|
233
235
|
console.log('✔ compiled — the collection is live (schema ops prove sources with a real compile)');
|
|
234
236
|
return 0;
|
|
235
237
|
}
|
|
236
238
|
|
|
239
|
+
// `dreamteamer collections rename doctors health/doctors`, or `… doctors --namespace health`.
|
|
240
|
+
// The whole point is that namespacing EXISTING data is one command instead of a six-step hand
|
|
241
|
+
// migration whose last step (rewriting references) dangles everything when forgotten.
|
|
242
|
+
function metaCollectionsRename(ws, store, flags, pos) {
|
|
243
|
+
const [oldName, explicitNew] = pos;
|
|
244
|
+
if (!oldName) throw new Error('usage: collections rename <old-name> <new-name> | <old-name> --namespace <ns>');
|
|
245
|
+
// `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
|
|
246
|
+
// which is the common case and saves retyping it.
|
|
247
|
+
const newName = explicitNew
|
|
248
|
+
?? (flags.namespace ? `${String(flags.namespace).replace(/^\/+|\/+$/g, '')}/${baseNameOf(oldName, normalizeNamespaces(ws.pkg.dreamteamer?.namespaces))}` : null);
|
|
249
|
+
if (!newName) throw new Error('missing new name — give it positionally or with --namespace <ns>');
|
|
250
|
+
|
|
251
|
+
const out = renameCollection(ws, store, oldName, newName);
|
|
252
|
+
if (flags.json) { emit(JSON.stringify(out)); return 0; }
|
|
253
|
+
if (!out.renamed) { console.log(`✔ ${oldName} — already named that, nothing to do`); return 0; }
|
|
254
|
+
console.log(`✔ ${oldName} → ${out.name}`);
|
|
255
|
+
if (out.from !== out.to) console.log(` records ${out.from} → ${out.to} (${out.records})`);
|
|
256
|
+
if (out.suffix) console.log(` suffix .${out.suffix.from}.md → .${out.suffix.to}.md`);
|
|
257
|
+
if (out.pathKept) console.log(` ⚠ storage.path kept as "${out.pathKept}" — it was authored, so the rename did not overrule it`);
|
|
258
|
+
console.log(` refs ${out.rewrites} rewritten`);
|
|
259
|
+
console.log('✔ compiled — the rename is live, in ONE commit');
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
237
263
|
// `dreamteamer collections rm widgets [--force]` — --force is required to drop a collection
|
|
238
264
|
// that still has records (removeCollection refuses otherwise, and says so).
|
|
239
265
|
function metaCollectionsRm(ws, store, flags, pos) {
|
package/src/compile.js
CHANGED
|
@@ -8,8 +8,11 @@ import Ajv from 'ajv';
|
|
|
8
8
|
import addFormats from 'ajv-formats';
|
|
9
9
|
import { load, dump } from './yaml.js';
|
|
10
10
|
import { slug } from './template.js';
|
|
11
|
-
import { walk } from './records.js';
|
|
11
|
+
import { walk, patternRe } from './records.js';
|
|
12
12
|
import { unknownOperators } from './filter.js';
|
|
13
|
+
import {
|
|
14
|
+
normalizeNamespaces, namespaceProblems, unqualifiedProblems, defaultStoragePath, storageOverlaps,
|
|
15
|
+
} from './namespace.js';
|
|
13
16
|
// circular on paper in earlier versions — safe: both sides only
|
|
14
17
|
// call at run time, same pattern as store.js ↔ compile.js.
|
|
15
18
|
import { runHarnessAdapters } from './harnesses.js';
|
|
@@ -387,7 +390,18 @@ export function compile({ root, pkg }) {
|
|
|
387
390
|
const srcDir = kindDir(source.root, kind);
|
|
388
391
|
if (!fs.existsSync(srcDir)) continue;
|
|
389
392
|
counts[kind] ??= 0;
|
|
390
|
-
|
|
393
|
+
// `collections/` is enumerated RECURSIVELY, so a namespaced descriptor can be authored at
|
|
394
|
+
// `collections/health/doctors.collection.yaml` — mirroring where it lands in the runtime and
|
|
395
|
+
// letting a workspace group its descriptors the same way its data is grouped.
|
|
396
|
+
//
|
|
397
|
+
// ⚠ This is load-bearing, not cosmetic. `schema-ops` derives a descriptor's source path from
|
|
398
|
+
// its name, so `add-field` on `health/doctors` writes the nested path; with a flat readdir
|
|
399
|
+
// that file was written, silently skipped, and the verb reported ✔ while changing nothing —
|
|
400
|
+
// the decision-156 shape again. Every other kind stays flat: their ids are single segments.
|
|
401
|
+
const names = kind === 'collections'
|
|
402
|
+
? [...walk(srcDir)].map((f) => path.relative(srcDir, f).split(path.sep).join('/'))
|
|
403
|
+
: fs.readdirSync(srcDir).sort();
|
|
404
|
+
for (const name of names) {
|
|
391
405
|
if (name.startsWith('.')) continue;
|
|
392
406
|
const entityId = name.replace(/\.[^.]+\.(yaml|md|json)$/, '');
|
|
393
407
|
if (disabled.has(`${source.name}/${entityId}`)) { disabledHits.add(`${source.name}/${entityId}`); continue; }
|
|
@@ -478,6 +492,21 @@ export function compile({ root, pkg }) {
|
|
|
478
492
|
templateDocs.set(m[1], { template: doc.template ?? {}, src: entry.sources[0] });
|
|
479
493
|
}
|
|
480
494
|
|
|
495
|
+
// ---- namespaces: the declared list, validated against what actually compiled ----------
|
|
496
|
+
// Declared in the WORKSPACE package.json only, never per-module. A module that could declare a
|
|
497
|
+
// namespace could rename where another module's records live, and the whole point of a namespace
|
|
498
|
+
// is that the workspace decides how its own data is partitioned. `namespaces` is also config
|
|
499
|
+
// rather than records for the same bootstrap reason `git-modules` is (docs/repos-and-modules.md):
|
|
500
|
+
// a reference has to be parseable before anything has been compiled.
|
|
501
|
+
const namespaces = normalizeNamespaces(config.namespaces);
|
|
502
|
+
const collectionNames = [...descriptorGroups.keys()];
|
|
503
|
+
for (const p of namespaceProblems(namespaces, collectionNames)) fail(p);
|
|
504
|
+
// The silent failure this whole feature had to fix: a slash in a collection name used to compile
|
|
505
|
+
// clean, land at `.dreamteamer/collections/<ns>/<name>.collection.yaml`, and then vanish — the
|
|
506
|
+
// descriptor loader read one directory level, so the collection was simply absent from the
|
|
507
|
+
// runtime while compile reported ✔ (the same shape as decision 156).
|
|
508
|
+
for (const p of unqualifiedProblems(collectionNames, namespaces)) fail(p);
|
|
509
|
+
|
|
481
510
|
// ---- who owns which collection, and which module IS the workspace ----------------
|
|
482
511
|
// Needed before the resolution loop so each descriptor can be validated against the graph as it
|
|
483
512
|
// is merged. The owner is the group member that does NOT declare `extends`; a group with two of
|
|
@@ -491,10 +520,13 @@ export function compile({ root, pkg }) {
|
|
|
491
520
|
const base = group.find((g) => !g.doc.extends);
|
|
492
521
|
if (base) collOwner.set(name, base.moduleName);
|
|
493
522
|
}
|
|
494
|
-
// The engine's own nine collections are an implicit dependency of every module:
|
|
495
|
-
//
|
|
496
|
-
// on the host it cannot run without would be ceremony, not
|
|
497
|
-
|
|
523
|
+
// The engine's own nine collections are an implicit dependency of every module: the entity kinds
|
|
524
|
+
// the compiler materializes, plus `repos` (because `repos ensure` clones them). Requiring every
|
|
525
|
+
// module to declare a dependency on the host it cannot run without would be ceremony, not
|
|
526
|
+
// verification. ⚠ `users` was in this set until 0.8.0 — a module still declaring
|
|
527
|
+
// `x-reference: users` now FAILS here, which is the intended loud outcome rather than a ref
|
|
528
|
+
// pointing at a collection nothing provides.
|
|
529
|
+
const CORE_COLLECTIONS = new Set([...KINDS, ...DERIVED_KINDS, 'repos']);
|
|
498
530
|
const wsDir = config['workspace-module'];
|
|
499
531
|
const wsModuleName = wsDir
|
|
500
532
|
? sources.find((s) => rel(s.root) === path.join('modules', wsDir))?.name
|
|
@@ -504,6 +536,7 @@ export function compile({ root, pkg }) {
|
|
|
504
536
|
counts.collections = 0;
|
|
505
537
|
let mergedCount = 0;
|
|
506
538
|
let templatedCount = 0;
|
|
539
|
+
const storageEntries = []; // {name, path, base} per collection — checked for overlap after the loop
|
|
507
540
|
for (const [name, group] of descriptorGroups) {
|
|
508
541
|
// a template's bytes feed the compiled descriptor, so it MUST be one of that descriptor's
|
|
509
542
|
// declared sources — otherwise editing the template leaves every consumer silently stale
|
|
@@ -555,6 +588,12 @@ export function compile({ root, pkg }) {
|
|
|
555
588
|
// the workspace root, read as zero records, and become writable through the store.
|
|
556
589
|
merged.storage ??= {};
|
|
557
590
|
const owned = dataOwners.get(storageOwnerOf(group, base));
|
|
591
|
+
// A namespaced collection's folder IS its namespace, nested: `health/doctors` →
|
|
592
|
+
// `data/health/doctors`. Derived rather than required so a descriptor never has to repeat its
|
|
593
|
+
// own name in a path, and so moving a collection between namespaces is a one-line edit.
|
|
594
|
+
// An authored `storage.path` still wins — registering an existing folder is a first-class case
|
|
595
|
+
// (skills/building-dreamteamer/references/collections.md).
|
|
596
|
+
merged.storage.path ??= defaultStoragePath(name, namespaces, config['data-path'] ?? 'data');
|
|
558
597
|
const storagePath = String(merged.storage.path ?? '');
|
|
559
598
|
const systemKinds = [...KINDS, ...DERIVED_KINDS];
|
|
560
599
|
const isSystem = systemKinds.includes(storagePath) || systemKinds.includes(storagePath.replace(/^system\//, ''));
|
|
@@ -566,6 +605,7 @@ export function compile({ root, pkg }) {
|
|
|
566
605
|
} else {
|
|
567
606
|
merged.storage.repo = '.';
|
|
568
607
|
}
|
|
608
|
+
storageEntries.push({ name, path: merged.storage.path, base: merged.storage.base });
|
|
569
609
|
for (const [at, tpl, target] of staleDisplayKeywords(merged.schema)) {
|
|
570
610
|
const fix = target
|
|
571
611
|
? `either DELETE it (a reference to "${target}" now inherits that collection's \`title_template\`) or rename it to \`x-title-template\` if this field really needs its own`
|
|
@@ -580,6 +620,16 @@ export function compile({ root, pkg }) {
|
|
|
580
620
|
} catch (e) {
|
|
581
621
|
fail(`collection "${name}": schema is not a valid JSON Schema — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
|
|
582
622
|
}
|
|
623
|
+
// Same reasoning one line up, for the OTHER regex a descriptor carries. `patternRe` throws on a
|
|
624
|
+
// malformed pattern, and it is called from `store.add` and from `check` — so without this gate a
|
|
625
|
+
// typo'd `id.pattern` surfaces as a raw "Invalid regular expression" from inside a write instead
|
|
626
|
+
// of as a compile error naming the descriptor.
|
|
627
|
+
if (merged.id?.pattern !== undefined) {
|
|
628
|
+
if (typeof merged.id.pattern !== 'string') fail(`collection "${name}": id.pattern must be a string (got ${JSON.stringify(merged.id.pattern)})`);
|
|
629
|
+
try { patternRe(merged.id.pattern); } catch (e) {
|
|
630
|
+
fail(`collection "${name}": id.pattern is not a valid regular expression — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
583
633
|
// ---- the reference contract: every target is owned, depended on, or declared a peer ----
|
|
584
634
|
// Attribution is unioned across the whole group rather than taken from the base, because the
|
|
585
635
|
// merge keeps no per-field provenance — an overlay that adds a ref field would otherwise be
|
|
@@ -650,6 +700,12 @@ export function compile({ root, pkg }) {
|
|
|
650
700
|
if (extenders.length) mergedCount++;
|
|
651
701
|
}
|
|
652
702
|
|
|
703
|
+
// ---- no collection may sit inside another's folder -------------------------------
|
|
704
|
+
// Checked HERE because it is the first moment every path is resolved (namespace nesting, the
|
|
705
|
+
// `owns-data` module prefix and any authored override all already applied). See
|
|
706
|
+
// namespace.storageOverlaps for what this silently did before it was checked.
|
|
707
|
+
for (const p of storageOverlaps(storageEntries)) fail(p);
|
|
708
|
+
|
|
653
709
|
// ---- modules, projected ---------------------------------------------------------
|
|
654
710
|
// One record per discovered module, written from what discovery and the package pass already
|
|
655
711
|
// established. `package.json` remains the source of truth and compile keeps reading it — this
|
|
@@ -749,6 +805,12 @@ export function compile({ root, pkg }) {
|
|
|
749
805
|
// fail at compile, not silently at render (review finding 5)
|
|
750
806
|
const badOps = view?.filter ? [...unknownOperators(view.filter)] : [];
|
|
751
807
|
if (badOps.length) fail(`${rt}: unknown filter operator(s) ${badOps.join(', ')}`);
|
|
808
|
+
// `@me` died with the `users` collection in 0.8.0. It expanded to `users/<slug>`, so on this
|
|
809
|
+
// engine it can only ever match nothing — and a filter that narrows to zero rows is the exact
|
|
810
|
+
// silent failure this block exists to prevent. Refuse it by name, with the fix.
|
|
811
|
+
if (view?.filter && JSON.stringify(view.filter).includes('"@me"')) {
|
|
812
|
+
fail(`${rt}: filter uses "@me", which was removed with the \`users\` collection in 0.8.0 — it would now match nothing.\n filter on a field this workspace owns instead (e.g. { status: { _eq: "todo" } }).`);
|
|
813
|
+
}
|
|
752
814
|
}
|
|
753
815
|
|
|
754
816
|
// ---- command-binding validation --------------------------------------------------
|
|
@@ -795,12 +857,17 @@ export function compile({ root, pkg }) {
|
|
|
795
857
|
const anyFlat = sources.some((s) => KINDS.some((k) => fs.existsSync(path.join(s.root, k))));
|
|
796
858
|
const anyNested = sources.some((s) => KINDS.some((k) => fs.existsSync(path.join(s.root, 'system', k))));
|
|
797
859
|
const sourceLayout = anyFlat && anyNested ? 'mixed' : anyNested ? 'nested' : 'flat';
|
|
798
|
-
const { outputs: adapterOutputs, summary: harnessSummary } = runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout });
|
|
860
|
+
const { outputs: adapterOutputs, summary: harnessSummary } = runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout, namespaces, version: engineVer });
|
|
799
861
|
|
|
800
862
|
// ---- provenance manifest ------------------------------------------------------
|
|
801
863
|
const manifest = {
|
|
802
864
|
compiled: new Date().toISOString(),
|
|
803
865
|
host: engineId(),
|
|
866
|
+
// The declared namespace list, carried across the boundary so the RECORD layer can split a
|
|
867
|
+
// reference without importing the compiler or re-reading package.json — the same reason
|
|
868
|
+
// `storage.base` is a field instead of a path test. An older runtime has no key here, which
|
|
869
|
+
// reads as "no namespaces", which is exactly right for a workspace that never declared any.
|
|
870
|
+
namespaces,
|
|
804
871
|
modules: sources.map((s) => ({ name: s.name, channel: s.channel, root: rel(s.root) || '.' })),
|
|
805
872
|
ui: uiModules.sort(),
|
|
806
873
|
'adapter-outputs': adapterOutputs.sort(),
|
package/src/harnesses.js
CHANGED
|
@@ -21,7 +21,7 @@ export const STAMP = '<!-- generated by dreamteamer compile — do not edit; sou
|
|
|
21
21
|
const BEGIN = '<!-- dreamteamer:begin (generated — do not edit inside this block) -->';
|
|
22
22
|
const END = '<!-- dreamteamer:end -->';
|
|
23
23
|
|
|
24
|
-
export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat' }) {
|
|
24
|
+
export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat', namespaces = [], version = 'unknown' }) {
|
|
25
25
|
const outputs = [];
|
|
26
26
|
const summary = [];
|
|
27
27
|
const rel = (p) => path.relative(root, p);
|
|
@@ -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) : null);
|
|
59
|
+
writeBlock(root, 'CLAUDE.md', on('claude-code') ? orientationBlock('claude-code', skillsIndex, sourceLayout, namespaces, version) : 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) : null);
|
|
76
|
+
writeBlock(root, 'AGENTS.md', on('codex') || on('pi') ? orientationBlock('agents-md', skillsIndex, sourceLayout, namespaces, version) : 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) : null);
|
|
81
|
+
writeBlock(root, 'GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout, namespaces, version) : 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)}\n\n${STAMP}\n`;
|
|
86
|
+
const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version)}\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
|
}
|
|
@@ -127,15 +127,25 @@ function buildSkillsIndex(entries) {
|
|
|
127
127
|
* 'nested' (the pre-2026-08-05 `<module>/system/skills/`), or 'mixed'. It is passed in rather than
|
|
128
128
|
* assumed because generated prose that contradicts the workspace is worse than no prose: this block
|
|
129
129
|
* is the first thing an agent session reads, and a workspace still on the old layout was being told
|
|
130
|
-
* to write somewhere it does not keep its sources.
|
|
131
|
-
|
|
130
|
+
* to write somewhere it does not keep its sources.
|
|
131
|
+
*
|
|
132
|
+
* `namespaces` is here for exactly the same reason, and it matters more. A reference is
|
|
133
|
+
* `<collection>/<id>`, and an id is ALSO a slash path — so in a workspace with declared namespaces an
|
|
134
|
+
* agent that splits `health/doctors/dana-levi` at the first slash reads a collection that does not
|
|
135
|
+
* exist. Naming the declared list is what makes the grammar decidable from this block alone, without
|
|
136
|
+
* 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') {
|
|
132
138
|
const sourcesLine = {
|
|
133
139
|
flat: '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
|
|
134
140
|
nested: '`modules/<module>/system/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
|
|
135
141
|
mixed: '`modules/<module>/<kind>/`, or `<module>/system/<kind>/` where a module still nests it —\n`collections/`, `skills/`, `agents/`, `commands/`,',
|
|
136
142
|
}[sourceLayout] ?? '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,';
|
|
137
143
|
const lines = [
|
|
138
|
-
|
|
144
|
+
// PASSED IN, never hardcoded: this string used to say "v0.6" literally, which was correct for
|
|
145
|
+
// exactly one minor release and then quietly wrong in every workspace it had been written into.
|
|
146
|
+
// Passed rather than imported because compile.js already computes it and imports THIS module —
|
|
147
|
+
// reaching back for `engineVersion` would close a cycle for the sake of one string.
|
|
148
|
+
`this workspace is operated by dreamteamer v${version}. **read the \`using-dreamteamer\` skill before`,
|
|
139
149
|
'working with data.** schemas (read): `.dreamteamer/collections/` (provenance:',
|
|
140
150
|
'`.dreamteamer/manifest.yaml`). sources (write): ' + sourcesLine,
|
|
141
151
|
'`command-bindings/`, `ui-views/`, `collection-templates/`',
|
|
@@ -145,6 +155,17 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat') {
|
|
|
145
155
|
'check`) after bulk edits; run `dreamteamer compile` (`npm run compile`) after changing any',
|
|
146
156
|
'source or installing modules.',
|
|
147
157
|
];
|
|
158
|
+
// ⚠ Only when the workspace HAS namespaces. Telling an agent about a feature this workspace does
|
|
159
|
+
// not use is the same failure as telling it the wrong source layout — prose that contradicts the
|
|
160
|
+
// workspace is worse than no prose.
|
|
161
|
+
if (namespaces.length) {
|
|
162
|
+
lines.push(
|
|
163
|
+
`this workspace declares NAMESPACES: ${namespaces.map((n) => `\`${n}\``).join(', ')}. a collection`,
|
|
164
|
+
'in one is named with its prefix (`health/doctors`), stores records under `data/<namespace>/`,',
|
|
165
|
+
'and is referenced as `<namespace>/<collection>/<id>` — so split a reference at the end of the',
|
|
166
|
+
'DECLARED prefix, not at the first slash. collections with no prefix are unaffected.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
148
169
|
// claude-code discovers skills natively (Skill tool) — an index in CLAUDE.md is pure
|
|
149
170
|
// context bloat there. every other harness gets the trigger index + discovery pointers.
|
|
150
171
|
if (flavor !== 'claude-code') {
|
package/src/init.js
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { dump } from './yaml.js';
|
|
8
|
-
import { slugOrHash } from './template.js';
|
|
9
7
|
import { discoverModules, KINDS } from './compile.js';
|
|
10
8
|
import { Store } from './store.js';
|
|
11
9
|
|
|
@@ -76,7 +74,17 @@ export function init({ flags = {} } = {}) {
|
|
|
76
74
|
'data-path': dataPath,
|
|
77
75
|
harnesses,
|
|
78
76
|
'gitignore-runtime-folder': true,
|
|
79
|
-
|
|
77
|
+
// The workspace's own sources live in `modules/default/`, and the folder is named for its ROLE,
|
|
78
|
+
// not for the vault. It used to be named after the workspace, and that name went stale twice in
|
|
79
|
+
// one repo (`hq3` → `gk`, decision 213 reversed by 224) — each rename rewriting every path that
|
|
80
|
+
// RESOLVES while the historical documents deliberately kept the old spelling, so a stale-looking
|
|
81
|
+
// `modules/hq3` was correct in prose and a bug in a path. A role name cannot go stale.
|
|
82
|
+
//
|
|
83
|
+
// `default` is deliberately the same word `RESERVED_NAMESPACES` holds (namespace.js): this module
|
|
84
|
+
// owns the DEFAULT-namespace collections, and the default namespace is the empty prefix. The one
|
|
85
|
+
// misreading it invites — `default/tasks` — is a compile error whose message states the rule.
|
|
86
|
+
// Override with `--workspace-module <name>` if a workspace wants its own spelling.
|
|
87
|
+
'workspace-module': flags['workspace-module'] ?? 'default',
|
|
80
88
|
'git-modules': {},
|
|
81
89
|
disable: [],
|
|
82
90
|
...pkg.dreamteamer,
|
|
@@ -102,23 +110,16 @@ export function init({ flags = {} } = {}) {
|
|
|
102
110
|
fs.mkdirSync(path.join(root, dataPath), { recursive: true });
|
|
103
111
|
fs.mkdirSync(path.join(root, 'state'), { recursive: true });
|
|
104
112
|
|
|
105
|
-
//
|
|
113
|
+
// NO user record is seeded, and there is no `users` collection — both removed from core in 0.8.0.
|
|
114
|
+
// It failed the "does the ENGINE read it?" test on a circular justification: `users` was core
|
|
115
|
+
// because `@me` resolved against it, and `@me` existed because `users` was core. Nothing else in
|
|
116
|
+
// the compiler, the store or `check` ever read a user record. One record per workspace, whose only
|
|
117
|
+
// job was to restate `git config user.name` in a file that then had to AGREE with it — and when it
|
|
118
|
+
// disagreed the symptom was an empty inbox with no error (decision 99b), a trap that now cannot
|
|
119
|
+
// happen because there is nothing to disagree with.
|
|
106
120
|
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// /inbox view work by construction. A workspace where a user record is authored by hand under a
|
|
110
|
-
// different id gets an EMPTY inbox with no error (decision 99b) — hence the setup-script step in
|
|
111
|
-
// any workspace with a second person.
|
|
112
|
-
//
|
|
113
|
-
// No `everyone` team is seeded: `teams` was removed from core 2026-07-31. It was a one-record
|
|
114
|
-
// abstraction with no reader — nothing in the engine, in `check`, or in any view resolved a team.
|
|
115
|
-
const gitName = tryGit(root, ['config', 'user.name']) ?? 'operator';
|
|
116
|
-
const gitEmail = tryGit(root, ['config', 'user.email']);
|
|
117
|
-
const userId = slugOrHash(gitName);
|
|
118
|
-
const usersDir = path.join(root, dataPath, 'users');
|
|
119
|
-
fs.mkdirSync(usersDir, { recursive: true });
|
|
120
|
-
const userFile = path.join(usersDir, `${userId}.user.yaml`);
|
|
121
|
-
if (!fs.existsSync(userFile)) fs.writeFileSync(userFile, dump({ name: gitName, ...(gitEmail ? { email: gitEmail } : {}) }));
|
|
121
|
+
// A workspace that needs people as records ships its own collection (a module's `contacts` already
|
|
122
|
+
// does), and reads the operator from git where it needs one. `teams` went the same way 2026-07-31.
|
|
122
123
|
|
|
123
124
|
// .gitignore + .env.example (append-if-missing, never clobber)
|
|
124
125
|
appendMissing(path.join(root, '.gitignore'), GITIGNORE);
|
package/src/namespace.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// NAMESPACES — how a collection name is scoped, and how a reference splits back apart.
|
|
2
|
+
//
|
|
3
|
+
// A namespace is a slash-delimited prefix on a collection name: `health/doctors` is the collection
|
|
4
|
+
// `doctors` in the namespace `health`, and its records live under `data/health/doctors/`. A
|
|
5
|
+
// reference to one of those records is `health/doctors/dana-levi`.
|
|
6
|
+
//
|
|
7
|
+
// ⚠ THE WHOLE PROBLEM IN ONE LINE: an id is ALSO a slash-delimited path (`meetings/2026/07/kickoff`
|
|
8
|
+
// is one collection and a three-segment id), so `a/b/c` is either collection `a` + id `b/c` or
|
|
9
|
+
// collection `a/b` + id `c`, and nothing about the STRING says which.
|
|
10
|
+
//
|
|
11
|
+
// So namespaces are DECLARED, in the workspace package.json:
|
|
12
|
+
//
|
|
13
|
+
// "dreamteamer": { "namespaces": ["health", "finance", "work/clients"] }
|
|
14
|
+
//
|
|
15
|
+
// and every split consults that closed set, longest match first. The alternative — inferring the
|
|
16
|
+
// boundary from whichever collections happen to exist — was rejected: it makes the meaning of a
|
|
17
|
+
// reference depend on the current descriptor set, so installing a module could silently re-point
|
|
18
|
+
// references in records nobody edited. A declared list also turns the dangerous case (a namespace
|
|
19
|
+
// whose name collides with a collection's) into a compile error instead of a longest-prefix win.
|
|
20
|
+
//
|
|
21
|
+
// The DEFAULT namespace is the empty prefix. `tasks/kickoff` is a reference into it, `data/tasks/`
|
|
22
|
+
// is where it lives, and that is exactly what every workspace already has — which is why adopting
|
|
23
|
+
// namespaces migrates nothing. `default` is reserved precisely so there is never a second spelling
|
|
24
|
+
// for the same collection.
|
|
25
|
+
//
|
|
26
|
+
// This module is deliberately PURE — the declared list arrives as an argument. compile validates it
|
|
27
|
+
// and writes it into the manifest; runtime.js hands it to the record layer. Nothing here reads a
|
|
28
|
+
// file, so all of it is unit-testable without a workspace.
|
|
29
|
+
|
|
30
|
+
/** Never a namespace: it would give the default namespace a second, prefixed spelling. */
|
|
31
|
+
export const RESERVED_NAMESPACES = new Set(['default']);
|
|
32
|
+
|
|
33
|
+
/** One segment of a namespace or collection name: the id-safe alphabet the rest of the engine uses. */
|
|
34
|
+
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The declared list, cleaned and ordered for prefix matching: de-duplicated, slash-trimmed, and
|
|
38
|
+
* sorted LONGEST FIRST so a nested namespace (`work/clients`) is tested before its parent (`work`).
|
|
39
|
+
* Order is the correctness property here, not a nicety — parent-first would claim `work/clients/acme`
|
|
40
|
+
* for the namespace `work`, making the collection `clients` and the id `acme` on a workspace where
|
|
41
|
+
* `work/clients` is the namespace and the collection is something else entirely.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeNamespaces(list) {
|
|
44
|
+
if (!Array.isArray(list)) return [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const raw of list) {
|
|
47
|
+
if (typeof raw !== 'string') continue;
|
|
48
|
+
const ns = raw.trim().replace(/^\/+|\/+$/g, '');
|
|
49
|
+
if (ns) seen.add(ns);
|
|
50
|
+
}
|
|
51
|
+
return [...seen].sort((a, b) => b.length - a.length || a.localeCompare(b));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything wrong with a declared list, as sentences — or `[]`. `collectionNames` is every compiled
|
|
56
|
+
* collection's qualified name, which is what makes the collision check possible at all.
|
|
57
|
+
*
|
|
58
|
+
* compile calls this and fails on a non-empty result. It is a separate function from the compiler so
|
|
59
|
+
* the rules can be tested directly, and so the error text lives beside the semantics it protects.
|
|
60
|
+
*/
|
|
61
|
+
export function namespaceProblems(namespaces, collectionNames = []) {
|
|
62
|
+
const problems = [];
|
|
63
|
+
const names = new Set(collectionNames);
|
|
64
|
+
for (const ns of namespaces) {
|
|
65
|
+
const segments = ns.split('/');
|
|
66
|
+
for (const seg of segments) {
|
|
67
|
+
if (RESERVED_NAMESPACES.has(seg)) {
|
|
68
|
+
problems.push(`namespace "${ns}": "${seg}" is reserved — the default namespace is the EMPTY prefix, so a collection in it is spelled \`tasks\`, never \`default/tasks\`.`);
|
|
69
|
+
} else if (!SEGMENT.test(seg)) {
|
|
70
|
+
problems.push(`namespace "${ns}": segment "${seg}" must be lowercase alphanumeric with single hyphens (it becomes a folder name and part of every reference).`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// The collision that makes a slash-delimited namespace dangerous: with BOTH a namespace
|
|
74
|
+
// `health` and a collection `health`, the reference `health/doctors/dana-levi` is a record of
|
|
75
|
+
// collection `health/doctors` AND a record of collection `health` with the nested id
|
|
76
|
+
// `doctors/dana-levi`. Longest-match would silently pick the first and make the second
|
|
77
|
+
// unreferenceable. Refused up front instead.
|
|
78
|
+
if (names.has(ns)) {
|
|
79
|
+
problems.push(`namespace "${ns}" collides with the collection of the same name — a reference like "${ns}/x/y" would be ambiguous. Rename one.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return problems;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `health` + `doctors` → `health/doctors`; the default namespace (empty) → `doctors`. */
|
|
86
|
+
export function qualify(namespace, name) {
|
|
87
|
+
const ns = String(namespace ?? '').replace(/^\/+|\/+$/g, '');
|
|
88
|
+
return ns ? `${ns}/${name}` : String(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The declared namespace a qualified collection name sits in, or `''` for the default namespace.
|
|
93
|
+
* Matched against the declared list rather than by cutting at the last slash, because a collection
|
|
94
|
+
* name is only namespaced if its prefix was actually declared — see `unqualifiedProblems`.
|
|
95
|
+
*/
|
|
96
|
+
export function namespaceOf(qualified, namespaces) {
|
|
97
|
+
for (const ns of namespaces) if (qualified.startsWith(ns + '/')) return ns;
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The bare collection name inside its namespace: `health/doctors` → `doctors`. */
|
|
102
|
+
export function baseNameOf(qualified, namespaces) {
|
|
103
|
+
const ns = namespaceOf(qualified, namespaces);
|
|
104
|
+
return ns ? qualified.slice(ns.length + 1) : qualified;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A collection name carrying a slash whose prefix is NOT declared, which is the silent-failure this
|
|
109
|
+
* whole module exists to prevent: every reference to it would split at the first slash, name a
|
|
110
|
+
* collection that does not exist, and dangle. Returns problems as sentences, or `[]`.
|
|
111
|
+
*/
|
|
112
|
+
export function unqualifiedProblems(collectionNames, namespaces) {
|
|
113
|
+
const problems = [];
|
|
114
|
+
for (const name of collectionNames) {
|
|
115
|
+
if (!name.includes('/')) continue;
|
|
116
|
+
if (namespaceOf(name, namespaces)) continue;
|
|
117
|
+
const guess = name.slice(0, name.lastIndexOf('/'));
|
|
118
|
+
problems.push(`collection "${name}" is namespaced, but "${guess}" is not declared — add it to \`dreamteamer.namespaces\` in package.json, or every reference to this collection will split at the first slash and dangle.`);
|
|
119
|
+
}
|
|
120
|
+
return problems;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Split a reference into `{ collection, id }`, or `null` when it is not a reference shape.
|
|
125
|
+
*
|
|
126
|
+
* THE one place the boundary is decided. Everything that parses a reference — the store's write-time
|
|
127
|
+
* check, `check`'s report, the extension's go-to-definition — goes through here, so a namespace can
|
|
128
|
+
* never mean one thing on write and another on read.
|
|
129
|
+
*/
|
|
130
|
+
export function parseRef(ref, namespaces = []) {
|
|
131
|
+
if (typeof ref !== 'string' || !ref) return null;
|
|
132
|
+
for (const ns of namespaces) {
|
|
133
|
+
if (!ref.startsWith(ns + '/')) continue;
|
|
134
|
+
const rest = ref.slice(ns.length + 1);
|
|
135
|
+
const slash = rest.indexOf('/');
|
|
136
|
+
// `health/doctors` alone names a COLLECTION, not a record — there is no id, so it is not a
|
|
137
|
+
// reference. Falling through to the unnamespaced split would call it collection `health`,
|
|
138
|
+
// which is the ambiguity this module refuses everywhere else.
|
|
139
|
+
if (slash < 1 || slash === rest.length - 1) return null;
|
|
140
|
+
return { collection: `${ns}/${rest.slice(0, slash)}`, id: rest.slice(slash + 1) };
|
|
141
|
+
}
|
|
142
|
+
const slash = ref.indexOf('/');
|
|
143
|
+
if (slash < 1 || slash === ref.length - 1) return null;
|
|
144
|
+
return { collection: ref.slice(0, slash), id: ref.slice(slash + 1) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The folder a collection's records belong in, workspace-relative and WITHOUT any module prefix
|
|
149
|
+
* (compile adds that for an `owns-data` module). The namespace becomes real directory nesting, which
|
|
150
|
+
* is the point: `health/doctors` lands in `data/health/doctors/` rather than beside `data/tasks/`.
|
|
151
|
+
*/
|
|
152
|
+
export function defaultStoragePath(qualified, namespaces, dataPath = 'data') {
|
|
153
|
+
const ns = namespaceOf(qualified, namespaces);
|
|
154
|
+
const base = ns ? qualified.slice(ns.length + 1) : qualified;
|
|
155
|
+
return ns ? `${dataPath}/${ns}/${base}` : `${dataPath}/${base}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Storage paths that swallow each other, as sentences — or `[]`. `entries` is `[{name, path}]`.
|
|
160
|
+
*
|
|
161
|
+
* ⚠ MEASURED DATA LOSS, not a hypothetical. Give collection A the path `data/health` and collection
|
|
162
|
+
* B `data/health/doctors`, and A's recursive walk indexes B's records as its own: `dt A list` prints
|
|
163
|
+
* B's records under A's name, `check` reports B's fields as unknown fields of A, and a write through
|
|
164
|
+
* A can overwrite a record of B. compile reported ✔ through all of it, because nothing ever compared
|
|
165
|
+
* two collections' paths. Namespaces make near-misses like this ordinary, so the check is no longer
|
|
166
|
+
* optional.
|
|
167
|
+
*
|
|
168
|
+
* Segment-wise on purpose: `data/health` must not flag `data/health-notes`.
|
|
169
|
+
*/
|
|
170
|
+
export function storageOverlaps(entries) {
|
|
171
|
+
const problems = [];
|
|
172
|
+
const sorted = [...entries].filter((e) => e.path).sort((a, b) => a.path.localeCompare(b.path));
|
|
173
|
+
for (const outer of sorted) {
|
|
174
|
+
for (const inner of sorted) {
|
|
175
|
+
if (outer === inner || outer.base !== inner.base) continue;
|
|
176
|
+
if (!inner.path.startsWith(outer.path + '/')) continue;
|
|
177
|
+
problems.push(`collection "${inner.name}" stores records under "${inner.path}", which is INSIDE "${outer.name}"'s folder "${outer.path}" — the outer collection would index the inner one's records as its own. Give one of them a folder of its own.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return problems;
|
|
181
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { load } from './yaml.js';
|
|
16
|
+
import { normalizeNamespaces } from './namespace.js';
|
|
16
17
|
|
|
17
18
|
export const RUNTIME_DIR = '.dreamteamer';
|
|
18
19
|
|
|
@@ -72,9 +73,13 @@ export function loadDescriptors(root) {
|
|
|
72
73
|
const dir = runtimeKindDir(root, 'collections');
|
|
73
74
|
if (!fs.existsSync(dir)) return null;
|
|
74
75
|
const out = new Map();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// RECURSIVE, because a namespaced collection compiles to `collections/<ns>/<name>.collection.yaml`
|
|
77
|
+
// and this loop used to read exactly one directory level. ⚠ That was a SILENT failure, not an
|
|
78
|
+
// error: compile wrote the nested file and reported ✔, this returned a Map without it, and the
|
|
79
|
+
// collection was simply absent — `dt <c> list` said "unknown collection" for something that had
|
|
80
|
+
// just compiled successfully. Keep the walk.
|
|
81
|
+
for (const f of walkDescriptors(dir)) {
|
|
82
|
+
const d = load(fs.readFileSync(f, 'utf8'));
|
|
78
83
|
d.storage ??= {};
|
|
79
84
|
d.storage.base ??= derivedBase(d);
|
|
80
85
|
out.set(d.name, d);
|
|
@@ -82,6 +87,27 @@ export function loadDescriptors(root) {
|
|
|
82
87
|
return out;
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/** Every `*.collection.yaml` under a directory, at any depth, in a stable order. */
|
|
91
|
+
function walkDescriptors(dir, out = []) {
|
|
92
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
93
|
+
if (e.name.startsWith('.')) continue;
|
|
94
|
+
const p = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) walkDescriptors(p, out);
|
|
96
|
+
else if (e.name.endsWith('.collection.yaml')) out.push(p);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The workspace's declared namespaces, longest-first — the closed set every reference is split
|
|
103
|
+
* against. Read off the MANIFEST rather than package.json so the record layer keeps its single
|
|
104
|
+
* dependency on the compiled artifact (the `sourceRoots()` precedent), and so a runtime compiled
|
|
105
|
+
* before namespaces existed answers `[]` instead of throwing.
|
|
106
|
+
*/
|
|
107
|
+
export function namespaces(root) {
|
|
108
|
+
return normalizeNamespaces(readManifest(root)?.namespaces);
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
/**
|
|
86
112
|
* Which root `storage.path` is relative to — the whole of what the record layer needs to know about
|
|
87
113
|
* the system/data distinction, as DATA rather than as a string test it has to perform. `runtime` =
|