@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.
Files changed (45) hide show
  1. package/bin/zuzuu.mjs +8 -1
  2. package/package.json +1 -1
  3. package/web-app/dist/zuzuu-api.js +125 -20
  4. package/web-app/web-dist/assets/{DiffTab-CihRJjzf.js → DiffTab-BpGp1akx.js} +1 -1
  5. package/web-app/web-dist/assets/{MonacoFile-DJvpGyW2.js → MonacoFile-CqbVacUZ.js} +1 -1
  6. package/web-app/web-dist/assets/{cssMode-R1Bks9TO.js → cssMode-Dx3ub8Pk.js} +1 -1
  7. package/web-app/web-dist/assets/{dist-jCnX6g-O.js → dist-C6R6xoyX.js} +1 -1
  8. package/web-app/web-dist/assets/{htmlMode-Csqnn3yv.js → htmlMode-DM6oHc7c.js} +1 -1
  9. package/web-app/web-dist/assets/index-DHpC851f.js +268 -0
  10. package/web-app/web-dist/assets/index-O-t1gyMG.css +2 -0
  11. package/web-app/web-dist/assets/{jsonMode-DRBg9jwi.js → jsonMode-DflaUwqW.js} +1 -1
  12. package/web-app/web-dist/assets/{monaco-setup-Dszx738Y.js → monaco-setup-wbBeb0oN.js} +3 -3
  13. package/web-app/web-dist/assets/{tsMode-9YOHYiVQ.js → tsMode-DRwkDcoK.js} +1 -1
  14. package/web-app/web-dist/index.html +2 -2
  15. package/zuzuu/actions/adapter.mjs +12 -20
  16. package/zuzuu/actions/convert.mjs +10 -9
  17. package/zuzuu/actions/dispatch.mjs +12 -7
  18. package/zuzuu/actions/inbox.mjs +5 -5
  19. package/zuzuu/actions/manifest.mjs +48 -30
  20. package/zuzuu/actions/schema.mjs +9 -3
  21. package/zuzuu/commands/act-author.mjs +23 -13
  22. package/zuzuu/commands/act.mjs +3 -5
  23. package/zuzuu/commands/doctor.mjs +2 -15
  24. package/zuzuu/commands/explain.mjs +4 -4
  25. package/zuzuu/commands/faculty.mjs +75 -0
  26. package/zuzuu/commands/generation.mjs +2 -4
  27. package/zuzuu/commands/hook.mjs +7 -5
  28. package/zuzuu/commands/init.mjs +14 -1
  29. package/zuzuu/commands/migrate.mjs +348 -1
  30. package/zuzuu/digest.mjs +18 -13
  31. package/zuzuu/faculty/envelope.mjs +290 -0
  32. package/zuzuu/faculty/generation.mjs +53 -47
  33. package/zuzuu/faculty/items.mjs +75 -0
  34. package/zuzuu/guardrails/adapter.mjs +18 -49
  35. package/zuzuu/guardrails.mjs +72 -24
  36. package/zuzuu/instructions/adapter.mjs +30 -30
  37. package/zuzuu/knowledge/items.mjs +56 -91
  38. package/zuzuu/live/install.mjs +1 -1
  39. package/zuzuu/memory/adapter.mjs +27 -52
  40. package/zuzuu/miners/actions.mjs +14 -20
  41. package/zuzuu/miners/guardrails.mjs +8 -11
  42. package/zuzuu/miners/instructions.mjs +10 -10
  43. package/zuzuu/scaffold.mjs +99 -38
  44. package/web-app/web-dist/assets/index-D_MPtALn.css +0 -2
  45. 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 (action.json + run.mjs/SKILL.md).
68
- const parts = ['action.json', 'run.mjs', 'SKILL.md']
69
- .map((f) => join(adir, f))
70
- .filter((p) => existsSync(p));
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
- rulesHash: fileHashOrNull(join(agentDir, 'guardrails', 'rules.json')),
125
+ items: guardrailFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
115
126
  },
116
127
  instructions: {
117
- projectHash: fileHashOrNull(join(agentDir, 'instructions', 'project.md')),
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
- * For item-list faculties (knowledge/actions/memory) reports added/changed/removed
191
- * id lists. For hash-only faculties (guardrails/instructions, and knowledge's
192
- * registry) reports a `changed` boolean when the scalar hash differs. When there
193
- * is no parent (forkedFrom null), everything present counts as added.
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
- // single-file faculties
251
- const rules = join(agentDir, 'guardrails', 'rules.json');
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(rules));
256
+ writeFileSync(dest, readFileSync(it.src));
256
257
  }
257
- const proj = join(agentDir, 'instructions', 'project.md');
258
- if (existsSync(proj)) {
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(proj));
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 single-file faculties from the snapshot
373
- const grules = join(base, 'guardrails', 'rules.json');
374
- if (existsSync(grules)) {
375
- const dest = join(agentDir, 'guardrails', 'rules.json');
376
- mkdirSync(dirname(dest), { recursive: true });
377
- writeFileSync(dest, readFileSync(grules));
378
- restored++;
379
- }
380
- const proj = join(base, 'instructions', 'project.md');
381
- if (existsSync(proj)) {
382
- const dest = join(agentDir, 'instructions', 'project.md');
383
- mkdirSync(dirname(dest), { recursive: true });
384
- writeFileSync(dest, readFileSync(proj));
385
- restored++;
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 (WS2-T4). Wraps the rules engine behind the
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: loads .zuzuu/guardrails/rules.json (seeding {version:1,rules:[]} if
11
- // absent), appends the rule or replaces an existing one with the same id,
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 into rules.json.
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
- // Ensure the guardrails dir exists
95
- mkdirSync(join(agentDir, 'guardrails'), { recursive: true });
96
-
97
- const data = loadRulesFile(agentDir);
98
- if (!Array.isArray(data.rules)) data.rules = [];
99
-
100
- const idx = data.rules.findIndex((r) => r.id === id);
101
- // Store only the canonical fields (id, action, tool, pattern, reason)
102
- const entry = {
103
- id: rule.id,
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