dreamteamer 0.6.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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +83 -0
  4. package/agents/dreamteamer.agent.md +7 -0
  5. package/bin/dreamteamer.js +65 -0
  6. package/collection-templates/docs.collection-template.yaml +14 -0
  7. package/collection-templates/entity.collection-template.yaml +16 -0
  8. package/collections/agents.collection.yaml +33 -0
  9. package/collections/collection-templates.collection.yaml +20 -0
  10. package/collections/collections.collection.yaml +78 -0
  11. package/collections/command-bindings.collection.yaml +46 -0
  12. package/collections/commands.collection.yaml +36 -0
  13. package/collections/repos.collection.yaml +42 -0
  14. package/collections/skills.collection.yaml +22 -0
  15. package/collections/ui-views.collection.yaml +48 -0
  16. package/collections/users.collection.yaml +21 -0
  17. package/package.json +58 -0
  18. package/skills/building-dreamteamer/SKILL.md +117 -0
  19. package/skills/building-dreamteamer/references/agents.md +44 -0
  20. package/skills/building-dreamteamer/references/before-you-build.md +42 -0
  21. package/skills/building-dreamteamer/references/collections.md +120 -0
  22. package/skills/building-dreamteamer/references/commands.md +69 -0
  23. package/skills/building-dreamteamer/references/skills.md +73 -0
  24. package/skills/building-dreamteamer/references/ui-components.md +78 -0
  25. package/skills/building-dreamteamer/references/ui-views.md +59 -0
  26. package/skills/using-dreamteamer/SKILL.md +100 -0
  27. package/skills/using-dreamteamer/references/git-events.md +64 -0
  28. package/skills/using-dreamteamer/references/records.md +102 -0
  29. package/src/check.js +193 -0
  30. package/src/cli.js +250 -0
  31. package/src/collections-cli.js +389 -0
  32. package/src/commit.js +117 -0
  33. package/src/compile.js +747 -0
  34. package/src/events.js +124 -0
  35. package/src/field-values.js +69 -0
  36. package/src/filter.js +107 -0
  37. package/src/harnesses.js +233 -0
  38. package/src/history.js +64 -0
  39. package/src/init.js +307 -0
  40. package/src/presentation.js +190 -0
  41. package/src/record-commands.js +84 -0
  42. package/src/records.js +73 -0
  43. package/src/runtime.js +96 -0
  44. package/src/schema-ops.js +263 -0
  45. package/src/semver.js +32 -0
  46. package/src/server.js +291 -0
  47. package/src/store.js +450 -0
  48. package/src/template.js +98 -0
  49. package/src/temporal.js +149 -0
  50. package/src/workspace.js +51 -0
  51. package/src/yaml.js +6 -0
package/src/events.js ADDED
@@ -0,0 +1,124 @@
1
+ // item events — DERIVED from git history, never observed live (the slice-5 contract in
2
+ // using-dreamteamer → references/git-events.md): a closed laptop loses nothing, every evaluation is
3
+ // auditable and replayable forever. history IS the queue; there is no events file.
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { EXT } from './records.js';
7
+
8
+ /** Record events between two points, across EVERY repo that holds records. `from` is a sha or a
9
+ * date — a sha is meaningless in another repo, so it is resolved to its commit DATE and each
10
+ * repo then resolves that date to its own sha. Still cursor-less and stores nothing, so it can
11
+ * be run twice with no consequence. */
12
+ export function deriveEvents(root, descriptors, from, to = 'HEAD') {
13
+ const byRepo = new Map();
14
+ for (const d of descriptors.values()) {
15
+ const p = d.storage?.path;
16
+ if (!p || d.storage.base === 'runtime') continue; // runtime entities aren't item events
17
+ const repo = d.storage.repo ?? '.';
18
+ if (!byRepo.has(repo)) byRepo.set(repo, []);
19
+ byRepo.get(repo).push(p);
20
+ }
21
+ const when = asDate(root, from);
22
+ const events = [];
23
+ for (const [repo, dirs] of byRepo) {
24
+ const cwd = path.resolve(root, repo);
25
+ const prefix = repo === '.' ? '' : `${repo}/`;
26
+ // descriptors carry WORKSPACE-relative paths; git in the module repo wants its own
27
+ const relDirs = dirs.map((d) => (prefix && d.startsWith(prefix) ? d.slice(prefix.length) : d));
28
+ // a repo whose history STARTS after the baseline (the agentlog clone is younger than the
29
+ // workspace) has no commit to diff against — but every record in it IS new since then,
30
+ // so fall back to the empty tree rather than silently contributing zero events
31
+ const fromSha = shaAt(cwd, when) ?? EMPTY_TREE;
32
+ let out = '';
33
+ try {
34
+ out = execFileSync(
35
+ 'git', ['diff', '--name-status', '-z', `${fromSha}..${to}`, '--', ...relDirs],
36
+ { cwd },
37
+ ).toString();
38
+ } catch { continue; }
39
+ events.push(...parseNameStatus(out, prefix, descriptors, repo));
40
+ }
41
+ return events;
42
+ }
43
+
44
+ // git's own "no parent" object — the diff baseline for a repo younger than the requested date
45
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
46
+
47
+ /** A sha or a date in → an ISO instant. A sha is resolved in the WORKSPACE repo, since that is
48
+ * the only repo a caller could have got one from. */
49
+ function asDate(root, from) {
50
+ // a BARE date is pinned to midnight: git's approxidate fills unspecified fields from NOW, so
51
+ // `--before=2026-08-01` means 2026-08-01 at the current clock time and the same command
52
+ // answers differently in the morning and the evening (measured, 2026-08-03)
53
+ if (/^\d{4}-\d{2}-\d{2}$/.test(from)) return `${from}T00:00:00`;
54
+ if (/^\d{4}-\d{2}-\d{2}/.test(from)) return from;
55
+ try {
56
+ return execFileSync('git', ['show', '-s', '--format=%cI', from], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] })
57
+ .toString().trim();
58
+ } catch {
59
+ throw new Error(`--since "${from}" is neither a date (YYYY-MM-DD) nor a commit in this workspace`);
60
+ }
61
+ }
62
+
63
+ function shaAt(cwd, when) {
64
+ try {
65
+ return execFileSync('git', ['rev-list', '-1', `--before=${when}`, 'HEAD'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
66
+ .toString().trim() || null;
67
+ } catch { return null; }
68
+ }
69
+
70
+ /** `git diff --name-status -z` output → record events. `prefix` turns each REPO-relative path
71
+ * into the WORKSPACE-relative one pathToRecord expects; without it every module-owned path
72
+ * matches nothing and the repo silently contributes zero events. Note the -z layout: unlike
73
+ * `status --porcelain -z`, the status letter and the path are SEPARATE NUL-terminated chunks. */
74
+ function parseNameStatus(out, prefix, descriptors, repo) {
75
+ const parts = out.split('\0').filter((s) => s.length > 0);
76
+ const events = [];
77
+ const push = (type, relPath) => {
78
+ const rec = pathToRecord(descriptors, relPath);
79
+ if (rec) events.push({ type, ...rec, path: relPath, repo });
80
+ };
81
+ for (let i = 0; i < parts.length; ) {
82
+ const status = parts[i++];
83
+ const p1 = prefix + parts[i++];
84
+ if (status.startsWith('R') || status.startsWith('C')) {
85
+ const p2 = prefix + parts[i++];
86
+ if (status.startsWith('R')) push('item-removed', p1);
87
+ push('item-added', p2);
88
+ } else {
89
+ push(status[0] === 'A' ? 'item-added' : status[0] === 'D' ? 'item-removed' : 'item-updated', p1);
90
+ }
91
+ }
92
+ return events;
93
+ }
94
+
95
+ /** map a workspace-relative path to {collection, id} via storage.path longest-prefix
96
+ * match + suffix/codec (file shape) or entry (folder shape). non-records → null. */
97
+ export function pathToRecord(descriptors, relPath) {
98
+ let best = null;
99
+ for (const d of descriptors.values()) {
100
+ const base = d.storage?.path;
101
+ if (!base || d.storage.base === 'runtime') continue; // runtime entities aren't item events
102
+ if (!relPath.startsWith(base + '/')) continue;
103
+ if (best && base.length <= best.storage.path.length) continue;
104
+ best = d;
105
+ }
106
+ if (!best) return null;
107
+ const rest = relPath.slice(best.storage.path.length + 1);
108
+ if (best.storage.shape === 'folder') {
109
+ const entry = best.storage.entry ?? 'SKILL.md';
110
+ if (!rest.endsWith('/' + entry)) return null;
111
+ return { collection: best.name, id: rest.slice(0, -(entry.length + 1)) };
112
+ }
113
+ const tail = `.${best.storage.suffix}${EXT[best.storage.codec ?? 'md']}`;
114
+ if (!rest.endsWith(tail)) return null;
115
+ return { collection: best.name, id: rest.slice(0, -tail.length) };
116
+ }
117
+
118
+ /** the commit that last touched `path` inside the range — the event's provenance sha,
119
+ * and one leg of the deterministic run-dedupe key (trigger + item + commit). */
120
+ export function eventCommit(root, fromSha, toSha, relPath) {
121
+ const out = execFileSync('git', ['log', '--format=%H', '-1', `${fromSha}..${toSha}`, '--', relPath], { cwd: root })
122
+ .toString().trim();
123
+ return out || toSha;
124
+ }
@@ -0,0 +1,69 @@
1
+ // distinct values actually present in a collection's field.
2
+ //
3
+ // Why this exists: a filter (or a command-binding validator) can only offer a dropdown when
4
+ // something tells it the value set. `enum` in the descriptor does that — but most string fields
5
+ // aren't enums and never will be. `meetings.status` and `meetings.visibility` are plain
6
+ // `type: string`, so the filter builder correctly fell back to a free-text box and the operator
7
+ // had to know the vocabulary by heart (operator 2026-07-28: "still no dropdown for many things,
8
+ // visibility, status — why?").
9
+ //
10
+ // The data already knows. This derives the vocabulary from the records themselves, so every
11
+ // low-cardinality field becomes selectable with no schema change and no risk of `check` failing
12
+ // on a value that predates an enum someone added later.
13
+ import { bodyField } from './store.js';
14
+
15
+ /** Above this many distinct values a dropdown stops being a dropdown — report and stop counting. */
16
+ export const DEFAULT_LIMIT = 50;
17
+
18
+ /**
19
+ * `{ collection, field, values: [{value, count}], total, truncated }` — most-used first, then
20
+ * alphabetical, so the dropdown leads with what the operator actually uses.
21
+ *
22
+ * Array-valued fields (tags, attendees) contribute each ENTRY, not the array: filtering
23
+ * `attendees` by `contacts/ada` is the useful question, and `matchesFilter` already gives array
24
+ * fields containment semantics for `_eq`/`_in`, so the two halves agree.
25
+ *
26
+ * Bodies are skipped outright — a markdown body has one distinct value per record and counting
27
+ * them is pure waste. Objects are skipped too: there is no sane dropdown entry for a subtree.
28
+ */
29
+ export function distinctValues(store, collection, field, { limit = DEFAULT_LIMIT } = {}) {
30
+ const d = store.descriptor(collection);
31
+ const prop = d.schema?.properties?.[field];
32
+ if (!prop && field !== 'id') {
33
+ throw new Error(`unknown field "${field}" on ${collection} (known: ${Object.keys(d.schema?.properties ?? {}).join(', ')})`);
34
+ }
35
+ if (prop?.['x-body']) return { collection, field, values: [], total: 0, truncated: false, skipped: 'body' };
36
+ const isObject = prop?.type === 'object' || prop?.items?.type === 'object';
37
+ if (isObject) return { collection, field, values: [], total: 0, truncated: false, skipped: 'object' };
38
+
39
+ // An enum already IS the vocabulary — hand it back verbatim (counts omitted: the schema's
40
+ // answer must not shrink just because no record happens to use a legal value yet).
41
+ const declared = prop?.enum ?? prop?.items?.enum;
42
+ if (Array.isArray(declared) && declared.length) {
43
+ return { collection, field, values: declared.map((value) => ({ value, count: null })), total: declared.length, truncated: false, source: 'enum' };
44
+ }
45
+
46
+ const bf = bodyField(d);
47
+ const counts = new Map();
48
+ for (const { id, fields } of store.readAll(collection)) {
49
+ const raw = field === 'id' ? id : fields[field];
50
+ if (raw == null || raw === '' || (bf && field === bf)) continue;
51
+ for (const v of Array.isArray(raw) ? raw : [raw]) {
52
+ if (v == null || v === '' || typeof v === 'object') continue;
53
+ counts.set(v, (counts.get(v) ?? 0) + 1);
54
+ }
55
+ }
56
+
57
+ const sorted = [...counts.entries()]
58
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
59
+ .map(([value, count]) => ({ value, count }));
60
+
61
+ return {
62
+ collection,
63
+ field,
64
+ values: sorted.slice(0, limit),
65
+ total: sorted.length,
66
+ truncated: sorted.length > limit,
67
+ source: 'data',
68
+ };
69
+ }
package/src/filter.js ADDED
@@ -0,0 +1,107 @@
1
+ // record filtering — the operator set harvested from an earlier engine's
2
+ // query/compare.ts (Directus-style), trimmed to what the studio FilterBuilder
3
+ // and saved views actually emit. evaluated server-side over parsed records.
4
+ // negative operators deliberately reject null (SQL semantics).
5
+ import { compareValues } from './temporal.js';
6
+
7
+ export function matchesFilter(record, filter, resolve) {
8
+ if (filter == null || typeof filter !== 'object') return true;
9
+ for (const [key, cond] of Object.entries(filter)) {
10
+ if (key === '_and') { if (!cond.every((c) => matchesFilter(record, c, resolve))) return false; continue; }
11
+ if (key === '_or') { if (!cond.some((c) => matchesFilter(record, c, resolve))) return false; continue; }
12
+ if (!matchesField(record[key], cond, resolve)) return false;
13
+ }
14
+ return true;
15
+ }
16
+
17
+ function matchesField(value, cond, resolve) {
18
+ if (cond === null || typeof cond !== 'object' || Array.isArray(cond)) return compare('_eq', value, cond);
19
+ for (const [op, operand] of Object.entries(cond)) {
20
+ if (!op.startsWith('_')) {
21
+ // one-hop relational condition (tier 1): a non-operator key means the field holds a
22
+ // `<collection>/<id>` ref (or an array of them) — resolve and evaluate the sub-condition
23
+ // against the target record. array refs use _some semantics (any target matches).
24
+ // no resolver wired, a dangling ref, or a non-ref value NARROWS, never widens — same
25
+ // fail-closed posture as unknown operators. inbound refs are tier 2 (not supported).
26
+ if (!resolve) return false;
27
+ const refs = (Array.isArray(value) ? value : [value]).filter((r) => typeof r === 'string');
28
+ if (!refs.some((r) => { const target = resolve(r); return target && matchesField(target[op], operand, resolve); })) return false;
29
+ continue;
30
+ }
31
+ if (!compare(op, value, operand)) return false;
32
+ }
33
+ return true;
34
+ }
35
+
36
+ function compare(op, v, o) {
37
+ const s = (x) => String(x ?? '');
38
+ const empty = v == null || v === '' || (Array.isArray(v) && v.length === 0);
39
+ // array field values (tags, attendees): containment semantics for eq/contains
40
+ const arr = Array.isArray(v);
41
+ switch (op) {
42
+ case '_eq': return arr ? v.includes(o) : looseEq(v, o);
43
+ case '_neq': return v != null && !(arr ? v.includes(o) : looseEq(v, o));
44
+ case '_ieq': return s(v).toLowerCase() === s(o).toLowerCase();
45
+ case '_nieq': return v != null && s(v).toLowerCase() !== s(o).toLowerCase();
46
+ case '_lt': return v != null && cmp(v, o) < 0;
47
+ case '_lte': return v != null && cmp(v, o) <= 0;
48
+ case '_gt': return v != null && cmp(v, o) > 0;
49
+ case '_gte': return v != null && cmp(v, o) >= 0;
50
+ case '_in': return toArray(o).some((x) => (arr ? v.includes(x) : looseEq(v, x)));
51
+ case '_nin': return v != null && !toArray(o).some((x) => (arr ? v.includes(x) : looseEq(v, x)));
52
+ case '_null': return o ? v == null : v != null;
53
+ case '_nnull': return o ? v != null : v == null;
54
+ case '_empty': return o ? empty : !empty;
55
+ case '_nempty': return o ? !empty : empty;
56
+ case '_contains': return arr ? v.some((x) => s(x).includes(s(o))) : s(v).includes(s(o));
57
+ case '_ncontains': return v != null && !s(v).includes(s(o));
58
+ case '_icontains': return arr ? v.some((x) => s(x).toLowerCase().includes(s(o).toLowerCase())) : s(v).toLowerCase().includes(s(o).toLowerCase());
59
+ case '_starts_with': return s(v).startsWith(s(o));
60
+ case '_istarts_with': return s(v).toLowerCase().startsWith(s(o).toLowerCase());
61
+ case '_ends_with': return s(v).endsWith(s(o));
62
+ case '_iends_with': return s(v).toLowerCase().endsWith(s(o).toLowerCase());
63
+ case '_between': { const [a, b] = toArray(o); return v != null && cmp(v, a) >= 0 && cmp(v, b) <= 0; }
64
+ case '_nbetween': { const [a, b] = toArray(o); return v != null && (cmp(v, a) < 0 || cmp(v, b) > 0); }
65
+ case '_regex': try { return new RegExp(String(o)).test(s(v)); } catch { return false; }
66
+ default:
67
+ // unknown operator NARROWS, never widens (review finding 5): filters are load-bearing
68
+ // in compiled ui-views — a typo'd _nq matching everything showed every user's tasks
69
+ // with no signal. warn once per operator per process.
70
+ warnUnknownOp(op);
71
+ return false;
72
+ }
73
+ }
74
+
75
+ export const KNOWN_OPERATORS = new Set(['_eq', '_neq', '_ieq', '_nieq', '_lt', '_lte', '_gt', '_gte', '_in', '_nin', '_null', '_nnull', '_empty', '_nempty', '_contains', '_ncontains', '_icontains', '_starts_with', '_istarts_with', '_ends_with', '_iends_with', '_between', '_nbetween', '_regex', '_and', '_or']);
76
+
77
+ const warned = new Set();
78
+ function warnUnknownOp(op) {
79
+ if (warned.has(op)) return;
80
+ warned.add(op);
81
+ console.warn(`⚠ unknown filter operator "${op}" — treated as matching NOTHING (known: ${[...KNOWN_OPERATORS].join(', ')})`);
82
+ }
83
+
84
+ // walk a filter tree and return every operator key not in the known set — compile
85
+ // validates ui-view filters with this so a typo'd operator fails loudly at compile time.
86
+ export function unknownOperators(filter, found = new Set()) {
87
+ if (filter == null || typeof filter !== 'object') return found;
88
+ for (const [key, cond] of Object.entries(filter)) {
89
+ if (key === '_and' || key === '_or') {
90
+ for (const c of Array.isArray(cond) ? cond : []) unknownOperators(c, found);
91
+ } else if (key.startsWith('_')) {
92
+ if (!KNOWN_OPERATORS.has(key)) found.add(key);
93
+ } else if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) {
94
+ // field conditions recurse like filters: operator maps at any depth are checked,
95
+ // and non-operator keys (one-hop relational conditions) descend into their sub-filter
96
+ unknownOperators(cond, found);
97
+ }
98
+ }
99
+ return found;
100
+ }
101
+
102
+ const looseEq = (v, o) => v === o || String(v) === String(o) || (typeof v === 'number' && Number(o) === v);
103
+ const toArray = (o) => (Array.isArray(o) ? o : String(o).split(',').map((x) => x.trim()));
104
+ // ordering lives in temporal.js: a date-time carries its own local offset, so `_gt`/`_lt` have to
105
+ // compare INSTANTS. String order would put `…T12:00+03:00` after `…T11:00+01:00`, which is the
106
+ // earlier moment. Numbers and plain strings behave exactly as before.
107
+ const cmp = compareValues;
@@ -0,0 +1,233 @@
1
+ // harness adapters — one thin stage per harness, dispatched from compile over the same
2
+ // compiled entries. the superpowers lesson, kept deliberately: CONTENT NEVER FORKS PER
3
+ // HARNESS — skills copy verbatim (+stamp), every root file gets the same managed
4
+ // orientation block; only the injection points differ per harness:
5
+ // claude-code → .claude/{skills,agents,commands} + CLAUDE.md block (native everything)
6
+ // codex → AGENTS.md block + .agents/skills mirror (~/.codex reads AGENTS.md)
7
+ // pi → AGENTS.md block + .agents/skills mirror (pi auto-discovers .agents/skills)
8
+ // cursor → .cursor/rules/dreamteamer.mdc + .agents/skills (.mdc is cursor's native rule file)
9
+ // gemini-cli → GEMINI.md block + .agents/skills mirror (GEMINI.md is its context file)
10
+ // managed blocks live in USER-OWNED files (committed); generated dirs (.claude, .agents,
11
+ // .cursor) are gitignored and pruned by stamp. removing a harness from config removes its
12
+ // block/file on the next compile.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { load, dump } from './yaml.js';
16
+
17
+ export const KNOWN_HARNESSES = ['claude-code', 'codex', 'pi', 'gemini-cli', 'cursor'];
18
+
19
+ export const STAMP = '<!-- generated by dreamteamer compile — do not edit; source of truth lives in modules/<module>/<kind>/ -->';
20
+
21
+ const BEGIN = '<!-- dreamteamer:begin (generated — do not edit inside this block) -->';
22
+ const END = '<!-- dreamteamer:end -->';
23
+
24
+ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat' }) {
25
+ const outputs = [];
26
+ const summary = [];
27
+ const rel = (p) => path.relative(root, p);
28
+ const write = (out, bytes) => {
29
+ const dest = path.join(root, out);
30
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
31
+ fs.writeFileSync(dest, bytes);
32
+ outputs.push(out);
33
+ };
34
+
35
+ for (const h of harnesses) {
36
+ if (!KNOWN_HARNESSES.includes(h)) console.warn(`⚠ unknown harness "${h}" in dreamteamer.harnesses — skipped (known: ${KNOWN_HARNESSES.join(', ')})`);
37
+ }
38
+ const on = (h) => harnesses.includes(h);
39
+ const skillsIndex = buildSkillsIndex(entries);
40
+
41
+ // ---- claude-code: native skills/agents/commands dirs + CLAUDE.md block ----------
42
+ if (on('claude-code')) {
43
+ let n = 0;
44
+ for (const [rt, e] of entries) {
45
+ if (rt.startsWith('skills/')) {
46
+ const out = path.join('.claude/skills', rt.slice('skills/'.length));
47
+ write(out, out.endsWith('.md') ? stampMd(e.bytes) : e.bytes);
48
+ n++;
49
+ } else if (rt.startsWith('agents/')) {
50
+ write(path.join('.claude/agents', path.basename(rt).replace(/\.agent\.md$/, '.md')), stampMd(transformAgent(e.bytes)));
51
+ n++;
52
+ } else if (rt.startsWith('commands/')) {
53
+ write(path.join('.claude/commands', path.basename(rt).replace(/\.command\.md$/, '.md')), stampMd(e.bytes));
54
+ n++;
55
+ }
56
+ }
57
+ summary.push(`claude-code → .claude (${n} files)`);
58
+ }
59
+ writeBlock(root, 'CLAUDE.md', on('claude-code') ? orientationBlock('claude-code', skillsIndex, sourceLayout) : null);
60
+
61
+ // ---- shared cross-agent skills mirror (.agents/skills) — codex/pi discover it,
62
+ // cursor/gemini blocks point at it. written once no matter how many harnesses use it.
63
+ const wantsMirror = on('codex') || on('pi') || on('cursor') || on('gemini-cli');
64
+ if (wantsMirror) {
65
+ let n = 0;
66
+ for (const [rt, e] of entries) {
67
+ if (!rt.startsWith('skills/')) continue;
68
+ const out = path.join('.agents/skills', rt.slice('skills/'.length));
69
+ write(out, out.endsWith('.md') ? stampMd(e.bytes) : e.bytes);
70
+ n++;
71
+ }
72
+ summary.push(`.agents/skills mirror (${n} files)`);
73
+ }
74
+
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);
77
+ if (on('codex')) summary.push('codex → AGENTS.md block');
78
+ if (on('pi')) summary.push('pi → AGENTS.md block + .agents/skills');
79
+
80
+ // ---- gemini-cli: GEMINI.md is its context file -----------------------------------
81
+ writeBlock(root, 'GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout) : null);
82
+ if (on('gemini-cli')) summary.push('gemini-cli → GEMINI.md block');
83
+
84
+ // ---- cursor: native .mdc rule (alwaysApply) ---------------------------------------
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`;
87
+ write('.cursor/rules/dreamteamer.mdc', Buffer.from(mdc));
88
+ summary.push('cursor → .cursor/rules/dreamteamer.mdc');
89
+ }
90
+
91
+ // ---- prune: anything WE stamped that this compile didn't produce ------------------
92
+ const current = new Set(outputs);
93
+ for (const dir of ['.claude/skills', '.claude/agents', '.claude/commands', '.agents/skills', '.cursor/rules']) {
94
+ const abs = path.join(root, dir);
95
+ if (!fs.existsSync(abs)) continue;
96
+ for (const f of walk(abs)) {
97
+ const relOut = rel(f);
98
+ if (current.has(relOut)) continue;
99
+ if (/\.(md|mdc)$/.test(f) && fs.readFileSync(f, 'utf8').includes(STAMP)) fs.rmSync(f);
100
+ }
101
+ }
102
+ for (const old of prevManifest?.['adapter-outputs'] ?? []) {
103
+ if (!current.has(old) && fs.existsSync(path.join(root, old))) fs.rmSync(path.join(root, old));
104
+ }
105
+ // pruning leaves empty skill folders behind — sweep them (and the roots when hollow)
106
+ for (const dir of ['.claude', '.agents', '.cursor']) pruneEmptyDirs(path.join(root, dir));
107
+
108
+ return { outputs, summary };
109
+ }
110
+
111
+ // skill id → description one-liners from each SKILL.md's frontmatter; the orientation
112
+ // block carries this index so harnesses without native skill discovery still get triggers.
113
+ function buildSkillsIndex(entries) {
114
+ const index = [];
115
+ for (const [rt, e] of entries) {
116
+ const m = /^skills\/([^/]+)\/SKILL\.md$/.exec(rt);
117
+ if (!m) continue;
118
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(e.bytes.toString('utf8'));
119
+ let desc = '';
120
+ try { desc = (fm ? load(fm[1]) : {})?.description ?? ''; } catch { /* unparseable frontmatter */ }
121
+ index.push({ id: m[1], desc: String(desc).replace(/\s+/g, ' ').trim() });
122
+ }
123
+ return index.sort((a, b) => a.id.localeCompare(b.id));
124
+ }
125
+
126
+ /** `sourceLayout` describes what THIS workspace actually looks like — 'flat' (`<module>/skills/`),
127
+ * 'nested' (the pre-2026-08-05 `<module>/system/skills/`), or 'mixed'. It is passed in rather than
128
+ * assumed because generated prose that contradicts the workspace is worse than no prose: this block
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
+ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat') {
132
+ const sourcesLine = {
133
+ flat: '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
134
+ nested: '`modules/<module>/system/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,',
135
+ mixed: '`modules/<module>/<kind>/`, or `<module>/system/<kind>/` where a module still nests it —\n`collections/`, `skills/`, `agents/`, `commands/`,',
136
+ }[sourceLayout] ?? '`modules/<module>/<kind>/` — `collections/`, `skills/`, `agents/`, `commands/`,';
137
+ const lines = [
138
+ 'this workspace is operated by dreamteamer v0.6. **read the `using-dreamteamer` skill before',
139
+ 'working with data.** schemas (read): `.dreamteamer/collections/` (provenance:',
140
+ '`.dreamteamer/manifest.yaml`). sources (write): ' + sourcesLine,
141
+ '`command-bindings/`, `ui-views/`, `collection-templates/`',
142
+ '(see manifest for channels). data: `data/`; operational records:',
143
+ '`state/`. records are `<id>.<suffix>.<ext>`',
144
+ 'files; ids are paths; references are `<collection>/<id>`. run `dreamteamer check` (`npm run',
145
+ 'check`) after bulk edits; run `dreamteamer compile` (`npm run compile`) after changing any',
146
+ 'source or installing modules.',
147
+ ];
148
+ // claude-code discovers skills natively (Skill tool) — an index in CLAUDE.md is pure
149
+ // context bloat there. every other harness gets the trigger index + discovery pointers.
150
+ if (flavor !== 'claude-code') {
151
+ lines.push(
152
+ '',
153
+ "skills — reusable techniques, one folder per skill at `.agents/skills/` (canonical:",
154
+ "`.dreamteamer/skills/`); each SKILL.md's description says when to load it. load",
155
+ 'the relevant skill BEFORE the task:',
156
+ ...skillsIndex.map((s) => `- \`${s.id}\` — ${s.desc}`),
157
+ '',
158
+ 'agent personas live at `.dreamteamer/agents/*.agent.md`; commands at',
159
+ '`.dreamteamer/commands/` (invoked as `/<name>` by the harness).',
160
+ );
161
+ }
162
+ return lines.join('\n');
163
+ }
164
+
165
+ // managed block in a USER-OWNED root file. content=null removes the block; a file left
166
+ // empty (or whitespace) after removal is deleted — we created it, we clean it up.
167
+ function writeBlock(root, filename, content) {
168
+ const file = path.join(root, filename);
169
+ const exists = fs.existsSync(file);
170
+ if (content == null) {
171
+ if (!exists) return;
172
+ let text = fs.readFileSync(file, 'utf8');
173
+ if (!text.includes(BEGIN)) return;
174
+ text = text.replace(new RegExp(`\\n?\\n?${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}\\n?`), '\n');
175
+ if (text.trim() === '') fs.rmSync(file);
176
+ else fs.writeFileSync(file, text);
177
+ return;
178
+ }
179
+ const block = `${BEGIN}\n${content}\n${END}`;
180
+ let text = exists ? fs.readFileSync(file, 'utf8') : '';
181
+ if (text.includes(BEGIN)) text = text.replace(new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}`), block);
182
+ else text = (text.trimEnd() + '\n\n' + block + '\n').replace(/^\n+/, '');
183
+ fs.writeFileSync(file, text);
184
+ }
185
+
186
+ export function stampMd(bytes) {
187
+ const text = bytes.toString('utf8');
188
+ return text.includes(STAMP) ? bytes : Buffer.from(text.trimEnd() + '\n\n' + STAMP + '\n');
189
+ }
190
+
191
+ // dreamteamer agent record -> claude-code subagent: `skills` is not a harness
192
+ // frontmatter key; translate it into a binding instruction in the body.
193
+ export function transformAgent(bytes) {
194
+ const text = bytes.toString('utf8');
195
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
196
+ if (!m) return bytes;
197
+ const doc = load(m[1]) ?? {};
198
+ const body = text.slice(m[0].length);
199
+ const skills = doc.skills ?? [];
200
+ delete doc.skills;
201
+ const skillLine = skills.length
202
+ ? `ALWAYS load these skills (Skill tool) before acting: ${skills.map((s) => String(s).replace(/^skills\//, '')).join(', ')}.\n\n`
203
+ : '';
204
+ return Buffer.from(`---\n${dump(doc)}---\n\n${skillLine}${body.trimStart()}`);
205
+ }
206
+
207
+ function escapeRe(s) {
208
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
209
+ }
210
+
211
+ // depth-first removal of empty directories; returns true when `dir` itself was removed.
212
+ // dotfile-only dirs count as non-empty (never delete something we can't see into).
213
+ function pruneEmptyDirs(dir) {
214
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false;
215
+ for (const name of fs.readdirSync(dir)) {
216
+ const p = path.join(dir, name);
217
+ if (fs.statSync(p).isDirectory()) pruneEmptyDirs(p);
218
+ }
219
+ if (fs.readdirSync(dir).length === 0) {
220
+ fs.rmdirSync(dir);
221
+ return true;
222
+ }
223
+ return false;
224
+ }
225
+
226
+ function* walk(dir) {
227
+ for (const name of fs.readdirSync(dir).sort()) {
228
+ if (name.startsWith('.')) continue;
229
+ const p = path.join(dir, name);
230
+ if (fs.statSync(p).isDirectory()) yield* walk(p);
231
+ else yield p;
232
+ }
233
+ }
package/src/history.js ADDED
@@ -0,0 +1,64 @@
1
+ // per-record revision history, straight out of git.
2
+ //
3
+ // Extracted because it had drifted into being UI-only in practice: the exact same `git log
4
+ // --follow --format=…` and `git diff <hash>~1 <hash>` lived inlined in `server.js`'s routes AND
5
+ // copy-pasted into the VS Code extension's `src/api.ts`, with no CLI verb anywhere — so "show me
6
+ // how this record changed" was a thing you could do by clicking and not by asking an agent. One
7
+ // implementation here, three callers (CLI, server, extension).
8
+ import { execFileSync } from 'node:child_process';
9
+ import path from 'node:path';
10
+ import { RUNTIME_DIR, readManifest } from './runtime.js';
11
+
12
+ const FORMAT = '%H%x00%an%x00%aI%x00%s';
13
+
14
+ /**
15
+ * The TRACKED file(s) behind a record — what git actually has commits for.
16
+ *
17
+ * For an ordinary data record that is the record itself. For a SYSTEM-stored one (collections,
18
+ * ui-views, skills, agents…) `store.read` hands back the compiled artifact under `.dreamteamer/`,
19
+ * which is gitignored — asking git about it returns nothing, so history silently reported "no
20
+ * history" for every schema and view record. The manifest already maps each runtime entry back to
21
+ * the source(s) that produced it; those are the files with a past.
22
+ *
23
+ * A layered record (a workspace module overriding a module-shipped one) has SEVERAL sources and
24
+ * genuinely changes when any of them does, so all of them are followed.
25
+ */
26
+ function trackedPaths(store, file) {
27
+ const rel = path.relative(store.root, file);
28
+ if (!rel.startsWith(RUNTIME_DIR + path.sep)) return [rel];
29
+ try {
30
+ const key = rel.split(path.sep).slice(1).join('/'); // drop the `.dreamteamer/` prefix
31
+ const sources = readManifest(store.root)?.entries?.[key]?.sources ?? [];
32
+ const paths = sources.map((s) => (typeof s === 'string' ? s : s?.path)).filter(Boolean);
33
+ return paths.length ? paths : [rel];
34
+ } catch {
35
+ return [rel]; // no manifest (never compiled) — nothing better to offer
36
+ }
37
+ }
38
+
39
+ /**
40
+ * `git log` for a record, newest first. `--follow` (so a rename keeps its past) only applies to a
41
+ * single pathspec — git rejects it outright with several — so a layered record trades it away.
42
+ */
43
+ export function history(store, collection, id) {
44
+ const { file } = store.read(collection, id);
45
+ const paths = trackedPaths(store, file);
46
+ const follow = paths.length === 1 ? ['--follow'] : [];
47
+ const out = execFileSync('git', ['log', ...follow, `--format=${FORMAT}`, '--', ...paths], { cwd: store.root }).toString();
48
+ return out
49
+ .trim()
50
+ .split('\n')
51
+ .filter(Boolean)
52
+ .map((line) => {
53
+ const [hash, author, date, subject] = line.split('\0');
54
+ return { hash, author, date, subject };
55
+ });
56
+ }
57
+
58
+ /** The patch one revision applied to this record. `hash~1` means the ROOT commit has no diff. */
59
+ export function historyDiff(store, collection, id, hash = 'HEAD') {
60
+ const { file } = store.read(collection, id);
61
+ const paths = trackedPaths(store, file);
62
+ const diff = execFileSync('git', ['diff', `${hash}~1`, hash, '--', ...paths], { cwd: store.root }).toString();
63
+ return { hash, path: paths.join(', '), diff };
64
+ }