@zuzuucodes/cli 1.0.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 +21 -0
- package/README.md +90 -0
- package/bin/zuzuu.mjs +133 -0
- package/experiments/experiment-1-trace-capture/adapters/claude-code.mjs +220 -0
- package/experiments/experiment-1-trace-capture/adapters/codex.mjs +201 -0
- package/experiments/experiment-1-trace-capture/adapters/gemini-cli.mjs +113 -0
- package/experiments/experiment-1-trace-capture/adapters/host-adapter.mjs +43 -0
- package/experiments/experiment-1-trace-capture/adapters/opencode.mjs +205 -0
- package/experiments/experiment-1-trace-capture/adapters/pi.mjs +218 -0
- package/experiments/experiment-1-trace-capture/adapters/registry.mjs +20 -0
- package/experiments/experiment-1-trace-capture/adapters/signals.mjs +44 -0
- package/experiments/experiment-1-trace-capture/core/event.mjs +58 -0
- package/experiments/experiment-1-trace-capture/core/ids.mjs +32 -0
- package/experiments/experiment-1-trace-capture/core/otlp.mjs +54 -0
- package/experiments/experiment-1-trace-capture/core/render.mjs +63 -0
- package/experiments/experiment-1-trace-capture/core/spans.mjs +43 -0
- package/package.json +56 -0
- package/zuzuu/actions/adapter.mjs +130 -0
- package/zuzuu/actions/convert.mjs +27 -0
- package/zuzuu/actions/dispatch.mjs +87 -0
- package/zuzuu/actions/inbox.mjs +56 -0
- package/zuzuu/actions/manifest.mjs +72 -0
- package/zuzuu/actions/marker.mjs +4 -0
- package/zuzuu/actions/runner.mjs +37 -0
- package/zuzuu/actions/schema.mjs +73 -0
- package/zuzuu/actions/trail.mjs +22 -0
- package/zuzuu/capture-core.mjs +49 -0
- package/zuzuu/commands/act-author.mjs +72 -0
- package/zuzuu/commands/act.mjs +101 -0
- package/zuzuu/commands/capture.mjs +32 -0
- package/zuzuu/commands/code.mjs +84 -0
- package/zuzuu/commands/digest.mjs +23 -0
- package/zuzuu/commands/distill.mjs +46 -0
- package/zuzuu/commands/doctor.mjs +197 -0
- package/zuzuu/commands/enable.mjs +195 -0
- package/zuzuu/commands/eval.mjs +101 -0
- package/zuzuu/commands/explain.mjs +119 -0
- package/zuzuu/commands/generation.mjs +107 -0
- package/zuzuu/commands/hook.mjs +209 -0
- package/zuzuu/commands/inbox.mjs +73 -0
- package/zuzuu/commands/init.mjs +89 -0
- package/zuzuu/commands/knowledge.mjs +152 -0
- package/zuzuu/commands/migrate.mjs +125 -0
- package/zuzuu/commands/review.mjs +299 -0
- package/zuzuu/commands/status.mjs +82 -0
- package/zuzuu/commands/trace.mjs +19 -0
- package/zuzuu/digest.mjs +149 -0
- package/zuzuu/eval/rank.mjs +31 -0
- package/zuzuu/eval/score.mjs +85 -0
- package/zuzuu/eval/signals.mjs +57 -0
- package/zuzuu/faculty/contract.mjs +19 -0
- package/zuzuu/faculty/gate.mjs +65 -0
- package/zuzuu/faculty/generation.mjs +392 -0
- package/zuzuu/faculty/proposal.mjs +166 -0
- package/zuzuu/faculty/provenance.mjs +35 -0
- package/zuzuu/faculty/registry.mjs +33 -0
- package/zuzuu/faculty/trail.mjs +27 -0
- package/zuzuu/guardrails/adapter.mjs +134 -0
- package/zuzuu/guardrails.mjs +89 -0
- package/zuzuu/inject.mjs +46 -0
- package/zuzuu/instructions/adapter.mjs +93 -0
- package/zuzuu/knowledge/adapter.mjs +99 -0
- package/zuzuu/knowledge/distill.mjs +237 -0
- package/zuzuu/knowledge/embed.mjs +52 -0
- package/zuzuu/knowledge/er.mjs +98 -0
- package/zuzuu/knowledge/inbox.mjs +43 -0
- package/zuzuu/knowledge/index.mjs +194 -0
- package/zuzuu/knowledge/items.mjs +154 -0
- package/zuzuu/knowledge/proposals.mjs +196 -0
- package/zuzuu/knowledge/registry.mjs +115 -0
- package/zuzuu/live/install.mjs +76 -0
- package/zuzuu/live/live-store.mjs +78 -0
- package/zuzuu/live/probe.mjs +55 -0
- package/zuzuu/live/reconcile.mjs +33 -0
- package/zuzuu/memory/adapter.mjs +121 -0
- package/zuzuu/miners/actions.mjs +118 -0
- package/zuzuu/miners/guardrails.mjs +174 -0
- package/zuzuu/miners/instructions.mjs +152 -0
- package/zuzuu/miners/knowledge.mjs +22 -0
- package/zuzuu/miners/memory.mjs +27 -0
- package/zuzuu/miners/registry.mjs +31 -0
- package/zuzuu/scaffold.mjs +213 -0
- package/zuzuu/session.mjs +72 -0
- package/zuzuu/store.mjs +104 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The Knowledge index — SQLite (node:sqlite, zero-dep), fully DERIVED from the
|
|
2
|
+
// item files and regenerable at any time: files are truth, this is the
|
|
3
|
+
// search/query plane ("pin definitions, observe data").
|
|
4
|
+
//
|
|
5
|
+
// Triple search on one store (the Apache-AGE pattern — graph inside relational —
|
|
6
|
+
// plus a vector column):
|
|
7
|
+
// relational: SQL over items/attrs (type/attribute filters)
|
|
8
|
+
// graph: recursive CTEs over rels (neighbors/paths; Cypher syntax
|
|
9
|
+
// arrives at the real AGE rung)
|
|
10
|
+
// semantic: vecs + cosine (populated when an embedding
|
|
11
|
+
// source exists — see embed.mjs)
|
|
12
|
+
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { mkdirSync, existsSync } from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { allItems } from './items.mjs';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
export const indexPath = (agentDir) => join(agentDir, 'knowledge', '.index.db');
|
|
20
|
+
|
|
21
|
+
function open(agentDir, { readOnly = false } = {}) {
|
|
22
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
23
|
+
const path = indexPath(agentDir);
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
const db = new DatabaseSync(path, readOnly && existsSync(path) ? { readOnly: true } : {});
|
|
26
|
+
return db;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SCHEMA = `
|
|
30
|
+
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, type TEXT, text TEXT, created_at TEXT, status TEXT);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS attrs(item TEXT, key TEXT, value TEXT, PRIMARY KEY(item, key));
|
|
32
|
+
CREATE TABLE IF NOT EXISTS rels(src TEXT, type TEXT, dst TEXT, PRIMARY KEY(src, type, dst));
|
|
33
|
+
CREATE TABLE IF NOT EXISTS vecs(item TEXT PRIMARY KEY, model TEXT, vec BLOB);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS attrs_kv ON attrs(key, value);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS rels_dst ON rels(dst);
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function ensureSchema(db) {
|
|
39
|
+
db.exec(SCHEMA);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Upsert one item into the index (keeps any existing vector). */
|
|
43
|
+
export function upsertItem(agentDir, item) {
|
|
44
|
+
const db = open(agentDir);
|
|
45
|
+
try {
|
|
46
|
+
ensureSchema(db);
|
|
47
|
+
db.prepare('INSERT OR REPLACE INTO items(id,type,text,created_at,status) VALUES(?,?,?,?,?)').run(
|
|
48
|
+
item.id, item.type, item.body ?? '', item.created_at ?? '', item.status ?? 'active');
|
|
49
|
+
db.prepare('DELETE FROM attrs WHERE item=?').run(item.id);
|
|
50
|
+
for (const [k, v] of Object.entries(item.attributes ?? {}))
|
|
51
|
+
db.prepare('INSERT OR REPLACE INTO attrs(item,key,value) VALUES(?,?,?)').run(item.id, k, String(v));
|
|
52
|
+
db.prepare('DELETE FROM rels WHERE src=?').run(item.id);
|
|
53
|
+
for (const r of item.relations ?? [])
|
|
54
|
+
db.prepare('INSERT OR REPLACE INTO rels(src,type,dst) VALUES(?,?,?)').run(item.id, r.type, r.target);
|
|
55
|
+
} finally {
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Full rebuild from the item files. Deterministic. Returns counts. */
|
|
61
|
+
export function reindex(agentDir) {
|
|
62
|
+
const { items, errors } = allItems(agentDir);
|
|
63
|
+
const db = open(agentDir);
|
|
64
|
+
try {
|
|
65
|
+
ensureSchema(db);
|
|
66
|
+
db.exec('DELETE FROM items; DELETE FROM attrs; DELETE FROM rels;'); // vecs kept (re-embedding is separate)
|
|
67
|
+
const insI = db.prepare('INSERT INTO items(id,type,text,created_at,status) VALUES(?,?,?,?,?)');
|
|
68
|
+
const insA = db.prepare('INSERT OR REPLACE INTO attrs(item,key,value) VALUES(?,?,?)');
|
|
69
|
+
const insR = db.prepare('INSERT OR REPLACE INTO rels(src,type,dst) VALUES(?,?,?)');
|
|
70
|
+
for (const it of items) {
|
|
71
|
+
insI.run(it.id, it.type, it.body ?? '', it.created_at ?? '', it.status ?? 'active');
|
|
72
|
+
for (const [k, v] of Object.entries(it.attributes ?? {})) insA.run(it.id, k, String(v));
|
|
73
|
+
for (const r of it.relations ?? []) insR.run(it.id, r.type, r.target);
|
|
74
|
+
}
|
|
75
|
+
// prune vectors for items that no longer exist
|
|
76
|
+
db.exec('DELETE FROM vecs WHERE item NOT IN (SELECT id FROM items)');
|
|
77
|
+
return { indexed: items.length, parseErrors: errors };
|
|
78
|
+
} finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Lexical + relational search (Notes-style scoring: id hit +10, attribute hit +5,
|
|
85
|
+
* body occurrences ×2 capped at 8). Filters: type, attribute k=v.
|
|
86
|
+
*/
|
|
87
|
+
export function search(agentDir, query, { type = null, attr = null, limit = 10 } = {}) {
|
|
88
|
+
if (!existsSync(indexPath(agentDir))) return [];
|
|
89
|
+
const db = open(agentDir, { readOnly: true });
|
|
90
|
+
try {
|
|
91
|
+
ensureSchema(db);
|
|
92
|
+
let rows = db.prepare('SELECT id, type, text, status FROM items').all();
|
|
93
|
+
if (type) rows = rows.filter((r) => r.type === type);
|
|
94
|
+
if (attr) {
|
|
95
|
+
const [k, v] = attr;
|
|
96
|
+
const ids = new Set(db.prepare('SELECT item FROM attrs WHERE key=? AND value=?').all(k, v).map((r) => r.item));
|
|
97
|
+
rows = rows.filter((r) => ids.has(r.id));
|
|
98
|
+
}
|
|
99
|
+
const attrRows = db.prepare('SELECT item, key, value FROM attrs').all();
|
|
100
|
+
const attrText = new Map();
|
|
101
|
+
for (const a of attrRows) attrText.set(a.item, (attrText.get(a.item) ?? '') + ' ' + a.key + ' ' + a.value);
|
|
102
|
+
const terms = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
103
|
+
const scored = rows.map((r) => {
|
|
104
|
+
let score = 0;
|
|
105
|
+
const id = r.id.toLowerCase();
|
|
106
|
+
const body = (r.text ?? '').toLowerCase();
|
|
107
|
+
const attrs = (attrText.get(r.id) ?? '').toLowerCase();
|
|
108
|
+
for (const t of terms) {
|
|
109
|
+
if (id.includes(t)) score += 10;
|
|
110
|
+
if (attrs.includes(t)) score += 5;
|
|
111
|
+
const hits = body.split(t).length - 1;
|
|
112
|
+
score += Math.min(hits * 2, 8);
|
|
113
|
+
}
|
|
114
|
+
return { ...r, score };
|
|
115
|
+
});
|
|
116
|
+
return scored
|
|
117
|
+
.filter((r) => (terms.length ? r.score > 0 : true))
|
|
118
|
+
.sort((a, b) => b.score - a.score)
|
|
119
|
+
.slice(0, limit);
|
|
120
|
+
} finally {
|
|
121
|
+
db.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Graph traversal: items within `depth` hops of `id` (optionally one relation
|
|
127
|
+
* type), via a recursive CTE — both directions (relations are conceptually
|
|
128
|
+
* bidirectional; inverses live in the registry).
|
|
129
|
+
*/
|
|
130
|
+
export function neighbors(agentDir, id, { relType = null, depth = 1 } = {}) {
|
|
131
|
+
if (!existsSync(indexPath(agentDir))) return [];
|
|
132
|
+
const db = open(agentDir, { readOnly: true });
|
|
133
|
+
try {
|
|
134
|
+
ensureSchema(db);
|
|
135
|
+
const typeCond = relType ? 'AND r.type = ?' : '';
|
|
136
|
+
const params = relType ? [id, relType, relType, depth] : [id, depth];
|
|
137
|
+
const sql = `
|
|
138
|
+
WITH RECURSIVE walk(node, via, hop) AS (
|
|
139
|
+
SELECT CASE WHEN r.src = ? THEN r.dst ELSE r.src END, r.type, 1
|
|
140
|
+
FROM rels r WHERE (r.src = walk_start() OR r.dst = walk_start()) ${typeCond}
|
|
141
|
+
UNION
|
|
142
|
+
SELECT CASE WHEN r.src = w.node THEN r.dst ELSE r.src END, r.type, w.hop + 1
|
|
143
|
+
FROM rels r JOIN walk w ON (r.src = w.node OR r.dst = w.node) ${typeCond ? 'AND r.type = ?' : ''}
|
|
144
|
+
WHERE w.hop < ?
|
|
145
|
+
) SELECT DISTINCT node, via, MIN(hop) AS hop FROM walk GROUP BY node, via`;
|
|
146
|
+
// node:sqlite has no custom functions; inline the start id instead of walk_start()
|
|
147
|
+
const inlined = sql.replaceAll('walk_start()', '?');
|
|
148
|
+
// param order: src-case ?, (start, start), [relType], [relType], depth
|
|
149
|
+
const finalParams = relType ? [id, id, id, relType, relType, depth] : [id, id, id, depth];
|
|
150
|
+
const rows = db.prepare(inlined).all(...finalParams);
|
|
151
|
+
return rows.filter((r) => r.node !== id);
|
|
152
|
+
} finally {
|
|
153
|
+
db.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Store / fetch embedding vectors (Float32 LE blobs). */
|
|
158
|
+
export function putVector(agentDir, itemId, model, floats) {
|
|
159
|
+
const db = open(agentDir);
|
|
160
|
+
try {
|
|
161
|
+
ensureSchema(db);
|
|
162
|
+
const buf = Buffer.from(new Float32Array(floats).buffer);
|
|
163
|
+
db.prepare('INSERT OR REPLACE INTO vecs(item,model,vec) VALUES(?,?,?)').run(itemId, model, buf);
|
|
164
|
+
} finally {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function allVectors(agentDir) {
|
|
170
|
+
if (!existsSync(indexPath(agentDir))) return [];
|
|
171
|
+
const db = open(agentDir, { readOnly: true });
|
|
172
|
+
try {
|
|
173
|
+
ensureSchema(db);
|
|
174
|
+
return db.prepare('SELECT item, model, vec FROM vecs').all().map((r) => ({
|
|
175
|
+
item: r.item,
|
|
176
|
+
model: r.model,
|
|
177
|
+
vec: new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4),
|
|
178
|
+
}));
|
|
179
|
+
} finally {
|
|
180
|
+
db.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Items in the index but lacking a vector (embedding backlog). */
|
|
185
|
+
export function unembedded(agentDir) {
|
|
186
|
+
if (!existsSync(indexPath(agentDir))) return [];
|
|
187
|
+
const db = open(agentDir, { readOnly: true });
|
|
188
|
+
try {
|
|
189
|
+
ensureSchema(db);
|
|
190
|
+
return db.prepare('SELECT id, type, text FROM items WHERE id NOT IN (SELECT item FROM vecs)').all();
|
|
191
|
+
} finally {
|
|
192
|
+
db.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Knowledge items — files as truth. One item per markdown file under
|
|
2
|
+
// agent/knowledge/items/<id>.md: a constrained-YAML frontmatter (we control both
|
|
3
|
+
// writer and reader; grammar below) + a prose body (the fact in your voice).
|
|
4
|
+
//
|
|
5
|
+
// ---
|
|
6
|
+
// id: test-command
|
|
7
|
+
// type: command
|
|
8
|
+
// created_at: 2026-06-10T12:00:00Z
|
|
9
|
+
// status: active
|
|
10
|
+
// attributes:
|
|
11
|
+
// command: npm test
|
|
12
|
+
// relations:
|
|
13
|
+
// - type: relates-to
|
|
14
|
+
// target: ci-pipeline
|
|
15
|
+
// commentary: optional
|
|
16
|
+
// provenance:
|
|
17
|
+
// - session: ses_abc
|
|
18
|
+
// ref: occurrences=12
|
|
19
|
+
// ---
|
|
20
|
+
// Body prose.
|
|
21
|
+
//
|
|
22
|
+
// Grammar (deliberately small): top-level scalar keys; ONE nested map
|
|
23
|
+
// (`attributes`); arrays of flat maps (`relations`, `provenance`). Values are
|
|
24
|
+
// single-line strings (quotes optional). Anything outside this grammar is a
|
|
25
|
+
// parse error — git-diffable simplicity beats YAML completeness here.
|
|
26
|
+
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
|
|
29
|
+
|
|
30
|
+
export const itemsDir = (agentDir) => join(agentDir, 'knowledge', 'items');
|
|
31
|
+
|
|
32
|
+
export function slugify(text, max = 60) {
|
|
33
|
+
return String(text)
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
36
|
+
.replace(/^-+|-+$/g, '')
|
|
37
|
+
.slice(0, max)
|
|
38
|
+
.replace(/-+$/, '') || 'item';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const unquote = (s) => {
|
|
42
|
+
const t = s.trim();
|
|
43
|
+
return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) ? t.slice(1, -1) : t;
|
|
44
|
+
};
|
|
45
|
+
const quoteIfNeeded = (s) => {
|
|
46
|
+
const t = String(s);
|
|
47
|
+
if (t.includes('\n')) throw new Error('item values must be single-line');
|
|
48
|
+
return /[:#'"\[\]{}]|^\s|\s$/.test(t) ? JSON.stringify(t) : t;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Parse an item file's text → item object. Throws on grammar violations. */
|
|
52
|
+
export function parseItem(text) {
|
|
53
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
54
|
+
if (!m) throw new Error('no frontmatter block');
|
|
55
|
+
const [, fm, body] = m;
|
|
56
|
+
const item = { attributes: {}, relations: [], provenance: [], body: body.trim() };
|
|
57
|
+
let section = null; // 'attributes' | 'relations' | 'provenance'
|
|
58
|
+
let current = null; // current array entry
|
|
59
|
+
for (const raw of fm.split('\n')) {
|
|
60
|
+
if (!raw.trim()) continue;
|
|
61
|
+
const indent = raw.match(/^ */)[0].length;
|
|
62
|
+
const line = raw.trim();
|
|
63
|
+
if (indent === 0) {
|
|
64
|
+
current = null;
|
|
65
|
+
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
66
|
+
if (!kv) throw new Error(`bad line: ${line}`);
|
|
67
|
+
const [, key, val] = kv;
|
|
68
|
+
if (['attributes', 'relations', 'provenance'].includes(key)) {
|
|
69
|
+
section = key;
|
|
70
|
+
if (val) throw new Error(`${key} must be a block`);
|
|
71
|
+
} else {
|
|
72
|
+
section = null;
|
|
73
|
+
item[key] = unquote(val);
|
|
74
|
+
}
|
|
75
|
+
} else if (section === 'attributes') {
|
|
76
|
+
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
77
|
+
if (!kv) throw new Error(`bad attribute line: ${line}`);
|
|
78
|
+
item.attributes[kv[1]] = unquote(kv[2]);
|
|
79
|
+
} else if (section === 'relations' || section === 'provenance') {
|
|
80
|
+
if (line.startsWith('- ')) {
|
|
81
|
+
current = {};
|
|
82
|
+
item[section].push(current);
|
|
83
|
+
const kv = line.slice(2).match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
84
|
+
if (!kv) throw new Error(`bad ${section} entry: ${line}`);
|
|
85
|
+
current[kv[1]] = unquote(kv[2]);
|
|
86
|
+
} else {
|
|
87
|
+
if (!current) throw new Error(`${section} entry continuation without "-": ${line}`);
|
|
88
|
+
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
89
|
+
if (!kv) throw new Error(`bad ${section} line: ${line}`);
|
|
90
|
+
current[kv[1]] = unquote(kv[2]);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
throw new Error(`unexpected indented line: ${line}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!item.id) throw new Error('item missing id');
|
|
97
|
+
if (!item.type) throw new Error('item missing type');
|
|
98
|
+
return item;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Serialize an item object → file text (the exact grammar parseItem reads). */
|
|
102
|
+
export function serializeItem(item) {
|
|
103
|
+
const lines = ['---'];
|
|
104
|
+
for (const key of ['id', 'type', 'created_at', 'status']) {
|
|
105
|
+
if (item[key] != null) lines.push(`${key}: ${quoteIfNeeded(item[key])}`);
|
|
106
|
+
}
|
|
107
|
+
const attrs = Object.entries(item.attributes ?? {});
|
|
108
|
+
if (attrs.length) {
|
|
109
|
+
lines.push('attributes:');
|
|
110
|
+
for (const [k, v] of attrs) lines.push(` ${k}: ${quoteIfNeeded(v)}`);
|
|
111
|
+
}
|
|
112
|
+
for (const section of ['relations', 'provenance']) {
|
|
113
|
+
const arr = item[section] ?? [];
|
|
114
|
+
if (!arr.length) continue;
|
|
115
|
+
lines.push(`${section}:`);
|
|
116
|
+
for (const entry of arr) {
|
|
117
|
+
const keys = Object.keys(entry);
|
|
118
|
+
keys.forEach((k, i) => lines.push(` ${i === 0 ? '- ' : ' '}${k}: ${quoteIfNeeded(entry[k])}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push('---', '');
|
|
122
|
+
return lines.join('\n') + (item.body ? item.body.trim() + '\n' : '');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Write an item to its canonical file. Returns the path. */
|
|
126
|
+
export function writeItem(agentDir, item) {
|
|
127
|
+
const dir = itemsDir(agentDir);
|
|
128
|
+
mkdirSync(dir, { recursive: true });
|
|
129
|
+
const path = join(dir, `${item.id}.md`);
|
|
130
|
+
writeFileSync(path, serializeItem(item));
|
|
131
|
+
return path;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function readItem(agentDir, id) {
|
|
135
|
+
const path = join(itemsDir(agentDir), `${id}.md`);
|
|
136
|
+
if (!existsSync(path)) return null;
|
|
137
|
+
return parseItem(readFileSync(path, 'utf8'));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** All items (parse errors collected, not thrown — audit surfaces them). */
|
|
141
|
+
export function allItems(agentDir) {
|
|
142
|
+
const dir = itemsDir(agentDir);
|
|
143
|
+
if (!existsSync(dir)) return { items: [], errors: [] };
|
|
144
|
+
const items = [];
|
|
145
|
+
const errors = [];
|
|
146
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
|
|
147
|
+
try {
|
|
148
|
+
items.push(parseItem(readFileSync(join(dir, f), 'utf8')));
|
|
149
|
+
} catch (e) {
|
|
150
|
+
errors.push({ file: f, error: e.message });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { items, errors };
|
|
154
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Proposals — the human gate, as files. The first build of DESIGN's Proposal
|
|
2
|
+
// entity: { candidate, source, evidence, er-verdict, status }. Pending under
|
|
3
|
+
// agent/knowledge/proposals/<id>.json; resolved ones move to proposals/archive/
|
|
4
|
+
// (an auditable history — approvals also show up as item-file diffs in git).
|
|
5
|
+
//
|
|
6
|
+
// Registry governance rides the same gate: an unregistered attribute/relation
|
|
7
|
+
// key seen ≥3 times across proposals becomes a REGISTRY proposal (where Notes
|
|
8
|
+
// silently auto-registered, we ask).
|
|
9
|
+
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { loadRegistry, validateItem } from './registry.mjs';
|
|
14
|
+
import { allItems, readItem, writeItem, slugify } from './items.mjs';
|
|
15
|
+
import { upsertItem } from './index.mjs';
|
|
16
|
+
import { resolve as erResolve, merge } from './er.mjs';
|
|
17
|
+
import { mechanicalScore } from '../eval/score.mjs';
|
|
18
|
+
|
|
19
|
+
export const proposalsDir = (agentDir) => join(agentDir, 'knowledge', 'proposals');
|
|
20
|
+
const archiveDir = (agentDir) => join(proposalsDir(agentDir), 'archive');
|
|
21
|
+
|
|
22
|
+
const shortHash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 6);
|
|
23
|
+
|
|
24
|
+
function writeProposal(agentDir, p) {
|
|
25
|
+
mkdirSync(proposalsDir(agentDir), { recursive: true });
|
|
26
|
+
writeFileSync(join(proposalsDir(agentDir), `${p.id}.json`), JSON.stringify(p, null, 2) + '\n');
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Run ER for a candidate and file a pending proposal (deduped per candidate). */
|
|
31
|
+
export function createProposal(agentDir, { candidate, source, evidence = {} }) {
|
|
32
|
+
const { items } = allItems(agentDir);
|
|
33
|
+
candidate.id = candidate.id || slugify(candidate.body);
|
|
34
|
+
const er = erResolve(candidate, items);
|
|
35
|
+
const id = `${candidate.id}-${shortHash(candidate.id + source)}`;
|
|
36
|
+
const existing = join(proposalsDir(agentDir), `${id}.json`);
|
|
37
|
+
if (existsSync(existing)) {
|
|
38
|
+
// refresh evidence on the pending proposal instead of duplicating it
|
|
39
|
+
const prev = JSON.parse(readFileSync(existing, 'utf8'));
|
|
40
|
+
prev.evidence = { ...prev.evidence, ...evidence };
|
|
41
|
+
prev.er = er;
|
|
42
|
+
// keep analysis in sync so scorer can use updated er verdict
|
|
43
|
+
prev.analysis = { ...(prev.analysis ?? {}), er: { verdict: er.verdict } };
|
|
44
|
+
prev.score = scoreProposal(prev);
|
|
45
|
+
return writeProposal(agentDir, prev);
|
|
46
|
+
}
|
|
47
|
+
const proposal = { id, kind: 'item', status: 'pending', created_at: new Date().toISOString(), source, candidate, evidence, er, analysis: { er: { verdict: er.verdict } } };
|
|
48
|
+
proposal.score = scoreProposal(proposal);
|
|
49
|
+
return writeProposal(agentDir, proposal);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Compute mechanicalScore for a proposal — fail-open (returns null on error). */
|
|
53
|
+
function scoreProposal(proposal) {
|
|
54
|
+
try {
|
|
55
|
+
const { score, confidence, rationale } = mechanicalScore(proposal, {});
|
|
56
|
+
return { score, confidence, rationale };
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listProposals(agentDir) {
|
|
63
|
+
const dir = proposalsDir(agentDir);
|
|
64
|
+
if (!existsSync(dir)) return [];
|
|
65
|
+
return readdirSync(dir)
|
|
66
|
+
.filter((f) => f.endsWith('.json'))
|
|
67
|
+
.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
|
|
68
|
+
.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getProposal(agentDir, id) {
|
|
72
|
+
const p = join(proposalsDir(agentDir), `${id}.json`);
|
|
73
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function archive(agentDir, proposal, status, extra = {}) {
|
|
77
|
+
mkdirSync(archiveDir(agentDir), { recursive: true });
|
|
78
|
+
const resolved = { ...proposal, status, resolved_at: new Date().toISOString(), ...extra };
|
|
79
|
+
writeFileSync(join(archiveDir(agentDir), `${proposal.id}.json`), JSON.stringify(resolved, null, 2) + '\n');
|
|
80
|
+
const pending = join(proposalsDir(agentDir), `${proposal.id}.json`);
|
|
81
|
+
if (existsSync(pending)) renameSync(pending, join(archiveDir(agentDir), `${proposal.id}.json`));
|
|
82
|
+
writeFileSync(join(archiveDir(agentDir), `${proposal.id}.json`), JSON.stringify(resolved, null, 2) + '\n');
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Apply an approved proposal's effects — the canonical write path, extracted so
|
|
88
|
+
* the Knowledge faculty adapter (WS2-T2) can call the *same* logic. This is the
|
|
89
|
+
* registry-branch + ER-merge + writeItem + upsertItem body; it does NOT archive
|
|
90
|
+
* (the caller decides lifecycle). Behaviour is identical to the old inline body.
|
|
91
|
+
* @returns {{ok:boolean, action:string, item?:string, warnings:string[]}}
|
|
92
|
+
*/
|
|
93
|
+
export function applyKnowledgeProposal(agentDir, proposal) {
|
|
94
|
+
const warnings = [];
|
|
95
|
+
const id = proposal.id;
|
|
96
|
+
|
|
97
|
+
if (proposal.kind === 'registry') {
|
|
98
|
+
const file = join(agentDir, 'knowledge', 'registry', `${proposal.registry}.json`);
|
|
99
|
+
const defs = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
|
100
|
+
if (proposal.registry === 'attributes') defs.push({ key: proposal.key, value: 'string', description: `registered via proposal ${id}` });
|
|
101
|
+
else defs.push({ name: proposal.key, inverse: proposal.key, description: `registered via proposal ${id} (symmetric — edit inverse if directional)` });
|
|
102
|
+
writeFileSync(file, JSON.stringify(defs, null, 2) + '\n');
|
|
103
|
+
return { ok: true, action: `registered ${proposal.registry.slice(0, -1)} '${proposal.key}'`, warnings };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const registry = loadRegistry(agentDir);
|
|
107
|
+
const cand = proposal.candidate;
|
|
108
|
+
const v = validateItem(registry, cand);
|
|
109
|
+
for (const k of v.unknownKeys.attributes) {
|
|
110
|
+
warnings.push(`dropped unregistered attribute '${k}' (approve its registry proposal first, then re-propose)`);
|
|
111
|
+
delete cand.attributes[k];
|
|
112
|
+
}
|
|
113
|
+
cand.relations = (cand.relations ?? []).filter((r) => {
|
|
114
|
+
if (registry.relations.has(r.type)) return true;
|
|
115
|
+
warnings.push(`dropped relation with unregistered type '${r.type}'`);
|
|
116
|
+
return false;
|
|
117
|
+
});
|
|
118
|
+
const v2 = validateItem(registry, cand);
|
|
119
|
+
if (!v2.ok) return { ok: false, action: 'invalid', warnings: [...warnings, ...v2.errors] };
|
|
120
|
+
|
|
121
|
+
let item;
|
|
122
|
+
let action;
|
|
123
|
+
if ((proposal.er?.verdict === 'enrich' || proposal.er?.verdict === 'duplicate') && proposal.er.match) {
|
|
124
|
+
const existing = readItem(agentDir, proposal.er.match);
|
|
125
|
+
if (existing) {
|
|
126
|
+
item = merge(existing, cand);
|
|
127
|
+
action = `enriched ${existing.id}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!item) {
|
|
131
|
+
item = { created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'), status: 'active', attributes: {}, relations: [], provenance: [], ...cand };
|
|
132
|
+
action = `created ${item.id}`;
|
|
133
|
+
}
|
|
134
|
+
writeItem(agentDir, item);
|
|
135
|
+
upsertItem(agentDir, item);
|
|
136
|
+
return { ok: true, action, item: item.id, warnings };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Approve: registry proposals append their key; item proposals write/merge the
|
|
141
|
+
* item (unknown keys are DROPPED with warnings — knowledge stays registry-clean).
|
|
142
|
+
* Delegates the effect to applyKnowledgeProposal, then archives.
|
|
143
|
+
* @returns {{ok:boolean, action:string, item?:string, warnings:string[]}}
|
|
144
|
+
*/
|
|
145
|
+
export function approveProposal(agentDir, id) {
|
|
146
|
+
const proposal = getProposal(agentDir, id);
|
|
147
|
+
if (!proposal) return { ok: false, action: 'not-found', warnings: [] };
|
|
148
|
+
|
|
149
|
+
const r = applyKnowledgeProposal(agentDir, proposal);
|
|
150
|
+
if (!r.ok) return r;
|
|
151
|
+
|
|
152
|
+
if (proposal.kind === 'registry') archive(agentDir, proposal, 'approved');
|
|
153
|
+
else archive(agentDir, proposal, 'approved', { applied: r.action });
|
|
154
|
+
return r;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function rejectProposal(agentDir, id, reason = '') {
|
|
158
|
+
const proposal = getProposal(agentDir, id);
|
|
159
|
+
if (!proposal) return { ok: false };
|
|
160
|
+
archive(agentDir, proposal, 'rejected', { reason });
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Registry governance: unknown keys appearing ≥3 times across all proposals
|
|
166
|
+
* (pending + archive) get a registry proposal filed — once.
|
|
167
|
+
*/
|
|
168
|
+
export function fileRegistryProposals(agentDir) {
|
|
169
|
+
const registry = loadRegistry(agentDir);
|
|
170
|
+
const counts = { attributes: {}, relations: {} };
|
|
171
|
+
const dirs = [proposalsDir(agentDir), archiveDir(agentDir)];
|
|
172
|
+
for (const dir of dirs) {
|
|
173
|
+
if (!existsSync(dir)) continue;
|
|
174
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
175
|
+
let p;
|
|
176
|
+
try {
|
|
177
|
+
p = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (p.kind !== 'item') continue;
|
|
182
|
+
for (const k of Object.keys(p.candidate?.attributes ?? {})) if (!registry.attributes.has(k)) counts.attributes[k] = (counts.attributes[k] ?? 0) + 1;
|
|
183
|
+
for (const r of p.candidate?.relations ?? []) if (!registry.relations.has(r.type)) counts.relations[r.type] = (counts.relations[r.type] ?? 0) + 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const filed = [];
|
|
187
|
+
for (const [kind, tally] of Object.entries(counts)) {
|
|
188
|
+
for (const [key, n] of Object.entries(tally)) {
|
|
189
|
+
if (n < 3) continue;
|
|
190
|
+
const id = `register-${kind.slice(0, 3)}-${slugify(key)}`;
|
|
191
|
+
if (getProposal(agentDir, id) || existsSync(join(archiveDir(agentDir), `${id}.json`))) continue;
|
|
192
|
+
filed.push(writeProposal(agentDir, { id, kind: 'registry', registry: kind, key, status: 'pending', created_at: new Date().toISOString(), evidence: { occurrences: n } }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return filed;
|
|
196
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The Knowledge registry — governance for item types, attribute keys, and
|
|
2
|
+
// relation types. Refined from the Notes vault's registry (types/dimensions/
|
|
3
|
+
// relations JSONL): relations keep their INVERSES; unlike Notes, unknown keys
|
|
4
|
+
// are never silently auto-registered — repeated use files a registry *proposal*
|
|
5
|
+
// (human-gated, like everything in this system).
|
|
6
|
+
//
|
|
7
|
+
// Files (tracked, seeded by `zuzuu init`): agent/knowledge/registry/
|
|
8
|
+
// types.json [{name, description}]
|
|
9
|
+
// attributes.json [{key, value, description}] value: "string"|"number"|"date"|"url"|{"enum":[...]}
|
|
10
|
+
// relations.json [{name, inverse, description}]
|
|
11
|
+
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
export const SEED_TYPES = [
|
|
16
|
+
{ name: 'fact', description: 'A declarative truth about the project/domain' },
|
|
17
|
+
{ name: 'entity', description: 'A named thing — file, module, service, person, system' },
|
|
18
|
+
{ name: 'command', description: 'A canonical command for this project (test, build, deploy…)' },
|
|
19
|
+
{ name: 'decision', description: 'A decision made, with its why' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const SEED_ATTRIBUTES = [
|
|
23
|
+
{ key: 'status', value: { enum: ['active', 'superseded'] }, description: 'Item lifecycle state' },
|
|
24
|
+
{ key: 'domain', value: 'string', description: 'Subject area the item belongs to' },
|
|
25
|
+
{ key: 'command', value: 'string', description: 'The literal command line (command items)' },
|
|
26
|
+
{ key: 'path', value: 'string', description: 'Filesystem path (entity items that are files/dirs)' },
|
|
27
|
+
{ key: 'url', value: 'url', description: 'External reference' },
|
|
28
|
+
{ key: 'decided_on', value: 'date', description: 'When a decision was made' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const SEED_RELATIONS = [
|
|
32
|
+
{ name: 'relates-to', inverse: 'relates-to', description: 'Generic association (symmetric)' },
|
|
33
|
+
{ name: 'part-of', inverse: 'has-part', description: 'Composition' },
|
|
34
|
+
{ name: 'depends-on', inverse: 'blocks', description: 'A needs B' },
|
|
35
|
+
{ name: 'supersedes', inverse: 'superseded-by', description: 'A replaces B' },
|
|
36
|
+
{ name: 'derived-from', inverse: 'source-of', description: 'A was distilled/derived from B' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const REG_FILES = { types: 'types.json', attributes: 'attributes.json', relations: 'relations.json' };
|
|
40
|
+
|
|
41
|
+
export function registryDir(agentDir) {
|
|
42
|
+
return join(agentDir, 'knowledge', 'registry');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Load the registry from agent/knowledge/registry/. Missing files → empty sets. */
|
|
46
|
+
export function loadRegistry(agentDir) {
|
|
47
|
+
const dir = registryDir(agentDir);
|
|
48
|
+
const read = (f) => {
|
|
49
|
+
const p = join(dir, f);
|
|
50
|
+
if (!existsSync(p)) return [];
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
53
|
+
return Array.isArray(data) ? data : [];
|
|
54
|
+
} catch {
|
|
55
|
+
return null; // unparseable — caller surfaces via audit
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const types = read(REG_FILES.types);
|
|
59
|
+
const attributes = read(REG_FILES.attributes);
|
|
60
|
+
const relations = read(REG_FILES.relations);
|
|
61
|
+
const broken = [types, attributes, relations].some((x) => x === null);
|
|
62
|
+
return {
|
|
63
|
+
ok: !broken,
|
|
64
|
+
types: new Map((types || []).map((t) => [t.name, t])),
|
|
65
|
+
attributes: new Map((attributes || []).map((a) => [a.key, a])),
|
|
66
|
+
relations: new Map((relations || []).map((r) => [r.name, r])),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
71
|
+
|
|
72
|
+
/** Validate one attribute value against its registry definition. */
|
|
73
|
+
export function validateAttribute(def, value) {
|
|
74
|
+
if (!def) return { ok: false, error: 'unregistered attribute key' };
|
|
75
|
+
const v = def.value;
|
|
76
|
+
const s = String(value);
|
|
77
|
+
if (v === 'string') return { ok: true };
|
|
78
|
+
if (v === 'number') return Number.isFinite(Number(s)) ? { ok: true } : { ok: false, error: 'not a number' };
|
|
79
|
+
if (v === 'date') return ISO_DATE.test(s) ? { ok: true } : { ok: false, error: 'not an ISO date' };
|
|
80
|
+
if (v === 'url') return /^https?:\/\/\S+$/.test(s) ? { ok: true } : { ok: false, error: 'not a URL' };
|
|
81
|
+
if (v && typeof v === 'object' && Array.isArray(v.enum)) {
|
|
82
|
+
return v.enum.includes(s) ? { ok: true } : { ok: false, error: `not in enum [${v.enum.join(', ')}]` };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true }; // unknown validator kind: permissive (fail-open governance)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate an item against the registry.
|
|
89
|
+
* @returns {{ok: boolean, errors: string[], unknownKeys: {attributes: string[], relations: string[]}}}
|
|
90
|
+
* unknownKeys are surfaced separately — they're proposal fodder, not hard errors
|
|
91
|
+
* for *candidates*; items.mjs treats them as errors for canonical items.
|
|
92
|
+
*/
|
|
93
|
+
export function validateItem(registry, item) {
|
|
94
|
+
const errors = [];
|
|
95
|
+
const unknownAttrs = [];
|
|
96
|
+
const unknownRels = [];
|
|
97
|
+
if (!registry.types.has(item.type)) errors.push(`unregistered type: ${item.type}`);
|
|
98
|
+
for (const [key, value] of Object.entries(item.attributes ?? {})) {
|
|
99
|
+
const def = registry.attributes.get(key);
|
|
100
|
+
if (!def) {
|
|
101
|
+
unknownAttrs.push(key);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const r = validateAttribute(def, value);
|
|
105
|
+
if (!r.ok) errors.push(`attribute ${key}: ${r.error}`);
|
|
106
|
+
}
|
|
107
|
+
for (const rel of item.relations ?? []) {
|
|
108
|
+
if (!rel.type || !rel.target) {
|
|
109
|
+
errors.push(`relation missing type/target: ${JSON.stringify(rel)}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!registry.relations.has(rel.type)) unknownRels.push(rel.type);
|
|
113
|
+
}
|
|
114
|
+
return { ok: errors.length === 0, errors, unknownKeys: { attributes: unknownAttrs, relations: unknownRels } };
|
|
115
|
+
}
|