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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +83 -0
- package/agents/dreamteamer.agent.md +7 -0
- package/bin/dreamteamer.js +65 -0
- package/collection-templates/docs.collection-template.yaml +14 -0
- package/collection-templates/entity.collection-template.yaml +16 -0
- package/collections/agents.collection.yaml +33 -0
- package/collections/collection-templates.collection.yaml +20 -0
- package/collections/collections.collection.yaml +78 -0
- package/collections/command-bindings.collection.yaml +46 -0
- package/collections/commands.collection.yaml +36 -0
- package/collections/repos.collection.yaml +42 -0
- package/collections/skills.collection.yaml +22 -0
- package/collections/ui-views.collection.yaml +48 -0
- package/collections/users.collection.yaml +21 -0
- package/package.json +58 -0
- package/skills/building-dreamteamer/SKILL.md +117 -0
- package/skills/building-dreamteamer/references/agents.md +44 -0
- package/skills/building-dreamteamer/references/before-you-build.md +42 -0
- package/skills/building-dreamteamer/references/collections.md +120 -0
- package/skills/building-dreamteamer/references/commands.md +69 -0
- package/skills/building-dreamteamer/references/skills.md +73 -0
- package/skills/building-dreamteamer/references/ui-components.md +78 -0
- package/skills/building-dreamteamer/references/ui-views.md +59 -0
- package/skills/using-dreamteamer/SKILL.md +100 -0
- package/skills/using-dreamteamer/references/git-events.md +64 -0
- package/skills/using-dreamteamer/references/records.md +102 -0
- package/src/check.js +193 -0
- package/src/cli.js +250 -0
- package/src/collections-cli.js +389 -0
- package/src/commit.js +117 -0
- package/src/compile.js +747 -0
- package/src/events.js +124 -0
- package/src/field-values.js +69 -0
- package/src/filter.js +107 -0
- package/src/harnesses.js +233 -0
- package/src/history.js +64 -0
- package/src/init.js +307 -0
- package/src/presentation.js +190 -0
- package/src/record-commands.js +84 -0
- package/src/records.js +73 -0
- package/src/runtime.js +96 -0
- package/src/schema-ops.js +263 -0
- package/src/semver.js +32 -0
- package/src/server.js +291 -0
- package/src/store.js +450 -0
- package/src/template.js +98 -0
- package/src/temporal.js +149 -0
- package/src/workspace.js +51 -0
- 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;
|
package/src/harnesses.js
ADDED
|
@@ -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
|
+
}
|