@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,107 @@
1
+ // zuzuu/commands/generation.mjs — `zuzuu generation` CLI (WS3-T1).
2
+ //
3
+ // zuzuu generation list generations (id · mintedAt · mintedFrom count · ● active)
4
+ // zuzuu generation mint manually mint a generation from the current faculty state
5
+ // zuzuu generation rollback <id> restore a past generation by content (flip active + restore)
6
+
7
+ import { paths, repoRoot } from '../store.mjs';
8
+ import {
9
+ listGenerations, readGeneration, activeGeneration, mintGeneration, rollback, diffGenerations,
10
+ } from '../faculty/generation.mjs';
11
+
12
+ function agentDir() {
13
+ return paths(repoRoot(process.cwd())).dir;
14
+ }
15
+
16
+ function list(dir) {
17
+ const ids = listGenerations(dir);
18
+ if (!ids.length) return console.log('no generations yet — mint one with `zuzuu generation mint`');
19
+ const active = activeGeneration(dir);
20
+ for (const id of ids) {
21
+ const lf = readGeneration(dir, id) ?? {};
22
+ const mark = id === active ? '●' : ' ';
23
+ const from = Array.isArray(lf.mintedFrom) ? lf.mintedFrom.length : 0;
24
+ console.log(`${mark} ${id} ${lf.mintedAt ?? '?'} mintedFrom:${from}`);
25
+ }
26
+ }
27
+
28
+ function mint(dir) {
29
+ const forkedFrom = activeGeneration(dir);
30
+ const lf = mintGeneration(dir, { forkedFrom });
31
+ console.log(`✓ minted ${lf.id}${forkedFrom ? ` (forkedFrom ${forkedFrom})` : ''} — now active`);
32
+ }
33
+
34
+ /** Pure: generation list payload — the zuzuu-web /generations source. */
35
+ export function generationListData(dir) {
36
+ const active = activeGeneration(dir);
37
+ const generations = listGenerations(dir).map((id) => {
38
+ const lf = readGeneration(dir, id) ?? {};
39
+ return { id, mintedAt: lf.mintedAt ?? null, mintedFrom: Array.isArray(lf.mintedFrom) ? lf.mintedFrom : [] };
40
+ });
41
+ return { active, generations };
42
+ }
43
+
44
+ /** Pure: generation diff payload, or null for an unknown id — the zuzuu-web /generation/:id source. */
45
+ export function generationShowData(dir, id) {
46
+ const d = diffGenerations(dir, id);
47
+ return d ? { id, ...d } : null;
48
+ }
49
+
50
+ /** Pure: the per-faculty diff lines for `generation show`. */
51
+ export function showLines(dir, id) {
52
+ const d = diffGenerations(dir, id);
53
+ if (!d) return null;
54
+ const lines = [];
55
+ lines.push(`${id} ${d.mintedAt ?? '?'}`);
56
+ lines.push(` forkedFrom: ${d.forkedFrom ?? '(none — first generation)'}`);
57
+ lines.push(` mintedFrom: ${d.mintedFrom.length} proposal(s)`);
58
+ lines.push(' changes vs parent:');
59
+ for (const f of ['knowledge', 'actions', 'memory']) {
60
+ const x = d.faculties[f] || { added: [], changed: [], removed: [] };
61
+ const parts = [];
62
+ if (x.added.length) parts.push(`+${x.added.length} added`);
63
+ if (x.changed.length) parts.push(`~${x.changed.length} changed`);
64
+ if (x.removed.length) parts.push(`-${x.removed.length} removed`);
65
+ if (f === 'knowledge' && x.registryChanged) parts.push('registry changed');
66
+ lines.push(` ${f}: ${parts.length ? parts.join(' · ') : 'no change'}`);
67
+ }
68
+ for (const f of ['guardrails', 'instructions']) {
69
+ lines.push(` ${f}: ${d.faculties[f]?.changed ? 'changed' : 'no change'}`);
70
+ }
71
+ return lines.join('\n');
72
+ }
73
+
74
+ function show(dir, id) {
75
+ if (!id) { console.error('usage: zuzuu generation show <id>'); process.exit(1); }
76
+ const out = showLines(dir, id);
77
+ if (out == null) { console.error(`no generation '${id}'`); process.exit(1); }
78
+ console.log(out);
79
+ }
80
+
81
+ function doRollback(dir, id) {
82
+ if (!id) { console.error('usage: zuzuu generation rollback <id>'); process.exit(1); }
83
+ if (!readGeneration(dir, id)) { console.error(`no generation '${id}'`); process.exit(1); }
84
+ const r = rollback(dir, id);
85
+ console.log(`✓ rolled back to ${id} — restored ${r.restored} item(s); active=${id}`);
86
+ }
87
+
88
+ export function generation(args) {
89
+ const dir = agentDir();
90
+ const sub = args._[0];
91
+ if (!sub || sub === 'list') {
92
+ if (args.json) { console.log(JSON.stringify(generationListData(dir))); return; }
93
+ return list(dir);
94
+ }
95
+ if (sub === 'mint') return mint(dir);
96
+ if (sub === 'show') {
97
+ if (args.json) {
98
+ const d = generationShowData(dir, args._[1]);
99
+ if (d == null) { console.error(`no generation '${args._[1]}'`); process.exit(1); }
100
+ console.log(JSON.stringify(d)); return;
101
+ }
102
+ return show(dir, args._[1]);
103
+ }
104
+ if (sub === 'rollback') return doRollback(dir, args._[1]);
105
+ console.error(`unknown: zuzuu generation ${sub}\nusage: zuzuu generation [list|show <id>|mint|rollback <id>]`);
106
+ process.exit(1);
107
+ }
@@ -0,0 +1,209 @@
1
+ // `zuzuu hook <Event>` — the callback Claude Code invokes on lifecycle hooks.
2
+ //
3
+ // Design B: the hook is a lifecycle SIGNAL + re-capture TRIGGER, never a span
4
+ // builder. Each relevant event re-parses the transcript through the proven
5
+ // capture path (idempotent, deterministic ids) and advances the live record.
6
+ //
7
+ // SessionStart -> open live record (active) + capture
8
+ // Stop -> heartbeat + re-capture (status active) [fires per turn]
9
+ // SessionEnd -> capture (status completed) + close live record
10
+ //
11
+ // MUST always exit 0 and never block — a throwing hook would disrupt the agent
12
+ // session. `runHook` wraps everything; failures degrade silently.
13
+
14
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import { byName } from '../../experiments/experiment-1-trace-capture/adapters/registry.mjs';
17
+ import { captureTrace } from '../capture-core.mjs';
18
+ import { SessionState } from '../session.mjs';
19
+ import { openLive, touchLive, closeLive } from '../live/live-store.mjs';
20
+ import { loadRules, evaluate, toPreToolUseDecision, toGeminiDecision } from '../guardrails.mjs';
21
+ import { paths, liveDir as liveDirOf } from '../store.mjs';
22
+ import { computeDigest } from '../digest.mjs';
23
+ import { activeGeneration } from '../faculty/generation.mjs';
24
+
25
+ // Lifecycle events, normalized across hosts (verified by observing each host):
26
+ // open — session starts
27
+ // turn — agent finished a response turn (per-turn "still alive"); re-capture
28
+ // end — clean session end (rare: most hosts have none → staleness reconciles)
29
+ // Claude: SessionStart/Stop/SessionEnd · OpenCode: session.created/idle/deleted
30
+ // Gemini: SessionStart/AfterAgent/SessionEnd · Codex: SessionStart/UserPromptSubmit/Stop (no clean end)
31
+ const OPEN = new Set(['SessionStart', 'session.created', 'session_start']);
32
+ const TURN = new Set(['Stop', 'session.idle', 'AfterAgent', 'UserPromptSubmit', 'turn_end']);
33
+ const END = new Set(['SessionEnd', 'session.deleted', 'session_shutdown']);
34
+
35
+ /** Gemini's adapter reads logs.json filtered by sessionId; derive it from the
36
+ * hook's transcript_path (~/.gemini/tmp/<proj>/chats/session-*.json → ../../logs.json). */
37
+ export function geminiRef(payload = {}) {
38
+ const tp = payload.transcript_path || '';
39
+ const projDir = dirname(dirname(tp)); // .../chats/x.json → .../<proj>
40
+ return { file: join(projDir, 'logs.json'), sessionId: payload.session_id };
41
+ }
42
+
43
+ function safeCapture(adapter, ref, status, cwd, generation = null) {
44
+ if (!adapter || !ref) return;
45
+ try {
46
+ captureTrace({ adapter, ref, status, cwd, generation });
47
+ } catch {
48
+ /* source not yet readable, etc. — never break the hook */
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Core dispatch — host-agnostic. The capture `ref` is host-specific: Claude
54
+ * re-parses the transcript file (`transcript_path`); OpenCode re-reads its
55
+ * SQLite store keyed by `session_id`. Pure-ish (injected now/cwd) for tests.
56
+ */
57
+ export function handleHook({ event, payload = {}, cwd = process.cwd(), now = Date.now(), host = 'claude-code' }) {
58
+ const id = payload.session_id;
59
+ if (!id) return { event, skipped: 'no session_id' };
60
+ let ref;
61
+ if (host === 'opencode') ref = id; // adapter reads its DB by id
62
+ else if (host === 'gemini-cli') ref = geminiRef(payload);
63
+ else if (host === 'pi') ref = payload.session_id; // pi: extension passes the session file path as --session; adapter parse() reads it
64
+ else ref = payload.transcript_path; // claude-code, codex: the transcript/rollout file
65
+ const adapter = byName(host);
66
+
67
+ if (OPEN.has(event)) {
68
+ // Pin the active generation at session open (WS3-T3). Fail-open: a missing
69
+ // generation is null — never throws, never blocks the host.
70
+ let generation = null;
71
+ try { generation = activeGeneration(paths(cwd).dir); } catch { /* fail-open */ }
72
+ try {
73
+ openLive({ id, host, transcriptPath: ref, startedAt: new Date(now).toISOString(), now, generation }, cwd);
74
+ safeCapture(adapter, ref, SessionState.ACTIVE, cwd, generation);
75
+ } catch { /* live/capture hiccup must not block grounding below */ }
76
+ writeLiveDigest(cwd); // universal grounding channel — every host reads agent/.live/digest.md
77
+ } else if (TURN.has(event)) {
78
+ touchLive({ id, host, transcriptPath: ref, now }, cwd);
79
+ safeCapture(adapter, ref, SessionState.ACTIVE, cwd);
80
+ } else if (END.has(event)) {
81
+ safeCapture(adapter, ref, SessionState.COMPLETED, cwd);
82
+ closeLive(id, cwd);
83
+ } else {
84
+ return { event, skipped: 'unhandled event' };
85
+ }
86
+ return { event, id, host };
87
+ }
88
+
89
+ /**
90
+ * The Guardrails gate (PreToolUse). Evaluates the tool call against
91
+ * agent/guardrails/rules.json and prints Claude's hookSpecificOutput decision —
92
+ * or NOTHING (exit 0, no JSON = defer to the host's normal permission flow).
93
+ * That silence is the fail-open: engine errors and rule-file problems can slow
94
+ * nothing down and block nothing. Matched decisions are logged for the trace.
95
+ */
96
+ // Session ids are usually clean (uuids, ses_…), but some hosts pass a file PATH
97
+ // as the session id (pi → the session-file path). Sanitize before using it in a
98
+ // filename, or the log write silently fails into a non-existent nested path.
99
+ function guardrailsLogName(sessionId) {
100
+ const safe = String(sessionId || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_').slice(-120);
101
+ return `guardrails-${safe || 'unknown'}.jsonl`;
102
+ }
103
+
104
+ const GATE_EVENTS = new Set(['PreToolUse', 'BeforeTool']);
105
+
106
+ /**
107
+ * Evaluate a tool call against rules.json and return the host's block decision
108
+ * (or null = fail-open / no match → host's normal flow). Logs matched decisions.
109
+ * codex + claude-code → hookSpecificOutput · gemini-cli → {decision,reason}
110
+ */
111
+ export function gateDecision({ host = 'claude-code', payload = {}, cwd = process.cwd() } = {}) {
112
+ try {
113
+ const { dir } = paths(cwd);
114
+ const loaded = loadRules(join(dir, 'guardrails', 'rules.json'));
115
+ if (!loaded.ok) return null;
116
+ const verdict = evaluate(loaded.rules, { tool: payload.tool_name, input: payload.tool_input });
117
+ if (verdict) {
118
+ try {
119
+ const liveDir = liveDirOf(dir);
120
+ mkdirSync(liveDir, { recursive: true });
121
+ appendFileSync(
122
+ join(liveDir, guardrailsLogName(payload.session_id)),
123
+ JSON.stringify({ at: new Date().toISOString(), host, tool: payload.tool_name, ...verdict }) + '\n',
124
+ );
125
+ } catch { /* logging must not affect the gate */ }
126
+ }
127
+ return (host === 'gemini-cli' || host === 'opencode' || host === 'pi') ? toGeminiDecision(verdict) : toPreToolUseDecision(verdict);
128
+ } catch {
129
+ return null; // fail open
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Universal digest delivery (Design B side effect, not a span builder). Computes
135
+ * the faculty digest and writes it to `agent/.live/digest.md` — the ONE channel
136
+ * every host can read at session start (the faculty block points here). Claude
137
+ * also gets it inline via sessionStartContext; the other 4 hosts rely on this
138
+ * file. Fail-open: any error is swallowed (never break the host).
139
+ * @param {string} cwd repo cwd; paths() resolves the agent/ home under it
140
+ */
141
+ export function writeLiveDigest(cwd = process.cwd()) {
142
+ try {
143
+ const agentDir = paths(cwd).dir;
144
+ const { text } = computeDigest(agentDir);
145
+ if (!text || !text.trim()) return;
146
+ const liveDir = liveDirOf(agentDir);
147
+ mkdirSync(liveDir, { recursive: true });
148
+ writeFileSync(join(liveDir, 'digest.md'), text);
149
+ } catch {
150
+ /* fail-open — grounding is best-effort, never blocks the host */
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build Claude Code's SessionStart additionalContext payload from the faculty
156
+ * digest. Returns null on ANY failure (fail-open: the session proceeds with no
157
+ * injected context, never a broken hook).
158
+ * @param {string} cwd repo cwd; paths() resolves the agent/ home under it
159
+ */
160
+ export function sessionStartContext(cwd = process.cwd()) {
161
+ try {
162
+ const agentDir = paths(cwd).dir;
163
+ const { text } = computeDigest(agentDir);
164
+ if (!text || !text.trim()) return null;
165
+ return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } };
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * CLI entry. Claude hooks pipe a JSON payload on stdin; OpenCode's plugin passes
173
+ * `--host opencode --session <id>` (no stdin). Always exits 0 (never break the agent).
174
+ */
175
+ export function runHook(event, { host = 'claude-code', session } = {}) {
176
+ let payload = {};
177
+ if ((host === 'opencode' || host === 'pi') && !GATE_EVENTS.has(event)) {
178
+ payload = { session_id: session }; // opencode/pi lifecycle: id via --session (fire-and-forget)
179
+ } else {
180
+ // claude/gemini/codex always pipe JSON; opencode pipes it for the gate event too
181
+ try {
182
+ // fd 0, not '/dev/stdin' — the device-path form breaks for piped stdin on
183
+ // Linux (CI caught this; macOS masked it).
184
+ payload = JSON.parse(readFileSync(0, 'utf8'));
185
+ } catch {
186
+ /* no/garbage stdin */
187
+ }
188
+ }
189
+ try {
190
+ if (GATE_EVENTS.has(event)) {
191
+ const decision = gateDecision({ host, payload });
192
+ if (decision) process.stdout.write(JSON.stringify(decision));
193
+ } else {
194
+ try { handleHook({ event, payload, host }); } catch { /* capture failure is silent — never blocks the digest or the host */ }
195
+ // Claude consumes additionalContext inline; the other hosts read
196
+ // agent/.live/digest.md (written by handleHook's OPEN branch). Scoping the
197
+ // stdout push to Claude avoids emitting an unread schema to Gemini/Codex.
198
+ if (event === 'SessionStart' && host === 'claude-code') {
199
+ try {
200
+ const ctx = sessionStartContext();
201
+ if (ctx) process.stdout.write(JSON.stringify(ctx));
202
+ } catch { /* digest failure is silent (fail-open) */ }
203
+ }
204
+ }
205
+ } catch {
206
+ /* never break the agent */
207
+ }
208
+ process.exit(0);
209
+ }
@@ -0,0 +1,73 @@
1
+ // zuzuu/commands/inbox.mjs — `zuzuu inbox` (WS-C).
2
+ //
3
+ // One glance at what is pending YOUR approval, per faculty. Counts pending
4
+ // proposals across all five faculties, shows a one-line title for each, and
5
+ // points at `zuzuu review`. Fail-soft per faculty — a broken adapter or unreadable
6
+ // proposal never sinks the whole view.
7
+
8
+ import { paths } from '../store.mjs';
9
+ import { FACULTIES } from '../faculty/contract.mjs';
10
+ import { listProposals } from '../faculty/proposal.mjs';
11
+ import * as registry from '../faculty/registry.mjs';
12
+ import '../knowledge/adapter.mjs'; // self-registers the 'knowledge' adapter
13
+ import '../actions/adapter.mjs'; // self-registers the 'actions' adapter
14
+ import '../guardrails/adapter.mjs'; // self-registers the 'guardrails' adapter
15
+ import '../instructions/adapter.mjs'; // self-registers the 'instructions' adapter
16
+ import '../memory/adapter.mjs'; // self-registers the 'memory' adapter
17
+
18
+ /** Best-effort one-line title for a proposal (adapter.render → payload → id). */
19
+ function titleOf(faculty, p) {
20
+ try {
21
+ const a = registry.get(faculty);
22
+ if (a && typeof a.render === 'function') {
23
+ const line = a.render(p).line;
24
+ if (line) return line.trim();
25
+ }
26
+ } catch { /* fail-soft */ }
27
+ const body = p.payload?.body ?? p.candidate?.body ?? p.payload?.id ?? p.id;
28
+ return String(body).split('\n')[0].slice(0, 60);
29
+ }
30
+
31
+ /** Pure: flat list of pending proposals across faculties (id, faculty, title) — the zuzuu-web /inbox source. */
32
+ export function inboxData(agentDir) {
33
+ const pending = [];
34
+ for (const faculty of FACULTIES) {
35
+ let proposals = [];
36
+ try { proposals = listProposals(agentDir, faculty); } catch { proposals = []; }
37
+ for (const p of proposals) pending.push({ id: p.id, faculty, title: titleOf(faculty, p) });
38
+ }
39
+ return { pending, total: pending.length };
40
+ }
41
+
42
+ /**
43
+ * Pure-ish: gather pending counts per faculty for a home.
44
+ * @returns {{ rows: Array<{faculty, count, first}>, total: number }}
45
+ */
46
+ export function inboxRows(agentDir) {
47
+ const rows = [];
48
+ let total = 0;
49
+ for (const faculty of FACULTIES) {
50
+ let proposals = [];
51
+ try { proposals = listProposals(agentDir, faculty); } catch { proposals = []; }
52
+ if (!proposals.length) continue;
53
+ total += proposals.length;
54
+ rows.push({ faculty, count: proposals.length, first: titleOf(faculty, proposals[0]) });
55
+ }
56
+ return { rows, total };
57
+ }
58
+
59
+ /** `zuzuu inbox` — print what is pending review. */
60
+ export function inbox(args = {}, log = console.log) {
61
+ const agentDir = args.agentDir || paths().dir;
62
+ if (args.json) { log(JSON.stringify(inboxData(agentDir))); return; }
63
+ const { rows, total } = inboxRows(agentDir);
64
+ if (!total) {
65
+ log("inbox: nothing pending — you're all caught up.");
66
+ return;
67
+ }
68
+ log(`inbox — ${total} pending your approval:`);
69
+ for (const { faculty, count, first } of rows) {
70
+ log(` ${faculty}: ${count} pending — ${first}`);
71
+ }
72
+ log('→ run `zuzuu review` to approve/reject (each approval mints a generation checkpoint).');
73
+ }
@@ -0,0 +1,89 @@
1
+ // `zuzuu init` — git-style, context-aware, idempotent scaffold of the faculty home.
2
+ //
3
+ // empty dir → greenfield: full scaffold + create AGENTS.md/CLAUDE.md
4
+ // non-empty, no agent/ → brownfield: scaffold + inject block into existing
5
+ // instruction files (user content untouched)
6
+ // agent/ exists → "Reinitialized": create missing pieces only (no-op
7
+ // on a complete home; never overwrites anything)
8
+
9
+ import { join, basename } from 'node:path';
10
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { applyScaffold, ensureGitignore, homeExists } from '../scaffold.mjs';
12
+ import { injectBlock, facultiesBlock, hasBlock, BLOCK_VERSION } from '../inject.mjs';
13
+ import { detected } from '../../experiments/experiment-1-trace-capture/adapters/registry.mjs';
14
+ import { repoRoot } from '../store.mjs';
15
+
16
+ const HOST_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'];
17
+ // dotfiles/dirs that don't make a directory "a project" for emptiness purposes
18
+ const IGNORABLE = new Set(['.git', '.DS_Store']);
19
+
20
+ function isEmptyDir(cwd) {
21
+ return readdirSync(cwd).filter((e) => !IGNORABLE.has(e)).length === 0;
22
+ }
23
+
24
+ /** Inject (or create) instruction files. Returns {injected:[], created:[]} */
25
+ function serveInstructions(cwd, { greenfield }) {
26
+ const existing = HOST_FILES.filter((f) => existsSync(join(cwd, f)));
27
+ const injected = [];
28
+ const created = [];
29
+ for (const f of existing) {
30
+ const path = join(cwd, f);
31
+ const text = readFileSync(path, 'utf8');
32
+ // current-version block present → nothing to do; older version → replace in
33
+ // place (the markers are versioned for exactly this); absent → append.
34
+ if (text.includes(`zuzuu:faculties:v${BLOCK_VERSION}`)) continue;
35
+ writeFileSync(path, injectBlock(text));
36
+ injected.push(hasBlock(text) ? `${f} (upgraded → v${BLOCK_VERSION})` : f);
37
+ }
38
+ // AGENTS.md is the universal steering file Codex / OpenCode / pi all read
39
+ // (Claude=CLAUDE.md, Gemini=GEMINI.md have their own). Guarantee it carries the
40
+ // block in EVERY mode — including brownfield — so those three hosts are served.
41
+ // No-clobber: only create when absent (existing ones were handled by the loop above).
42
+ if (!existsSync(join(cwd, 'AGENTS.md'))) {
43
+ writeFileSync(join(cwd, 'AGENTS.md'), facultiesBlock() + '\n');
44
+ created.push('AGENTS.md');
45
+ }
46
+ // Greenfield convenience: also give Claude its CLAUDE.md.
47
+ if (greenfield && !existsSync(join(cwd, 'CLAUDE.md'))) {
48
+ writeFileSync(join(cwd, 'CLAUDE.md'), facultiesBlock() + '\n');
49
+ created.push('CLAUDE.md');
50
+ }
51
+ return { injected, created };
52
+ }
53
+
54
+ export function init(args = {}) {
55
+ // Root at the git toplevel when inside a repo (same base the store uses for
56
+ // agent/), falling back to cwd — one project, one home, like .git/.
57
+ const cwd = repoRoot(process.cwd());
58
+ if (cwd !== process.cwd()) console.log(`(project root: ${cwd})`);
59
+ const reinit = homeExists(cwd);
60
+ const greenfield = !reinit && isEmptyDir(cwd);
61
+
62
+ const plan = applyScaffold(cwd);
63
+ const ignoreAdded = ensureGitignore(cwd);
64
+ const { injected, created } = serveInstructions(cwd, { greenfield });
65
+
66
+ const createdCount = plan.dirs.length + plan.files.length + (plan.manifestMissing ? 1 : 0);
67
+
68
+ if (reinit) {
69
+ console.log(`Reinitialized existing zuzuu home in ${join(cwd, 'agent')}/`);
70
+ if (createdCount) console.log(` restored : ${createdCount} missing piece(s)`);
71
+ if (injected.length) console.log(` injected : faculty block → ${injected.join(', ')}`);
72
+ if (!createdCount && !injected.length && !ignoreAdded.length) console.log(' (complete — nothing to do)');
73
+ } else if (greenfield) {
74
+ console.log(`Initialized empty zuzuu home in ${join(cwd, 'agent')}/`);
75
+ console.log(` faculties : knowledge/ memory/ actions/ instructions/ guardrails/ (+ agent.json manifest)`);
76
+ console.log(` steering : created ${created.join(' + ')} pointing your agent at its faculties`);
77
+ console.log(` next : \`zuzuu enable\` for live capture · \`zuzuu digest\` to preview the grounding your agent opens with · start your agent in ${basename(cwd)}/`);
78
+ } else {
79
+ console.log(`Initialized zuzuu home in existing project ${join(cwd, 'agent')}/`);
80
+ console.log(` faculties : knowledge/ memory/ actions/ instructions/ guardrails/ (+ agent.json manifest)`);
81
+ const steer = [];
82
+ if (injected.length) steer.push(`injected → ${injected.join(', ')}`);
83
+ if (created.length) steer.push(`created ${created.join(' + ')} (read by Codex/OpenCode/pi)`);
84
+ if (steer.length) console.log(` steering : ${steer.join(' · ')}`);
85
+ const hosts = detected().map((a) => a.name).join(', ');
86
+ if (hosts) console.log(` hosts : detected ${hosts} — \`zuzuu capture\` works now; \`zuzuu enable\` for live`);
87
+ }
88
+ if (ignoreAdded.length) console.log(` gitignore : +${ignoreAdded.join(' ')}`);
89
+ }
@@ -0,0 +1,152 @@
1
+ // Knowledge CLI: `zuzuu remember` (human-direct write — the human IS the gate),
2
+ // `zuzuu recall` (one command, three search modes), `zuzuu knowledge reindex|audit`.
3
+
4
+ import { paths } from '../store.mjs';
5
+ import { loadRegistry, validateItem } from '../knowledge/registry.mjs';
6
+ import { slugify, writeItem, readItem, allItems } from '../knowledge/items.mjs';
7
+ import { upsertItem, reindex, search, neighbors, indexPath, putVector, allVectors, unembedded } from '../knowledge/index.mjs';
8
+ import { detectEmbedder, embed, cosine } from '../knowledge/embed.mjs';
9
+
10
+ const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
11
+ const parsePair = (s) => {
12
+ const i = String(s).indexOf('=');
13
+ if (i < 1) throw new Error(`expected key=value, got: ${s}`);
14
+ return [s.slice(0, i), s.slice(i + 1)];
15
+ };
16
+
17
+ /** Empty-result copy for recall: distinguish "no items" from "no match". */
18
+ export function recallEmptyMessage({ itemCount, query }) {
19
+ if (!itemCount) return '(no knowledge yet — add facts with `zuzuu remember`)';
20
+ return `(no matches for "${query}" — try other terms, or \`zuzuu knowledge reindex\`)`;
21
+ }
22
+
23
+ /** zuzuu remember "text" [--type t] [--id slug] [--attr k=v]... [--rel type=target]... */
24
+ export function remember(args) {
25
+ const text = args._.join(' ').trim();
26
+ if (!text) {
27
+ console.error('usage: zuzuu remember "the fact, in prose" [--type fact|entity|command|decision] [--attr k=v] [--rel type=target]');
28
+ process.exit(1);
29
+ }
30
+ const agentDir = paths().dir;
31
+ const registry = loadRegistry(agentDir);
32
+ const item = {
33
+ id: args.id || slugify(text),
34
+ type: args.type || 'fact',
35
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
36
+ status: 'active',
37
+ attributes: Object.fromEntries(asArray(args.attr).map(parsePair)),
38
+ relations: asArray(args.rel).map((r) => {
39
+ const [type, target] = parsePair(r);
40
+ return { type, target };
41
+ }),
42
+ provenance: [{ session: 'manual', ref: 'zuzuu remember' }],
43
+ body: text,
44
+ };
45
+ if (readItem(agentDir, item.id)) {
46
+ console.error(`item '${item.id}' already exists — pick --id, or evolve it via the proposal flow`);
47
+ process.exit(1);
48
+ }
49
+ const v = validateItem(registry, item);
50
+ const unknown = [...v.unknownKeys.attributes, ...v.unknownKeys.relations];
51
+ if (!v.ok || unknown.length) {
52
+ for (const e of v.errors) console.error(` ✗ ${e}`);
53
+ for (const k of unknown) console.error(` ✗ unregistered key: ${k} (register it in agent/knowledge/registry/ first)`);
54
+ process.exit(1);
55
+ }
56
+ const path = writeItem(agentDir, item);
57
+ upsertItem(agentDir, item);
58
+ console.log(`remembered → ${path}`);
59
+ console.log(` id: ${item.id} type: ${item.type}${item.relations.length ? ` relations: ${item.relations.length}` : ''}`);
60
+ }
61
+
62
+ /** zuzuu recall "query" [--type t] [--attr k=v] [--related-to id [--depth n]] [--semantic] */
63
+ export async function recall(args) {
64
+ const agentDir = paths().dir;
65
+ const query = args._.join(' ').trim();
66
+
67
+ if (args['related-to']) {
68
+ const rows = neighbors(agentDir, args['related-to'], { relType: args.rel || null, depth: Number(args.depth || 1) });
69
+ if (!rows.length) return console.log('(no related items)');
70
+ for (const r of rows) console.log(` ${r.node} ←${r.via}→ (${r.hop} hop${r.hop > 1 ? 's' : ''})`);
71
+ return;
72
+ }
73
+
74
+ if (args.semantic) {
75
+ const e = await detectEmbedder();
76
+ if (!e.available) {
77
+ console.error(`semantic search unavailable: ${e.reason}`);
78
+ process.exit(2);
79
+ }
80
+ const qv = await embed(e.model, query);
81
+ const vecs = allVectors(agentDir);
82
+ if (!vecs.length) {
83
+ console.error('no embedded items yet — run `zuzuu knowledge reindex` with ollama up');
84
+ process.exit(2);
85
+ }
86
+ const ranked = vecs.map((v) => ({ item: v.item, sim: cosine(qv, v.vec) })).sort((a, b) => b.sim - a.sim).slice(0, Number(args.limit || 5));
87
+ for (const r of ranked) {
88
+ const it = readItem(agentDir, r.item);
89
+ console.log(` ${r.sim.toFixed(3)} ${r.item} ${it ? '— ' + it.body.slice(0, 60) : ''}`);
90
+ }
91
+ return;
92
+ }
93
+
94
+ const rows = search(agentDir, query, {
95
+ type: args.type || null,
96
+ attr: args.attr ? parsePair(asArray(args.attr)[0]) : null,
97
+ limit: Number(args.limit || 10),
98
+ });
99
+ if (!rows.length) {
100
+ const { items } = allItems(agentDir);
101
+ return console.log(recallEmptyMessage({ itemCount: items.length, query }));
102
+ }
103
+ for (const r of rows) console.log(` [${String(r.score).padStart(3)}] ${r.id} (${r.type}) ${r.text.slice(0, 70).replace(/\n/g, ' ')}`);
104
+ }
105
+
106
+ /** zuzuu knowledge reindex|audit */
107
+ export async function knowledge(args) {
108
+ const sub = args._[0];
109
+ const agentDir = paths().dir;
110
+ if (sub === 'reindex') {
111
+ const r = reindex(agentDir);
112
+ console.log(`indexed ${r.indexed} item(s) → ${indexPath(agentDir)}`);
113
+ for (const e of r.parseErrors) console.log(` ✗ ${e.file}: ${e.error}`);
114
+ // embed what we can, if an embedder exists
115
+ const e = await detectEmbedder();
116
+ if (e.available) {
117
+ const todo = unembedded(agentDir);
118
+ for (const it of todo) {
119
+ try {
120
+ putVector(agentDir, it.id, e.model, await embed(e.model, `${it.id}\n${it.text}`));
121
+ } catch (err) {
122
+ console.log(` ✗ embed ${it.id}: ${err.message}`);
123
+ }
124
+ }
125
+ if (todo.length) console.log(` embedded ${todo.length} item(s) via ollama/${e.model}`);
126
+ } else {
127
+ console.log(` (vectors skipped — ${e.reason})`);
128
+ }
129
+ return;
130
+ }
131
+ if (sub === 'audit') {
132
+ const registry = loadRegistry(agentDir);
133
+ const { items, errors } = allItems(agentDir);
134
+ let problems = 0;
135
+ if (!registry.ok) { console.log(' ✗ registry file unparseable'); problems++; }
136
+ for (const e of errors) { console.log(` ✗ ${e.file}: ${e.error}`); problems++; }
137
+ const ids = new Set(items.map((i) => i.id));
138
+ for (const it of items) {
139
+ const v = validateItem(registry, it);
140
+ for (const err of v.errors) { console.log(` ✗ ${it.id}: ${err}`); problems++; }
141
+ for (const k of v.unknownKeys.attributes) console.log(` ⚠ ${it.id}: unregistered attribute '${k}'`);
142
+ for (const k of v.unknownKeys.relations) console.log(` ⚠ ${it.id}: unregistered relation '${k}'`);
143
+ for (const r of it.relations ?? []) if (!ids.has(r.target)) console.log(` ⚠ ${it.id}: dangling relation → ${r.target}`);
144
+ }
145
+ const e = await detectEmbedder();
146
+ console.log(` embeddings: ${e.available ? `available (ollama/${e.model})` : e.reason}`);
147
+ console.log(problems ? `${problems} problem(s)` : `audit clean — ${items.length} item(s)`);
148
+ process.exit(problems ? 1 : 0);
149
+ }
150
+ console.error('usage: zuzuu knowledge reindex|audit');
151
+ process.exit(1);
152
+ }