@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,197 @@
1
+ // `zuzuu doctor` — environment health + session sanity. Exits non-zero only on
2
+ // real problems (warnings don't fail). Phase 2 will also reconcile lost sessions.
3
+
4
+ import { mkdirSync, accessSync, constants } from 'node:fs';
5
+ import { detected } from '../../experiments/experiment-1-trace-capture/adapters/registry.mjs';
6
+ import { paths, gitInfo } from '../store.mjs';
7
+ import { listLive } from '../live/live-store.mjs';
8
+ import { reconcile } from '../live/reconcile.mjs';
9
+ import { planScaffold, homeExists } from '../scaffold.mjs';
10
+ import { loadRegistry } from '../knowledge/registry.mjs';
11
+ import { allItems } from '../knowledge/items.mjs';
12
+ import { listProposals } from '../knowledge/proposals.mjs';
13
+ import { detectEmbedder } from '../knowledge/embed.mjs';
14
+ import { activeGeneration, readGeneration, snapshotFaculties } from '../faculty/generation.mjs';
15
+
16
+ /**
17
+ * Pure drift checker (WS3-T3). Compares the current faculty hashes against the
18
+ * active generation's pinned `faculties` manifest. Fail-open: any error returns
19
+ * { error } rather than throwing.
20
+ *
21
+ * Returns:
22
+ * { noneActive: true } — no generation pinned yet
23
+ * { generationId, drifted: [] } — active gen, drifted items (may be empty)
24
+ * { error } — unexpected failure (fail-open)
25
+ *
26
+ * Each drifted entry: { id, faculty, reason: 'hash_changed'|'added'|'removed',
27
+ * pinned?: string, current?: string }
28
+ */
29
+ export function detectDrift(agentDir) {
30
+ try {
31
+ const genId = activeGeneration(agentDir);
32
+ if (!genId) return { noneActive: true };
33
+
34
+ const lockfile = readGeneration(agentDir, genId);
35
+ if (!lockfile) return { noneActive: true };
36
+
37
+ const current = snapshotFaculties(agentDir);
38
+ const pinned = lockfile.faculties || {};
39
+ const drifted = [];
40
+
41
+ // Compare per-faculty item arrays (knowledge, actions, memory).
42
+ for (const faculty of ['knowledge', 'actions', 'memory']) {
43
+ const pinnedItems = (pinned[faculty]?.items ?? []);
44
+ const currentItems = (current[faculty]?.items ?? []);
45
+
46
+ const pinnedMap = new Map(pinnedItems.map((i) => [i.id, i.hash]));
47
+ const currentMap = new Map(currentItems.map((i) => [i.id, i.hash]));
48
+
49
+ // Check for changed or removed items
50
+ for (const [id, hash] of pinnedMap) {
51
+ if (!currentMap.has(id)) {
52
+ drifted.push({ id, faculty, reason: 'removed', pinned: hash });
53
+ } else if (currentMap.get(id) !== hash) {
54
+ drifted.push({ id, faculty, reason: 'hash_changed', pinned: hash, current: currentMap.get(id) });
55
+ }
56
+ }
57
+
58
+ // Check for added items
59
+ for (const [id, hash] of currentMap) {
60
+ if (!pinnedMap.has(id)) {
61
+ drifted.push({ id, faculty, reason: 'added', current: hash });
62
+ }
63
+ }
64
+ }
65
+
66
+ // Compare single-file faculties (guardrails.rulesHash, instructions.projectHash)
67
+ const singleFile = [
68
+ { faculty: 'guardrails', field: 'rulesHash' },
69
+ { faculty: 'instructions', field: 'projectHash' },
70
+ ];
71
+ for (const { faculty, field } of singleFile) {
72
+ const pinnedHash = pinned[faculty]?.[field] ?? null;
73
+ const currentHash = current[faculty]?.[field] ?? null;
74
+ if (pinnedHash !== currentHash) {
75
+ drifted.push({ id: field, faculty, reason: 'hash_changed', pinned: pinnedHash, current: currentHash });
76
+ }
77
+ }
78
+
79
+ // Compare knowledge.registryHash
80
+ const pinnedReg = pinned.knowledge?.registryHash ?? null;
81
+ const currentReg = current.knowledge?.registryHash ?? null;
82
+ if (pinnedReg !== currentReg) {
83
+ drifted.push({ id: 'registryHash', faculty: 'knowledge', reason: 'hash_changed', pinned: pinnedReg, current: currentReg });
84
+ }
85
+
86
+ return { generationId: genId, drifted };
87
+ } catch (err) {
88
+ return { error: String(err) };
89
+ }
90
+ }
91
+
92
+ /** The closing line: honest about warnings, never "all good" under them. */
93
+ export function summaryLine(problems, warnings) {
94
+ if (problems) return `\n${problems} problem(s) found`;
95
+ if (warnings) return `\n${warnings} warning(s) — see ⚠ above`;
96
+ return '\nall good';
97
+ }
98
+
99
+ export async function doctor() {
100
+ let problems = 0;
101
+ let warnings = 0;
102
+ const ok = (m) => console.log(` ✓ ${m}`);
103
+ const info = (m) => console.log(` · ${m}`);
104
+ const warn = (m) => {
105
+ console.log(` ⚠ ${m}`);
106
+ warnings++;
107
+ };
108
+ const bad = (m) => {
109
+ console.log(` ✗ ${m}`);
110
+ problems++;
111
+ };
112
+
113
+ console.log('zuzuu doctor\n');
114
+
115
+ // Node
116
+ const major = Number(process.versions.node.split('.')[0]);
117
+ if (major >= 21) ok(`Node ${process.versions.node}`);
118
+ else if (major >= 20) warn(`Node ${process.versions.node} — capture works; \`npm test\` glob needs ≥21`);
119
+ else bad(`Node ${process.versions.node} — too old, please use ≥20 (22 LTS recommended)`);
120
+
121
+ // git
122
+ const { commit, branch } = gitInfo();
123
+ if (commit) ok(`git repo on ${branch} @ ${commit.slice(0, 8)}`);
124
+ else info("not a git repo — capture works; sessions just won’t link to a commit");
125
+
126
+ // agent/ writable
127
+ const { dir } = paths();
128
+ try {
129
+ mkdirSync(dir, { recursive: true });
130
+ accessSync(dir, constants.W_OK);
131
+ ok(`agent/ writable (${dir})`);
132
+ } catch {
133
+ bad(`agent/ not writable (${dir})`);
134
+ }
135
+
136
+ // faculty home (served by `zuzuu init`)
137
+ const root = paths().root;
138
+ if (!homeExists(root)) {
139
+ warn('no faculty home — run `zuzuu init` to scaffold knowledge/memory/actions/instructions');
140
+ } else {
141
+ const missing = planScaffold(root);
142
+ const gaps = missing.dirs.length + missing.files.length + (missing.manifestMissing ? 1 : 0);
143
+ if (gaps) warn(`faculty home incomplete (${gaps} piece(s) missing) — rerun \`zuzuu init\``);
144
+ else ok('faculty home complete (knowledge/ memory/ actions/ instructions/ guardrails/)');
145
+ }
146
+
147
+ // knowledge faculty
148
+ if (homeExists(root)) {
149
+ const reg = loadRegistry(dir);
150
+ if (!reg.ok) bad('knowledge registry unparseable');
151
+ else {
152
+ const { items, errors } = allItems(dir);
153
+ if (errors.length) warn(`${errors.length} knowledge item(s) unparseable`);
154
+ const pending = listProposals(dir).length;
155
+ ok(`knowledge: ${items.length} item(s), ${pending} pending proposal(s)${pending ? ' — run \`zuzuu review\`' : ''}`);
156
+ const e = await detectEmbedder();
157
+ if (!e.available) warn(`semantic search off — ${e.reason}`);
158
+ else ok(`embeddings available (ollama/${e.model})`);
159
+ }
160
+ }
161
+
162
+ // hosts
163
+ const hosts = detected();
164
+ if (hosts.length) ok(`hosts detected: ${hosts.map((h) => h.name).join(', ')}`);
165
+ else warn('no supported agent data found — use Claude Code or Gemini CLI, then `zuzuu capture`');
166
+
167
+ // generation drift check (WS3-T3)
168
+ try {
169
+ const { dir: agentDir } = paths();
170
+ const drift = detectDrift(agentDir);
171
+ if (drift.noneActive) {
172
+ info('generation: no generation pinned yet — run `zuzuu generation mint`');
173
+ } else if (drift.error) {
174
+ warn(`generation drift check failed: ${drift.error}`);
175
+ } else if (drift.drifted.length === 0) {
176
+ ok(`generation ${drift.generationId} — no faculty drift`);
177
+ } else {
178
+ warn(`generation ${drift.generationId} — ${drift.drifted.length} drifted item(s):`);
179
+ for (const d of drift.drifted) {
180
+ info(` drift: ${d.faculty}/${d.id} (${d.reason})`);
181
+ }
182
+ }
183
+ } catch {
184
+ /* drift check must never break doctor — fail-open */
185
+ }
186
+
187
+ // live-session reconciliation: close out lost/killed sessions (no SessionEnd).
188
+ const before = listLive().length;
189
+ const reconciled = reconcile();
190
+ if (reconciled.length) warn(`reconciled ${reconciled.length} lost session(s) → abandoned`);
191
+ const live = listLive().length;
192
+ if (live) ok(`${live} live session(s) active`);
193
+ else if (!before) ok('no live sessions');
194
+
195
+ console.log(summaryLine(problems, warnings));
196
+ process.exit(problems ? 1 : 0);
197
+ }
@@ -0,0 +1,195 @@
1
+ // `zuzuu enable` / `zuzuu disable` — install/remove background lifecycle hooks in the
2
+ // project's Claude Code settings, entire.io-style: enable once, then capture is
3
+ // invisible. The hook command is wrapped so it ALWAYS exits 0 — if node or zuzuu is
4
+ // missing it degrades silently and never breaks your agent.
5
+
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
9
+ import { repoRoot } from '../store.mjs';
10
+ import { addHooks, removeHooks, isInstalled, LIFECYCLE_EVENTS, GATE_EVENTS, addHookEntries, removeHookEntries } from '../live/install.mjs';
11
+
12
+ const BIN = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'zuzuu.mjs');
13
+
14
+ // `|| true` → exit 0 even if node/zuzuu is absent (graceful degradation).
15
+ const commandFor = (event) => `node "${BIN}" hook ${event} || true`;
16
+
17
+ // Gemini settings.json + Codex hooks.json share Claude's hook shape; only the
18
+ // path + event names differ. Events from the Phase-0 real-wire captures.
19
+ const HOST_HOOKS = {
20
+ 'gemini-cli': {
21
+ file: (cwd) => join(repoRoot(cwd), '.gemini', 'settings.json'),
22
+ events: ['SessionStart', 'AfterAgent', 'SessionEnd', 'BeforeTool'],
23
+ note: 'fires headless + interactive; project-level honored',
24
+ },
25
+ codex: {
26
+ file: (cwd) => join(repoRoot(cwd), '.codex', 'hooks.json'),
27
+ events: ['SessionStart', 'Stop', 'PreToolUse'],
28
+ note: 'INTERACTIVE only (codex exec fires no hooks); no clean end → `zuzuu doctor` reconciles',
29
+ },
30
+ };
31
+ const hostCommandFor = (host) => (event) => `node "${BIN}" hook ${event} --host ${host} || true`;
32
+
33
+ function settingsPath(cwd) {
34
+ return join(repoRoot(cwd), '.claude', 'settings.json');
35
+ }
36
+
37
+ function readSettings(path) {
38
+ if (!existsSync(path)) return {};
39
+ try {
40
+ return JSON.parse(readFileSync(path, 'utf8'));
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function writeSettings(path, obj) {
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ writeFileSync(path, JSON.stringify(obj, null, 2) + '\n');
49
+ }
50
+
51
+ // --- OpenCode: install a project plugin (.opencode/plugins/zuzuu.js) that fires the
52
+ // zuzuu hook on lifecycle events + gates tools. Spawns the real node (with node:sqlite), not bun.
53
+ // plural dir (.opencode/plugins/) is the documented default.
54
+ const NODE = process.execPath;
55
+ const opencodePluginPath = (cwd) => join(repoRoot(cwd), '.opencode', 'plugins', 'zuzuu.js');
56
+ const opencodePlugin = () => `// installed by \`zuzuu enable --host opencode\` — live capture + guardrails gate (graceful: never breaks OpenCode).
57
+ import { spawn, spawnSync } from "node:child_process";
58
+ const NODE = ${JSON.stringify(NODE)};
59
+ const ZUZUU = ${JSON.stringify(BIN)};
60
+ const fire = (event, id) => { try { spawn(NODE, [ZUZUU, "hook", event, "--host", "opencode", "--session", id], { stdio: "ignore", detached: true }).unref(); } catch {} };
61
+ export const Zuzuu = async () => ({
62
+ event: async ({ event }) => {
63
+ try {
64
+ const id = event?.properties?.sessionID;
65
+ if (!id) return;
66
+ if (event.type === "session.created") fire("session.created", id);
67
+ else if (event.type === "session.idle") fire("session.idle", id);
68
+ else if (event.type === "session.deleted") fire("session.deleted", id);
69
+ } catch {}
70
+ },
71
+ // The guardrails gate: tool.execute.before fires for every tool (real-wire verified).
72
+ // input = { tool, sessionID, callID }; output = { args }. Throw to block. Fail-open.
73
+ "tool.execute.before": async (input, output) => {
74
+ let deny = null;
75
+ try {
76
+ const payload = JSON.stringify({ tool_name: input?.tool, tool_input: output?.args, session_id: input?.sessionID });
77
+ const res = spawnSync(NODE, [ZUZUU, "hook", "PreToolUse", "--host", "opencode"], { input: payload, encoding: "utf8", timeout: 5000 });
78
+ const out = (res && res.stdout) || "";
79
+ let decision = null;
80
+ for (const line of out.split("\\n")) { const t = line.trim(); if (t.startsWith("{")) { try { decision = JSON.parse(t); } catch {} } }
81
+ if (decision && decision.decision === "deny") deny = decision.reason || "blocked by zuzuu guardrail";
82
+ } catch { /* any gate/spawn error fails OPEN (deny stays null → tool proceeds) */ }
83
+ // Throw OUTSIDE the try: only an intentional deny blocks; an engine error can never be mistaken for one.
84
+ if (deny) throw new Error(deny);
85
+ },
86
+ });
87
+ `;
88
+
89
+ const piExtPath = (cwd) => join(repoRoot(cwd), '.pi', 'extensions', 'zuzuu.ts');
90
+ const piExtension = () => `// installed by \`zuzuu enable --host pi\` — live capture + guardrails gate (graceful: never breaks pi).
91
+ import { spawn, spawnSync } from "node:child_process";
92
+ const NODE = ${JSON.stringify(NODE)};
93
+ const ZUZUU = ${JSON.stringify(BIN)};
94
+ export default function (pi) {
95
+ const fire = (event, ctx) => {
96
+ try {
97
+ const file = ctx?.sessionManager?.getSessionFile?.();
98
+ if (!file) return;
99
+ spawn(NODE, [ZUZUU, "hook", event, "--host", "pi", "--session", file], { stdio: "ignore", detached: true }).unref();
100
+ } catch {}
101
+ };
102
+ pi.on("session_start", async (_e, ctx) => fire("session_start", ctx));
103
+ pi.on("turn_end", async (_e, ctx) => fire("turn_end", ctx));
104
+ pi.on("session_shutdown", async (_e, ctx) => fire("session_shutdown", ctx));
105
+ // gate: tool_call → run the shared zuzuu gate; block on deny. Fail-open.
106
+ pi.on("tool_call", async (event, ctx) => {
107
+ try {
108
+ const file = ctx?.sessionManager?.getSessionFile?.();
109
+ const payload = JSON.stringify({ tool_name: event?.toolName, tool_input: event?.input, session_id: file });
110
+ const res = spawnSync(NODE, [ZUZUU, "hook", "PreToolUse", "--host", "pi"], { input: payload, encoding: "utf8", timeout: 5000 });
111
+ const out = (res && res.stdout) || "";
112
+ let decision = null;
113
+ for (const line of out.split("\\n")) { const t = line.trim(); if (t.startsWith("{")) { try { decision = JSON.parse(t); } catch {} } }
114
+ if (decision && decision.decision === "deny") return { block: true, reason: decision.reason || "blocked by zuzuu guardrail" };
115
+ } catch {}
116
+ return undefined; // allow / no-match / any error → proceed (fail-open)
117
+ });
118
+ }
119
+ `;
120
+
121
+ export function enable(args = {}) {
122
+ if (args.host === 'gemini-cli' || args.host === 'codex') {
123
+ const spec = HOST_HOOKS[args.host];
124
+ const path = spec.file();
125
+ writeSettings(path, addHookEntries(readSettings(path), hostCommandFor(args.host), spec.events));
126
+ console.log(`zuzuu enabled — live capture + gate installed (${args.host})`);
127
+ console.log(` config : ${path}`);
128
+ console.log(` hooks : ${spec.events.join(', ')}`);
129
+ console.log(` note : ${spec.note}`);
130
+ console.log(` disable: zuzuu disable --host ${args.host}`);
131
+ return;
132
+ }
133
+ if ((args.host || 'claude-code') === 'opencode') {
134
+ const say = args.quiet ? () => {} : console.log; // `zuzuu code` wires quietly + prints its own summary
135
+ const path = opencodePluginPath();
136
+ mkdirSync(dirname(path), { recursive: true });
137
+ writeFileSync(path, opencodePlugin());
138
+ say('zuzuu enabled for OpenCode — live capture + guardrails gate installed');
139
+ say(` plugin : ${path}`);
140
+ say(' events : session.created/idle/deleted (capture) · tool.execute.before (gate)');
141
+ say(' note : no clean end signal — ended/killed sessions reconcile via `zuzuu doctor`');
142
+ say(' scope : new OpenCode sessions in this repo; disable: zuzuu disable --host opencode');
143
+ return;
144
+ }
145
+ if (args.host === 'pi') {
146
+ const path = piExtPath();
147
+ mkdirSync(dirname(path), { recursive: true });
148
+ writeFileSync(path, piExtension());
149
+ console.log('zuzuu enabled for pi — live capture + guardrails gate installed');
150
+ console.log(` extension : ${path}`);
151
+ console.log(' events : session_start/turn_end/session_shutdown (capture) · tool_call (gate)');
152
+ console.log(' note : headless `pi -p` needs `--approve` to load project extensions; no clean end → `zuzuu doctor` reconciles');
153
+ console.log(' disable : zuzuu disable --host pi');
154
+ return;
155
+ }
156
+ const path = settingsPath();
157
+ writeSettings(path, addHooks(readSettings(path), commandFor));
158
+ console.log('zuzuu enabled — live capture installed (Claude Code)');
159
+ console.log(` settings : ${path}`);
160
+ console.log(` hooks : ${[...LIFECYCLE_EVENTS, ...GATE_EVENTS].join(", ")} (lifecycle + guardrails gate; exit 0 if zuzuu absent)`);
161
+ console.log(' scope : new sessions in this repo (restart your agent to pick them up)');
162
+ console.log(' disable : zuzuu disable');
163
+ }
164
+
165
+ export function disable(args = {}) {
166
+ if (args.host === 'gemini-cli' || args.host === 'codex') {
167
+ const path = HOST_HOOKS[args.host].file();
168
+ if (!existsSync(path)) { console.log(`nothing to disable (no ${path})`); return; }
169
+ writeSettings(path, removeHookEntries(readSettings(path)));
170
+ console.log(`zuzuu disabled — hooks removed from ${path}`);
171
+ return;
172
+ }
173
+ if ((args.host || 'claude-code') === 'opencode') {
174
+ const p = opencodePluginPath();
175
+ const removed = existsSync(p);
176
+ if (removed) rmSync(p, { force: true });
177
+ console.log(removed ? 'zuzuu disabled for OpenCode — plugin removed' : 'nothing to disable (no OpenCode plugin)');
178
+ return;
179
+ }
180
+ if (args.host === 'pi') {
181
+ const path = piExtPath();
182
+ if (existsSync(path)) { rmSync(path, { force: true }); console.log(`zuzuu disabled for pi — extension removed (${path})`); }
183
+ else console.log('nothing to disable (no pi extension)');
184
+ return;
185
+ }
186
+ const path = settingsPath();
187
+ if (!existsSync(path)) {
188
+ console.log('nothing to disable (no .claude/settings.json)');
189
+ return;
190
+ }
191
+ writeSettings(path, removeHooks(readSettings(path)));
192
+ console.log(`zuzuu disabled — lifecycle hooks removed from ${path}`);
193
+ }
194
+
195
+ export { isInstalled };
@@ -0,0 +1,101 @@
1
+ // `zuzuu eval [--faculty f]` — non-interactive proposal ranking table.
2
+ // Loads proposals across all faculties (or one with --faculty), ranks them
3
+ // highest-score first, and prints a table:
4
+ // <score> [<conf>] <faculty>/<id> — <rationale>
5
+ //
6
+ // Also exports `evalLine` — a small pure helper used by `zuzuu review` to render
7
+ // a one-line eval annotation per proposal card.
8
+
9
+ import { paths, readIndex } from '../store.mjs';
10
+ import * as registry from '../faculty/registry.mjs';
11
+ import { listProposals as spineListProposals } from '../faculty/proposal.mjs';
12
+ import { rank } from '../eval/rank.mjs';
13
+ import { getScorer } from '../eval/score.mjs';
14
+ import '../knowledge/adapter.mjs'; // self-registers the 'knowledge' adapter
15
+ import '../actions/adapter.mjs'; // self-registers the 'actions' adapter
16
+ import '../guardrails/adapter.mjs'; // self-registers the 'guardrails' adapter
17
+ import '../instructions/adapter.mjs'; // self-registers the 'instructions' adapter
18
+ import '../memory/adapter.mjs'; // self-registers the 'memory' adapter
19
+
20
+ /**
21
+ * Format one eval annotation line for a proposal card in `zuzuu review`.
22
+ * Pure: no FS, no Date.now(). Accepts a scoreResult from mechanicalScore/rank.
23
+ *
24
+ * @param {{ score: number, confidence: string, rationale: string }} scoreResult
25
+ * @returns {string} e.g. "eval: 0.775 [high] · recurring + cross-session"
26
+ */
27
+ export function evalLine({ score, confidence, rationale }) {
28
+ const warn = confidence === 'low' ? ' ⚠ low-signal' : '';
29
+ return `eval: ${score} [${confidence}] · ${rationale}${warn}`;
30
+ }
31
+
32
+ /**
33
+ * Build sessionMtimes from the sessions index — cheap best-effort.
34
+ * Falls back to {} on any error.
35
+ * @param {string} [cwd]
36
+ * @returns {Record<string, number>}
37
+ */
38
+ function buildSessionMtimes(cwd) {
39
+ try {
40
+ const idx = readIndex(cwd);
41
+ const map = {};
42
+ for (const s of idx.sessions ?? []) {
43
+ if (!s.id) continue;
44
+ // prefer startedAt ms; fall back to 0 (neutral recency)
45
+ const ms = s.startedAt ? Date.parse(s.startedAt) : 0;
46
+ if (!isNaN(ms) && ms > 0) map[s.id] = ms;
47
+ }
48
+ return map;
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ /** Collect proposals for a given adapter (mirrors review.mjs's facultyPending). */
55
+ function collectProposals(agentDir, adapter) {
56
+ if (typeof adapter.listProposals === 'function') return adapter.listProposals(agentDir);
57
+ return spineListProposals(agentDir, adapter.name);
58
+ }
59
+
60
+ /**
61
+ * Core of `zuzuu eval` — exported so tests can inject a custom log fn.
62
+ *
63
+ * @param {object} args Parsed CLI args.
64
+ * @param {Function} [log=console.log] Output sink (injectable for tests).
65
+ */
66
+ export function evalCmd(args, log = console.log) {
67
+ const agentDir = paths().dir;
68
+ const onlyFaculty = args?.faculty ?? null;
69
+ const adapters = registry.all();
70
+ const sessionMtimes = buildSessionMtimes();
71
+ const now = Date.now();
72
+ const scorer = getScorer();
73
+
74
+ // Gather proposals from all (or the one filtered) faculty adapters.
75
+ const allEntries = [];
76
+ for (const adapter of adapters) {
77
+ if (onlyFaculty && adapter.name !== onlyFaculty) continue;
78
+ const proposals = collectProposals(agentDir, adapter);
79
+ for (const proposal of proposals) {
80
+ allEntries.push({ proposal, faculty: adapter.name });
81
+ }
82
+ }
83
+
84
+ if (!allEntries.length) {
85
+ log('no pending proposals');
86
+ return;
87
+ }
88
+
89
+ // Rank all entries together.
90
+ const rawProposals = allEntries.map((e) => e.proposal);
91
+ const ranked = rank(rawProposals, scorer, { now, sessionMtimes });
92
+
93
+ // Build faculty lookup for display: proposal.id → faculty name.
94
+ const facultyByProposalId = new Map(allEntries.map((e) => [e.proposal.id, e.faculty]));
95
+
96
+ for (const { proposal, score, confidence, rationale } of ranked) {
97
+ const faculty = facultyByProposalId.get(proposal.id) ?? '?';
98
+ const warn = confidence === 'low' ? ' ⚠' : '';
99
+ log(`${String(score).padEnd(6)} [${confidence}] ${faculty}/${proposal.id} — ${rationale}${warn}`);
100
+ }
101
+ }
@@ -0,0 +1,119 @@
1
+ // zuzuu/commands/explain.mjs — `zuzuu explain [topic]` (WS-B).
2
+ //
3
+ // The product, explained from the CLI: the five faculties, the graduation loop,
4
+ // and how to get in the loop (inbox · review · generation). Pure text builder
5
+ // (explainText) + a thin printer (explain) so it's trivially testable.
6
+
7
+ const FACULTY_ONE_LINERS = [
8
+ 'knowledge — what is TRUE (semantic facts)',
9
+ 'memory — what HAPPENED (episodic notes)',
10
+ 'actions — how to DO (runbooks + runnable scripts)',
11
+ 'instructions — who to BE (the pinned steering / system prompt)',
12
+ 'guardrails — what NOT to do (enforced tool gates)',
13
+ ];
14
+
15
+ const FACULTY_CONTRACTS = {
16
+ knowledge:
17
+ 'knowledge — the semantic faculty: what is TRUE. Durable facts the agent can ' +
18
+ 'recall (lexical · graph · semantic). New facts land in knowledge/inbox/, become ' +
19
+ 'proposals/, and on approval graduate to knowledge/items/.',
20
+ memory:
21
+ 'memory — the episodic faculty: what HAPPENED. Notes from real sessions — ' +
22
+ 'decisions, gotchas, context — proposed to memory/inbox/, reviewed, then pinned ' +
23
+ 'as memory/entries/.',
24
+ actions:
25
+ 'actions — the procedural faculty: how to DO. Runbooks and runnable scripts. ' +
26
+ 'Propose one with `zuzuu act propose <slug>` (lands in actions/inbox/); on approval ' +
27
+ 'it becomes an active action you can run with `zuzuu act <slug>`.',
28
+ instructions:
29
+ 'instructions — the directive faculty: who to BE. The pinned steering artifact ' +
30
+ '(instructions/project.md) that grounds every session. Empty by default — the ' +
31
+ 'digest tells the agent to interview you and draft it for your approval.',
32
+ guardrails:
33
+ 'guardrails — the protective faculty: what NOT to do, ENFORCED. Rules in ' +
34
+ 'guardrails/rules.json gate tool calls (deny > ask > allow) before they run. ' +
35
+ 'A refusal here is policy, not preference. The gate fails open — never breaks the host.',
36
+ };
37
+
38
+ const LOOP_DIAGRAM = [
39
+ ' session → mine → inbox/ → proposals/ → (zuzuu review: you approve) → faculty + a new generation',
40
+ ].join('\n');
41
+
42
+ const VALID_TOPICS = 'topics: faculties · graduation · knowledge · memory · actions · instructions · guardrails';
43
+
44
+ function overview() {
45
+ return [
46
+ 'zuzuu — five faculties your coding agent grows from real use, human-gated.',
47
+ '',
48
+ 'The 5 faculties:',
49
+ ...FACULTY_ONE_LINERS.map((l) => ' ' + l),
50
+ '',
51
+ 'The graduation loop:',
52
+ LOOP_DIAGRAM,
53
+ '',
54
+ ' Nothing graduates without you. Each approval mints a generation — an',
55
+ ' immutable checkpoint you can roll back to.',
56
+ '',
57
+ 'Get in the loop:',
58
+ ' zuzuu inbox what is pending your approval',
59
+ ' zuzuu review walk each proposal: approve / reject / edit',
60
+ ' zuzuu generation list the generations you have minted (rollback anytime)',
61
+ '',
62
+ 'More: `zuzuu explain faculties` · `zuzuu explain graduation` · `zuzuu explain <faculty>`',
63
+ ].join('\n');
64
+ }
65
+
66
+ function faculties() {
67
+ return [
68
+ 'The 5 faculties — each us-owned, trace-grown, generation-pinned, served:',
69
+ '',
70
+ ...FACULTY_ONE_LINERS.map((l) => ' ' + l),
71
+ '',
72
+ 'Promotion path (the same for every faculty):',
73
+ ' inbox/ agent-proposed candidates (raw)',
74
+ ' proposals/ reviewable records (with evidence + analysis)',
75
+ ' items/ graduated — pinned into the active generation',
76
+ '',
77
+ 'You move a candidate along by running `zuzuu review`.',
78
+ ].join('\n');
79
+ }
80
+
81
+ function graduation() {
82
+ return [
83
+ 'The graduation loop — how observability becomes a new generation:',
84
+ '',
85
+ LOOP_DIAGRAM,
86
+ '',
87
+ '1. A real session is mined into candidate learnings → faculty inbox/.',
88
+ '2. Candidates become proposals/ (evidence + entity-resolution analysis).',
89
+ '3. THE HUMAN GATE: `zuzuu review` walks each one — you approve, reject, or edit.',
90
+ ' Nothing graduates without you (Proposals are always human-approved in v1).',
91
+ '4. Each review that approves anything mints a GENERATION — an immutable,',
92
+ ' content-addressed checkpoint of the whole faculty state.',
93
+ '5. Rollback is flipping a pointer: `zuzuu generation rollback <id>` restores a',
94
+ ' past generation by content. Your approvals are never lost.',
95
+ '',
96
+ 'Inspect: `zuzuu generation list` · `zuzuu generation show <id>`.',
97
+ ].join('\n');
98
+ }
99
+
100
+ /**
101
+ * Pure: return the explanation text for a topic.
102
+ * No topic → overview. Unknown topic → overview + a topics hint.
103
+ * @param {string} [topic]
104
+ * @returns {string}
105
+ */
106
+ export function explainText(topic) {
107
+ if (!topic) return overview();
108
+ const t = String(topic).toLowerCase();
109
+ if (t === 'faculties') return faculties();
110
+ if (t === 'graduation') return graduation();
111
+ if (FACULTY_CONTRACTS[t]) return FACULTY_CONTRACTS[t];
112
+ // unknown → overview + hint
113
+ return overview() + '\n\nunknown topic "' + topic + '" — ' + VALID_TOPICS;
114
+ }
115
+
116
+ /** Printer: `zuzuu explain [topic]`. */
117
+ export function explain(args, log = console.log) {
118
+ log(explainText(args?._?.[0]));
119
+ }