@zuzuucodes/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/bin/zuzuu.mjs +133 -0
  4. package/experiments/experiment-1-trace-capture/adapters/claude-code.mjs +220 -0
  5. package/experiments/experiment-1-trace-capture/adapters/codex.mjs +201 -0
  6. package/experiments/experiment-1-trace-capture/adapters/gemini-cli.mjs +113 -0
  7. package/experiments/experiment-1-trace-capture/adapters/host-adapter.mjs +43 -0
  8. package/experiments/experiment-1-trace-capture/adapters/opencode.mjs +205 -0
  9. package/experiments/experiment-1-trace-capture/adapters/pi.mjs +218 -0
  10. package/experiments/experiment-1-trace-capture/adapters/registry.mjs +20 -0
  11. package/experiments/experiment-1-trace-capture/adapters/signals.mjs +44 -0
  12. package/experiments/experiment-1-trace-capture/core/event.mjs +58 -0
  13. package/experiments/experiment-1-trace-capture/core/ids.mjs +32 -0
  14. package/experiments/experiment-1-trace-capture/core/otlp.mjs +54 -0
  15. package/experiments/experiment-1-trace-capture/core/render.mjs +63 -0
  16. package/experiments/experiment-1-trace-capture/core/spans.mjs +43 -0
  17. package/package.json +56 -0
  18. package/zuzuu/actions/adapter.mjs +130 -0
  19. package/zuzuu/actions/convert.mjs +27 -0
  20. package/zuzuu/actions/dispatch.mjs +87 -0
  21. package/zuzuu/actions/inbox.mjs +56 -0
  22. package/zuzuu/actions/manifest.mjs +72 -0
  23. package/zuzuu/actions/marker.mjs +4 -0
  24. package/zuzuu/actions/runner.mjs +37 -0
  25. package/zuzuu/actions/schema.mjs +73 -0
  26. package/zuzuu/actions/trail.mjs +22 -0
  27. package/zuzuu/capture-core.mjs +49 -0
  28. package/zuzuu/commands/act-author.mjs +72 -0
  29. package/zuzuu/commands/act.mjs +101 -0
  30. package/zuzuu/commands/capture.mjs +32 -0
  31. package/zuzuu/commands/code.mjs +84 -0
  32. package/zuzuu/commands/digest.mjs +23 -0
  33. package/zuzuu/commands/distill.mjs +46 -0
  34. package/zuzuu/commands/doctor.mjs +197 -0
  35. package/zuzuu/commands/enable.mjs +195 -0
  36. package/zuzuu/commands/eval.mjs +101 -0
  37. package/zuzuu/commands/explain.mjs +119 -0
  38. package/zuzuu/commands/generation.mjs +107 -0
  39. package/zuzuu/commands/hook.mjs +209 -0
  40. package/zuzuu/commands/inbox.mjs +73 -0
  41. package/zuzuu/commands/init.mjs +89 -0
  42. package/zuzuu/commands/knowledge.mjs +152 -0
  43. package/zuzuu/commands/migrate.mjs +125 -0
  44. package/zuzuu/commands/review.mjs +299 -0
  45. package/zuzuu/commands/status.mjs +82 -0
  46. package/zuzuu/commands/trace.mjs +19 -0
  47. package/zuzuu/digest.mjs +149 -0
  48. package/zuzuu/eval/rank.mjs +31 -0
  49. package/zuzuu/eval/score.mjs +85 -0
  50. package/zuzuu/eval/signals.mjs +57 -0
  51. package/zuzuu/faculty/contract.mjs +19 -0
  52. package/zuzuu/faculty/gate.mjs +65 -0
  53. package/zuzuu/faculty/generation.mjs +392 -0
  54. package/zuzuu/faculty/proposal.mjs +166 -0
  55. package/zuzuu/faculty/provenance.mjs +35 -0
  56. package/zuzuu/faculty/registry.mjs +33 -0
  57. package/zuzuu/faculty/trail.mjs +27 -0
  58. package/zuzuu/guardrails/adapter.mjs +134 -0
  59. package/zuzuu/guardrails.mjs +89 -0
  60. package/zuzuu/inject.mjs +46 -0
  61. package/zuzuu/instructions/adapter.mjs +93 -0
  62. package/zuzuu/knowledge/adapter.mjs +99 -0
  63. package/zuzuu/knowledge/distill.mjs +237 -0
  64. package/zuzuu/knowledge/embed.mjs +52 -0
  65. package/zuzuu/knowledge/er.mjs +98 -0
  66. package/zuzuu/knowledge/inbox.mjs +43 -0
  67. package/zuzuu/knowledge/index.mjs +194 -0
  68. package/zuzuu/knowledge/items.mjs +154 -0
  69. package/zuzuu/knowledge/proposals.mjs +196 -0
  70. package/zuzuu/knowledge/registry.mjs +115 -0
  71. package/zuzuu/live/install.mjs +76 -0
  72. package/zuzuu/live/live-store.mjs +78 -0
  73. package/zuzuu/live/probe.mjs +55 -0
  74. package/zuzuu/live/reconcile.mjs +33 -0
  75. package/zuzuu/memory/adapter.mjs +121 -0
  76. package/zuzuu/miners/actions.mjs +118 -0
  77. package/zuzuu/miners/guardrails.mjs +174 -0
  78. package/zuzuu/miners/instructions.mjs +152 -0
  79. package/zuzuu/miners/knowledge.mjs +22 -0
  80. package/zuzuu/miners/memory.mjs +27 -0
  81. package/zuzuu/miners/registry.mjs +31 -0
  82. package/zuzuu/scaffold.mjs +213 -0
  83. package/zuzuu/session.mjs +72 -0
  84. package/zuzuu/store.mjs +104 -0
@@ -0,0 +1,166 @@
1
+ // zuzuu/faculty/proposal.mjs
2
+ // Unified Proposal record for the faculty spine (WS2-T1).
3
+ // Mirrors zuzuu/knowledge/proposals.mjs's id scheme (<slug>-<shortHash(slug+source)>)
4
+ // and extends it to be faculty-agnostic.
5
+ //
6
+ // Dual-read: transparently normalises legacy {candidate, er} → {payload, analysis}
7
+ // so old knowledge proposals are readable without a migration step.
8
+
9
+ import { join } from 'node:path';
10
+ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
11
+ import { createHash } from 'node:crypto';
12
+ import { proposalsDir, archiveDir } from './contract.mjs';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // internal helpers
16
+ // ---------------------------------------------------------------------------
17
+ const shortHash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 6);
18
+
19
+ /** Normalise a raw JSON object from disk (handles legacy candidate/er keys). */
20
+ function normalise(raw, faculty) {
21
+ if (!raw) return null;
22
+ const rec = { ...raw };
23
+ // faculty: always set (default to the arg if absent)
24
+ if (!rec.faculty) rec.faculty = faculty;
25
+ // dual-read: map legacy `candidate` → `payload`
26
+ if (!rec.payload && rec.candidate !== undefined) {
27
+ rec.payload = rec.candidate;
28
+ }
29
+ // dual-read: map legacy `er` → `analysis.er`
30
+ if (!rec.analysis && rec.er !== undefined) {
31
+ rec.analysis = { er: rec.er };
32
+ }
33
+ // ensure defaults
34
+ if (!rec.analysis) rec.analysis = {};
35
+ if (!rec.evidence) rec.evidence = {};
36
+ if (!rec.provenance) rec.provenance = [];
37
+ return rec;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // public API
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Deterministic proposal id: `<slug>-<shortHash(slug + source)>`.
46
+ * Replicates the scheme in zuzuu/knowledge/proposals.mjs exactly.
47
+ */
48
+ export function proposalId(slug, source) {
49
+ return `${slug}-${shortHash(slug + source)}`;
50
+ }
51
+
52
+ /**
53
+ * Build a proposal record object (does not write to disk).
54
+ * Defaults: analysis={}, evidence={}, provenance=[].
55
+ */
56
+ export function makeProposal({ faculty, kind, source, payload, analysis = {}, evidence = {}, provenance = [] }) {
57
+ const slug = (payload && payload.id) ? payload.id : kind;
58
+ const id = proposalId(slug, source);
59
+ return {
60
+ id,
61
+ faculty,
62
+ kind,
63
+ status: 'pending',
64
+ created_at: new Date().toISOString(),
65
+ source,
66
+ payload: payload ?? {},
67
+ analysis,
68
+ evidence,
69
+ provenance,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Write a proposal record to `agent/<faculty>/proposals/<id>.json`.
75
+ * Creates directories as needed. Returns the written path.
76
+ */
77
+ export function writeProposal(agentDir, proposal) {
78
+ const dir = proposalsDir(agentDir, proposal.faculty);
79
+ mkdirSync(dir, { recursive: true });
80
+ const path = join(dir, `${proposal.id}.json`);
81
+ writeFileSync(path, JSON.stringify(proposal, null, 2) + '\n');
82
+ return path;
83
+ }
84
+
85
+ /**
86
+ * Read and normalise a single proposal by faculty + id.
87
+ * Returns null if the file does not exist (never throws).
88
+ */
89
+ export function readProposal(agentDir, faculty, id) {
90
+ const path = join(proposalsDir(agentDir, faculty), `${id}.json`);
91
+ if (!existsSync(path)) return null;
92
+ try {
93
+ return normalise(JSON.parse(readFileSync(path, 'utf8')), faculty);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * List all pending proposals for a faculty (files in proposals/ not in archive/).
101
+ * Normalises each record. Skips unreadable files (fail-soft).
102
+ */
103
+ export function listProposals(agentDir, faculty) {
104
+ const dir = proposalsDir(agentDir, faculty);
105
+ if (!existsSync(dir)) return [];
106
+ return readdirSync(dir)
107
+ .filter((f) => f.endsWith('.json'))
108
+ .map((f) => {
109
+ try {
110
+ return normalise(JSON.parse(readFileSync(join(dir, f), 'utf8')), faculty);
111
+ } catch {
112
+ return null;
113
+ }
114
+ })
115
+ .filter(Boolean)
116
+ .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
117
+ }
118
+
119
+ /**
120
+ * Move a proposal from pending into archive/ with a resolved status.
121
+ * @param {string} status - 'approved' | 'rejected'
122
+ * @param {string} [reason] - human-readable reason
123
+ * @param {string} [applied] - what was done on approval
124
+ * @returns the resolved record
125
+ */
126
+ export function archiveProposal(agentDir, faculty, id, { status, reason = '', applied = '' } = {}) {
127
+ const pending = join(proposalsDir(agentDir, faculty), `${id}.json`);
128
+ const archDir = archiveDir(agentDir, faculty);
129
+ mkdirSync(archDir, { recursive: true });
130
+
131
+ // read & normalise the pending record (or whatever we can find)
132
+ let proposal;
133
+ if (existsSync(pending)) {
134
+ try {
135
+ proposal = normalise(JSON.parse(readFileSync(pending, 'utf8')), faculty);
136
+ } catch {
137
+ proposal = { id, faculty };
138
+ }
139
+ } else {
140
+ proposal = { id, faculty };
141
+ }
142
+
143
+ const resolved = {
144
+ ...proposal,
145
+ status,
146
+ resolved_at: new Date().toISOString(),
147
+ reason,
148
+ applied,
149
+ };
150
+
151
+ const archPath = join(archDir, `${id}.json`);
152
+ writeFileSync(archPath, JSON.stringify(resolved, null, 2) + '\n');
153
+
154
+ // remove the pending file (rename is atomic; fall back to just leaving archive)
155
+ if (existsSync(pending)) {
156
+ try {
157
+ renameSync(pending, archPath);
158
+ // re-write with resolved fields (rename replaces content, so write again)
159
+ writeFileSync(archPath, JSON.stringify(resolved, null, 2) + '\n');
160
+ } catch {
161
+ // archive already written above; ignore rename failure
162
+ }
163
+ }
164
+
165
+ return resolved;
166
+ }
@@ -0,0 +1,35 @@
1
+ // zuzuu/faculty/provenance.mjs
2
+ // Lightweight provenance helpers for the faculty spine.
3
+ // A provenance entry is { session, trace, ref } (all optional fields).
4
+
5
+ /**
6
+ * Build a provenance entry, dropping any undefined keys.
7
+ * @param {{ session?: string, trace?: string, ref?: string }} opts
8
+ * @returns {{ session?: string, trace?: string, ref?: string }}
9
+ */
10
+ export function prov({ session, trace, ref } = {}) {
11
+ const entry = {};
12
+ if (session !== undefined) entry.session = session;
13
+ if (trace !== undefined) entry.trace = trace;
14
+ if (ref !== undefined) entry.ref = ref;
15
+ return entry;
16
+ }
17
+
18
+ /**
19
+ * Merge two provenance arrays, deduplicating by `${session}|${ref}`.
20
+ * @param {Array} [a=[]]
21
+ * @param {Array} [b=[]]
22
+ * @returns {Array}
23
+ */
24
+ export function mergeProvenance(a = [], b = []) {
25
+ const seen = new Set();
26
+ const result = [];
27
+ for (const entry of [...a, ...b]) {
28
+ const key = `${entry.session ?? ''}|${entry.ref ?? ''}`;
29
+ if (!seen.has(key)) {
30
+ seen.add(key);
31
+ result.push(entry);
32
+ }
33
+ }
34
+ return result;
35
+ }
@@ -0,0 +1,33 @@
1
+ // zuzuu/faculty/registry.mjs
2
+ // Faculty adapter registry — a module-level Map keyed by adapter.name.
3
+ // Adapters register themselves on import; consumers query by name or list all.
4
+ //
5
+ // This is the registration surface only. Adapters are added in later work units.
6
+
7
+ /** @type {Map<string, object>} */
8
+ const _registry = new Map();
9
+
10
+ /**
11
+ * Register an adapter (keyed by adapter.name). Overwrites if already registered.
12
+ * @param {{ name: string, [key: string]: any }} adapter
13
+ */
14
+ export function register(adapter) {
15
+ _registry.set(adapter.name, adapter);
16
+ }
17
+
18
+ /**
19
+ * Retrieve a registered adapter by name. Returns undefined if not found.
20
+ * @param {string} name
21
+ * @returns {{ name: string, [key: string]: any } | undefined}
22
+ */
23
+ export function get(name) {
24
+ return _registry.get(name);
25
+ }
26
+
27
+ /**
28
+ * Return all registered adapters as an array.
29
+ * @returns {Array<{ name: string, [key: string]: any }>}
30
+ */
31
+ export function all() {
32
+ return [..._registry.values()];
33
+ }
@@ -0,0 +1,27 @@
1
+ // zuzuu/faculty/trail.mjs
2
+ // Generalised faculty observability trail (WS2-T1).
3
+ // Extends the pattern from zuzuu/actions/trail.mjs to any faculty:
4
+ // each faculty gets its own agent/.live/<faculty>.jsonl file.
5
+ //
6
+ // Fail-soft: a logging failure must never affect the caller.
7
+
8
+ import { join } from 'node:path';
9
+ import { mkdirSync, appendFileSync } from 'node:fs';
10
+ import { liveDir } from '../store.mjs';
11
+
12
+ /**
13
+ * Append a trail entry for a faculty. Never throws.
14
+ * @param {string} agentDir - path to the faculty home (agent/)
15
+ * @param {string} faculty - e.g. 'knowledge', 'actions', 'guardrails'
16
+ * @param {object} entry - arbitrary fields; `at` is stamped automatically
17
+ */
18
+ export function recordTrail(agentDir, faculty, entry = {}) {
19
+ try {
20
+ const dir = liveDir(agentDir);
21
+ mkdirSync(dir, { recursive: true });
22
+ const rec = { at: new Date().toISOString(), ...entry };
23
+ appendFileSync(join(dir, `${faculty}.jsonl`), JSON.stringify(rec) + '\n');
24
+ } catch {
25
+ /* logging must never affect the caller */
26
+ }
27
+ }
@@ -0,0 +1,134 @@
1
+ // zuzuu/guardrails/adapter.mjs
2
+ // The Guardrails faculty adapter (WS2-T4). Wraps the rules engine behind the
3
+ // faculty-spine adapter contract — { name, ingest, validate, apply, render } —
4
+ // so `zuzuu review` can surface and approve/reject rule proposals the same way it
5
+ // does Knowledge proposals.
6
+ //
7
+ // A guardrails proposal payload is a single rule record:
8
+ // { id, action: deny|ask|allow, tool, pattern, reason }
9
+ //
10
+ // apply: loads agent/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.
13
+ //
14
+ // Registers itself on import.
15
+
16
+ import { join } from 'node:path';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import * as registry from '../faculty/registry.mjs';
19
+
20
+ const name = 'guardrails';
21
+ const VALID_ACTIONS = new Set(['deny', 'ask', 'allow']);
22
+
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
+ // ---------------------------------------------------------------------------
42
+ // adapter contract
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Ingest a raw rule object. Pass-through: rule fields are the payload.
47
+ * @param {string} agentDir
48
+ * @param {object} raw — expected shape: { id, action, tool, pattern, reason }
49
+ * or { payload: { ... } } from the spine
50
+ */
51
+ function ingest(_agentDir, raw) {
52
+ const payload = raw?.payload ?? raw ?? {};
53
+ return { payload, analysis: {}, dedupeKey: payload.id };
54
+ }
55
+
56
+ /**
57
+ * Validate a rule payload.
58
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
59
+ */
60
+ function validate(_agentDir, payload) {
61
+ const errors = [];
62
+ if (!payload?.id || typeof payload.id !== 'string' || !payload.id.trim()) {
63
+ errors.push('rule id is required (non-empty string slug)');
64
+ }
65
+ if (!VALID_ACTIONS.has(payload?.action)) {
66
+ errors.push(`action must be one of deny|ask|allow (got '${payload?.action}')`);
67
+ }
68
+ if (!payload?.tool || typeof payload.tool !== 'string') {
69
+ errors.push('tool is required (exact tool name or \'*\')');
70
+ }
71
+ if (typeof payload?.pattern !== 'string' || !payload.pattern) {
72
+ errors.push('pattern is required (a non-empty regex string)');
73
+ } else {
74
+ try {
75
+ new RegExp(payload.pattern); // eslint-disable-line no-new
76
+ } catch (e) {
77
+ errors.push(`pattern does not compile as a RegExp: ${e.message}`);
78
+ }
79
+ }
80
+ if (!payload?.reason || !String(payload.reason).trim()) {
81
+ errors.push('reason is required (non-empty)');
82
+ }
83
+ return { ok: errors.length === 0, errors, warnings: [] };
84
+ }
85
+
86
+ /**
87
+ * Apply an approved rule proposal: upsert into rules.json.
88
+ * @returns {{ok:boolean, action:string, itemIds:string[]}}
89
+ */
90
+ function apply(agentDir, proposal) {
91
+ const rule = proposal?.payload ?? {};
92
+ 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');
116
+ return { ok: true, action: `added rule ${id}`, itemIds: [id] };
117
+ }
118
+
119
+ /**
120
+ * Render a rule proposal for the human gate.
121
+ * @returns {{line:string, card:string}}
122
+ */
123
+ function render(proposal) {
124
+ const r = proposal?.payload ?? {};
125
+ const summary = `${r.action ?? '?'} ${r.tool ?? '*'} /${r.pattern ?? ''}/ — ${r.reason ?? ''}`;
126
+ return {
127
+ line: `${r.id ?? ''} [rule] ${summary}`,
128
+ card: summary,
129
+ };
130
+ }
131
+
132
+ export const adapter = { name, ingest, validate, apply, render };
133
+
134
+ registry.register(adapter);
@@ -0,0 +1,89 @@
1
+ // The Guardrails faculty — v1 rule engine (pure; I/O lives in the hook command).
2
+ //
3
+ // Rules are DATA, not code: agent/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).
6
+ //
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" } ] }
12
+ //
13
+ // Evaluation: collect every matching rule, then severity wins — deny > ask >
14
+ // allow (an explicit allow can whitelist past a later ask/deny only if it is
15
+ // NOT outweighed; severity beats file order so a sloppy rule ordering can never
16
+ // silently disarm a deny).
17
+ //
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.
21
+
22
+ import { readFileSync } from 'node:fs';
23
+
24
+ const SEVERITY = { deny: 3, ask: 2, allow: 1 };
25
+ const ACTIONS = new Set(Object.keys(SEVERITY));
26
+
27
+ /** Parse + validate a rules file. Fail-open: returns ok:false on any problem. */
28
+ export function loadRules(path) {
29
+ 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' };
32
+ 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
+ }
37
+ 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 ?? '') });
39
+ } catch (e) {
40
+ return { ok: false, rules: [], error: `bad pattern in ${r.id}: ${e.message}` };
41
+ }
42
+ }
43
+ return { ok: true, rules };
44
+ } catch (e) {
45
+ return { ok: false, rules: [], error: e.message };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Evaluate a tool call against loaded rules.
51
+ * @param {Array} rules from loadRules().rules
52
+ * @param {{tool:string, input:any}} call
53
+ * @returns {null | {action:'deny'|'ask'|'allow', rule:string, reason:string}}
54
+ * null = no rule matched → defer to the host's normal permission flow
55
+ */
56
+ export function evaluate(rules, { tool, input }) {
57
+ const haystack = typeof input === 'string' ? input : JSON.stringify(input ?? {});
58
+ let winner = null;
59
+ for (const r of rules) {
60
+ if (r.tool !== '*' && r.tool !== tool) continue;
61
+ if (!r.re.test(haystack)) continue;
62
+ if (!winner || SEVERITY[r.action] > SEVERITY[winner.action]) {
63
+ winner = { action: r.action, rule: r.id, reason: r.reason || `matched guardrail ${r.id}` };
64
+ }
65
+ }
66
+ return winner;
67
+ }
68
+
69
+ /**
70
+ * Gemini CLI block shape: stdout JSON { decision: "deny", reason } (exit 0).
71
+ * Gemini has no "ask" decision → defer (null) so its own approval flow runs.
72
+ * Only an explicit deny blocks.
73
+ */
74
+ export function toGeminiDecision(verdict) {
75
+ if (!verdict || verdict.action !== 'deny') return null;
76
+ return { decision: 'deny', reason: `guardrail ${verdict.rule}: ${verdict.reason}` };
77
+ }
78
+
79
+ /** Map a verdict to Claude Code's PreToolUse hookSpecificOutput (verified schema). */
80
+ export function toPreToolUseDecision(verdict) {
81
+ if (!verdict || verdict.action === 'allow') return null; // no output → normal flow (fail-open / explicit allow)
82
+ return {
83
+ hookSpecificOutput: {
84
+ hookEventName: 'PreToolUse',
85
+ permissionDecision: verdict.action, // 'deny' | 'ask'
86
+ permissionDecisionReason: `guardrail ${verdict.rule}: ${verdict.reason}`,
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,46 @@
1
+ // Delimiter-block injection for host instruction files (CLAUDE.md / AGENTS.md /
2
+ // GEMINI.md) — the supermemory coexistence pattern: our content lives between
3
+ // versioned markers; injecting again replaces OUR block only and never touches
4
+ // the user's surrounding text. Pure string functions; I/O stays in the command.
5
+
6
+ const BEGIN = (v) => `<!-- >>> zuzuu:faculties:v${v} >>> -->`;
7
+ const END = '<!-- <<< zuzuu:faculties <<< -->';
8
+ // Matches our block at ANY version, so an older block is replaced in place by
9
+ // the current `zuzuu:faculties` block — never duplicated.
10
+ const BLOCK_RE = /[ \t]*<!-- >>> zuzuu:faculties:v\d+ >>> -->[\s\S]*?<!-- <<< zuzuu:faculties <<< -->[ \t]*\n?/;
11
+
12
+ export const BLOCK_VERSION = 8;
13
+
14
+ /** The block content served to host agents. Keep short — it's steering, not docs. */
15
+ export function facultiesBlock(version = BLOCK_VERSION) {
16
+ return `${BEGIN(version)}
17
+ ## zuzuu — agent faculty home
18
+
19
+ This project has a zuzuu faculty home at \`agent/\` (managed by the zuzuu CLI). Work to this contract:
20
+
21
+ - **Ground.** At session start, read \`agent/.live/digest.md\` if it exists — your *zuzuu digest* (instructions, knowledge, actions, proposals, guardrails), regenerated each session. Trust it as ground truth; don't re-derive what it states or re-read faculty files it already summarized. (On Claude Code the same brief also arrives inline at session start.)
22
+ - **Cite in-flight.** When an answer draws on a stored fact, say \`from knowledge: <id>\`; when you follow a runbook/action, name it. Make the faculty visible.
23
+ - **Harvest at close.** Before ending, propose durable learnings as one-fact files in \`agent/knowledge/inbox/\` (plain text is fine), and propose any reusable procedure with \`zuzuu act propose <slug>\` (it lands in \`actions/inbox/\`). A human reviews both via \`zuzuu review\`. Never write \`knowledge/items/\` or active \`actions/\` directly.
24
+ - **Respect \`agent/guardrails/\`** — hard rules, *enforced* on tool calls by the zuzuu gate; a refusal there is policy, not preference.
25
+ - Do **not** read \`agent/.traces/\` or \`agent/.live/\` (zuzuu observability internals) — **except \`agent/.live/digest.md\`, which is written for you.**
26
+ ${END}`;
27
+ }
28
+
29
+ export function hasBlock(text) {
30
+ return BLOCK_RE.test(text);
31
+ }
32
+
33
+ /**
34
+ * Insert or replace our block in `text`. User content is never modified:
35
+ * existing block → replaced in place; no block → appended with one blank line.
36
+ */
37
+ export function injectBlock(text, block = facultiesBlock()) {
38
+ if (hasBlock(text)) return text.replace(BLOCK_RE, block + '\n');
39
+ const sep = text.length === 0 ? '' : text.endsWith('\n\n') ? '' : text.endsWith('\n') ? '\n' : '\n\n';
40
+ return text + sep + block + '\n';
41
+ }
42
+
43
+ /** Remove our block (for the future `zuzuu deinit`); user content untouched. */
44
+ export function removeBlock(text) {
45
+ return text.replace(BLOCK_RE, '');
46
+ }
@@ -0,0 +1,93 @@
1
+ // zuzuu/instructions/adapter.mjs
2
+ // The Instructions faculty adapter (WS2-T4). Wraps steering-amendment proposals
3
+ // behind the faculty-spine adapter contract — { name, ingest, validate, apply,
4
+ // render } — so `zuzuu review` can surface and approve them uniformly.
5
+ //
6
+ // An instructions proposal payload is a steering amendment:
7
+ // { text } — a line or paragraph to append to project.md
8
+ //
9
+ // apply: appends the text as a line to agent/instructions/project.md (creates
10
+ // the file if absent; never duplicates an already-present line).
11
+ //
12
+ // Registers itself on import.
13
+
14
+ import { join } from 'node:path';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
+ import * as registry from '../faculty/registry.mjs';
17
+
18
+ const name = 'instructions';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function projectMdPath(agentDir) {
25
+ return join(agentDir, 'instructions', 'project.md');
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // adapter contract
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Ingest a raw amendment object. Pass-through: the payload IS the amendment.
34
+ */
35
+ function ingest(_agentDir, raw) {
36
+ const payload = raw?.payload ?? raw ?? {};
37
+ return { payload, analysis: {} };
38
+ }
39
+
40
+ /**
41
+ * Validate an amendment payload.
42
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
43
+ */
44
+ function validate(_agentDir, payload) {
45
+ const errors = [];
46
+ if (!payload?.text || !String(payload.text).trim()) {
47
+ errors.push('text is required (non-empty steering amendment)');
48
+ }
49
+ return { ok: errors.length === 0, errors, warnings: [] };
50
+ }
51
+
52
+ /**
53
+ * Apply an approved amendment: append text to project.md (idempotent on
54
+ * identical lines — won't duplicate a line already present).
55
+ * @returns {{ok:boolean, action:string, itemIds:string[]}}
56
+ */
57
+ function apply(agentDir, proposal) {
58
+ const text = proposal?.payload?.text ?? '';
59
+
60
+ // Ensure the instructions dir exists
61
+ mkdirSync(join(agentDir, 'instructions'), { recursive: true });
62
+
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)) {
68
+ return { ok: true, action: 'amended instructions (already present)', itemIds: [] };
69
+ }
70
+
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: [] };
76
+ }
77
+
78
+ /**
79
+ * Render an amendment proposal for the human gate.
80
+ * @returns {{line:string, card:string}}
81
+ */
82
+ function render(proposal) {
83
+ const text = proposal?.payload?.text ?? '';
84
+ const preview = text.slice(0, 80).replace(/\n/g, ' ');
85
+ return {
86
+ line: `[amendment] ${preview}`,
87
+ card: text,
88
+ };
89
+ }
90
+
91
+ export const adapter = { name, ingest, validate, apply, render };
92
+
93
+ registry.register(adapter);