@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
@@ -1,48 +1,96 @@
1
1
  // The Guardrails faculty — v1 rule engine (pure; I/O lives in the hook command).
2
2
  //
3
- // Rules are DATA, not code: .zuzuu/guardrails/rules.json, ordered, declarative
4
- // a *definition* in the pin-definitions sense (versioned in git, graduates via
5
- // proposals like every faculty's contents).
3
+ // Rules are DATA, not code: one envelope item per rule under
4
+ // .zuzuu/guardrails/items/<id>.md (the Faculty Standard, W24) *definitions*
5
+ // in the pin-definitions sense (versioned in git, graduate via proposals like
6
+ // every faculty's contents).
6
7
  //
7
- // { "version": 1,
8
- // "rules": [ { "id": "no-root-wipe", "action": "deny",
9
- // "tool": "Bash", // exact tool name, or "*"
10
- // "pattern": "rm\\s+-rf\\s+/", // regex over the tool INPUT (stringified)
11
- // "reason": "destructive root delete" } ] }
8
+ // ---
9
+ // id: no-root-wipe
10
+ // faculty: guardrails
11
+ // kind: rule
12
+ // title:
13
+ // payload:
14
+ // action: deny # deny | ask | allow
15
+ // tool: Bash # exact tool name, or "*"
16
+ // pattern: "rm\\s+-rf\\s+/" # regex over the tool INPUT (stringified)
17
+ // reason: destructive root delete
18
+ // ---
19
+ // (optional rationale prose)
12
20
  //
13
21
  // Evaluation: collect every matching rule, then severity wins — deny > ask >
14
22
  // allow (an explicit allow can whitelist past a later ask/deny only if it is
15
23
  // NOT outweighed; severity beats file order so a sloppy rule ordering can never
16
24
  // silently disarm a deny).
17
25
  //
18
- // FAIL-OPEN: any malformed rule/file yields { ok:false } and no decision — the
19
- // host proceeds through its normal permission flow. A guardrail bug must never
20
- // brick the agent; misses are logged, not fatal.
26
+ // FAIL-OPEN, per item: a malformed rule file is SKIPPED (and counted in
27
+ // `skipped`), never a crash and never a block the other rules still apply.
28
+ // The gate runs per tool call, so loads are cached on the items dir's stat
29
+ // signature (names+mtimes+sizes): re-parse only when something changed. No
30
+ // derived file — the item files stay the single source of truth.
21
31
 
22
- import { readFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
34
+ import { parseEnvelope } from './faculty/envelope.mjs';
23
35
 
24
36
  const SEVERITY = { deny: 3, ask: 2, allow: 1 };
25
37
  const ACTIONS = new Set(Object.keys(SEVERITY));
26
38
 
27
- /** Parse + validate a rules file. Fail-open: returns ok:false on any problem. */
28
- export function loadRules(path) {
39
+ // dir { sig, result } — tiny in-memory cache (per process; the spawned gate
40
+ // pays one cold load, long-lived processes like `zuzuu web` skip re-parses).
41
+ const cache = new Map();
42
+
43
+ /** Compile one parsed envelope item into a rule, or null if malformed. */
44
+ function compileRule(item) {
45
+ const p = item?.payload ?? {};
46
+ if (!item?.id || !ACTIONS.has(p.action) || typeof p.pattern !== 'string' || !p.pattern) return null;
47
+ try {
48
+ return { id: String(item.id), action: p.action, tool: p.tool || '*', re: new RegExp(p.pattern, 'i'), reason: String(p.reason ?? '') };
49
+ } catch {
50
+ return null; // uncompilable pattern → skip this rule only
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Load the rules from a guardrails faculty dir (…/guardrails) by composing the
56
+ * envelope items under its items/ subdir. FAIL-OPEN: a missing dir is zero
57
+ * rules; a malformed item is skipped + counted, never a crash.
58
+ * @param {string} guardrailsDir the faculty dir (e.g. <home>/guardrails)
59
+ * @returns {{ok: boolean, rules: Array, skipped: Array<{file: string, error: string}>}}
60
+ */
61
+ export function loadRules(guardrailsDir) {
29
62
  try {
30
- const data = JSON.parse(readFileSync(path, 'utf8'));
31
- if (!Array.isArray(data.rules)) return { ok: false, rules: [], error: 'rules is not an array' };
63
+ const dir = join(guardrailsDir, 'items');
64
+ let names;
65
+ try {
66
+ names = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
67
+ } catch {
68
+ return { ok: true, rules: [], skipped: [] }; // no items dir → no rules (normal flow)
69
+ }
70
+ let sig = null;
71
+ try {
72
+ sig = names.map((n) => { const s = statSync(join(dir, n)); return `${n}:${s.mtimeMs}:${s.size}`; }).join('|');
73
+ const hit = cache.get(dir);
74
+ if (hit && hit.sig === sig) return hit.result;
75
+ } catch { sig = null; /* stat race → just load uncached */ }
76
+
32
77
  const rules = [];
33
- for (const r of data.rules) {
34
- if (!r || typeof r !== 'object' || !ACTIONS.has(r.action) || typeof r.pattern !== 'string') {
35
- return { ok: false, rules: [], error: `malformed rule: ${JSON.stringify(r).slice(0, 80)}` };
36
- }
78
+ const skipped = [];
79
+ for (const f of names) {
37
80
  try {
38
- rules.push({ id: String(r.id ?? `rule-${rules.length}`), action: r.action, tool: r.tool || '*', re: new RegExp(r.pattern, 'i'), reason: String(r.reason ?? '') });
81
+ const { ok, item, errors } = parseEnvelope(readFileSync(join(dir, f), 'utf8'));
82
+ const rule = ok ? compileRule(item) : null;
83
+ if (rule) rules.push(rule);
84
+ else skipped.push({ file: f, error: ok ? 'malformed rule payload' : (errors[0] ?? 'parse error') });
39
85
  } catch (e) {
40
- return { ok: false, rules: [], error: `bad pattern in ${r.id}: ${e.message}` };
86
+ skipped.push({ file: f, error: e.message });
41
87
  }
42
88
  }
43
- return { ok: true, rules };
89
+ const result = { ok: true, rules, skipped };
90
+ if (sig != null) cache.set(dir, { sig, result });
91
+ return result;
44
92
  } catch (e) {
45
- return { ok: false, rules: [], error: e.message };
93
+ return { ok: false, rules: [], skipped: [], error: e.message }; // engine trouble → fail open
46
94
  }
47
95
  }
48
96
 
@@ -1,30 +1,26 @@
1
1
  // zuzuu/instructions/adapter.mjs
2
- // The Instructions faculty adapter (WS2-T4). Wraps steering-amendment proposals
2
+ // The Instructions faculty adapter. Wraps steering-amendment proposals
3
3
  // behind the faculty-spine adapter contract — { name, ingest, validate, apply,
4
4
  // render } — so `zuzuu review` can surface and approve them uniformly.
5
5
  //
6
6
  // An instructions proposal payload is a steering amendment:
7
- // { text } — a line or paragraph to append to project.md
7
+ // { id?, text } — a line or paragraph of steering
8
8
  //
9
- // apply: appends the text as a line to .zuzuu/instructions/project.md (creates
10
- // the file if absent; never duplicates an already-present line).
9
+ // apply: writes the amendment as a Faculty Standard envelope item under
10
+ // .zuzuu/instructions/items/<id>.md (kind: amendment; body = the text).
11
+ // The pinned steering itself lives at items/steering.md; future
12
+ // amendments are MORE items, never edits to steering. Idempotent: a
13
+ // text already present in any instructions item is not duplicated.
11
14
  //
12
15
  // Registers itself on import.
13
16
 
14
- import { join } from 'node:path';
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
17
  import * as registry from '../faculty/registry.mjs';
18
+ import { listFacultyItems, writeFacultyItem } from '../faculty/items.mjs';
19
+ import { deriveTitle } from '../faculty/envelope.mjs';
20
+ import { slugify } from '../knowledge/items.mjs';
17
21
 
18
22
  const name = 'instructions';
19
23
 
20
- // ---------------------------------------------------------------------------
21
- // helpers
22
- // ---------------------------------------------------------------------------
23
-
24
- function projectMdPath(agentDir) {
25
- return join(agentDir, 'instructions', 'project.md');
26
- }
27
-
28
24
  // ---------------------------------------------------------------------------
29
25
  // adapter contract
30
26
  // ---------------------------------------------------------------------------
@@ -50,29 +46,33 @@ function validate(_agentDir, payload) {
50
46
  }
51
47
 
52
48
  /**
53
- * Apply an approved amendment: append text to project.md (idempotent on
54
- * identical lines — won't duplicate a line already present).
49
+ * Apply an approved amendment: write an amendment item (idempotent on
50
+ * identical text — won't duplicate steering already present in any item).
55
51
  * @returns {{ok:boolean, action:string, itemIds:string[]}}
56
52
  */
57
53
  function apply(agentDir, proposal) {
58
- const text = proposal?.payload?.text ?? '';
59
-
60
- // Ensure the instructions dir exists
61
- mkdirSync(join(agentDir, 'instructions'), { recursive: true });
54
+ const text = String(proposal?.payload?.text ?? '').trim();
62
55
 
63
- const path = projectMdPath(agentDir);
64
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
65
-
66
- // Idempotence: skip if the exact text is already present
67
- if (existing.includes(text)) {
56
+ // Idempotence: skip if the exact text already lives in an instructions item
57
+ const { items } = listFacultyItems(agentDir, 'instructions');
58
+ if (items.some((i) => String(i.body ?? '').includes(text))) {
68
59
  return { ok: true, action: 'amended instructions (already present)', itemIds: [] };
69
60
  }
70
61
 
71
- // Append (with trailing newline)
72
- const separator = existing && !existing.endsWith('\n') ? '\n' : '';
73
- writeFileSync(path, existing + separator + text + '\n');
74
-
75
- return { ok: true, action: 'amended instructions', itemIds: [] };
62
+ const id = proposal?.payload?.id || slugify(text, 50);
63
+ writeFacultyItem(agentDir, {
64
+ id,
65
+ faculty: name,
66
+ kind: 'amendment',
67
+ title: deriveTitle(text, id),
68
+ status: 'active',
69
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
70
+ provenance: Array.isArray(proposal?.provenance) ? proposal.provenance : [],
71
+ payload: { scope: 'project' },
72
+ body: text,
73
+ });
74
+
75
+ return { ok: true, action: 'amended instructions', itemIds: [id] };
76
76
  }
77
77
 
78
78
  /**
@@ -1,31 +1,35 @@
1
- // Knowledge items — files as truth. One item per markdown file under
2
- // .zuzuu/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).
1
+ // Knowledge items — files as truth, in the Faculty Standard envelope (W24).
2
+ // One item per markdown file under .zuzuu/knowledge/items/<id>.md:
4
3
  //
5
4
  // ---
6
5
  // id: test-command
7
- // type: command
8
- // created_at: 2026-06-10T12:00:00Z
6
+ // faculty: knowledge
7
+ // kind: command
8
+ // title: The test command
9
9
  // status: active
10
- // attributes:
11
- // command: npm test
12
- // relations:
13
- // - type: relates-to
14
- // target: ci-pipeline
15
- // commentary: optional
10
+ // created_at: 2026-06-10T12:00:00Z
16
11
  // provenance:
17
12
  // - session: ses_abc
18
13
  // ref: occurrences=12
14
+ // payload:
15
+ // type: command
16
+ // attributes:
17
+ // command: npm test
18
+ // relations:
19
+ // - type: relates-to
20
+ // target: ci-pipeline
19
21
  // ---
20
22
  // Body prose.
21
23
  //
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.
24
+ // This module is a thin wrapper over faculty/envelope.mjs: the ON-DISK format
25
+ // is the envelope; the IN-MEMORY item shape stays the historical knowledge one
26
+ // ({id, type, created_at, status, attributes, relations, provenance, body}) so
27
+ // the registry/ER/index/digest pipeline is untouched. Ids are unchanged by the
28
+ // standard — only frontmatter keys moved (type/attributes/relations → payload).
26
29
 
27
30
  import { join } from 'node:path';
28
31
  import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
32
+ import { parseEnvelope, serializeEnvelope, deriveTitle } from '../faculty/envelope.mjs';
29
33
 
30
34
  export const itemsDir = (agentDir) => join(agentDir, 'knowledge', 'items');
31
35
 
@@ -38,88 +42,49 @@ export function slugify(text, max = 60) {
38
42
  .replace(/-+$/, '') || 'item';
39
43
  }
40
44
 
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. */
45
+ /**
46
+ * Parse an item file's text → the in-memory knowledge item. Throws on grammar
47
+ * violations (callers catch allItems collects, inbox falls back to prose).
48
+ */
52
49
  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');
50
+ const { ok, item: env, errors } = parseEnvelope(text);
51
+ if (!ok) throw new Error(errors[0] ?? 'invalid envelope');
52
+ if (env.faculty !== 'knowledge') throw new Error(`not a knowledge item (faculty: ${env.faculty})`);
53
+ const p = env.payload ?? {};
54
+ const item = {
55
+ attributes: p.attributes ?? {},
56
+ relations: p.relations ?? [],
57
+ provenance: env.provenance ?? [],
58
+ body: env.body,
59
+ };
60
+ item.id = env.id;
61
+ item.type = p.type ?? env.kind;
62
+ if (env.created_at != null) item.created_at = env.created_at;
63
+ if (env.updated_at != null) item.updated_at = env.updated_at;
64
+ if (env.status != null) item.status = env.status;
97
65
  if (!item.type) throw new Error('item missing type');
98
66
  return item;
99
67
  }
100
68
 
101
- /** Serialize an item object → file text (the exact grammar parseItem reads). */
69
+ /** Serialize an in-memory knowledge item → envelope file text. */
102
70
  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' : '');
71
+ if (!item.id) throw new Error('item missing id');
72
+ if (!item.type) throw new Error('item missing type');
73
+ const payload = { type: item.type };
74
+ if (Object.keys(item.attributes ?? {}).length) payload.attributes = item.attributes;
75
+ if ((item.relations ?? []).length) payload.relations = item.relations;
76
+ return serializeEnvelope({
77
+ id: item.id,
78
+ faculty: 'knowledge',
79
+ kind: item.type,
80
+ title: item.title ?? deriveTitle(item.body, item.id),
81
+ status: item.status,
82
+ created_at: item.created_at,
83
+ updated_at: item.updated_at,
84
+ provenance: item.provenance ?? [],
85
+ payload,
86
+ body: item.body,
87
+ });
123
88
  }
124
89
 
125
90
  /** Write an item to its canonical file. Returns the path. */
@@ -13,7 +13,7 @@ const DENY_RULES = ['Read(./.zuzuu/.traces/**)', 'Read(./.zuzuu/.live/**)'];
13
13
 
14
14
  // Minimal hook set: lifecycle (Design B re-captures the transcript — no
15
15
  // PostToolUse needed) + the PreToolUse Guardrails GATE (the one place we *do*
16
- // sit on the hot path: it evaluates .zuzuu/guardrails/rules.json per tool call,
16
+ // sit on the hot path: it evaluates the .zuzuu/guardrails/items/ rules per tool call,
17
17
  // fails open, and stays silent unless a rule matches).
18
18
  export const LIFECYCLE_EVENTS = ['SessionStart', 'Stop', 'SessionEnd'];
19
19
  export const GATE_EVENTS = ['PreToolUse'];
@@ -1,58 +1,26 @@
1
1
  // zuzuu/memory/adapter.mjs
2
- // The Memory faculty adapter (WS2-T4). Wraps episode proposals behind the
2
+ // The Memory faculty adapter. Wraps episode proposals behind the
3
3
  // faculty-spine adapter contract — { name, ingest, validate, apply, render } —
4
4
  // so `zuzuu review` can surface and approve memory entries uniformly.
5
5
  //
6
- // A memory proposal payload is an episode record matching the WS1 Memory schema:
7
- // { id, date, title, provenance, body }
6
+ // A memory proposal payload is an episode record:
7
+ // { id, date, title, provenance: {sessions, hosts}, tags, body }
8
8
  // id format: mem-<YYYY-MM-DD>-<slug>
9
9
  //
10
- // apply: writes .zuzuu/memory/entries/<id>.md with YAML frontmatter (status: curated)
11
- // and the body sections (Attempted / Resulted / Remember next time).
10
+ // apply: writes .zuzuu/memory/entries/<id>.md as a Faculty Standard envelope
11
+ // (kind: episode; payload = {sessions, hosts, tags}; body = the
12
+ // Attempted / Resulted / Remember-next-time sections).
12
13
  //
13
14
  // Registers itself on import.
14
15
 
15
- import { join } from 'node:path';
16
- import { writeFileSync, mkdirSync } from 'node:fs';
17
16
  import * as registry from '../faculty/registry.mjs';
17
+ import { writeFacultyItem } from '../faculty/items.mjs';
18
18
 
19
19
  const name = 'memory';
20
20
 
21
21
  // mem-<YYYY-MM-DD>-<slug>: the id must START with "mem-"
22
22
  const MEM_ID_RE = /^mem-/;
23
23
 
24
- // ---------------------------------------------------------------------------
25
- // helpers
26
- // ---------------------------------------------------------------------------
27
-
28
- function entriesDir(agentDir) {
29
- return join(agentDir, 'memory', 'entries');
30
- }
31
-
32
- function entryPath(agentDir, id) {
33
- return join(entriesDir(agentDir), `${id}.md`);
34
- }
35
-
36
- /** Render YAML frontmatter block from the payload fields. */
37
- function renderFrontmatter(payload) {
38
- const lines = ['---'];
39
- lines.push(`id: ${payload.id}`);
40
- if (payload.date) lines.push(`date: ${payload.date}`);
41
- if (payload.title) lines.push(`title: ${payload.title}`);
42
- if (payload.provenance) {
43
- lines.push('provenance:');
44
- const p = payload.provenance;
45
- if (Array.isArray(p.sessions)) lines.push(` sessions: [${p.sessions.join(', ')}]`);
46
- if (Array.isArray(p.hosts)) lines.push(` hosts: [${p.hosts.join(', ')}]`);
47
- }
48
- if (Array.isArray(payload.tags) && payload.tags.length) {
49
- lines.push(`tags: [${payload.tags.join(', ')}]`);
50
- }
51
- lines.push('status: curated');
52
- lines.push('---');
53
- return lines.join('\n');
54
- }
55
-
56
24
  // ---------------------------------------------------------------------------
57
25
  // adapter contract
58
26
  // ---------------------------------------------------------------------------
@@ -83,22 +51,29 @@ function validate(_agentDir, payload) {
83
51
  }
84
52
 
85
53
  /**
86
- * Apply an approved episode proposal: write the entry Markdown file.
54
+ * Apply an approved episode proposal: write the envelope entry file.
87
55
  * @returns {{ok:boolean, action:string, itemIds:string[]}}
88
56
  */
89
57
  function apply(agentDir, proposal) {
90
- const payload = proposal?.payload ?? {};
91
- const id = payload.id;
92
-
93
- mkdirSync(entriesDir(agentDir), { recursive: true });
94
-
95
- const frontmatter = renderFrontmatter(payload);
96
- const body = payload.body ?? '';
97
- const content = frontmatter + '\n' + body + (body.endsWith('\n') ? '' : '\n');
98
-
99
- writeFileSync(entryPath(agentDir, id), content);
100
-
101
- return { ok: true, action: `wrote memory ${id}`, itemIds: [id] };
58
+ const p = proposal?.payload ?? {};
59
+ const envPayload = {};
60
+ if (Array.isArray(p.provenance?.sessions) && p.provenance.sessions.length) envPayload.sessions = p.provenance.sessions.map(String);
61
+ if (Array.isArray(p.provenance?.hosts) && p.provenance.hosts.length) envPayload.hosts = p.provenance.hosts.map(String);
62
+ if (Array.isArray(p.tags) && p.tags.length) envPayload.tags = p.tags.map(String);
63
+
64
+ writeFacultyItem(agentDir, {
65
+ id: p.id,
66
+ faculty: name,
67
+ kind: 'episode',
68
+ title: p.title,
69
+ status: 'active',
70
+ created_at: p.date || new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
71
+ provenance: Array.isArray(proposal?.provenance) ? proposal.provenance : [],
72
+ payload: envPayload,
73
+ body: p.body ?? '',
74
+ });
75
+
76
+ return { ok: true, action: `wrote memory ${p.id}`, itemIds: [p.id] };
102
77
  }
103
78
 
104
79
  /**
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
10
  import { slugify } from '../knowledge/items.mjs';
11
11
  import { isSafeSlug, actionsDir, inboxDir } from '../actions/manifest.mjs';
12
+ import { serializeEnvelope } from '../faculty/envelope.mjs';
12
13
  import { register } from './registry.mjs';
13
14
 
14
15
  // Must match the constant in knowledge/distill.mjs (adjacent Bash separator).
@@ -86,27 +87,20 @@ export function propose(agentDir, aggregated) {
86
87
 
87
88
  mkdirSync(inboxSlug, { recursive: true });
88
89
 
89
- // action.jsonminimal manifest (no run.mjs; this is a runbook action).
90
- const manifest = {
91
- slug,
92
- title,
93
- description: `Recurring command sequence detected from session traces: ${steps.join(' → ')}.`,
94
- promptSnippet,
95
- };
96
- writeFileSync(join(inboxSlug, 'action.json'), JSON.stringify(manifest, null, 2) + '\n');
97
-
98
- // SKILL.md — numbered runbook steps.
90
+ // ACTION.mda runbook envelope (no run.mjs; the body IS the procedure).
91
+ // The body's first line is the digest one-liner (promptSnippet).
99
92
  const stepsBlock = steps.map((cmd, i) => `${i + 1}. \`${cmd}\``).join('\n');
100
- const skillMd = `---
101
- name: ${title}
102
- description: ${manifest.description}
103
- ---
104
-
105
- ## Steps
106
-
107
- ${stepsBlock}
108
- `;
109
- writeFileSync(join(inboxSlug, 'SKILL.md'), skillMd);
93
+ writeFileSync(join(inboxSlug, 'ACTION.md'), serializeEnvelope({
94
+ id: slug,
95
+ faculty: 'actions',
96
+ kind: 'runbook',
97
+ title,
98
+ status: 'active',
99
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
100
+ provenance: [],
101
+ payload: {},
102
+ body: `${promptSnippet}\n\nRecurring command sequence detected from session traces.\n\n## Steps\n\n${stepsBlock}`,
103
+ }));
110
104
 
111
105
  count++;
112
106
  }
@@ -16,9 +16,9 @@
16
16
  // Self-registers on import.
17
17
 
18
18
  import { join } from 'node:path';
19
- import { existsSync, readFileSync } from 'node:fs';
20
19
  import { slugify } from '../knowledge/items.mjs';
21
20
  import { makeProposal, writeProposal, listProposals, isArchivedResolved } from '../faculty/proposal.mjs';
21
+ import { loadRules as loadRuleItems } from '../guardrails.mjs';
22
22
  import { register } from './registry.mjs';
23
23
 
24
24
  // ---------------------------------------------------------------------------
@@ -48,14 +48,12 @@ function guardId(cmd) {
48
48
  return 'guard-' + slugify(cmd, 50);
49
49
  }
50
50
 
51
- /** Load rules.json; returns { version, rules:[] } if absent/unreadable. */
52
- function loadRules(agentDir) {
53
- const path = join(agentDir, 'guardrails', 'rules.json');
54
- if (!existsSync(path)) return { version: 1, rules: [] };
51
+ /** Ids of live rule items (guardrails/items/*.md); absent/unreadable → none. */
52
+ function liveRuleIds(agentDir) {
55
53
  try {
56
- return JSON.parse(readFileSync(path, 'utf8'));
54
+ return new Set(loadRuleItems(join(agentDir, 'guardrails')).rules.map((r) => r.id));
57
55
  } catch {
58
- return { version: 1, rules: [] };
56
+ return new Set();
59
57
  }
60
58
  }
61
59
 
@@ -126,7 +124,7 @@ export function aggregate(sessions, { minFailures = 3, minSessions = 2 } = {}) {
126
124
  * Write a guardrails proposal into .zuzuu/guardrails/proposals/ for each candidate.
127
125
  * Idempotent:
128
126
  * - skips if a guardrails proposal with the same payload.id already exists
129
- * - skips if rules.json already has a rule with that id
127
+ * - skips if a live rule item (guardrails/items/<id>.md) already exists
130
128
  * - skips if the id is already resolved in proposals/archive/ — a rejection
131
129
  * is remembered; re-distilling never resurrects it
132
130
  *
@@ -141,9 +139,8 @@ export function propose(agentDir, aggregated) {
141
139
  const existing = listProposals(agentDir, 'guardrails');
142
140
  const existingIds = new Set(existing.map((p) => p.payload?.id).filter(Boolean));
143
141
 
144
- // Load existing rules (ids already applied).
145
- const rulesData = loadRules(agentDir);
146
- const rulesIds = new Set((rulesData.rules ?? []).map((r) => r.id).filter(Boolean));
142
+ // Live rule items (ids already applied).
143
+ const rulesIds = liveRuleIds(agentDir);
147
144
 
148
145
  let count = 0;
149
146
  for (const c of aggregated) {