@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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/bin/zuzuu.mjs +133 -0
- package/experiments/experiment-1-trace-capture/adapters/claude-code.mjs +220 -0
- package/experiments/experiment-1-trace-capture/adapters/codex.mjs +201 -0
- package/experiments/experiment-1-trace-capture/adapters/gemini-cli.mjs +113 -0
- package/experiments/experiment-1-trace-capture/adapters/host-adapter.mjs +43 -0
- package/experiments/experiment-1-trace-capture/adapters/opencode.mjs +205 -0
- package/experiments/experiment-1-trace-capture/adapters/pi.mjs +218 -0
- package/experiments/experiment-1-trace-capture/adapters/registry.mjs +20 -0
- package/experiments/experiment-1-trace-capture/adapters/signals.mjs +44 -0
- package/experiments/experiment-1-trace-capture/core/event.mjs +58 -0
- package/experiments/experiment-1-trace-capture/core/ids.mjs +32 -0
- package/experiments/experiment-1-trace-capture/core/otlp.mjs +54 -0
- package/experiments/experiment-1-trace-capture/core/render.mjs +63 -0
- package/experiments/experiment-1-trace-capture/core/spans.mjs +43 -0
- package/package.json +56 -0
- package/zuzuu/actions/adapter.mjs +130 -0
- package/zuzuu/actions/convert.mjs +27 -0
- package/zuzuu/actions/dispatch.mjs +87 -0
- package/zuzuu/actions/inbox.mjs +56 -0
- package/zuzuu/actions/manifest.mjs +72 -0
- package/zuzuu/actions/marker.mjs +4 -0
- package/zuzuu/actions/runner.mjs +37 -0
- package/zuzuu/actions/schema.mjs +73 -0
- package/zuzuu/actions/trail.mjs +22 -0
- package/zuzuu/capture-core.mjs +49 -0
- package/zuzuu/commands/act-author.mjs +72 -0
- package/zuzuu/commands/act.mjs +101 -0
- package/zuzuu/commands/capture.mjs +32 -0
- package/zuzuu/commands/code.mjs +84 -0
- package/zuzuu/commands/digest.mjs +23 -0
- package/zuzuu/commands/distill.mjs +46 -0
- package/zuzuu/commands/doctor.mjs +197 -0
- package/zuzuu/commands/enable.mjs +195 -0
- package/zuzuu/commands/eval.mjs +101 -0
- package/zuzuu/commands/explain.mjs +119 -0
- package/zuzuu/commands/generation.mjs +107 -0
- package/zuzuu/commands/hook.mjs +209 -0
- package/zuzuu/commands/inbox.mjs +73 -0
- package/zuzuu/commands/init.mjs +89 -0
- package/zuzuu/commands/knowledge.mjs +152 -0
- package/zuzuu/commands/migrate.mjs +125 -0
- package/zuzuu/commands/review.mjs +299 -0
- package/zuzuu/commands/status.mjs +82 -0
- package/zuzuu/commands/trace.mjs +19 -0
- package/zuzuu/digest.mjs +149 -0
- package/zuzuu/eval/rank.mjs +31 -0
- package/zuzuu/eval/score.mjs +85 -0
- package/zuzuu/eval/signals.mjs +57 -0
- package/zuzuu/faculty/contract.mjs +19 -0
- package/zuzuu/faculty/gate.mjs +65 -0
- package/zuzuu/faculty/generation.mjs +392 -0
- package/zuzuu/faculty/proposal.mjs +166 -0
- package/zuzuu/faculty/provenance.mjs +35 -0
- package/zuzuu/faculty/registry.mjs +33 -0
- package/zuzuu/faculty/trail.mjs +27 -0
- package/zuzuu/guardrails/adapter.mjs +134 -0
- package/zuzuu/guardrails.mjs +89 -0
- package/zuzuu/inject.mjs +46 -0
- package/zuzuu/instructions/adapter.mjs +93 -0
- package/zuzuu/knowledge/adapter.mjs +99 -0
- package/zuzuu/knowledge/distill.mjs +237 -0
- package/zuzuu/knowledge/embed.mjs +52 -0
- package/zuzuu/knowledge/er.mjs +98 -0
- package/zuzuu/knowledge/inbox.mjs +43 -0
- package/zuzuu/knowledge/index.mjs +194 -0
- package/zuzuu/knowledge/items.mjs +154 -0
- package/zuzuu/knowledge/proposals.mjs +196 -0
- package/zuzuu/knowledge/registry.mjs +115 -0
- package/zuzuu/live/install.mjs +76 -0
- package/zuzuu/live/live-store.mjs +78 -0
- package/zuzuu/live/probe.mjs +55 -0
- package/zuzuu/live/reconcile.mjs +33 -0
- package/zuzuu/memory/adapter.mjs +121 -0
- package/zuzuu/miners/actions.mjs +118 -0
- package/zuzuu/miners/guardrails.mjs +174 -0
- package/zuzuu/miners/instructions.mjs +152 -0
- package/zuzuu/miners/knowledge.mjs +22 -0
- package/zuzuu/miners/memory.mjs +27 -0
- package/zuzuu/miners/registry.mjs +31 -0
- package/zuzuu/scaffold.mjs +213 -0
- package/zuzuu/session.mjs +72 -0
- 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
|
+
}
|