dreamteamer 0.26.0 → 0.28.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.
@@ -82,6 +82,10 @@ function collectionRow(d) {
82
82
  // it — the extension reads the presentation contract, not raw descriptors.
83
83
  if (typeof d.sort_field === 'string') meta.sort_field = d.sort_field;
84
84
 
85
+ // The collection's PARTITION, and the reserved value `system` is how a surface knows to draw this
86
+ // collection as the workspace's machinery rather than in the record tree. It is carried in `meta`
87
+ // beside the other presentation keys because that is the question it answers; the top-level
88
+ // `system` below answers a different one and they must never be folded together.
85
89
  if (typeof d.group === 'string') meta.group = d.group;
86
90
  if (typeof d.description === 'string' && d.description.length > 0) meta.description = d.description;
87
91
  // ⚠ `system` IS NOT `readonly`, AND SAYING SO COST A RELEASE. Until 0.19.0 the two were the
@@ -107,10 +111,25 @@ function collectionRow(d) {
107
111
  // already answers with its own sentence naming the fix, which is the contract the schema
108
112
  // surfaces are built on.
109
113
  //
110
- // `system` STAYS and is unchanged: the CLI and REST dispatch key on it, and it is what puts a
111
- // kind in the schema surface rather than the data one. A consumer that disables editing must
112
- // key on `meta.readonly` (per field, as `id`, `last-modified` and relation mirrors do) or on
113
- // the verb it is about to offer — never on `system`.
114
+ // `system` STAYS and is unchanged: the CLI and REST dispatch key on it. A consumer that disables
115
+ // editing must key on `meta.readonly` (per field, as `id`, `last-modified` and relation mirrors
116
+ // do) or on the verb it is about to offer — never on `system`.
117
+ //
118
+ // ⚠ AND `system` IS NO LONGER WHAT PUTS A KIND ON THE SCHEMA SURFACE — that clause stood here
119
+ // until `repos` and belongs to `meta.group` now. TWO QUESTIONS, KEPT SEPARATE, exactly as
120
+ // `buildCollectionsIndex` in src/harnesses.js keeps them:
121
+ //
122
+ // `system` decides HOW A WRITE IS PERFORMED. Is this collection's storage the compiled
123
+ // runtime? The CLI and the REST layer dispatch on it, and a surface that gets
124
+ // it wrong answers 400 to a write the store would have accepted.
125
+ // `meta.group` decides WHERE THE COLLECTION IS DRAWN. Its reserved value `system` says this
126
+ // is the workspace's machinery rather than one of its domain nouns, and folds
127
+ // the collection out of the record tree and onto the schema surface.
128
+ //
129
+ // The two agreed for every collection until `repos` — machinery whose records are ordinary,
130
+ // hand-edited files under `data/` — and it is why they must never be collapsed into one: a
131
+ // surface routing a `repos` write by the partition sends it to the system write path, whose
132
+ // entity ops know no such kind, and the write dies as a 400. Measured.
114
133
  const system = d.storage?.base === 'runtime';
115
134
  return { collection: d.name, meta, system };
116
135
  }
package/src/ref.js CHANGED
@@ -1,15 +1,37 @@
1
- // split "<collection>/<id>" against the DECLARED collections — longest prefix at a "/" boundary,
2
- // because both collection names and ids may contain slashes (namespaces; path-shaped ids).
1
+ // The words a collection answers to on the command line: its declared name and its `singular`
2
+ // (compile stamps one on every descriptor — derived by inflection, authorable where inflection is
3
+ // wrong — and refuses two collections whose words collide). `dt add task …` and `dt add tasks …`
4
+ // are the same call. ⚠ TYPED INPUT ONLY: a reference VALUE inside a record (`tasks/kickoff`) is
5
+ // parsed by namespace.js's parseRef against declared names and never learns the singular, so
6
+ // `task/kickoff` in a field stays the dangling reference `check` reports it as.
7
+ function* words(descriptors) {
8
+ for (const [name, d] of descriptors) {
9
+ yield [name, name];
10
+ if (d?.singular && d.singular !== name) yield [d.singular, name];
11
+ }
12
+ }
13
+
14
+ /** The declared collection a typed word names — the name itself or its singular — else null. */
15
+ export function canonicalCollection(descriptors, word) {
16
+ if (descriptors.has(word)) return word;
17
+ for (const [w, name] of words(descriptors)) if (w === word) return name;
18
+ return null;
19
+ }
20
+
21
+ // split "<collection>/<id>" against the DECLARED collections and their singulars — longest prefix
22
+ // at a "/" boundary, because both collection names and ids may contain slashes (namespaces;
23
+ // path-shaped ids). The collection returned is always the declared NAME, whichever word was typed.
3
24
  export function splitRef(descriptors, ref) {
4
25
  let best = null;
5
- for (const name of descriptors.keys()) {
6
- if (ref === name || ref.startsWith(name + '/')) {
7
- if (!best || name.length > best.length) best = name;
26
+ let bestName = null;
27
+ for (const [w, name] of words(descriptors)) {
28
+ if (ref === w || ref.startsWith(w + '/')) {
29
+ if (!best || w.length > best.length) { best = w; bestName = name; }
8
30
  }
9
31
  }
10
32
  if (!best) throw new Error(`unknown collection in reference "${ref}" (known: ${[...descriptors.keys()].sort().join(', ')})`);
11
33
  if (ref === best) throw new Error(`reference "${ref}" names a collection but no record id`);
12
- return { collection: best, id: ref.slice(best.length + 1) };
34
+ return { collection: bestName, id: ref.slice(best.length + 1) };
13
35
  }
14
36
 
15
37
  /**
package/src/schema-ops.js CHANGED
@@ -755,6 +755,9 @@ const COLLECTION_SETTABLE = {
755
755
  title: (v) => String(v),
756
756
  title_template: (v) => String(v),
757
757
  icon: (v) => String(v),
758
+ // The collection's partition. `group=system` is the reserved value: it moves the collection out
759
+ // of the block's domain listing and onto a surface's schema surface, and changes nothing about
760
+ // where its records live or whether they can be written.
758
761
  group: (v) => String(v),
759
762
  sort_field: (v) => String(v),
760
763
  order: (v) => {
package/src/workspace.js CHANGED
@@ -49,3 +49,53 @@ export function nestedAsModule(outer, inner) {
49
49
  .split(path.sep)
50
50
  .some((seg) => MODULE_SEGMENTS.has(seg));
51
51
  }
52
+
53
+ /** The VS Code-family extension for this workspace, as the editor learns of it: a recommendation
54
+ * in `.vscode/extensions.json`, which VS Code, Cursor and code-server all read on open and offer to
55
+ * install. Written by `init` and kept current by `compile`, because a compiled workspace that never
56
+ * names its editor left a first-run agent searching a registry for the id (measured 2026-09-24:
57
+ * three install attempts, two of them into a directory the running editor did not read). An
58
+ * existing file is MERGED, never replaced: a file the operator authored (comments included) is left
59
+ * alone whenever it already names the extension; one that does not is re-read as JSON and gains the
60
+ * id, and one that is neither is left with a warning rather than clobbered. */
61
+ export const EDITOR_EXTENSION_ID = 'dreamteamer.dreamteamer-vscode';
62
+ export function ensureEditorRecommendation(root, warn = console.warn) {
63
+ const file = path.join(root, '.vscode', 'extensions.json');
64
+ if (!fs.existsSync(file)) {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ fs.writeFileSync(file, JSON.stringify({ recommendations: [EDITOR_EXTENSION_ID] }, null, '\t') + '\n');
67
+ return 'written';
68
+ }
69
+ const text = fs.readFileSync(file, 'utf8');
70
+ if (text.includes(EDITOR_EXTENSION_ID)) return 'present';
71
+ let parsed;
72
+ try { parsed = JSON.parse(text); } catch {
73
+ warn(`⚠ .vscode/extensions.json does not recommend ${EDITOR_EXTENSION_ID} and is not plain JSON, so it was left alone — add the id to its "recommendations" by hand`);
74
+ return 'left';
75
+ }
76
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { warn(`⚠ .vscode/extensions.json is not an object — left alone; add ${EDITOR_EXTENSION_ID} to "recommendations" by hand`); return 'left'; }
77
+ parsed.recommendations = [...(Array.isArray(parsed.recommendations) ? parsed.recommendations : []), EDITOR_EXTENSION_ID];
78
+ fs.writeFileSync(file, JSON.stringify(parsed, null, '\t') + '\n');
79
+ return 'merged';
80
+ }
81
+
82
+ /** `.env.example` lists every env key the installed modules declare — the file the missing-key
83
+ * warning points at, which used to carry two comment lines and none of the keys it was cited for.
84
+ * Append-only and idempotent: a key already named in the file (as `KEY=` or `# KEY`) is not added
85
+ * again, and nothing an operator wrote is touched. Values never appear here — an example does. */
86
+ export function ensureEnvExample(root, entries, header = '') {
87
+ if (!entries.length) return [];
88
+ const file = path.join(root, '.env.example');
89
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : header;
90
+ const named = new Set([...existing.matchAll(/^\s*#?\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]));
91
+ const added = [];
92
+ let out = existing;
93
+ for (const e of entries) {
94
+ if (named.has(e.key)) continue;
95
+ const who = e.modules?.length ? ` (module ${e.modules.join(', ')})` : '';
96
+ out = out.trimEnd() + `\n\n# ${e.description ?? `declared by ${e.modules.join(', ')}`}${e.description ? who : ''}\n${e.key}=${e.example ?? ''}\n`;
97
+ added.push(e.key);
98
+ }
99
+ if (added.length) fs.writeFileSync(file, out.replace(/^\n+/, ''));
100
+ return added;
101
+ }