dreamteamer 0.10.0 → 0.12.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 CHANGED
@@ -63,6 +63,30 @@ The shape of a record is deliberately dull, because dull is what survives:
63
63
  - a write lands on disk; `dreamteamer commit` publishes it, one commit per repo
64
64
  - schemas are JSON Schema in a YAML file, one per collection
65
65
 
66
+ ### Machine-specific references
67
+
68
+ Some things a record points at only exist on one machine — a synced Drive folder, an external disk,
69
+ a checkout somewhere else. Those are written as **templates**, never as absolute paths:
70
+
71
+ ```yaml
72
+ source_file: ${env:FILES_FOLDER}/2026/q3.pdf
73
+ ```
74
+
75
+ Three variables, borrowing VS Code's grammar: `${env:NAME}` — declared in `dreamteamer.vars` in
76
+ `package.json`, valued in the gitignored `.env` — plus `${workspaceFolder}` and `${userHome}`.
77
+ One verb renders them:
78
+
79
+ ```bash
80
+ npx dreamteamer resolve '${env:FILES_FOLDER}/x' # → /Volumes/annex/x
81
+ npx dreamteamer resolve <collection>/<id> <field> # render what a record already holds
82
+ ```
83
+
84
+ **Templates are ordinary data — write them literally; nothing substitutes until `resolve` is
85
+ called.** `get`, `list`, `check` and every harness see the template verbatim, which is exactly what
86
+ makes the record mean the same thing on every machine instead of quietly meaning two things. An
87
+ undeclared key and a declared-but-absent one are different errors, and `compile` warns — by name,
88
+ never by value — when a declared var has nothing behind it here.
89
+
66
90
  ## Modular
67
91
 
68
92
  **Data and skills are the new app structure.** A coding agent with the right skills over the right
@@ -77,6 +77,13 @@ schema:
77
77
  type: array
78
78
  items: { type: string }
79
79
  description: The columns a list view shows by default.
80
+ sort_field:
81
+ type: string
82
+ description: >-
83
+ Which field carries MANUAL order — the one a drag writes. The field must be declared by this
84
+ collection's own schema, and holds a fractional index (`dt <collection> move`), never an
85
+ integer: renumbering is a multi-file commit against git. A surface offers dragging only while
86
+ it is sorted by this field, because a handle that reorders nothing is a lie.
80
87
  icon:
81
88
  type: string
82
89
  description: material-symbols-outlined icon name, drawn in the nav and page header. The VS Code tree maps it to the nearest codicon — an unmapped name falls back to a generic cylinder, so pick one that is already mapped or add the row.
@@ -1,4 +1,7 @@
1
1
  name: repos
2
+ description: >-
3
+ A git repository this workspace knows about — where it lives and how a working tree is
4
+ materialized on demand.
2
5
  # An external git repo attached to this workspace. Owns CLONE LIFECYCLE ONLY — a repo record
3
6
  # never contributes schema, skills or UI (that is what a module is, declared in package.json
4
7
  # `dreamteamer.git-modules`, because modules must be restorable BEFORE compile can run).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.10.0",
4
- "description": "A workspace compiler for coding agents \u2014 schema-validated records as plain files over git, compiled into every harness",
3
+ "version": "0.12.0",
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>",
7
7
  "homepage": "https://github.com/dreamteamer/dreamteamer#readme",
@@ -47,6 +47,7 @@
47
47
  "ajv": "^8.17.1",
48
48
  "ajv-formats": "^3.0.1",
49
49
  "express": "^5.2.1",
50
+ "fractional-indexing": "^4.0.0",
50
51
  "js-yaml": "^4.1.0"
51
52
  },
52
53
  "dreamteamer": {
@@ -37,7 +37,7 @@ Three tie-breakers worth internalising, because they are the ones that go wrong:
37
37
  - **a multi-step process is a CHAIN OF COMMANDS, not an entity.** There is no workflow kind: a
38
38
  `workflows` collection with run records, triggers and an executor existed until 2026-07-31 and was
39
39
  removed after three days of measurement showed the work being done by a command chain instead. Write
40
- one command per step, bind each to its collection so `dt commands for <ref>` shows what applies, and
40
+ one command per step, bind each to its collection so `dt commands <ref>` shows what applies, and
41
41
  a command whose body invokes the others in order if the sequence needs a name. The record's own state
42
42
  is the progress marker — which is what made the run records redundant.
43
43
 
@@ -54,7 +54,7 @@ These were duplicated across seven skills; they are true for all of them.
54
54
  makes the id lie.
55
55
  3. **The meta-descriptor IS the spec.** Every kind is itself a collection:
56
56
  `.dreamteamer/collections/<kind>.collection.yaml` lists every key it may carry with its
57
- allowed values. Read that, plus a real one (`dt <kind> get <id>`), instead of learning the shape
57
+ allowed values. Read that, plus a real one (`dt get <kind>/<id>`), instead of learning the shape
58
58
  from prose. Prose drifts; the descriptor cannot.
59
59
  4. **`npm run compile`, then `npm run check`.** Compile materializes the runtime and the harness
60
60
  adapters; check validates refs and shapes and never modifies files. Neither is optional.
@@ -66,10 +66,10 @@ These were duplicated across seven skills; they are true for all of them.
66
66
  7. **Never edit generated output.** `.dreamteamer/`, `.claude/`, `.agents/`, `.cursor/` are all
67
67
  overwritten and pruned on the next compile. If you found the thing you want to change in one of
68
68
  those, you are in the wrong file.
69
- 8. **The CLI refuses system-stored records on purpose.** `dt skills set …` will not work; edit the
69
+ 8. **The CLI refuses system-stored records on purpose.** `dt set skills/<id> …` will not work; edit the
70
70
  module source and compile. The exceptions are the meta verbs that write sources *through* a
71
- compile gate — `collections add`, `<collection> add-field`, `ui-views add|set` — which exist so
72
- an uncompilable source can never land in history.
71
+ compile gate — `schema add-collection`, `schema add-field <collection>`, `schema add-view|set-view` —
72
+ which exist so an uncompilable source can never land in history.
73
73
  9. **Never duplicate a procedure across records.** A command body that restates a skill, an agent
74
74
  body that inlines its skill's steps, a command that re-types another command's prompt — each is two
75
75
  copies that drift. Reference the one that owns it.
@@ -99,7 +99,7 @@ a collection about people, meetings, tasks, products, content — belongs in a m
99
99
  version of it belongs in the `recipes` repo rather than here.
100
100
 
101
101
  **The test is: does the ENGINE read it?** Core's collections are the entity kinds the compiler itself
102
- materializes, plus `repos` (because `repos ensure` clones them). Everything else has been ejected on
102
+ materializes, plus `repos` (because `ensure` clones them). Everything else has been ejected on
103
103
  exactly that test — `teams` (nothing resolved a
104
104
  team), `mounts` (a one-implementation adapter enum over an `.env` key), `module-registries` (zero
105
105
  readers), `workflows`/`workflow-runs`/`workflow-triggers`/`cursors` and `migrations`/`migration-runs`
@@ -7,12 +7,12 @@ One descriptor file: `modules/<module>/collections/<name>.collection.yaml`. The
7
7
 
8
8
  | goal | how |
9
9
  |---|---|
10
- | new collection from a template | `dt collections add --name research-docs --template docs` |
11
- | move one into a namespace | `dt collections rename doctors health/doctors` (or `doctors --namespace health`) |
12
- | templateless | `dt collections add --name <n>` — emits a minimal compilable schema |
13
- | add a field | `dt <collection> add-field --name urgent --type boolean --default-value false` |
14
- | change / drop a field | `dt <collection> update-field …` · `remove-field --name <f>` |
15
- | delete a collection | `dt collections rm <name>` |
10
+ | new collection from a template | `dt schema add-collection --name research-docs --template docs` |
11
+ | move one into a namespace | `dt schema rename-collection doctors health/doctors` (or `doctors --namespace health`) |
12
+ | templateless | `dt schema add-collection --name <n>` — emits a minimal compilable schema |
13
+ | add a field | `dt schema add-field <collection> --name urgent --type boolean --default-value false` |
14
+ | change / drop a field | `dt schema update-field <collection> …` · `schema remove-field <collection> --name <f>` |
15
+ | delete a collection | `dt schema rm-collection <name>` |
16
16
  | what templates exist | `.dreamteamer/collection-templates/` |
17
17
 
18
18
  `--type` is sugar over JSON Schema: `string`/`text`, `markdown`, `boolean`, `number`, `integer`,
@@ -21,7 +21,7 @@ or a bare collection name for a reference into it. `--required true` widens `req
21
21
 
22
22
  ⚠ **The meta verbs write the WORKSPACE module only.** To change a field on a collection another
23
23
  module owns, either edit that module's descriptor by hand or add an `extends:` overlay.
24
- **`dt collections rename <old> <new>`** moves the descriptor, the records, the record filenames and
24
+ **`dt schema rename-collection <old> <new>`** moves the descriptor, the records, the record filenames and
25
25
  every inbound reference in ONE commit — including `x-reference` targets in other descriptors and any
26
26
  ui-view pointing at it. `<old> --namespace <ns>` is sugar for moving it into a namespace under the same
27
27
  bare name. It refuses a compiled source, a module-owned collection, a taken name, and an undeclared
@@ -35,7 +35,7 @@ A collection name may carry a slash-delimited namespace, and it becomes real dir
35
35
 
36
36
  | declare in the workspace `package.json` | create it | lands in | referenced as |
37
37
  |---|---|---|---|
38
- | `"namespaces": ["health"]` | `dt collections add --namespace health --name doctors` | `data/health/doctors/` | `health/doctors/dana-levi` |
38
+ | `"namespaces": ["health"]` | `dt schema add-collection --namespace health --name doctors` | `data/health/doctors/` | `health/doctors/dana-levi` |
39
39
 
40
40
  - **The default namespace is the empty prefix.** `tasks` stays `data/tasks/` and `tasks/kickoff`, so
41
41
  common entities need no prefix and adopting namespaces migrates nothing. `default` is RESERVED —
@@ -93,7 +93,7 @@ templates: [collection-templates/provenance] # merged at compile, every time
93
93
  reference already inherits its TARGET collection's `title_template`; author it there instead, once,
94
94
  rather than on every field pointing at it.
95
95
  - **Do not enum a field after the fact.** Enumerating a vocabulary the records already violate makes
96
- `check` fail on every pre-existing value. `dt <collection> values <field>` derives the real
96
+ `check` fail on every pre-existing value. `dt values <collection> <field>` derives the real
97
97
  vocabulary from the data — a filter dropdown gets it for free without locking the set.
98
98
  - **`icon` / `group`** are the studio nav's material-symbol icon and folder; ungrouped collections
99
99
  list at the top. `list_fields` is the SEED a module ships, not a competing source of truth — a
@@ -13,7 +13,7 @@ description: triage every open task assigned to me, one at a time
13
13
  argument-hint: "[assignee]"
14
14
  ---
15
15
  load this workspace's tasks skill. list my open tasks
16
- (`npm run --silent dt -- tasks list --status todo`), then walk them one at a
16
+ (`npm run --silent dt -- list tasks --status todo`), then walk them one at a
17
17
  time: restate it, ask me to keep / reassign / drop, apply the decision with `tasks set`.
18
18
  done when the list is empty or I say stop.
19
19
  ```
@@ -55,7 +55,7 @@ description: audio present, no transcript yet
55
55
  - **Pick the signal carefully.** "Is it transcribed?" is `transcription._nempty` (the provenance
56
56
  object), NOT `transcript._nempty` — a body can be filled by hand with no provenance, which is
57
57
  exactly the case worth flagging as not-yet-done.
58
- - Read the queue with `dt commands for <collection>[/<id>] [--ids a,b] [--json]`.
58
+ - Read the queue with `dt commands <collection>[/<id>] [--ids a,b] [--json]`.
59
59
 
60
60
  ## common mistakes
61
61
 
@@ -46,7 +46,7 @@ and silently reverts to a fallback ordering on the next load.
46
46
 
47
47
  ## the CLI can write these
48
48
 
49
- `dt ui-views add|set|rm` — `set` takes dotted keys (`options.sort=-date`) and derives the record id
49
+ `dt schema add-view|set-view|rm-view` — `set-view` takes dotted keys (`options.sort=-date`) and derives the record id
50
50
  with the descriptor's own template, so a view saved from the CLI and one saved from the UI land on
51
51
  the **same record**. This is the one system-stored kind with full CLI write support, because it goes
52
52
  through the same compile gate.
@@ -45,7 +45,7 @@ demand:
45
45
 
46
46
  `npm run --silent dt -- help` is the command surface — don't learn the generic verbs and flags from
47
47
  prose; prose drifts. it does **not** list the purpose-built verbs some collections have
48
- (`collections add`, `<collection> add-field`, `repos ensure`) — those live in the skill that owns
48
+ (`schema add-collection`, `schema add-field <collection>`, `ensure`) — those live in the skill that owns
49
49
  them, and a verb absent from `help` still works.
50
50
 
51
51
  what you need to know *about* the CLI: collection verbs validate hard (invalid writes, **including
@@ -85,10 +85,37 @@ and deliberately nothing else — including nothing about people. There is no `u
85
85
  - workspace-level rules live in `CLAUDE.md`, and a workspace's decision log (where one exists) wins
86
86
  over older documents.
87
87
  - **session greeting** — surface the operator's inbox from whatever collection this workspace uses for
88
- work, e.g. `npm run --silent dt -- tasks list --status todo`. ⚠ **there is no `users` collection and
88
+ work, e.g. `npm run --silent dt -- list tasks --status todo`. ⚠ **there is no `users` collection and
89
89
  no `@me`** (both removed in 0.8.0); read the operator from `git config user.name` at the point you
90
90
  need one, and never filter on a person unless this workspace owns a collection of them.
91
91
 
92
+ ## machine-specific references
93
+
94
+ a path that exists on only one machine — a synced folder, an external disk — is written as a
95
+ **template**, never as an absolute path:
96
+
97
+ ```yaml
98
+ source_file: ${env:FILES_FOLDER}/2026/q3.pdf
99
+ ```
100
+
101
+ | variable | renders to |
102
+ |---|---|
103
+ | `${env:NAME}` | `NAME`'s value in the workspace's `.env` — and only if `NAME` is listed in `dreamteamer.vars` in `package.json` |
104
+ | `${workspaceFolder}` | the workspace root, absolute |
105
+ | `${userHome}` | the current user's home directory |
106
+
107
+ - **declare the key before using it**: `"dreamteamer": { "vars": ["FILES_FOLDER"] }`. an undeclared
108
+ key and a declared-but-absent one are deliberately different errors — the first is a typo, the
109
+ second is a machine nobody has set up. `npm run compile` warns per declared var with no value in
110
+ `.env`, naming keys only.
111
+ - **render with `dt resolve`, the only substitution point**: `dt resolve '${env:FILES_FOLDER}/x'`, or
112
+ `dt resolve <collection>/<id> <field>` to render what a record already holds (an array field prints
113
+ one item per line). an argument containing `${` is always a template, so a ref-shaped one is never
114
+ split as a reference.
115
+ - ⚠ **templates are ordinary data — write them literally; nothing substitutes until resolve is
116
+ called.** `dt get`, `list`, `check` and every harness read the template verbatim. an un-namespaced
117
+ `${VAR}` is inert, so prose may mention `${…}` freely.
118
+
92
119
  ## common mistakes
93
120
 
94
121
  | mistake | why it bites |
@@ -99,3 +126,4 @@ and deliberately nothing else — including nothing about people. There is no `u
99
126
  | bare refs (`ada`, `data/contacts/x.contact.md`) | refs are `<collection>/<id>`; anything else fails check |
100
127
  | assuming a write was committed | it was not, unless `auto-commit` is on — `dt status` says what is pending |
101
128
  | `git add -A` in a shared tree | steals another session's uncommitted work, invisibly |
129
+ | an absolute machine path in a record | it is wrong on every other machine — write `${env:NAME}` and declare the key |
@@ -19,8 +19,8 @@ purpose.
19
19
  ## the verbs
20
20
 
21
21
  `npm run --silent dt -- help` lists the generic record verbs and their flags. read them there.
22
- but **`help` is not the whole surface** — collections with a purpose-built verb (`collections add`,
23
- `<collection> add-field`, `repos ensure`) don't appear in it, and a verb missing from `help`
22
+ but **`help` is not the whole surface** — collections with a purpose-built verb (`schema add-collection`,
23
+ `schema add-field <collection>`, `ensure`) don't appear in it, and a verb missing from `help`
24
24
  is not a verb that doesn't exist. when a skill names a verb, use the verb.
25
25
 
26
26
  what the help text can't tell you either way:
@@ -30,7 +30,7 @@ what the help text can't tell you either way:
30
30
  got) or an id that misses `id.pattern`. a rejected write leaves no partial state.
31
31
  - **every write verb commits by itself**, with the right subject — never stack another commit
32
32
  on top.
33
- - `set <id> <field>=` with an empty value **removes** the field; array fields take a
33
+ - `set <collection>/<id> <field>=` with an empty value **removes** the field; array fields take a
34
34
  comma-separated value (`--attendees contacts/a,contacts/b`).
35
35
  - `--json` works on every verb — use it whenever you're going to parse the output.
36
36
  - `npm run check` validates the whole workspace after the fact: report-only, never rewrites.
@@ -45,10 +45,10 @@ to label one of its records) — both resolved by compile from the id unless aut
45
45
 
46
46
  ## writing a record by hand
47
47
 
48
- **default to `dt <collection> add`** — id, defaults, validation and commit in one line. hand-write
48
+ **default to `dt add <collection>`** — id, defaults, validation and commit in one line. hand-write
49
49
  only when the CLI can't express the value: a nested map, or a long structured body.
50
50
 
51
- when you do, don't reconstruct the shape from the schema — **`dt <collection> get <existing-id>
51
+ when you do, don't reconstruct the shape from the schema — **`dt get <collection>/<existing-id>
52
52
  --json` prints the exact shape a valid record has**: which fields, which ref forms, dates as
53
53
  strings. copy a sibling, change what differs, and:
54
54
 
@@ -81,8 +81,8 @@ A collection may be scoped under a namespace declared in the workspace `package.
81
81
  QUALIFIED name is the collection's name everywhere:
82
82
 
83
83
  ```bash
84
- dt health/doctors add --name "Dana Levi" # → data/health/doctors/dana-levi.doctor.md
85
- dt health/visits add --name Checkup --date 2026-03-04 --doctor health/doctors/dana-levi
84
+ dt add health/doctors --name "Dana Levi" # → data/health/doctors/dana-levi.doctor.md
85
+ dt add health/visits --name Checkup --date 2026-03-04 --doctor health/doctors/dana-levi
86
86
  ```
87
87
 
88
88
  - a reference is still `<collection>/<id>` — `health/doctors/dana-levi` is the collection
package/src/cli.js CHANGED
@@ -1,5 +1,14 @@
1
- // dreamteamer CLI — noun-verb grammar over the same primitives every surface uses.
2
- // this phase ships: compile, check, status. collection verbs land next.
1
+ // dreamteamer CLI — VERB-FIRST: `dt <verb> [<target>]`, over the same primitives every surface uses.
2
+ //
3
+ // The verb set is CLOSED. `run()` switches on it and anything unrecognised is an error, because the
4
+ // predecessor grammar (`dt <collection> <verb>`) made the FALLBACK the collection path: a typo
5
+ // dispatched to a collection lookup and answered "unknown collection", which is a true sentence
6
+ // about the wrong thing. It also meant no verb could ever be named without a noun in front of it
7
+ // — `dt resolve '<string>'` had nowhere to live — and a namespaced reference had to be typed as two
8
+ // arguments that only the caller knew belonged together.
9
+ //
10
+ // This file TRANSLATES; it does not implement. Every record and schema verb lands on
11
+ // `collectionCommand(ws, collection, verb, args)`, whose signature is unchanged.
3
12
  import fs from 'node:fs';
4
13
  import path from 'node:path';
5
14
  import { execFileSync } from 'node:child_process';
@@ -11,6 +20,8 @@ import { init, install, installClone, update, listRepos } from './init.js';
11
20
  import { deriveEvents } from './events.js';
12
21
  import { commitPending } from './commit.js';
13
22
  import { Store } from './store.js';
23
+ import { splitRef } from './ref.js';
24
+ import { parseEnvValues, renderTemplate } from './env-vars.js';
14
25
 
15
26
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
16
27
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -18,9 +29,64 @@ import { Store } from './store.js';
18
29
  const QUIET = ['ignore', 'pipe', 'ignore'];
19
30
 
20
31
 
21
- const USAGE = `usage: dreamteamer <command> | dreamteamer <collection> <verb> …
32
+ const USAGE = `usage: dreamteamer <verb> [<target>] [flags]
22
33
 
23
- commands:
34
+ record verbs (hard validation — invalid writes are rejected before disk).
35
+ A <target> is either a collection name or a <collection>/<id> reference; the reference splits at
36
+ the longest DECLARED collection prefix, so finance/transactions/2026/03/coffee is ONE argument:
37
+ list <collection> [--filter k=v] [--where <json>] [--sort [-]<field>] [--json]
38
+ (--where takes the studio's operator set, e.g.
39
+ '{"starts":{"_gte":"2026-07-01"}}'; date-times
40
+ sort and compare as instants, across offsets)
41
+ get <collection>/<id> [--json]
42
+ add <collection> --<field> <value> … [--id <explicit-id>]
43
+ set <collection>/<id> <field>=<value> …
44
+ rm <collection>/<id> [--force]
45
+ rename <collection>/<id> <new-id> (rewrites all inbound refs, ONE commit)
46
+ move <collection>/<id> --after|--before <id> | --top | --bottom
47
+ move <collection> --init (place every record that has no sort value yet)
48
+ values <collection> <field> [--limit n] (the vocabulary a field actually uses —
49
+ what a filter/validator offers as choices)
50
+ history <collection>/<id> [--json] (git revisions of this record, newest first)
51
+ diff <collection>/<id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
52
+ revert <collection>/<id> --hash <sha> (restore the content at <sha>, as a NEW commit)
53
+ commands <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
54
+ available / done / not-applicable)
55
+ ensure <repos-id> | --all [--json] (materialize an attached repo's working tree ON
56
+ DEMAND — never at install; --all is the explicit
57
+ opt-in, e.g. before going offline)
58
+ resolve '<string>' | <collection>/<id> <field>
59
+ (render \${env:NAME} · \${workspaceFolder} ·
60
+ \${userHome} — the ONLY substitution point; a
61
+ record keeps the template verbatim. An array
62
+ field prints one item per line)
63
+
64
+ schema verbs (write SOURCES through a compile gate, never the runtime — a different act, so a
65
+ different word in front of it):
66
+ schema add-collection --name <name> [--namespace <ns>] [--template docs|entity]
67
+ (--namespace health --name doctors === --name health/doctors; the
68
+ namespace must already be declared in dreamteamer.namespaces, and
69
+ records land in data/<ns>/<name>/)
70
+ schema rm-collection <name> [--force] (--force required if it still has records)
71
+ schema rename-collection <old> <new> (or <old> --namespace <ns> to move it into one)
72
+ moves the descriptor AND the records, re-suffixes files when the
73
+ suffix was derived, rewrites every inbound reference, ONE commit
74
+ schema add-field <collection> --name <field> --type <type> [--options a,b] [--default-value v]
75
+ [--required true] [--description "what this field means"]
76
+ types: string text markdown boolean number integer date datetime
77
+ enum tags <collection> — a date-time may be written as
78
+ "2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
79
+ stamped on for you (2026-07-28T12:00:00+03:00)
80
+ schema update-field <collection> --name <field> --type <type> [--options a,b] [--default-value v]
81
+ [--required true|false] [--description "…"]
82
+ (an existing description survives a retype)
83
+ schema remove-field <collection> --name <field>
84
+ schema add-view --path </route> --target list --collection collections/<c> --layout <id>
85
+ [--id <id>] [k.v=…]
86
+ schema set-view <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
87
+ schema rm-view <id>
88
+
89
+ workspace verbs:
24
90
  init write the workspace skeleton into the current directory (never compiles)
25
91
  --version print the engine version (works anywhere)
26
92
  install restore git_modules/ from the lockfile map; --clone <url> [name] adds one
@@ -35,51 +101,30 @@ commands:
35
101
  commit publish records already written to disk: samples git status over every
36
102
  collection's record dirs, one commit PER REPO, subject composed from the
37
103
  status letters. [<collection> …] to scope, [-m <subject>], [--dry-run]
104
+ help this text
105
+ `;
38
106
 
39
- collection verbs (hard validation invalid writes are rejected before disk):
40
- <collection> list [--filter k=v] [--where <json>] [--sort [-]<field>] [--json]
41
- (--where takes the studio's operator set, e.g.
42
- '{"starts":{"_gte":"2026-07-01"}}'; date-times
43
- sort and compare as instants, across offsets)
44
- <collection> get <id> [--json]
45
- <collection> add --<field> <value> … [--id <explicit-id>]
46
- <collection> set <id> <field>=<value> …
47
- <collection> rm <id> [--force]
48
- <collection> rename <old-id> <new-id> (rewrites all inbound refs, ONE commit)
49
- <collection> history <id> [--json] (git revisions of this record, newest first)
50
- <collection> diff <id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
51
- <collection> revert <id> --hash <sha> (restore the content at <sha>, as a NEW commit)
107
+ // Record verbs, split by what their <target> means. `move` and `commands` are in NEITHER set: both
108
+ // accept either shape, and which one it is has to be decided against the declared collections.
109
+ const REF_VERBS = new Set(['get', 'set', 'rm', 'rename', 'history', 'diff', 'revert']);
110
+ const COLLECTION_VERBS = new Set(['list', 'add', 'values']);
111
+ const EITHER_VERBS = new Set(['move', 'commands']);
52
112
 
53
- repo attachment (working trees are materialized ON DEMAND, never at install):
54
- repos ensure <id> [--json] (clone if missing, then print the path; idempotent)
55
- repos ensure --all [--json] (explicit opt-in: everything, e.g. before going offline)
56
-
57
- meta verbs (schema operations — write SOURCES through a compile gate, never the runtime):
58
- collections add --name <name> [--namespace <ns>] [--template docs|entity]
59
- (--namespace health --name doctors === --name health/doctors; the
60
- namespace must already be declared in dreamteamer.namespaces, and
61
- records land in data/<ns>/<name>/)
62
- collections rm <name> [--force] (--force required if it still has records)
63
- collections rename <old> <new> (or <old> --namespace <ns> to move it into one)
64
- moves the descriptor AND the records, re-suffixes files when the
65
- suffix was derived, rewrites every inbound reference, ONE commit
66
- <collection> add-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true]
67
- [--description "what this field means"]
68
- types: string text markdown boolean number integer date datetime
69
- enum tags <collection> — a date-time may be written as
70
- "2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
71
- stamped on for you (2026-07-28T12:00:00+03:00)
72
- <collection> update-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true|false]
73
- [--description "…"] (an existing description survives a retype)
74
- <collection> remove-field --name <field>
75
- ui-views add --path </route> --target list --collection collections/<c> --layout <id> [--id <id>] [k.v=…]
76
- ui-views set <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
77
- ui-views rm <id>
78
- commands for <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
79
- available / done / not-applicable)
80
- <collection> values <field> [--limit n] (the vocabulary a field actually uses —
81
- what a filter/validator offers as choices)
82
- `;
113
+ // `schema <op>` → the (collection, verb) pair the implementation layer already answers to. The
114
+ // collections are literals: `collections` and `ui-views` are SYSTEM-stored, which is precisely what
115
+ // makes these a separate group in the grammar rather than records like any other.
116
+ const SCHEMA_OPS = {
117
+ 'add-collection': ['collections', 'add'],
118
+ 'rm-collection': ['collections', 'rm'],
119
+ 'rename-collection': ['collections', 'rename'],
120
+ 'add-view': ['ui-views', 'add'],
121
+ 'set-view': ['ui-views', 'set'],
122
+ 'rm-view': ['ui-views', 'rm'],
123
+ };
124
+ // These three name their collection POSITIONALLY (`schema add-field contacts --name phone`) and keep
125
+ // their existing verb spelling on it — the schema group is a prefix here, not a rename.
126
+ const SCHEMA_FIELD_OPS = new Set(['add-field', 'update-field', 'remove-field']);
127
+ const SCHEMA_OP_LIST = [...Object.keys(SCHEMA_OPS), ...SCHEMA_FIELD_OPS].join(' | ');
83
128
 
84
129
  export function run(argv) {
85
130
  const [cmd, ...rest] = argv;
@@ -96,6 +141,10 @@ export function run(argv) {
96
141
  for (let i = 0; i < rest.length; i++) if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1];
97
142
  process.exit(init({ flags }));
98
143
  }
144
+ if (!cmd) {
145
+ console.log(USAGE);
146
+ process.exit(0);
147
+ }
99
148
  const ws = findWorkspace();
100
149
  switch (cmd) {
101
150
  case 'install': {
@@ -202,7 +251,7 @@ export function run(argv) {
202
251
  if (repos.length) {
203
252
  const here = repos.filter((r) => r.present).length;
204
253
  console.log(`repos: ${here}/${repos.length} materialized`);
205
- for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer repos ensure ${r.id})`);
254
+ for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer ensure ${r.id})`);
206
255
  }
207
256
  } catch { /* no repos descriptor compiled — nothing to report */ }
208
257
  // Uncommitted records are invisible to `dt changes` (it diffs commits), so the
@@ -226,14 +275,25 @@ export function run(argv) {
226
275
  case 'help':
227
276
  console.log(USAGE);
228
277
  process.exit(0);
229
- default: {
230
- if (!cmd || rest.length === 0) {
231
- console.log(USAGE);
232
- process.exit(cmd ? 1 : 0);
233
- }
278
+ case 'list': case 'add': case 'values':
279
+ case 'get': case 'set': case 'rm': case 'rename': case 'history': case 'diff': case 'revert':
280
+ case 'move': case 'commands':
234
281
  warnIfStale(ws.root);
235
- process.exit(collectionCommand(ws, cmd, rest[0], rest.slice(1)));
236
- }
282
+ process.exit(dispatchRecordVerb(ws, cmd, rest));
283
+ // `repos ensure` lost its noun: the repos collection is still where the declaration lives,
284
+ // but materializing one is a verb the operator types, not a record write.
285
+ case 'ensure':
286
+ warnIfStale(ws.root);
287
+ process.exit(collectionCommand(ws, 'repos', 'ensure', rest));
288
+ case 'schema':
289
+ warnIfStale(ws.root);
290
+ process.exit(dispatchSchemaVerb(ws, rest));
291
+ case 'resolve':
292
+ process.exit(resolveVariables(ws, rest));
293
+ default:
294
+ console.error(`✖ unknown verb "${cmd}" — dreamteamer is verb-first since 0.12.0: dt <verb> [<target>]`);
295
+ console.error(USAGE);
296
+ process.exit(1);
237
297
  }
238
298
  } catch (e) {
239
299
  console.error(`✖ ${e.message}`);
@@ -241,6 +301,110 @@ export function run(argv) {
241
301
  }
242
302
  }
243
303
 
304
+ /** Translate `dt <verb> <target> …` into the noun-verb call the implementation layer takes. */
305
+ function dispatchRecordVerb(ws, verb, args) {
306
+ const [target, ...rest] = args;
307
+ if (!target) throw new Error(`dt ${verb} needs a target — see \`dreamteamer help\``);
308
+ // A flag in the target slot is a word-order mistake, not a collection: without this,
309
+ // `dt list --json contacts` reported `unknown collection "--json"` and dumped every name.
310
+ if (target.startsWith('--')) throw new Error(`dt ${verb} takes its target BEFORE the flags: dreamteamer ${verb} <target> ${target} …`);
311
+ if (COLLECTION_VERBS.has(verb)) return collectionCommand(ws, target, verb, rest);
312
+ if (REF_VERBS.has(verb)) {
313
+ const { collection, id } = splitRef(new Store(ws).descriptors, target);
314
+ return collectionCommand(ws, collection, verb, [id, ...rest]);
315
+ }
316
+ // EITHER_VERBS from here: a bare collection is legal for both — `move <collection> --init`,
317
+ // `commands <collection>`.
318
+ const { descriptors } = new Store(ws);
319
+ if (descriptors.has(target)) {
320
+ return verb === 'move'
321
+ ? collectionCommand(ws, target, 'move', rest)
322
+ : collectionCommand(ws, 'commands', 'for', [target, ...rest]);
323
+ }
324
+ const { collection, id } = splitRef(descriptors, target);
325
+ if (verb === 'move') return collectionCommand(ws, collection, 'move', [id, ...rest]);
326
+ // `commands for <c>/<id>` split its own target at the FIRST slash, which cannot name a
327
+ // namespaced collection. splitRef can, so the id is handed over as `--ids` — the same
328
+ // `commandsFor(store, collection, ids)` call, reached without re-encoding the reference.
329
+ // Ours goes FIRST so an explicit `--ids` from the caller still wins (last flag parsed wins).
330
+ return collectionCommand(ws, 'commands', 'for', [collection, '--ids', id, ...rest]);
331
+ }
332
+
333
+ /**
334
+ * `dt resolve '<string>'` | `dt resolve <collection>/<id> <field>` — the ONLY place a `${env:…}`
335
+ * template becomes a value. Records hold the template verbatim; no read path substitutes anything,
336
+ * so a reference means the same thing on every machine and the file says which is which.
337
+ *
338
+ * THE HEURISTIC: the first argument is a REFERENCE iff it contains no `${` AND splits against a
339
+ * declared collection. Both halves are needed. `${` first, because `docs/${env:X}` is ref-SHAPED
340
+ * and must not be split as one — a reference can never contain a template, so the marker decides it
341
+ * outright. Then the split, because a plain path (`/tmp/x`, `nope/q3`) must render to itself rather
342
+ * than report an unknown collection it was never naming. Two accepted consequences: a bare
343
+ * collection name resolves to itself (splitRef refuses it, so it is "just a string"), and in an
344
+ * UNCOMPILED workspace every argument is a string, because there are no descriptors to split
345
+ * against — which keeps `dt resolve '${env:K}'` working before the first compile.
346
+ */
347
+ function resolveVariables(ws, args) {
348
+ const [target, field] = args;
349
+ if (!target) throw new Error("dt resolve takes a string template or a <collection>/<id> and a field: dreamteamer resolve '${env:FILES_FOLDER}/x'");
350
+ // resolve has no flags, so a flag-shaped target is a mistake — and the one that costs is
351
+ // `dt resolve --help`, which would otherwise print `--help` back and exit 0.
352
+ if (target.startsWith('--')) throw new Error(`dt resolve takes a string or a <collection>/<id>, not a flag ("${target}") — see \`dreamteamer help\``);
353
+ const declared = ws.pkg.dreamteamer?.vars ?? [];
354
+ const envFile = path.join(ws.root, '.env');
355
+ const env = parseEnvValues(fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : '');
356
+ const ctx = { env, workspaceFolder: ws.root, declared };
357
+
358
+ let ref = null;
359
+ let store = null;
360
+ if (!target.includes('${')) {
361
+ try {
362
+ store = new Store(ws);
363
+ ref = splitRef(store.descriptors, target);
364
+ } catch { ref = null; }
365
+ }
366
+ // An argument nobody reads is a silent wrong answer: `dt resolve docs/q3 source_file garbage`
367
+ // exited 0 on the field it happened to recognise.
368
+ const takes = ref ? 2 : 1;
369
+ if (args.length > takes) {
370
+ throw new Error(`dt resolve takes ${takes === 1 ? 'one string template' : 'a reference and ONE field'} — ${args.length - takes} extra argument(s): ${args.slice(takes).join(' ')}`);
371
+ }
372
+ if (!ref) {
373
+ console.log(renderTemplate(target, ctx));
374
+ return 0;
375
+ }
376
+ if (!field) throw new Error(`dt resolve ${target} needs a field name: dreamteamer resolve ${target} <field>`);
377
+ const { fields } = store.read(ref.collection, ref.id);
378
+ const value = fields[field];
379
+ if (value === undefined) throw new Error(`${target} has no field "${field}"`);
380
+ // RENDER EVERY ITEM BEFORE PRINTING ANY. An array prints one item per line so the output pipes
381
+ // into a shell loop unchanged — and a loop that does not check $? cannot tell a truncated list
382
+ // from a short one, so a failure on item n must not leave items 1..n-1 on stdout. Anything that
383
+ // is not text is refused rather than stringified: a number cannot hold a template.
384
+ const items = Array.isArray(value) ? value : [value];
385
+ const bad = items.findIndex((v) => typeof v !== 'string'); // index, not the value: an item may be null
386
+ if (bad > -1) throw new Error(`${target} field "${field}" holds a ${typeof items[bad]} — resolve renders text (a string, or a list of them)`);
387
+ const rendered = items.map((v) => renderTemplate(v, ctx));
388
+ if (rendered.length) console.log(rendered.join('\n'));
389
+ return 0;
390
+ }
391
+
392
+ /** Translate `dt schema <op> …` onto the same meta verbs `collectionCommand` already routes. */
393
+ function dispatchSchemaVerb(ws, args) {
394
+ const [op, ...rest] = args;
395
+ if (!op) throw new Error(`dt schema needs an operation — use ${SCHEMA_OP_LIST}`);
396
+ if (SCHEMA_FIELD_OPS.has(op)) {
397
+ const [collection, ...flags] = rest;
398
+ if (!collection || collection.startsWith('--')) {
399
+ throw new Error(`dt schema ${op} needs a collection: dreamteamer schema ${op} <collection> --name <field> …`);
400
+ }
401
+ return collectionCommand(ws, collection, op, flags);
402
+ }
403
+ const pair = SCHEMA_OPS[op];
404
+ if (!pair) throw new Error(`unknown schema operation "${op}" — use ${SCHEMA_OP_LIST}`);
405
+ return collectionCommand(ws, pair[0], pair[1], rest);
406
+ }
407
+
244
408
  function tryGit(cwd, args) {
245
409
  try { return execFileSync('git', args, { cwd, stdio: QUIET }).toString().trim() || null; } catch { return null; }
246
410
  }
@@ -1,6 +1,7 @@
1
- // noun-verb collection commands: dreamteamer <collection> list|get|add|set|rm|rename|history|diff|revert
2
- // + meta verbs: `collections add|rm`, `<collection> add-field|update-field|remove-field`,
3
- // `ui-views add|set|rm`
1
+ // The IMPLEMENTATION layer behind the verb-first CLI: `collectionCommand(ws, collection, verb, args)`
2
+ // is (collection, verb) shaped and stays that way — `cli.js` translates `dt <verb> <target>` onto it.
3
+ // So a spelling here (`collections add`, `<collection> add-field`, `commands for`, `repos ensure`) is
4
+ // an INTERNAL pair, not what the operator types; `dt schema add-collection` is what they type.
4
5
  import { execFileSync } from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
@@ -19,6 +20,7 @@ import { distinctValues } from './field-values.js';
19
20
  import { matchesFilter } from './filter.js';
20
21
  import { baseNameOf, normalizeNamespaces } from './namespace.js';
21
22
  import { sortRows } from './temporal.js';
23
+ import { keyBetween, placementKey } from './fractional-index.js';
22
24
  import { ensureRepo, ensureAllRepos } from './init.js';
23
25
 
24
26
  /**
@@ -26,7 +28,7 @@ import { ensureRepo, ensureAllRepos } from './init.js';
26
28
  *
27
29
  * `console.log` to a pipe is asynchronous, and every CLI path ends in `process.exit()`, which
28
30
  * discards whatever is still buffered. A shell pipeline hides the bug completely — the reader
29
- * drains concurrently, so `dreamteamer contacts list --json | wc -c` reports all 32381 bytes — but
31
+ * drains concurrently, so `dreamteamer list contacts --json | wc -c` reports all 32381 bytes — but
30
32
  * the way a script or a coding agent actually calls this is execFileSync/spawnSync, and there the
31
33
  * child exits with the pipe still full. Measured before this fix: the same command captured with
32
34
  * execFileSync returned exactly **8190 bytes** (one pipe buffer) of that 32381-byte document, i.e.
@@ -133,6 +135,40 @@ export function collectionCommand(ws, collection, verb, args) {
133
135
  flags.json ? emit(JSON.stringify({ id })) : console.log('✔ updated');
134
136
  return 0;
135
137
  }
138
+ // Manual ordering. ONE record is written per move — that is the entire feature; a dense
139
+ // integer would renumber everything below the insertion point and bury the change. The field is
140
+ // named by the descriptor (`sort_field`), never here, so a workspace may call it anything.
141
+ case 'move': {
142
+ const field = d.sort_field;
143
+ if (!field) throw new Error(`collection "${collection}" declares no sort_field — add one to its descriptor before ordering it by hand.`);
144
+
145
+ // Blanks sort FIRST (compareValues, via `?? ''`), so unplaced records surface at the top
146
+ // rather than hiding at the bottom, and ties fall back to id order: `walk` reads name-sorted
147
+ // and Array.sort is stable. That is the tiebreak two agents landing on the same key rely on.
148
+ const rows = [];
149
+ for (const { id: rid, fields } of store.readAll(collection)) rows.push({ id: rid, key: fields[field] ?? '' });
150
+ sortRows(rows, 'key');
151
+
152
+ if (flags.init) {
153
+ // Idempotent: a record that already carries a key keeps it, so --init can be re-run after
154
+ // adding records without disturbing an order the operator set by hand.
155
+ let prev = rows.filter((r) => r.key).pop()?.key ?? null;
156
+ let written = 0;
157
+ for (const r of rows) {
158
+ if (r.key) continue;
159
+ prev = keyBetween(prev, null);
160
+ store.set(collection, r.id, { [field]: prev });
161
+ written++;
162
+ }
163
+ flags.json ? emit(JSON.stringify({ placed: written })) : console.log(written ? `✔ placed ${written} record(s) in ${field}` : '✔ nothing to place');
164
+ return 0;
165
+ }
166
+
167
+ const id = need(pos, 0, 'id');
168
+ store.set(collection, id, { [field]: placementKey(rows, id, flags, collection) });
169
+ flags.json ? emit(JSON.stringify({ id })) : console.log('✔ moved');
170
+ return 0;
171
+ }
136
172
  case 'rm': {
137
173
  const id = need(pos, 0, 'id');
138
174
  const { inboundIgnored } = store.rm(collection, id, { force: !!flags.force });
@@ -146,7 +182,7 @@ export function collectionCommand(ws, collection, verb, args) {
146
182
  if (out.touched) console.log(`✔ rewrote ${out.rewrites} inbound reference(s) across ${out.touched} file(s)`);
147
183
  return 0;
148
184
  }
149
- // `dreamteamer meetings values status` — the vocabulary a field ACTUALLY uses, so a filter
185
+ // `dreamteamer values meetings status` — the vocabulary a field ACTUALLY uses, so a filter
150
186
  // or a command-binding validator can offer a dropdown for a plain `type: string` field that
151
187
  // no enum describes (operator: "still no dropdown for many things, visibility, status").
152
188
  case 'values': {
@@ -181,7 +217,7 @@ export function collectionCommand(ws, collection, verb, args) {
181
217
  // the hash is REQUIRED and has no default: "revert" with an implied target is how you
182
218
  // destroy the wrong record. `<c> history <id>` is where you get one.
183
219
  const hash = typeof flags.hash === 'string' ? flags.hash : pos[1];
184
- if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer ${collection} history ${id}\` to pick one`);
220
+ if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer history ${collection}/${id}\` to pick one`);
185
221
  const out = store.revert(collection, id, hash);
186
222
  flags.json ? emit(JSON.stringify(out)) : console.log(out.reverted ? `✔ reverted ${collection}/${id} to ${String(hash).slice(0, 7)}` : `= already identical to ${String(hash).slice(0, 7)} — nothing changed`);
187
223
  return 0;
@@ -193,16 +229,18 @@ export function collectionCommand(ws, collection, verb, args) {
193
229
 
194
230
 
195
231
 
196
- // `dreamteamer commands for <collection>[/<id>] [--ids <id>[,…]] [--json]` which bound
197
- // commands apply, in which state (available / done / not-applicable). THE engine surface
198
- // behind the studio's Commands tab (engine/UI parity: the verb lands first, the button second).
232
+ // Which bound commands apply, in which state (available / done / not-applicable). THE engine
233
+ // surface behind the studio's Commands tab (engine/UI parity: the verb lands first, the button
234
+ // second).
235
+ //
236
+ // ⚠ The COLLECTION arrives already resolved, and the ids only ever through `--ids`. This used to
237
+ // split its own target at the first slash — which cannot name a namespaced collection, so
238
+ // `commands finance/transactions` asked for a collection called "finance" and every namespaced
239
+ // target failed. `cli.js` owns reference resolution now (splitRef, longest declared prefix) and is
240
+ // the only caller, so a second, weaker splitter here could only ever disagree with it.
199
241
  function metaCommandsFor(ws, store, flags, pos) {
200
- const target = need(pos, 0, 'collection[/id]');
201
- const slash = target.indexOf('/');
202
- const collection = slash > 0 ? target.slice(0, slash) : target;
203
- const ids = slash > 0
204
- ? [target.slice(slash + 1)]
205
- : typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
242
+ const collection = need(pos, 0, 'collection');
243
+ const ids = typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
206
244
  const out = commandsFor(store, collection, ids);
207
245
  if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
208
246
  if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
@@ -241,7 +279,7 @@ function metaCollectionsAdd(ws, store, flags) {
241
279
  // migration whose last step (rewriting references) dangles everything when forgotten.
242
280
  function metaCollectionsRename(ws, store, flags, pos) {
243
281
  const [oldName, explicitNew] = pos;
244
- if (!oldName) throw new Error('usage: collections rename <old-name> <new-name> | <old-name> --namespace <ns>');
282
+ if (!oldName) throw new Error('usage: dreamteamer schema rename-collection <old> <new> | <old> --namespace <ns>');
245
283
  // `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
246
284
  // which is the common case and saves retyping it.
247
285
  const newName = explicitNew
package/src/compile.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  // call at run time, same pattern as store.js ↔ compile.js.
18
18
  import { runHarnessAdapters } from './harnesses.js';
19
19
  import { satisfies } from './semver.js';
20
+ import { parseEnvValues } from './env-vars.js';
20
21
  import { DERIVED_KINDS, readManifest, runtimeDir } from './runtime.js';
21
22
 
22
23
  // re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
@@ -295,21 +296,37 @@ export function compile({ root, pkg }) {
295
296
  declaredEnv.get(k).push(source.name);
296
297
  }
297
298
  }
298
- if (declaredEnv.size) {
299
+ // `dreamteamer.vars` is the WORKSPACE's own declaration (root package.json, not a module's): the
300
+ // keys a `${env:NAME}` template is allowed to name. Same missing-key question as
301
+ // `dreamteamer.env`, one .env parse, two warnings — a module needs its key to RUN, a var is
302
+ // needed the moment someone calls `dt resolve`, and only the workspace can declare one.
303
+ if (config.vars !== undefined && (!Array.isArray(config.vars) || config.vars.some((v) => typeof v !== 'string'))) {
304
+ fail(`dreamteamer.vars must be a list of env key names (got ${JSON.stringify(config.vars)})`);
305
+ }
306
+ const declaredVars = config.vars ?? [];
307
+ if (declaredEnv.size || declaredVars.length) {
299
308
  // .env is parsed for KEY names ONLY — values never reach any output or the manifest
300
309
  const envPath = path.join(root, '.env');
301
310
  if (!fs.existsSync(envPath)) {
302
- console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
311
+ if (declaredEnv.size) console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
312
+ if (declaredVars.length) console.warn(`⚠ no .env — dreamteamer.vars declares ${declaredVars.join(', ')}, so no \${env:…} template can render here (see .env.example)`);
303
313
  } else {
304
- const present = new Set();
305
- for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
306
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
307
- if (m) present.add(m[1]);
308
- }
314
+ // THE ONE PARSER, not a key regex of our own. This used to hand-roll
315
+ // `/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/`, which accepted two lines
316
+ // `parseEnvValues` deliberately drops (`KEY =value`, and an indented key) and scanned a
317
+ // quoted value's continuation lines for keys. Either disagreement produces the worst
318
+ // pairing there is: compile says nothing and then `dt resolve` answers "no value in
319
+ // .env" about a line the operator is looking straight at. Values are read here and
320
+ // never printed — the warnings below name keys only.
321
+ const present = new Set(parseEnvValues(fs.readFileSync(envPath, 'utf8')).keys());
309
322
  for (const [k, mods] of declaredEnv) {
310
323
  if (present.has(k)) continue;
311
324
  for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k} — missing from .env (see .env.example)`);
312
325
  }
326
+ for (const k of declaredVars) {
327
+ if (present.has(k)) continue;
328
+ console.warn(`⚠ dreamteamer.vars declares ${k} — missing from .env, so \${env:${k}} cannot render on this machine`);
329
+ }
313
330
  }
314
331
  }
315
332
 
@@ -630,6 +647,15 @@ export function compile({ root, pkg }) {
630
647
  fail(`collection "${name}": id.pattern is not a valid regular expression — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
631
648
  }
632
649
  }
650
+ // `sort_field` names a field of this collection's OWN schema. Without this gate a typo
651
+ // surfaces as "dragging does nothing" while the drag handle is still offered — a silent lie,
652
+ // and the ordering it writes would land in a field no reader sorts by.
653
+ if (merged.sort_field !== undefined) {
654
+ if (typeof merged.sort_field !== 'string') fail(`collection "${name}": sort_field must be a string (got ${JSON.stringify(merged.sort_field)})`);
655
+ if (!(merged.schema?.properties ?? {})[merged.sort_field]) {
656
+ fail(`collection "${name}": sort_field "${merged.sort_field}" is not a field of its schema — declare it, or point sort_field at one that exists (${group.map((g) => g.src.path).join(', ')}).`);
657
+ }
658
+ }
633
659
  // ---- the reference contract: every target is owned, depended on, or declared a peer ----
634
660
  // Attribution is unioned across the whole group rather than taken from the base, because the
635
661
  // merge keeps no per-field provenance — an overlay that adds a ref field would otherwise be
@@ -0,0 +1,40 @@
1
+ // ${env:KEY} / ${workspaceFolder} / ${userHome} — VS Code's variable grammar, three variables.
2
+ // Values render ONLY on explicit request (dt resolve); records are never auto-substituted.
3
+ // parseEnvValues reads .env TEXT only (never shell-evaluates). A QUOTED value (single or double)
4
+ // may span multiple lines — the closing quote just has to end some later line, not the one it
5
+ // opened on — because `[^"\\]`/`[^']` match newlines in JS regex same as any other character.
6
+ // An UNQUOTED value is line-bound: it runs to end of line, so it can't span lines.
7
+ // Two accepted non-goals, unchanged on purpose: `KEY =value` (space before `=`) doesn't match the
8
+ // key pattern and is silently dropped, no diagnostic; an unquoted value keeps a trailing inline
9
+ // `# comment` as part of its text (quote it to strip one).
10
+ import os from 'node:os';
11
+
12
+ export function parseEnvValues(text) {
13
+ const out = new Map();
14
+ // KEY=value with optional `export `, optional quotes; value is whatever follows on the SAME line
15
+ const re = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'[^']*'|[^\n]*)$/gm;
16
+ let m;
17
+ while ((m = re.exec(text))) {
18
+ let v = m[2].trim();
19
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
20
+ out.set(m[1], v);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ const SUPPORTED = 'supported: ${env:NAME}, ${workspaceFolder}, ${userHome}';
26
+
27
+ export function renderTemplate(str, { env, workspaceFolder, declared }) {
28
+ return str.replace(/\$\{([A-Za-z]+)(?::([A-Za-z0-9_]*))?\}/g, (whole, ns, arg) => {
29
+ if (arg === undefined) {
30
+ if (ns === 'workspaceFolder') return workspaceFolder;
31
+ if (ns === 'userHome') return os.homedir();
32
+ return whole; // un-namespaced ${VAR}: not ours, inert — prose mentions ${…} freely
33
+ }
34
+ if (ns !== 'env') throw new Error(`\${${ns}:${arg}} is not a dreamteamer variable — ${SUPPORTED}`);
35
+ if (!arg) throw new Error(`\${env:} needs a key name — ${SUPPORTED}`);
36
+ if (!declared.includes(arg)) throw new Error(`\${env:${arg}}: "${arg}" is not declared in dreamteamer.vars (workspace package.json) — declared: ${declared.join(', ') || '(none)'}`);
37
+ if (!env.has(arg)) throw new Error(`\${env:${arg}} is declared but has no value in .env on this machine`);
38
+ return env.get(arg);
39
+ });
40
+ }
@@ -0,0 +1,44 @@
1
+ // Manual ordering keys. Named for the ALGORITHM: the FIELD is named per collection via
2
+ // `sort_field`, and nothing here may assume what it is called.
3
+ //
4
+ // ⚠ THE ALPHABET IS LOAD-BEARING. `compareValues` (temporal.js) ends in `localeCompare`, which is
5
+ // locale-aware, so the library's DEFAULT base-62 keys mis-sort here: prepending three times gives
6
+ // `Zy Zz a0`, and `sortRows` returns `a0 Zy Zz`. Same trap that breaks fractional indexing on
7
+ // Postgres under `en_US.utf8` instead of `C`. a-z is the intersection where three things hold at
8
+ // once — locale order agrees with codepoint order, and no key can parse as a number (the numeric
9
+ // branch of `compareValues` would sort "9" after "10") or as a temporal.
10
+ //
11
+ // test/unit/fractional-index.test.js asserts the base-62 failure directly, so this argument cannot
12
+ // be deleted as noise.
13
+ //
14
+ // Why a dependency rather than 75 lines of our own: the naive midpoint (halve toward the open end)
15
+ // degenerates on APPEND, which is the commonest write — measured at 200-character keys after 1000
16
+ // appends. The library's integer-part-with-magnitude-head keeps that at 4.
17
+ import { generateKeyBetween } from 'fractional-indexing';
18
+
19
+ const DIGITS = 'abcdefghijklmnopqrstuvwxyz';
20
+
21
+ /** A key strictly between `a` and `b`. Either may be null for an open end. */
22
+ export const keyBetween = (a, b) => generateKeyBetween(a ?? null, b ?? null, DIGITS);
23
+
24
+ /**
25
+ * The key that puts `id` where `dest` asks, given the collection's records in current sort order
26
+ * (`[{ id, key }]`, blanks first). PURE — the caller does the reading and the writing, which is what
27
+ * lets the CLI and the HTTP surface share one placement rule instead of two that drift.
28
+ *
29
+ * Fails closed on a destination that is not placed yet: with no key on the target there is nothing to
30
+ * compute against, and guessing would silently put the record somewhere the operator did not ask for.
31
+ */
32
+ export function placementKey(rows, id, dest, collection = '<collection>') {
33
+ const placed = rows.filter((r) => r.key && r.id !== id);
34
+ const at = (t) => {
35
+ const i = placed.findIndex((r) => r.id === t);
36
+ if (i < 0) throw new Error(`"${t}" has no sort value yet — run \`dreamteamer move ${collection} --init\` first. nothing was written.`);
37
+ return i;
38
+ };
39
+ if (dest.top) return keyBetween(null, placed[0]?.key ?? null);
40
+ if (dest.bottom) return keyBetween(placed[placed.length - 1]?.key ?? null, null);
41
+ if (typeof dest.after === 'string') { const i = at(dest.after); return keyBetween(placed[i].key, placed[i + 1]?.key ?? null); }
42
+ if (typeof dest.before === 'string') { const i = at(dest.before); return keyBetween(placed[i - 1]?.key ?? null, placed[i].key); }
43
+ throw new Error('say where to put it — --after <id>, --before <id>, --top or --bottom. nothing was written.');
44
+ }
package/src/harnesses.js CHANGED
@@ -189,7 +189,7 @@ function collectionsSection(index, namespaces) {
189
189
  'COLLECTIONS — the nouns of this workspace. The descriptor at',
190
190
  '`.dreamteamer/collections/<name>.collection.yaml` is the authority on fields, id shape and',
191
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.',
192
+ 'with `dt add <collection>`: it generates the id and rejects invalid writes before disk.',
193
193
  ];
194
194
  const data = index.filter((c) => !c.system);
195
195
  for (const group of ['', ...namespaces]) {
@@ -273,7 +273,7 @@ function bindingsSection(entries) {
273
273
  byCollection.get(coll).push(`/${cmd}${gate ? ` (${gate})` : ''}`);
274
274
  }
275
275
  if (!byCollection.size) return [];
276
- const lines = ['', 'VERBS BOUND TO COLLECTIONS (`dt commands for <collection>[/<id>]` answers per record):'];
276
+ const lines = ['', 'VERBS BOUND TO COLLECTIONS (`dt commands <collection>[/<id>]` answers per record):'];
277
277
  for (const coll of [...byCollection.keys()].sort()) lines.push(`- ${coll} — ${byCollection.get(coll).sort().join(' · ')}`);
278
278
  return lines;
279
279
  }
@@ -60,6 +60,11 @@ function collectionRow(d) {
60
60
  if (typeof d.order === 'number') meta.order = d.order;
61
61
  if (Array.isArray(d.list_fields)) meta.list_fields = d.list_fields;
62
62
  if (typeof d.icon === 'string') meta.icon = d.icon;
63
+ // Manual ordering: a surface can only offer a drag handle if it knows WHICH field a drop writes,
64
+ // and the field name is per-collection. Without this the descriptor key exists and no UI can see
65
+ // it — the extension reads the presentation contract, not raw descriptors.
66
+ if (typeof d.sort_field === 'string') meta.sort_field = d.sort_field;
67
+
63
68
  if (typeof d.group === 'string') meta.group = d.group;
64
69
  if (typeof d.description === 'string' && d.description.length > 0) meta.description = d.description;
65
70
  // A compiled collection is READ-ONLY through the record layer, and the UI needs to say so
package/src/ref.js ADDED
@@ -0,0 +1,13 @@
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).
3
+ export function splitRef(descriptors, ref) {
4
+ 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;
8
+ }
9
+ }
10
+ if (!best) throw new Error(`unknown collection in reference "${ref}" (known: ${[...descriptors.keys()].sort().join(', ')})`);
11
+ 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) };
13
+ }
package/src/runtime.js CHANGED
@@ -27,7 +27,7 @@ export const DERIVED_KINDS = ['modules'];
27
27
 
28
28
  /**
29
29
  * Where a human edits a compiled collection, as one sentence. ONE definition, because there are two
30
- * consumers who must never drift: the store's refusal (`dt modules set …`) and the presentation
30
+ * consumers who must never drift: the store's refusal (`dt set modules/<id> …`) and the presentation
31
31
  * projection the UI reads to explain a disabled button. This repo's own history is the argument —
32
32
  * `git log`/`git diff` and the `?sort=` comparator were each hand-copied into the extension and
33
33
  * went wrong in both places.
package/src/server.js CHANGED
@@ -12,6 +12,7 @@ import { createCollection, removeCollection, addField, updateField, removeField,
12
12
  import { history, historyDiff } from './history.js';
13
13
  import { matchesFilter } from './filter.js';
14
14
  import { sortRows } from './temporal.js';
15
+ import { placementKey } from './fractional-index.js';
15
16
  import { commandsFor, recordResolver } from './record-commands.js';
16
17
  import { distinctValues } from './field-values.js';
17
18
 
@@ -131,6 +132,33 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
131
132
  res.json({ id: idParam(req) });
132
133
  });
133
134
 
135
+ // Manual ordering. NOT Directus's `PATCH /utils/sort/:collection` (decision #16: nothing
136
+ // Directus-flavored lives here).
137
+ //
138
+ // ⚠ THE VERB COMES BEFORE THE WILDCARD, and it must. `…/records/*id` is greedy because ids are
139
+ // PATHS, so `…/records/charlie/position` reads as the record `charlie/position` and the ordinary
140
+ // record PATCH answers first — measured, as a 404 from a route that looked correct. Same reasoning
141
+ // as the `*name` note above: one greedy wildcard, and it goes last.
142
+ //
143
+ // Key generation stays in the ENGINE, next to the comparator it has to agree with. A client that
144
+ // computed keys itself would carry the a-z alphabet rule in a second repo, and the failure when
145
+ // those drift is silent mis-ordering, not an error.
146
+ api.patch('/collections/:name/position/*id', (req, res) => {
147
+ const d = store.descriptor(req.params.name);
148
+ const field = d.sort_field;
149
+ if (!field) return res.status(400).json({ error: `collection "${d.name}" declares no sort_field` });
150
+ const rows = [];
151
+ for (const { id, fields } of store.readAll(d.name)) rows.push({ id, key: fields[field] ?? '' });
152
+ sortRows(rows, 'key');
153
+ const id = idParam(req);
154
+ try {
155
+ store.set(d.name, id, { [field]: placementKey(rows, id, req.body ?? {}, d.name) });
156
+ res.json({ id });
157
+ } catch (e) {
158
+ res.status(400).json({ error: e.message });
159
+ }
160
+ });
161
+
134
162
  api.delete('/collections/:name/records/*id', (req, res) => {
135
163
  store.rm(req.params.name, idParam(req), { force: req.query.force === 'true' });
136
164
  res.json({ id: idParam(req) });