@zuzuucodes/cli 1.3.1 → 1.5.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/bin/zuzuu.mjs +8 -1
- package/package.json +1 -1
- package/web-app/dist/zuzuu-api.js +125 -20
- package/web-app/web-dist/assets/{DiffTab-CihRJjzf.js → DiffTab-BpGp1akx.js} +1 -1
- package/web-app/web-dist/assets/{MonacoFile-DJvpGyW2.js → MonacoFile-CqbVacUZ.js} +1 -1
- package/web-app/web-dist/assets/{cssMode-R1Bks9TO.js → cssMode-Dx3ub8Pk.js} +1 -1
- package/web-app/web-dist/assets/{dist-jCnX6g-O.js → dist-C6R6xoyX.js} +1 -1
- package/web-app/web-dist/assets/{htmlMode-Csqnn3yv.js → htmlMode-DM6oHc7c.js} +1 -1
- package/web-app/web-dist/assets/index-DHpC851f.js +268 -0
- package/web-app/web-dist/assets/index-O-t1gyMG.css +2 -0
- package/web-app/web-dist/assets/{jsonMode-DRBg9jwi.js → jsonMode-DflaUwqW.js} +1 -1
- package/web-app/web-dist/assets/{monaco-setup-Dszx738Y.js → monaco-setup-wbBeb0oN.js} +3 -3
- package/web-app/web-dist/assets/{tsMode-9YOHYiVQ.js → tsMode-DRwkDcoK.js} +1 -1
- package/web-app/web-dist/index.html +2 -2
- package/zuzuu/actions/adapter.mjs +12 -20
- package/zuzuu/actions/convert.mjs +10 -9
- package/zuzuu/actions/dispatch.mjs +12 -7
- package/zuzuu/actions/inbox.mjs +5 -5
- package/zuzuu/actions/manifest.mjs +48 -30
- package/zuzuu/actions/schema.mjs +9 -3
- package/zuzuu/commands/act-author.mjs +23 -13
- package/zuzuu/commands/act.mjs +3 -5
- package/zuzuu/commands/doctor.mjs +2 -15
- package/zuzuu/commands/explain.mjs +4 -4
- package/zuzuu/commands/faculty.mjs +75 -0
- package/zuzuu/commands/generation.mjs +2 -4
- package/zuzuu/commands/hook.mjs +7 -5
- package/zuzuu/commands/init.mjs +14 -1
- package/zuzuu/commands/migrate.mjs +348 -1
- package/zuzuu/digest.mjs +18 -13
- package/zuzuu/faculty/envelope.mjs +290 -0
- package/zuzuu/faculty/generation.mjs +53 -47
- package/zuzuu/faculty/items.mjs +75 -0
- package/zuzuu/guardrails/adapter.mjs +18 -49
- package/zuzuu/guardrails.mjs +72 -24
- package/zuzuu/instructions/adapter.mjs +30 -30
- package/zuzuu/knowledge/items.mjs +56 -91
- package/zuzuu/live/install.mjs +1 -1
- package/zuzuu/memory/adapter.mjs +27 -52
- package/zuzuu/miners/actions.mjs +14 -20
- package/zuzuu/miners/guardrails.mjs +8 -11
- package/zuzuu/miners/instructions.mjs +10 -10
- package/zuzuu/scaffold.mjs +99 -38
- package/web-app/web-dist/assets/index-D_MPtALn.css +0 -2
- package/web-app/web-dist/assets/index-Ye54YyTn.js +0 -267
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// zuzuu/faculty/envelope.mjs — the Faculty Standard envelope (W24).
|
|
2
|
+
//
|
|
3
|
+
// ONE storage format across all five faculties: one file per item, markdown
|
|
4
|
+
// prose + a strict constrained-YAML frontmatter we control on both ends (no
|
|
5
|
+
// YAML lib — grown from the knowledge items grammar):
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// id: test-command # required, slug
|
|
9
|
+
// faculty: knowledge # required, one of the 5
|
|
10
|
+
// kind: command # required; per-faculty kinds (FACULTY_KINDS)
|
|
11
|
+
// title: "Test command" # required, single line
|
|
12
|
+
// status: active # active | archived
|
|
13
|
+
// created_at: 2026-06-12T00:00:00Z
|
|
14
|
+
// updated_at: … # optional
|
|
15
|
+
// provenance: # optional list of flat maps {session, ref}
|
|
16
|
+
// - session: ses_abc
|
|
17
|
+
// ref: occurrences=12
|
|
18
|
+
// payload: # faculty-typed machine fields
|
|
19
|
+
// type: command # scalar
|
|
20
|
+
// attributes: # one-level map of scalars
|
|
21
|
+
// command: npm test
|
|
22
|
+
// relations: # list of flat maps
|
|
23
|
+
// - type: relates-to
|
|
24
|
+
// target: ci-pipeline
|
|
25
|
+
// ---
|
|
26
|
+
// <markdown prose body>
|
|
27
|
+
//
|
|
28
|
+
// Grammar (deliberately small): top-level scalar keys; `provenance` = a list of
|
|
29
|
+
// flat maps; `payload` = a block of scalars, one-level maps of scalars, lists
|
|
30
|
+
// of flat maps, or lists of scalars. Values are single-line strings (JSON
|
|
31
|
+
// double-quoting when they carry specials — round-trip exact, incl. backslashes
|
|
32
|
+
// in guardrail regexes). Anything outside this grammar is a parse error.
|
|
33
|
+
//
|
|
34
|
+
// API: parseEnvelope(text) → {ok, item, errors} (never throws) ·
|
|
35
|
+
// serializeEnvelope(item) → text · validateEnvelope(item, payloadSchema).
|
|
36
|
+
// Payload validation rides the shared JSON-Schema-subset checker
|
|
37
|
+
// (actions/schema.mjs — type/required/enum/pattern on flat fields).
|
|
38
|
+
|
|
39
|
+
import { FACULTIES } from './contract.mjs';
|
|
40
|
+
import { validate as validateSchema } from '../actions/schema.mjs';
|
|
41
|
+
|
|
42
|
+
/** Per-faculty item kinds. `null` = open set (knowledge kinds are governed by
|
|
43
|
+
* the knowledge registry's types.json, not pinned here). */
|
|
44
|
+
export const FACULTY_KINDS = {
|
|
45
|
+
knowledge: null, // registry-governed (seed: fact|entity|command|decision)
|
|
46
|
+
memory: ['episode'],
|
|
47
|
+
actions: ['runbook', 'script'],
|
|
48
|
+
instructions: ['steering', 'amendment'],
|
|
49
|
+
guardrails: ['rule'],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Default payload schemas (JSON-Schema subset) — also seeded to
|
|
53
|
+
* .zuzuu/<faculty>/schema.json by `zuzuu init`. We author both ends. */
|
|
54
|
+
export const PAYLOAD_SCHEMAS = {
|
|
55
|
+
knowledge: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
required: ['type'],
|
|
58
|
+
properties: {
|
|
59
|
+
type: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
60
|
+
attributes: { type: 'object' },
|
|
61
|
+
relations: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
items: { type: 'object', required: ['type', 'target'], properties: { type: { type: 'string' }, target: { type: 'string' }, commentary: { type: 'string' } } },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
memory: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
sessions: { type: 'array', items: { type: 'string' } },
|
|
71
|
+
hosts: { type: 'array', items: { type: 'string' } },
|
|
72
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
actions: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
exec: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$' }, // sibling file name, no path escape
|
|
79
|
+
args: { type: 'object' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
instructions: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: { scope: { type: 'string' } },
|
|
85
|
+
},
|
|
86
|
+
guardrails: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
required: ['action', 'pattern', 'reason'],
|
|
89
|
+
properties: {
|
|
90
|
+
action: { type: 'string', enum: ['deny', 'ask', 'allow'] },
|
|
91
|
+
tool: { type: 'string' },
|
|
92
|
+
pattern: { type: 'string', minLength: 1 },
|
|
93
|
+
reason: { type: 'string', minLength: 1 },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; // spec is [a-z0-9-]; `_` tolerated for action slugs
|
|
99
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
100
|
+
const STATUSES = new Set(['active', 'archived']);
|
|
101
|
+
const TOP_KEYS = ['id', 'faculty', 'kind', 'title', 'status', 'created_at', 'updated_at'];
|
|
102
|
+
|
|
103
|
+
// --- scalar quoting (round-trip exact, incl. backslashes) --------------------
|
|
104
|
+
|
|
105
|
+
const unquote = (s) => {
|
|
106
|
+
const t = s.trim();
|
|
107
|
+
if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
|
|
108
|
+
try { return JSON.parse(t); } catch { return t.slice(1, -1); }
|
|
109
|
+
}
|
|
110
|
+
if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) return t.slice(1, -1);
|
|
111
|
+
return t;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const quoteIfNeeded = (s) => {
|
|
115
|
+
const t = String(s);
|
|
116
|
+
if (t.includes('\n')) throw new Error('envelope values must be single-line');
|
|
117
|
+
return /[:#'"\\\[\]{}]|^-\s|^\s|\s$/.test(t) || t === '' ? JSON.stringify(t) : t;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const KV = /^([A-Za-z_][\w-]*):\s*(.*)$/;
|
|
121
|
+
|
|
122
|
+
// --- parse -------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse an envelope file's text. Never throws.
|
|
126
|
+
* @returns {{ok: boolean, item: object|null, errors: string[]}}
|
|
127
|
+
*/
|
|
128
|
+
export function parseEnvelope(text) {
|
|
129
|
+
const errors = [];
|
|
130
|
+
const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
131
|
+
if (!m) return { ok: false, item: null, errors: ['no frontmatter block'] };
|
|
132
|
+
const [, fm, body] = m;
|
|
133
|
+
const item = { provenance: [], payload: {}, body: body.trim() };
|
|
134
|
+
|
|
135
|
+
let section = null; // 'provenance' | 'payload' | null
|
|
136
|
+
let payloadKey = null; // current sub-block key inside payload
|
|
137
|
+
let current = null; // current list entry (flat map) being filled
|
|
138
|
+
|
|
139
|
+
for (const raw of fm.split('\n')) {
|
|
140
|
+
if (!raw.trim()) continue;
|
|
141
|
+
const indent = raw.match(/^ */)[0].length;
|
|
142
|
+
const line = raw.trim();
|
|
143
|
+
|
|
144
|
+
if (indent === 0) {
|
|
145
|
+
section = null; payloadKey = null; current = null;
|
|
146
|
+
const kv = line.match(KV);
|
|
147
|
+
if (!kv) { errors.push(`bad line: ${line}`); continue; }
|
|
148
|
+
const [, key, val] = kv;
|
|
149
|
+
if (key === 'provenance' || key === 'payload') {
|
|
150
|
+
if (val) { errors.push(`${key} must be a block`); continue; }
|
|
151
|
+
section = key;
|
|
152
|
+
} else {
|
|
153
|
+
item[key] = unquote(val);
|
|
154
|
+
}
|
|
155
|
+
} else if (section === 'provenance') {
|
|
156
|
+
if (line.startsWith('- ')) {
|
|
157
|
+
const kv = line.slice(2).match(KV);
|
|
158
|
+
if (!kv) { errors.push(`bad provenance entry: ${line}`); continue; }
|
|
159
|
+
current = { [kv[1]]: unquote(kv[2]) };
|
|
160
|
+
item.provenance.push(current);
|
|
161
|
+
} else {
|
|
162
|
+
const kv = line.match(KV);
|
|
163
|
+
if (!current || !kv) { errors.push(`bad provenance line: ${line}`); continue; }
|
|
164
|
+
current[kv[1]] = unquote(kv[2]);
|
|
165
|
+
}
|
|
166
|
+
} else if (section === 'payload') {
|
|
167
|
+
if (indent === 2 && !line.startsWith('- ')) {
|
|
168
|
+
current = null;
|
|
169
|
+
const kv = line.match(KV);
|
|
170
|
+
if (!kv) { errors.push(`bad payload line: ${line}`); continue; }
|
|
171
|
+
const [, key, val] = kv;
|
|
172
|
+
if (val === '') { payloadKey = key; /* shape resolved by first child */ }
|
|
173
|
+
else { payloadKey = null; item.payload[key] = unquote(val); }
|
|
174
|
+
} else if (indent >= 4 && payloadKey) {
|
|
175
|
+
const slot = item.payload[payloadKey];
|
|
176
|
+
if (line.startsWith('- ')) {
|
|
177
|
+
const rest = line.slice(2);
|
|
178
|
+
if (!Array.isArray(slot)) {
|
|
179
|
+
if (slot !== undefined && typeof slot === 'object') { errors.push(`payload ${payloadKey}: mixed map/list`); continue; }
|
|
180
|
+
item.payload[payloadKey] = [];
|
|
181
|
+
}
|
|
182
|
+
const kv = rest.match(KV);
|
|
183
|
+
if (kv) {
|
|
184
|
+
current = { [kv[1]]: unquote(kv[2]) };
|
|
185
|
+
item.payload[payloadKey].push(current);
|
|
186
|
+
} else {
|
|
187
|
+
current = null;
|
|
188
|
+
item.payload[payloadKey].push(unquote(rest));
|
|
189
|
+
}
|
|
190
|
+
} else if (current && Array.isArray(slot)) {
|
|
191
|
+
const kv = line.match(KV);
|
|
192
|
+
if (!kv) { errors.push(`bad payload ${payloadKey} entry line: ${line}`); continue; }
|
|
193
|
+
current[kv[1]] = unquote(kv[2]);
|
|
194
|
+
} else {
|
|
195
|
+
// one-level map of scalars
|
|
196
|
+
if (slot === undefined) item.payload[payloadKey] = {};
|
|
197
|
+
else if (Array.isArray(item.payload[payloadKey])) { errors.push(`payload ${payloadKey}: mixed list/map`); continue; }
|
|
198
|
+
const kv = line.match(KV);
|
|
199
|
+
if (!kv) { errors.push(`bad payload ${payloadKey} line: ${line}`); continue; }
|
|
200
|
+
item.payload[payloadKey][kv[1]] = unquote(kv[2]);
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
errors.push(`unexpected indented line: ${line}`);
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
errors.push(`unexpected indented line: ${line}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// empty sub-blocks (key with no children) resolve to {} — already the default
|
|
211
|
+
if (!item.id) errors.push('item missing id');
|
|
212
|
+
if (!item.faculty) errors.push('item missing faculty');
|
|
213
|
+
if (!item.kind) errors.push('item missing kind');
|
|
214
|
+
return { ok: errors.length === 0, item, errors };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- serialize ----------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/** Serialize an envelope item → file text (the exact grammar parseEnvelope reads). */
|
|
220
|
+
export function serializeEnvelope(item) {
|
|
221
|
+
const lines = ['---'];
|
|
222
|
+
for (const key of TOP_KEYS) {
|
|
223
|
+
if (item[key] != null && item[key] !== '') lines.push(`${key}: ${quoteIfNeeded(item[key])}`);
|
|
224
|
+
}
|
|
225
|
+
const prov = item.provenance ?? [];
|
|
226
|
+
if (prov.length) {
|
|
227
|
+
lines.push('provenance:');
|
|
228
|
+
for (const entry of prov) {
|
|
229
|
+
Object.keys(entry).forEach((k, i) => lines.push(` ${i === 0 ? '- ' : ' '}${k}: ${quoteIfNeeded(entry[k])}`));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const payload = item.payload ?? {};
|
|
233
|
+
const pkeys = Object.keys(payload).filter((k) => payload[k] != null);
|
|
234
|
+
if (pkeys.length) {
|
|
235
|
+
lines.push('payload:');
|
|
236
|
+
for (const k of pkeys) {
|
|
237
|
+
const v = payload[k];
|
|
238
|
+
if (Array.isArray(v)) {
|
|
239
|
+
if (!v.length) continue;
|
|
240
|
+
lines.push(` ${k}:`);
|
|
241
|
+
for (const entry of v) {
|
|
242
|
+
if (entry !== null && typeof entry === 'object') {
|
|
243
|
+
Object.keys(entry).forEach((ek, i) => lines.push(` ${i === 0 ? '- ' : ' '}${ek}: ${quoteIfNeeded(entry[ek])}`));
|
|
244
|
+
} else {
|
|
245
|
+
lines.push(` - ${quoteIfNeeded(entry)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} else if (v !== null && typeof v === 'object') {
|
|
249
|
+
const entries = Object.entries(v);
|
|
250
|
+
if (!entries.length) continue;
|
|
251
|
+
lines.push(` ${k}:`);
|
|
252
|
+
for (const [mk, mv] of entries) lines.push(` ${mk}: ${quoteIfNeeded(mv)}`);
|
|
253
|
+
} else {
|
|
254
|
+
lines.push(` ${k}: ${quoteIfNeeded(v)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
lines.push('---', '');
|
|
259
|
+
return lines.join('\n') + (item.body ? String(item.body).trim() + '\n' : '');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- validate -----------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate an envelope item: required envelope fields + (optionally) its payload
|
|
266
|
+
* against a JSON-Schema-subset payload schema.
|
|
267
|
+
* @returns {{ok: boolean, errors: string[]}}
|
|
268
|
+
*/
|
|
269
|
+
export function validateEnvelope(item, payloadSchema = null) {
|
|
270
|
+
const errors = [];
|
|
271
|
+
if (!item || typeof item !== 'object') return { ok: false, errors: ['not an item'] };
|
|
272
|
+
if (!item.id || !ID_RE.test(item.id)) errors.push(`id must match ${ID_RE} (got '${item.id}')`);
|
|
273
|
+
if (!FACULTIES.includes(item.faculty)) errors.push(`faculty must be one of ${FACULTIES.join('|')} (got '${item.faculty}')`);
|
|
274
|
+
const kinds = FACULTY_KINDS[item.faculty];
|
|
275
|
+
if (!item.kind || !/^[a-z0-9][a-z0-9-]*$/.test(item.kind)) errors.push(`kind must be a slug (got '${item.kind}')`);
|
|
276
|
+
else if (Array.isArray(kinds) && !kinds.includes(item.kind)) errors.push(`kind must be one of ${kinds.join('|')} for ${item.faculty} (got '${item.kind}')`);
|
|
277
|
+
if (!item.title || typeof item.title !== 'string' || item.title.includes('\n')) errors.push('title is required (single line)');
|
|
278
|
+
if (item.status != null && !STATUSES.has(item.status)) errors.push(`status must be active|archived (got '${item.status}')`);
|
|
279
|
+
if (!item.created_at || !ISO_RE.test(String(item.created_at))) errors.push(`created_at must be ISO (got '${item.created_at}')`);
|
|
280
|
+
if (item.updated_at != null && !ISO_RE.test(String(item.updated_at))) errors.push(`updated_at must be ISO (got '${item.updated_at}')`);
|
|
281
|
+
if (item.provenance != null && !Array.isArray(item.provenance)) errors.push('provenance must be a list');
|
|
282
|
+
if (payloadSchema) errors.push(...validateSchema(payloadSchema, item.payload ?? {}, 'payload'));
|
|
283
|
+
return { ok: errors.length === 0, errors };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Derive a single-line title (first body line, de-markdowned) — fallback id. */
|
|
287
|
+
export function deriveTitle(body, id) {
|
|
288
|
+
const first = String(body ?? '').split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean);
|
|
289
|
+
return (first || String(id || 'item')).slice(0, 80);
|
|
290
|
+
}
|
|
@@ -61,13 +61,14 @@ function knowledgeFiles(agentDir) {
|
|
|
61
61
|
function actionFiles(agentDir) {
|
|
62
62
|
const dir = join(agentDir, 'actions');
|
|
63
63
|
return sortDirents(dir)
|
|
64
|
-
.filter((e) => e.isDirectory() && e.name !== 'inbox' && e.name !== 'proposals')
|
|
64
|
+
.filter((e) => e.isDirectory() && e.name !== 'inbox' && e.name !== 'proposals' && e.name !== '_rolledback')
|
|
65
65
|
.map((e) => {
|
|
66
66
|
const adir = join(dir, e.name);
|
|
67
|
-
// Hash the dir's defining files concatenated
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.filter((
|
|
67
|
+
// Hash the dir's defining files concatenated: the ACTION.md envelope
|
|
68
|
+
// (W24) + sibling scripts (*.mjs — run.mjs and any payload.exec module).
|
|
69
|
+
const parts = sortDirents(adir)
|
|
70
|
+
.filter((f) => f.isFile() && (f.name === 'ACTION.md' || f.name.endsWith('.mjs')))
|
|
71
|
+
.map((f) => join(adir, f.name));
|
|
71
72
|
const concat = Buffer.concat(parts.map((p) => readFileSync(p)));
|
|
72
73
|
return {
|
|
73
74
|
id: e.name, faculty: 'actions', files: parts.map((p) => p.slice(adir.length + 1)),
|
|
@@ -76,6 +77,20 @@ function actionFiles(agentDir) {
|
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** Flat envelope-item faculties share one enumerator. */
|
|
81
|
+
function mdItemFiles(agentDir, faculty, ...segments) {
|
|
82
|
+
const dir = join(agentDir, ...segments);
|
|
83
|
+
return sortDirents(dir)
|
|
84
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
85
|
+
.map((e) => {
|
|
86
|
+
const src = join(dir, e.name);
|
|
87
|
+
return { id: e.name.replace(/\.md$/, ''), faculty, src, rel: e.name, hash: sha256(readFileSync(src)) };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const guardrailFiles = (agentDir) => mdItemFiles(agentDir, 'guardrails', 'guardrails', 'items');
|
|
92
|
+
const instructionFiles = (agentDir) => mdItemFiles(agentDir, 'instructions', 'instructions', 'items');
|
|
93
|
+
|
|
79
94
|
function memoryFiles(agentDir) {
|
|
80
95
|
const dir = join(agentDir, 'memory', 'entries');
|
|
81
96
|
return sortDirents(dir)
|
|
@@ -93,10 +108,6 @@ function registryHash(agentDir) {
|
|
|
93
108
|
return sha256(Buffer.concat(files.map((e) => readFileSync(join(dir, e.name)))));
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
function fileHashOrNull(p) {
|
|
97
|
-
return existsSync(p) ? sha256(readFileSync(p)) : null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
111
|
/**
|
|
101
112
|
* Snapshot the current faculty state → the `faculties` manifest object.
|
|
102
113
|
* Tolerates missing files (empty arrays / null hashes).
|
|
@@ -111,10 +122,10 @@ export function snapshotFaculties(agentDir) {
|
|
|
111
122
|
items: actionFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
112
123
|
},
|
|
113
124
|
guardrails: {
|
|
114
|
-
|
|
125
|
+
items: guardrailFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
115
126
|
},
|
|
116
127
|
instructions: {
|
|
117
|
-
|
|
128
|
+
items: instructionFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
118
129
|
},
|
|
119
130
|
memory: {
|
|
120
131
|
items: memoryFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
@@ -169,9 +180,6 @@ export function readGeneration(agentDir, id) {
|
|
|
169
180
|
return existsSync(p) ? readJson(p) : null;
|
|
170
181
|
}
|
|
171
182
|
|
|
172
|
-
/** Item-list faculties carry {id,hash}[]; single-file faculties a *Hash scalar. */
|
|
173
|
-
const HASH_KEYS = { knowledge: 'registryHash', instructions: 'projectHash', guardrails: 'rulesHash' };
|
|
174
|
-
|
|
175
183
|
/** Diff two item-manifest arrays → {added, changed, removed} (id lists). */
|
|
176
184
|
function diffItems(parentItems = [], childItems = []) {
|
|
177
185
|
const p = new Map(parentItems.map((i) => [i.id, i.hash]));
|
|
@@ -187,11 +195,10 @@ function diffItems(parentItems = [], childItems = []) {
|
|
|
187
195
|
|
|
188
196
|
/**
|
|
189
197
|
* Per-faculty diff of generation `id` against its forkedFrom parent (pure).
|
|
190
|
-
*
|
|
191
|
-
* id lists
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* Returns null for an unknown id.
|
|
198
|
+
* ALL five faculties are item lists under the Faculty Standard (W24) —
|
|
199
|
+
* added/changed/removed id lists per faculty; knowledge additionally reports
|
|
200
|
+
* registryChanged. When there is no parent (forkedFrom null), everything
|
|
201
|
+
* present counts as added. Returns null for an unknown id.
|
|
195
202
|
*/
|
|
196
203
|
export function diffGenerations(agentDir, id) {
|
|
197
204
|
const child = readGeneration(agentDir, id);
|
|
@@ -200,17 +207,13 @@ export function diffGenerations(agentDir, id) {
|
|
|
200
207
|
const cf = child.faculties || {};
|
|
201
208
|
const pf = parent?.faculties || {};
|
|
202
209
|
const faculties = {};
|
|
203
|
-
for (const f of ['knowledge', 'actions', 'memory']) {
|
|
210
|
+
for (const f of ['knowledge', 'actions', 'memory', 'guardrails', 'instructions']) {
|
|
204
211
|
faculties[f] = diffItems(pf[f]?.items, cf[f]?.items);
|
|
205
212
|
// knowledge also has a registry hash
|
|
206
213
|
if (f === 'knowledge') {
|
|
207
214
|
faculties[f].registryChanged = (cf.knowledge?.registryHash ?? null) !== (pf.knowledge?.registryHash ?? null);
|
|
208
215
|
}
|
|
209
216
|
}
|
|
210
|
-
for (const f of ['guardrails', 'instructions']) {
|
|
211
|
-
const key = HASH_KEYS[f];
|
|
212
|
-
faculties[f] = { changed: (cf[f]?.[key] ?? null) !== (pf[f]?.[key] ?? null) };
|
|
213
|
-
}
|
|
214
217
|
return {
|
|
215
218
|
id,
|
|
216
219
|
forkedFrom: child.forkedFrom ?? null,
|
|
@@ -247,18 +250,15 @@ function copySnapshot(agentDir, id) {
|
|
|
247
250
|
writeFileSync(dest, readFileSync(join(a.adir, rel)));
|
|
248
251
|
}
|
|
249
252
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (existsSync(rules)) {
|
|
253
|
-
const dest = join(base, 'guardrails', 'rules.json');
|
|
253
|
+
for (const it of guardrailFiles(agentDir)) {
|
|
254
|
+
const dest = join(base, 'guardrails', it.rel);
|
|
254
255
|
mkdirSync(dirname(dest), { recursive: true });
|
|
255
|
-
writeFileSync(dest, readFileSync(
|
|
256
|
+
writeFileSync(dest, readFileSync(it.src));
|
|
256
257
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
const dest = join(base, 'instructions', 'project.md');
|
|
258
|
+
for (const it of instructionFiles(agentDir)) {
|
|
259
|
+
const dest = join(base, 'instructions', it.rel);
|
|
260
260
|
mkdirSync(dirname(dest), { recursive: true });
|
|
261
|
-
writeFileSync(dest, readFileSync(
|
|
261
|
+
writeFileSync(dest, readFileSync(it.src));
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
@@ -369,20 +369,26 @@ export function rollback(agentDir, id) {
|
|
|
369
369
|
}
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
// 4) restore
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
372
|
+
// 4) restore guardrails + instructions items (same item-list contract)
|
|
373
|
+
for (const [faculty, liveSeg] of [['guardrails', ['guardrails', 'items']], ['instructions', ['instructions', 'items']]]) {
|
|
374
|
+
const targetIds = new Set((target.faculties[faculty]?.items ?? []).map((i) => i.id));
|
|
375
|
+
for (const i of target.faculties[faculty]?.items ?? []) {
|
|
376
|
+
const snap = join(base, faculty, `${i.id}.md`);
|
|
377
|
+
if (existsSync(snap)) {
|
|
378
|
+
const dest = join(agentDir, ...liveSeg, `${i.id}.md`);
|
|
379
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
380
|
+
writeFileSync(dest, readFileSync(snap));
|
|
381
|
+
restored++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const liveDir = join(agentDir, ...liveSeg);
|
|
385
|
+
if (existsSync(liveDir)) {
|
|
386
|
+
for (const e of readdirSync(liveDir, { withFileTypes: true })) {
|
|
387
|
+
if (e.isFile() && e.name.endsWith('.md') && !targetIds.has(e.name.replace(/\.md$/, ''))) {
|
|
388
|
+
archive(agentDir, faculty, join(liveDir, e.name));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
// 5) regenerate the derived knowledge index + flip the pointer
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// zuzuu/faculty/items.mjs — where each faculty's envelope items live (W24).
|
|
2
|
+
//
|
|
3
|
+
// One standard, five homes:
|
|
4
|
+
// knowledge → knowledge/items/<id>.md
|
|
5
|
+
// memory → memory/entries/<id>.md
|
|
6
|
+
// instructions → instructions/items/<id>.md
|
|
7
|
+
// guardrails → guardrails/items/<id>.md
|
|
8
|
+
// actions → actions/<id>/ACTION.md (dir-shaped: scripts stay siblings)
|
|
9
|
+
//
|
|
10
|
+
// Listing is fail-soft: unparseable files are collected as errors, never thrown
|
|
11
|
+
// (mirrors knowledge allItems — audit surfaces them).
|
|
12
|
+
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { parseEnvelope, serializeEnvelope } from './envelope.mjs';
|
|
16
|
+
|
|
17
|
+
/** Flat item dirs per faculty (actions are dir-shaped — see itemPathFor). */
|
|
18
|
+
const ITEM_DIRS = {
|
|
19
|
+
knowledge: ['knowledge', 'items'],
|
|
20
|
+
memory: ['memory', 'entries'],
|
|
21
|
+
instructions: ['instructions', 'items'],
|
|
22
|
+
guardrails: ['guardrails', 'items'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The flat items dir for a faculty, or null for dir-shaped faculties (actions). */
|
|
26
|
+
export function itemsDirFor(agentDir, faculty) {
|
|
27
|
+
const rel = ITEM_DIRS[faculty];
|
|
28
|
+
return rel ? join(agentDir, ...rel) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Canonical envelope file path for one item. */
|
|
32
|
+
export function itemPathFor(agentDir, faculty, id) {
|
|
33
|
+
if (faculty === 'actions') return join(agentDir, 'actions', id, 'ACTION.md');
|
|
34
|
+
return join(itemsDirFor(agentDir, faculty), `${id}.md`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* All envelope items of a faculty. Parse errors collected, never thrown.
|
|
39
|
+
* @returns {{items: object[], errors: Array<{file: string, error: string}>}}
|
|
40
|
+
*/
|
|
41
|
+
export function listFacultyItems(agentDir, faculty) {
|
|
42
|
+
const items = [];
|
|
43
|
+
const errors = [];
|
|
44
|
+
if (faculty === 'actions') {
|
|
45
|
+
const base = join(agentDir, 'actions');
|
|
46
|
+
if (!existsSync(base)) return { items, errors };
|
|
47
|
+
for (const name of readdirSync(base).sort()) {
|
|
48
|
+
if (name === 'inbox' || name === 'proposals' || name === '_rolledback') continue;
|
|
49
|
+
const p = join(base, name, 'ACTION.md');
|
|
50
|
+
let isDir = false;
|
|
51
|
+
try { isDir = statSync(join(base, name)).isDirectory(); } catch { /* skip */ }
|
|
52
|
+
if (!isDir || !existsSync(p)) continue;
|
|
53
|
+
const { ok, item, errors: errs } = parseEnvelope(readFileSync(p, 'utf8'));
|
|
54
|
+
if (ok) items.push(item);
|
|
55
|
+
else errors.push({ file: `${name}/ACTION.md`, error: errs[0] ?? 'parse error' });
|
|
56
|
+
}
|
|
57
|
+
return { items, errors };
|
|
58
|
+
}
|
|
59
|
+
const dir = itemsDirFor(agentDir, faculty);
|
|
60
|
+
if (!dir || !existsSync(dir)) return { items, errors };
|
|
61
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
|
|
62
|
+
const { ok, item, errors: errs } = parseEnvelope(readFileSync(join(dir, f), 'utf8'));
|
|
63
|
+
if (ok) items.push(item);
|
|
64
|
+
else errors.push({ file: f, error: errs[0] ?? 'parse error' });
|
|
65
|
+
}
|
|
66
|
+
return { items, errors };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Write one envelope item to its canonical path. Returns the path. */
|
|
70
|
+
export function writeFacultyItem(agentDir, item) {
|
|
71
|
+
const path = itemPathFor(agentDir, item.faculty, item.id);
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
writeFileSync(path, serializeEnvelope(item));
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
@@ -1,43 +1,24 @@
|
|
|
1
1
|
// zuzuu/guardrails/adapter.mjs
|
|
2
|
-
// The Guardrails faculty adapter
|
|
2
|
+
// The Guardrails faculty adapter. Wraps the rules engine behind the
|
|
3
3
|
// faculty-spine adapter contract — { name, ingest, validate, apply, render } —
|
|
4
4
|
// so `zuzuu review` can surface and approve/reject rule proposals the same way it
|
|
5
5
|
// does Knowledge proposals.
|
|
6
6
|
//
|
|
7
7
|
// A guardrails proposal payload is a single rule record:
|
|
8
|
-
// { id, action: deny|ask|allow, tool, pattern, reason }
|
|
8
|
+
// { id, action: deny|ask|allow, tool, pattern, reason, body? }
|
|
9
9
|
//
|
|
10
|
-
// apply:
|
|
11
|
-
//
|
|
12
|
-
// then writes the file back.
|
|
10
|
+
// apply: writes the rule as a Faculty Standard envelope item at
|
|
11
|
+
// .zuzuu/guardrails/items/<id>.md (upsert — same id replaces the file).
|
|
13
12
|
//
|
|
14
13
|
// Registers itself on import.
|
|
15
14
|
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
15
|
import * as registry from '../faculty/registry.mjs';
|
|
16
|
+
import { writeFacultyItem } from '../faculty/items.mjs';
|
|
17
|
+
import { deriveTitle } from '../faculty/envelope.mjs';
|
|
19
18
|
|
|
20
19
|
const name = 'guardrails';
|
|
21
20
|
const VALID_ACTIONS = new Set(['deny', 'ask', 'allow']);
|
|
22
21
|
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// helpers
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
function rulesPath(agentDir) {
|
|
28
|
-
return join(agentDir, 'guardrails', 'rules.json');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function loadRulesFile(agentDir) {
|
|
32
|
-
const path = rulesPath(agentDir);
|
|
33
|
-
if (!existsSync(path)) return { version: 1, rules: [] };
|
|
34
|
-
try {
|
|
35
|
-
return JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
-
} catch {
|
|
37
|
-
return { version: 1, rules: [] };
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
22
|
// ---------------------------------------------------------------------------
|
|
42
23
|
// adapter contract
|
|
43
24
|
// ---------------------------------------------------------------------------
|
|
@@ -84,35 +65,23 @@ function validate(_agentDir, payload) {
|
|
|
84
65
|
}
|
|
85
66
|
|
|
86
67
|
/**
|
|
87
|
-
* Apply an approved rule proposal: upsert
|
|
68
|
+
* Apply an approved rule proposal: write the envelope item (upsert by id).
|
|
88
69
|
* @returns {{ok:boolean, action:string, itemIds:string[]}}
|
|
89
70
|
*/
|
|
90
71
|
function apply(agentDir, proposal) {
|
|
91
72
|
const rule = proposal?.payload ?? {};
|
|
92
73
|
const id = rule.id;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
action: rule.action,
|
|
105
|
-
tool: rule.tool,
|
|
106
|
-
pattern: rule.pattern,
|
|
107
|
-
reason: rule.reason,
|
|
108
|
-
};
|
|
109
|
-
if (idx >= 0) {
|
|
110
|
-
data.rules[idx] = entry;
|
|
111
|
-
} else {
|
|
112
|
-
data.rules.push(entry);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
writeFileSync(rulesPath(agentDir), JSON.stringify(data, null, 2) + '\n');
|
|
74
|
+
writeFacultyItem(agentDir, {
|
|
75
|
+
id,
|
|
76
|
+
faculty: name,
|
|
77
|
+
kind: 'rule',
|
|
78
|
+
title: deriveTitle(rule.reason, id),
|
|
79
|
+
status: 'active',
|
|
80
|
+
created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
81
|
+
provenance: Array.isArray(proposal?.provenance) ? proposal.provenance : [],
|
|
82
|
+
payload: { action: rule.action, tool: rule.tool || '*', pattern: rule.pattern, reason: rule.reason },
|
|
83
|
+
body: rule.body ?? '',
|
|
84
|
+
});
|
|
116
85
|
return { ok: true, action: `added rule ${id}`, itemIds: [id] };
|
|
117
86
|
}
|
|
118
87
|
|