@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,125 @@
|
|
|
1
|
+
// zuzuu/commands/migrate.mjs
|
|
2
|
+
// `zuzuu migrate` — one-time proposal schema migrator (WS2-T5).
|
|
3
|
+
//
|
|
4
|
+
// Tidies on-disk legacy Knowledge proposals from the old {candidate, er} shape
|
|
5
|
+
// to the unified spine shape {payload, analysis, faculty}. The spine already
|
|
6
|
+
// dual-reads both formats; this migrator exists so on-disk records are clean.
|
|
7
|
+
//
|
|
8
|
+
// Pure core: migrateProposals(agentDir) → { scanned, migrated, skipped }
|
|
9
|
+
// CLI surface: migrate(args) — resolves agentDir, runs core, prints summary.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { paths } from '../store.mjs';
|
|
14
|
+
import { proposalsDir, archiveDir } from '../faculty/contract.mjs';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// pure core — testable without process.*
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determine whether a parsed JSON record is already in the new shape.
|
|
22
|
+
* A record is "new" when it has `payload` AND `faculty` set.
|
|
23
|
+
* If it only has `candidate` and/or lacks `faculty` it is legacy.
|
|
24
|
+
*/
|
|
25
|
+
function isLegacy(rec) {
|
|
26
|
+
if (!rec || typeof rec !== 'object') return false;
|
|
27
|
+
// already migrated: has payload and faculty
|
|
28
|
+
if (rec.payload !== undefined && rec.faculty !== undefined) return false;
|
|
29
|
+
// legacy if it has candidate or er keys, or is simply missing faculty/payload
|
|
30
|
+
return rec.candidate !== undefined || rec.er !== undefined || rec.faculty === undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert a legacy record to the new unified shape.
|
|
35
|
+
* Returns the migrated record (caller writes it back).
|
|
36
|
+
*/
|
|
37
|
+
function migrateRecord(rec) {
|
|
38
|
+
const out = { ...rec };
|
|
39
|
+
|
|
40
|
+
// payload = candidate (drop candidate)
|
|
41
|
+
if (out.candidate !== undefined) {
|
|
42
|
+
if (out.payload === undefined) out.payload = out.candidate;
|
|
43
|
+
delete out.candidate;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// analysis = { er } (drop er)
|
|
47
|
+
if (out.er !== undefined) {
|
|
48
|
+
if (out.analysis === undefined) out.analysis = { er: out.er };
|
|
49
|
+
delete out.er;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// faculty defaults to 'knowledge' (only knowledge proposals exist pre-spine)
|
|
53
|
+
if (!out.faculty) out.faculty = 'knowledge';
|
|
54
|
+
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scan one directory of *.json files and migrate legacy records in-place.
|
|
60
|
+
* Fail-soft: bad JSON files are counted as skipped and never throw.
|
|
61
|
+
* Returns { migrated, scanned, skipped } for this directory.
|
|
62
|
+
*/
|
|
63
|
+
function migrateDir(dir) {
|
|
64
|
+
if (!existsSync(dir)) return { migrated: 0, scanned: 0, skipped: 0 };
|
|
65
|
+
|
|
66
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
67
|
+
let migrated = 0;
|
|
68
|
+
let skipped = 0;
|
|
69
|
+
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const fpath = join(dir, file);
|
|
72
|
+
let rec;
|
|
73
|
+
try {
|
|
74
|
+
rec = JSON.parse(readFileSync(fpath, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
skipped++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!isLegacy(rec)) {
|
|
81
|
+
skipped++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const migrated_rec = migrateRecord(rec);
|
|
87
|
+
writeFileSync(fpath, JSON.stringify(migrated_rec, null, 2) + '\n');
|
|
88
|
+
migrated++;
|
|
89
|
+
} catch {
|
|
90
|
+
skipped++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { migrated, scanned: files.length, skipped };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scan both pending and archived Knowledge proposals.
|
|
99
|
+
* Returns { scanned, migrated, skipped }.
|
|
100
|
+
*/
|
|
101
|
+
export function migrateProposals(agentDir) {
|
|
102
|
+
const pending = migrateDir(proposalsDir(agentDir, 'knowledge'));
|
|
103
|
+
const archived = migrateDir(archiveDir(agentDir, 'knowledge'));
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
scanned: pending.scanned + archived.scanned,
|
|
107
|
+
migrated: pending.migrated + archived.migrated,
|
|
108
|
+
skipped: pending.skipped + archived.skipped,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// CLI surface
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export function migrate() {
|
|
117
|
+
const agentDir = paths().dir;
|
|
118
|
+
const { scanned, migrated, skipped } = migrateProposals(agentDir);
|
|
119
|
+
console.log(`migrate: scanned ${scanned} proposal(s) — migrated ${migrated}, skipped ${skipped}`);
|
|
120
|
+
if (migrated > 0) {
|
|
121
|
+
console.log(' legacy candidate/er keys rewritten to payload/analysis.er + faculty:knowledge');
|
|
122
|
+
} else {
|
|
123
|
+
console.log(' nothing to migrate (all records already in new shape)');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// `zuzuu review` — the human gate, as a daily ritual. Walks pending proposals
|
|
2
|
+
// one-by-one: shows the candidate, its evidence, and the ER verdict (with the
|
|
3
|
+
// matched item when enrich/duplicate) → y approve · n reject · e edit · s skip ·
|
|
4
|
+
// q quit. Works piped (answers on stdin) — that's also how it's tested.
|
|
5
|
+
// Non-interactive surface: `zuzuu proposals list|show|approve|reject`.
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import { paths, readIndex } from '../store.mjs';
|
|
11
|
+
import { processInbox } from '../knowledge/inbox.mjs';
|
|
12
|
+
import { getProposal, proposalsDir } from '../knowledge/proposals.mjs';
|
|
13
|
+
import { readItem } from '../knowledge/items.mjs';
|
|
14
|
+
import * as registry from '../faculty/registry.mjs';
|
|
15
|
+
import * as gate from '../faculty/gate.mjs';
|
|
16
|
+
import { listProposals as spineListProposals } from '../faculty/proposal.mjs';
|
|
17
|
+
import { mintGeneration, activeGeneration } from '../faculty/generation.mjs';
|
|
18
|
+
import { rank } from '../eval/rank.mjs';
|
|
19
|
+
import { getScorer, mechanicalScore } from '../eval/score.mjs';
|
|
20
|
+
import { evalLine } from './eval.mjs';
|
|
21
|
+
import '../knowledge/adapter.mjs'; // self-registers the 'knowledge' adapter
|
|
22
|
+
import '../actions/adapter.mjs'; // self-registers the 'actions' adapter
|
|
23
|
+
import '../guardrails/adapter.mjs'; // self-registers the 'guardrails' adapter
|
|
24
|
+
import '../instructions/adapter.mjs'; // self-registers the 'instructions' adapter
|
|
25
|
+
import '../memory/adapter.mjs'; // self-registers the 'memory' adapter
|
|
26
|
+
|
|
27
|
+
/** Build sessionMtimes map from the sessions index — best-effort, fail-open. */
|
|
28
|
+
function buildSessionMtimes() {
|
|
29
|
+
try {
|
|
30
|
+
const idx = readIndex();
|
|
31
|
+
const map = {};
|
|
32
|
+
for (const s of idx.sessions ?? []) {
|
|
33
|
+
if (!s.id) continue;
|
|
34
|
+
const ms = s.startedAt ? Date.parse(s.startedAt) : 0;
|
|
35
|
+
if (!isNaN(ms) && ms > 0) map[s.id] = ms;
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Review walks faculties in a fixed order so piped sessions are deterministic
|
|
44
|
+
// (the combo smoke test feeds one stdin across the actions pass then knowledge).
|
|
45
|
+
const REVIEW_ORDER = ['actions', 'knowledge', 'guardrails', 'instructions', 'memory'];
|
|
46
|
+
|
|
47
|
+
/** Ordered list of adapters that have pending proposals to review. */
|
|
48
|
+
function pendingByFaculty(agentDir) {
|
|
49
|
+
const adapters = registry.all();
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const ordered = [];
|
|
52
|
+
for (const name of REVIEW_ORDER) {
|
|
53
|
+
const a = adapters.find((x) => x.name === name);
|
|
54
|
+
if (a) { ordered.push(a); seen.add(name); }
|
|
55
|
+
}
|
|
56
|
+
for (const a of adapters) if (!seen.has(a.name)) ordered.push(a);
|
|
57
|
+
const sessionMtimes = buildSessionMtimes();
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const scorer = getScorer();
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const a of ordered) {
|
|
62
|
+
let proposals = facultyPending(agentDir, a);
|
|
63
|
+
if (!proposals.length) continue;
|
|
64
|
+
// Rank proposals highest-score-first (display only — never changes approval/mint).
|
|
65
|
+
const ranked = rank(proposals, scorer, { now, sessionMtimes });
|
|
66
|
+
proposals = ranked.map((r) => r.proposal);
|
|
67
|
+
out.push({ adapter: a, proposals });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pending proposals for one adapter (dir-shaped adapters override listProposals). */
|
|
73
|
+
function facultyPending(agentDir, a) {
|
|
74
|
+
if (typeof a.listProposals === 'function') return a.listProposals(agentDir);
|
|
75
|
+
// JSON-record faculties: read via the spine (records carry both the spine shape
|
|
76
|
+
// and the legacy candidate/er keys the knowledge card renders from).
|
|
77
|
+
return spineListProposals(agentDir, a.name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function card(agentDir, p, i, total, scoreResult) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push(`\n━━ proposal ${i + 1}/${total} ── ${p.id} ── ${p.kind} ── source: ${p.source ?? '-'} ━━`);
|
|
83
|
+
if (p.kind === 'registry') {
|
|
84
|
+
lines.push(` register ${p.registry.slice(0, -1)}: '${p.key}' (seen ${p.evidence?.occurrences}× in candidates)`);
|
|
85
|
+
} else {
|
|
86
|
+
const c = p.candidate;
|
|
87
|
+
lines.push(` ${c.type}: ${c.body?.slice(0, 100).replace(/\n/g, ' ')}`);
|
|
88
|
+
for (const [k, v] of Object.entries(c.attributes ?? {})) lines.push(` · ${k} = ${v}`);
|
|
89
|
+
for (const r of c.relations ?? []) lines.push(` → ${r.type} ${r.target}`);
|
|
90
|
+
const ev = p.evidence ?? {};
|
|
91
|
+
if (Object.keys(ev).length) lines.push(` evidence: ${Object.entries(ev).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')}`);
|
|
92
|
+
const er = p.er ?? {};
|
|
93
|
+
lines.push(` er: ${er.verdict}${er.match ? ` → ${er.match}` : ''} (${(er.confidence ?? 0).toFixed(2)} · ${er.reason ?? ''})`);
|
|
94
|
+
if (er.match) {
|
|
95
|
+
const m = readItem(agentDir, er.match);
|
|
96
|
+
if (m) lines.push(` existing: ${m.body.slice(0, 80).replace(/\n/g, ' ')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Eval line — always shown; scoreResult computed by caller from ranked array.
|
|
100
|
+
if (scoreResult) lines.push(` ${evalLine(scoreResult)}`);
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pure: the graduation ceremony block shown when a generation is minted.
|
|
106
|
+
* @param {string} genId
|
|
107
|
+
* @param {string[]} approvedIds
|
|
108
|
+
* @param {Object<string,number>} byFaculty faculty → approval count
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function ceremonyBlock(genId, approvedIds, byFaculty) {
|
|
112
|
+
const n = approvedIds.length;
|
|
113
|
+
const breakdown = Object.entries(byFaculty)
|
|
114
|
+
.filter(([, c]) => c > 0)
|
|
115
|
+
.map(([f, c]) => `${f} +${c}`)
|
|
116
|
+
.join(' · ');
|
|
117
|
+
return [
|
|
118
|
+
`\n✓ generation ${genId} minted from ${n} approval(s)${breakdown ? ` — ${breakdown}` : ''}.`,
|
|
119
|
+
` inspect: zuzuu generation show ${genId} · roll back: zuzuu generation rollback ${genId}`,
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function review() {
|
|
124
|
+
const agentDir = paths().dir;
|
|
125
|
+
const inbox = processInbox(agentDir);
|
|
126
|
+
if (inbox.processed) console.log(`(processed ${inbox.processed} inbox candidate(s) → proposals)`);
|
|
127
|
+
const groups = pendingByFaculty(agentDir);
|
|
128
|
+
if (!groups.length) {
|
|
129
|
+
console.log('nothing to review — knowledge and actions are current');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Line-queue instead of rl.question: with piped stdin, lines that arrive
|
|
133
|
+
// between questions would otherwise be dropped (the readline-pipe race —
|
|
134
|
+
// caught by the first smoke test). EOF answers 'q' (graceful quit).
|
|
135
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
136
|
+
const queued = [];
|
|
137
|
+
let waiter = null;
|
|
138
|
+
let closed = false;
|
|
139
|
+
rl.on('line', (l) => {
|
|
140
|
+
if (waiter) {
|
|
141
|
+
const w = waiter;
|
|
142
|
+
waiter = null;
|
|
143
|
+
w(l);
|
|
144
|
+
} else queued.push(l);
|
|
145
|
+
});
|
|
146
|
+
rl.on('close', () => {
|
|
147
|
+
closed = true;
|
|
148
|
+
if (waiter) {
|
|
149
|
+
const w = waiter;
|
|
150
|
+
waiter = null;
|
|
151
|
+
w('q');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const ask = async (q) => {
|
|
155
|
+
process.stdout.write(q);
|
|
156
|
+
if (queued.length) return queued.shift();
|
|
157
|
+
if (closed) return 'q';
|
|
158
|
+
return new Promise((res) => {
|
|
159
|
+
waiter = res;
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const approvedIds = [];
|
|
164
|
+
const approvedByFaculty = {}; // faculty → count, for the graduation ceremony
|
|
165
|
+
let approved = 0, rejected = 0, skipped = 0;
|
|
166
|
+
let totalLeft = groups.reduce((n, g) => n + g.proposals.length, 0);
|
|
167
|
+
const sessionMtimes = buildSessionMtimes();
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const scorer = getScorer();
|
|
170
|
+
// One loop over faculties with pending proposals (adapter-driven, WS2-T3).
|
|
171
|
+
for (const { adapter, proposals } of groups) {
|
|
172
|
+
const isActions = adapter.name === 'actions';
|
|
173
|
+
for (let i = 0; i < proposals.length; i++) {
|
|
174
|
+
const p = proposals[i];
|
|
175
|
+
// Compute scoreResult for this proposal (fail-open).
|
|
176
|
+
let scoreResult = null;
|
|
177
|
+
try { scoreResult = scorer(p, { now, sessionMtimes }); } catch { /* fail-open */ }
|
|
178
|
+
// Card: knowledge keeps its rich card (ER + existing-item lookup); other
|
|
179
|
+
// faculties render through the adapter contract.
|
|
180
|
+
if (adapter.name === 'knowledge') console.log(card(agentDir, p, i, proposals.length, scoreResult));
|
|
181
|
+
else {
|
|
182
|
+
const r = adapter.render(p);
|
|
183
|
+
const [head, ...rest] = r.card.split('\n');
|
|
184
|
+
console.log(`\n━━ ${adapter.name} ${i + 1}/${proposals.length} ── ${head} ━━`);
|
|
185
|
+
if (rest.length) console.log(rest.join('\n'));
|
|
186
|
+
if (scoreResult) console.log(` ${evalLine(scoreResult)}`);
|
|
187
|
+
}
|
|
188
|
+
const prompt = isActions
|
|
189
|
+
? ' [y]activate [n]reject [s]kip [q]uit > '
|
|
190
|
+
: ' [y]approve [n]reject [e]dit [s]kip [q]uit > ';
|
|
191
|
+
let acted = false;
|
|
192
|
+
while (!acted) {
|
|
193
|
+
const a = (await ask(prompt)).trim().toLowerCase();
|
|
194
|
+
if (a === 'y') {
|
|
195
|
+
const r = gate.approve(agentDir, adapter.name, p.id);
|
|
196
|
+
if (isActions) console.log(r.ok ? ' ✓ activated' : ` ✗ ${(r.errors ?? [r.action]).join('; ')}`);
|
|
197
|
+
else { console.log(r.ok ? ` ✓ ${r.action}` : ` ✗ ${(r.errors ?? [r.action]).join('; ')}`); for (const w of r.warnings ?? []) console.log(` ⚠ ${w}`); }
|
|
198
|
+
if (r.ok) { approvedIds.push(p.id); approvedByFaculty[adapter.name] = (approvedByFaculty[adapter.name] ?? 0) + 1; }
|
|
199
|
+
approved++; totalLeft--; acted = true;
|
|
200
|
+
} else if (a === 'n') {
|
|
201
|
+
const reason = isActions ? '' : (await ask(' reason (optional) > ')).trim();
|
|
202
|
+
gate.reject(agentDir, adapter.name, p.id, reason);
|
|
203
|
+
console.log(' ✗ rejected');
|
|
204
|
+
rejected++; totalLeft--; acted = true;
|
|
205
|
+
} else if (a === 'e' && !isActions) {
|
|
206
|
+
const editor = process.env.EDITOR || 'vi';
|
|
207
|
+
spawnSync(editor, [join(proposalsDir(agentDir), `${p.id}.json`)], { stdio: 'inherit' });
|
|
208
|
+
const fresh = getProposal(agentDir, p.id);
|
|
209
|
+
if (fresh) {
|
|
210
|
+
proposals[i] = fresh;
|
|
211
|
+
let freshScore = null;
|
|
212
|
+
try { freshScore = scorer(fresh, { now, sessionMtimes }); } catch { /* fail-open */ }
|
|
213
|
+
console.log(card(agentDir, fresh, i, proposals.length, freshScore));
|
|
214
|
+
}
|
|
215
|
+
} else if (a === 's') {
|
|
216
|
+
skipped++; totalLeft--; acted = true;
|
|
217
|
+
} else if (a === 'q' || a === '') {
|
|
218
|
+
rl.close();
|
|
219
|
+
console.log(`\nreview: ${approved} approved · ${rejected} rejected · ${skipped} skipped · ${totalLeft} left`);
|
|
220
|
+
if (approvedIds.length > 0) {
|
|
221
|
+
const gen = mintGeneration(agentDir, { forkedFrom: activeGeneration(agentDir), mintedFrom: approvedIds });
|
|
222
|
+
console.log(ceremonyBlock(gen.id, approvedIds, approvedByFaculty));
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
rl.close();
|
|
230
|
+
console.log(`\nreview complete: ${approved} approved · ${rejected} rejected · ${skipped} skipped`);
|
|
231
|
+
if (approvedIds.length > 0) {
|
|
232
|
+
const gen = mintGeneration(agentDir, { forkedFrom: activeGeneration(agentDir), mintedFrom: approvedIds });
|
|
233
|
+
console.log(ceremonyBlock(gen.id, approvedIds, approvedByFaculty));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resolve which faculty owns a given proposal id (used when --faculty is omitted).
|
|
239
|
+
* Defaults to 'knowledge' (the historical path) when no other faculty claims it.
|
|
240
|
+
*/
|
|
241
|
+
function facultyOf(agentDir, id, only) {
|
|
242
|
+
if (only) return only;
|
|
243
|
+
for (const { adapter, proposals } of pendingByFaculty(agentDir)) {
|
|
244
|
+
if (proposals.some((p) => p.id === id)) return adapter.name;
|
|
245
|
+
}
|
|
246
|
+
return 'knowledge';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Non-interactive: zuzuu proposals list|show <id>|approve <id>|reject <id> [--reason r] [--faculty f] */
|
|
250
|
+
export function proposals(args) {
|
|
251
|
+
const agentDir = paths().dir;
|
|
252
|
+
const sub = args._[0] || 'list';
|
|
253
|
+
const only = args.faculty; // optional filter; default = all
|
|
254
|
+
if (sub === 'list') {
|
|
255
|
+
const inbox = processInbox(agentDir);
|
|
256
|
+
if (inbox.processed) console.log(`(processed ${inbox.processed} inbox candidate(s))`);
|
|
257
|
+
const groups = pendingByFaculty(agentDir).filter((g) => !only || g.adapter.name === only);
|
|
258
|
+
const any = groups.some((g) => g.proposals.length);
|
|
259
|
+
if (!any) return console.log('no pending proposals');
|
|
260
|
+
for (const { adapter, proposals } of groups) {
|
|
261
|
+
for (const p of proposals) {
|
|
262
|
+
// knowledge keeps its historical one-liner; other faculties use adapter.render
|
|
263
|
+
if (adapter.name === 'knowledge') {
|
|
264
|
+
const what = p.kind === 'registry'
|
|
265
|
+
? `register ${p.registry.slice(0, -1)} '${p.key}'`
|
|
266
|
+
: `${p.candidate.type}: ${p.candidate.body?.slice(0, 60).replace(/\n/g, ' ')}`;
|
|
267
|
+
console.log(` ${p.id} [${p.er?.verdict ?? p.kind}] ${what}`);
|
|
268
|
+
} else {
|
|
269
|
+
console.log(` ${adapter.render(p).line}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const id = args._[1];
|
|
276
|
+
if (sub === 'show') {
|
|
277
|
+
const faculty = facultyOf(agentDir, id, only);
|
|
278
|
+
const a = registry.get(faculty);
|
|
279
|
+
const p = (a && typeof a.getProposal === 'function') ? a.getProposal(agentDir, id) : getProposal(agentDir, id);
|
|
280
|
+
if (!p) return console.error('not found');
|
|
281
|
+
console.log(JSON.stringify(p, null, 2));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (sub === 'approve') {
|
|
285
|
+
const faculty = facultyOf(agentDir, id, only);
|
|
286
|
+
const r = gate.approve(agentDir, faculty, id);
|
|
287
|
+
console.log(r.ok ? `✓ ${r.action}` : `✗ ${(r.errors ?? [r.action]).join('; ')}`);
|
|
288
|
+
for (const w of r.warnings ?? []) console.log(`⚠ ${w}`);
|
|
289
|
+
process.exit(r.ok ? 0 : 1);
|
|
290
|
+
}
|
|
291
|
+
if (sub === 'reject') {
|
|
292
|
+
const faculty = facultyOf(agentDir, id, only);
|
|
293
|
+
const r = gate.reject(agentDir, faculty, id, args.reason || '');
|
|
294
|
+
console.log(r.ok ? '✓ rejected' : '✗ not found');
|
|
295
|
+
process.exit(r.ok ? 0 : 1);
|
|
296
|
+
}
|
|
297
|
+
console.error('usage: zuzuu proposals list|show <id>|approve <id>|reject <id> [--reason r] [--faculty f]');
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `zuzuu status` — detected hosts + recorded sessions (the git-native index).
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { detected } from '../../experiments/experiment-1-trace-capture/adapters/registry.mjs';
|
|
5
|
+
import { readIndex, paths } from '../store.mjs';
|
|
6
|
+
import { FACULTIES } from '../faculty/contract.mjs';
|
|
7
|
+
import { listProposals } from '../faculty/proposal.mjs';
|
|
8
|
+
import { activeGeneration as activeGenerationFn } from '../faculty/generation.mjs';
|
|
9
|
+
import { detectDrift } from './doctor.mjs';
|
|
10
|
+
|
|
11
|
+
const fmtDur = (ms) => (ms < 60_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 60_000).toFixed(1)}m`);
|
|
12
|
+
|
|
13
|
+
/** Pure: structured status for a faculty home (the zuzuu-web /status source). Fail-soft per field. */
|
|
14
|
+
export function statusData(agentDir) {
|
|
15
|
+
let active = null, drift = { dirty: false, items: [] };
|
|
16
|
+
const pending = {};
|
|
17
|
+
try { active = activeGenerationFn(agentDir); } catch { active = null; }
|
|
18
|
+
for (const f of FACULTIES) {
|
|
19
|
+
try { pending[f] = listProposals(agentDir, f).length; } catch { pending[f] = 0; }
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const d = detectDrift(agentDir);
|
|
23
|
+
const items = Array.isArray(d?.drifted) ? d.drifted : [];
|
|
24
|
+
drift = { dirty: items.length > 0, items };
|
|
25
|
+
} catch { /* fail-soft */ }
|
|
26
|
+
return { home: existsSync(agentDir), activeGeneration: active, pending, drift };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure: the faculties graduation line for `zuzuu status`. Fail-soft — any error in
|
|
31
|
+
* a sub-read degrades to a safe default rather than throwing.
|
|
32
|
+
* @param {string} agentDir
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function facultiesLine(agentDir) {
|
|
36
|
+
let gen = null, pending = 0, drifted = false;
|
|
37
|
+
try { gen = activeGenerationFn(agentDir); } catch { /* fail-soft */ }
|
|
38
|
+
try {
|
|
39
|
+
for (const f of FACULTIES) {
|
|
40
|
+
try { pending += listProposals(agentDir, f).length; } catch { /* per-faculty fail-soft */ }
|
|
41
|
+
}
|
|
42
|
+
} catch { /* fail-soft */ }
|
|
43
|
+
try {
|
|
44
|
+
const d = detectDrift(agentDir);
|
|
45
|
+
drifted = Array.isArray(d?.drifted) && d.drifted.length > 0;
|
|
46
|
+
} catch { /* fail-soft */ }
|
|
47
|
+
let line = `faculties: ${gen || 'no generation yet'} · ${pending} pending review`;
|
|
48
|
+
if (drifted) line += ' · ⚠ drift (run zuzuu doctor)';
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function status(args = {}) {
|
|
53
|
+
if (args.json) { console.log(JSON.stringify(statusData(paths().dir))); return; }
|
|
54
|
+
const { sessions } = readIndex();
|
|
55
|
+
console.log(`this project — recorded sessions (agent/sessions.json): ${sessions.length}`);
|
|
56
|
+
if (!sessions.length) {
|
|
57
|
+
console.log(' none yet — run `zuzuu capture`, or just start your agent (live capture)');
|
|
58
|
+
} else {
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log(' STATUS HOST DUR GIT T/TOOLS/ERR SESSION');
|
|
61
|
+
for (const s of sessions.slice(0, 12)) {
|
|
62
|
+
const dur = fmtDur(s.durationMs || 0).padStart(6);
|
|
63
|
+
const git = (s.git?.commit ? s.git.commit.slice(0, 7) : '-------').padEnd(8);
|
|
64
|
+
const cnt = `${s.counts?.turns ?? 0}/${s.counts?.tools ?? 0}/${s.counts?.errors ?? 0}`.padEnd(11);
|
|
65
|
+
console.log(` ${s.status.padEnd(10)} ${s.host.padEnd(13)} ${dur} ${git} ${cnt} ${s.id.slice(0, 8)}`);
|
|
66
|
+
}
|
|
67
|
+
if (sessions.length > 12) console.log(` … and ${sessions.length - 12} more`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try { console.log('\n' + facultiesLine(paths().dir)); } catch { /* fail-soft */ }
|
|
71
|
+
|
|
72
|
+
const hosts = detected();
|
|
73
|
+
console.log('\nhosts detected on this machine:');
|
|
74
|
+
if (!hosts.length) {
|
|
75
|
+
console.log(' (none — no supported agent data found)');
|
|
76
|
+
} else {
|
|
77
|
+
for (const a of hosts) {
|
|
78
|
+
const n = a.listSessions({ cwd: process.cwd() }).length;
|
|
79
|
+
console.log(` ● ${a.name} (${n} session${n === 1 ? '' : 's'} available)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `zuzuu trace [--last | <file>]` — print the span tree of a captured trace.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { loadSpans, renderTree } from '../../experiments/experiment-1-trace-capture/core/render.mjs';
|
|
5
|
+
import { lastTrace } from '../store.mjs';
|
|
6
|
+
|
|
7
|
+
export function trace(args) {
|
|
8
|
+
let file = args._[0];
|
|
9
|
+
if (args.last || !file) file = lastTrace();
|
|
10
|
+
if (!file) {
|
|
11
|
+
console.error('no trace found — run `zuzuu capture` first, or pass a file path');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
if (!existsSync(file)) {
|
|
15
|
+
console.error(`no such trace file: ${file}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
console.log(renderTree(loadSpans(file)));
|
|
19
|
+
}
|
package/zuzuu/digest.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// zuzuu/digest.mjs
|
|
2
|
+
// The grounding digest — a pure, deterministic, zero-network, no-model brief of
|
|
3
|
+
// the faculty home, injected at session start. Returns { text, sections }.
|
|
4
|
+
// I/O-free: callers (the CLI + the SessionStart hook) handle output. Every
|
|
5
|
+
// reader is wrapped so a single broken faculty never sinks the whole digest.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { allItems } from './knowledge/items.mjs';
|
|
10
|
+
import { listProposals } from './knowledge/proposals.mjs';
|
|
11
|
+
import { loadRules } from './guardrails.mjs';
|
|
12
|
+
import { allActions } from './actions/manifest.mjs';
|
|
13
|
+
|
|
14
|
+
const PLACEHOLDER_MARK = '<!-- Fill in:';
|
|
15
|
+
|
|
16
|
+
/** Read instructions/project.md; classify empty vs steering text. */
|
|
17
|
+
function readInstructions(agentDir) {
|
|
18
|
+
const path = join(agentDir, 'instructions', 'project.md');
|
|
19
|
+
let raw = '';
|
|
20
|
+
try {
|
|
21
|
+
raw = readFileSync(path, 'utf8');
|
|
22
|
+
} catch { /* missing or unreadable → treat as empty */ }
|
|
23
|
+
const stripped = raw.replace(/^#.*$/gm, '').trim();
|
|
24
|
+
const empty = !stripped || raw.includes(PLACEHOLDER_MARK);
|
|
25
|
+
return { empty, text: empty ? '' : raw.trim() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INTERVIEW = [
|
|
29
|
+
'Project steering is empty. Before substantive work, interview your human',
|
|
30
|
+
'(what is this project, its conventions, its priorities), draft',
|
|
31
|
+
'agent/instructions/project.md from their answers, and get their approval.',
|
|
32
|
+
].join(' ');
|
|
33
|
+
|
|
34
|
+
function knowledgeSection(agentDir, limit) {
|
|
35
|
+
try {
|
|
36
|
+
const { items } = allItems(agentDir);
|
|
37
|
+
const ranked = [...items]
|
|
38
|
+
.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)))
|
|
39
|
+
.slice(0, limit);
|
|
40
|
+
return { count: items.length, shown: ranked.map((i) => ({ id: i.id, type: i.type, body: i.body })) };
|
|
41
|
+
} catch {
|
|
42
|
+
return { count: 0, shown: [] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function proposalsSection(agentDir) {
|
|
47
|
+
try {
|
|
48
|
+
// count only pending — defensive if listProposals ever returns archived too
|
|
49
|
+
const pending = listProposals(agentDir).filter((p) => p.status === 'pending');
|
|
50
|
+
return { pending: pending.length };
|
|
51
|
+
} catch {
|
|
52
|
+
return { pending: 0 };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function actionsSection(agentDir, limit) {
|
|
57
|
+
try {
|
|
58
|
+
const list = allActions(agentDir);
|
|
59
|
+
return { count: list.length, shown: list.slice(0, limit).map((a) => ({ slug: a.slug, kind: a.kind, promptSnippet: a.promptSnippet })) };
|
|
60
|
+
} catch {
|
|
61
|
+
return { count: 0, shown: [] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function guardrailsSection(agentDir) {
|
|
66
|
+
try {
|
|
67
|
+
const loaded = loadRules(join(agentDir, 'guardrails', 'rules.json'));
|
|
68
|
+
return { ok: loaded.ok, count: loaded.ok ? loaded.rules.length : 0 };
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: false, count: 0 };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute the digest for a faculty home.
|
|
76
|
+
* @param {string} agentDir path to the agent/ directory
|
|
77
|
+
* @param {{ knowledgeLimit?: number, budget?: number }} options
|
|
78
|
+
* @returns {{ text: string, sections: object }}
|
|
79
|
+
*/
|
|
80
|
+
export function computeDigest(agentDir, { knowledgeLimit = 5, budget = 1500 } = {}) {
|
|
81
|
+
const charBudget = budget * 4;
|
|
82
|
+
const sections = {};
|
|
83
|
+
const lines = ['# zuzuu faculty digest', ''];
|
|
84
|
+
|
|
85
|
+
const instr = readInstructions(agentDir);
|
|
86
|
+
sections.instructions = instr;
|
|
87
|
+
lines.push('## Instructions');
|
|
88
|
+
lines.push(instr.empty ? INTERVIEW : instr.text);
|
|
89
|
+
lines.push('');
|
|
90
|
+
|
|
91
|
+
const knowledge = knowledgeSection(agentDir, knowledgeLimit);
|
|
92
|
+
lines.push('## Knowledge');
|
|
93
|
+
if (!knowledge.count) {
|
|
94
|
+
lines.push('(no items yet — propose facts to knowledge/inbox/)');
|
|
95
|
+
sections.knowledge = { ...knowledge, renderedCount: 0 };
|
|
96
|
+
} else {
|
|
97
|
+
lines.push(`${knowledge.count} item(s); most recent:`);
|
|
98
|
+
let shown = 0;
|
|
99
|
+
for (const it of knowledge.shown) {
|
|
100
|
+
const line = `- ${it.id} · ${it.type} · ${it.body.split('\n')[0].slice(0, 80)}`;
|
|
101
|
+
// join is O(items²) but trivial: once-per-session, knowledgeLimit default 5
|
|
102
|
+
if (lines.join('\n').length + line.length > charBudget && shown > 0) break;
|
|
103
|
+
lines.push(line);
|
|
104
|
+
shown++;
|
|
105
|
+
}
|
|
106
|
+
const dropped = knowledge.count - shown;
|
|
107
|
+
if (dropped > 0) lines.push(`- … (${dropped} more — \`zuzuu recall\`)`);
|
|
108
|
+
// `shown` = items actually rendered (after budget); `count` = total available
|
|
109
|
+
sections.knowledge = { ...knowledge, shown: knowledge.shown.slice(0, shown), renderedCount: shown };
|
|
110
|
+
}
|
|
111
|
+
lines.push('');
|
|
112
|
+
|
|
113
|
+
const actions = actionsSection(agentDir, knowledgeLimit);
|
|
114
|
+
sections.actions = actions;
|
|
115
|
+
if (actions.count) {
|
|
116
|
+
lines.push('## Actions');
|
|
117
|
+
lines.push(`${actions.count} available; run with \`zuzuu act <slug>\`:`);
|
|
118
|
+
let shownA = 0;
|
|
119
|
+
for (const a of actions.shown) {
|
|
120
|
+
const line = `- ${a.slug} · ${a.promptSnippet}`;
|
|
121
|
+
if (lines.join('\n').length + line.length > charBudget && shownA > 0) break;
|
|
122
|
+
lines.push(line);
|
|
123
|
+
shownA++;
|
|
124
|
+
}
|
|
125
|
+
const droppedA = actions.count - shownA;
|
|
126
|
+
if (droppedA > 0) lines.push(`- … (${droppedA} more — \`zuzuu act list\`)`);
|
|
127
|
+
lines.push('');
|
|
128
|
+
// mirror the Knowledge contract: shown reflects what actually rendered
|
|
129
|
+
sections.actions = { ...actions, shown: actions.shown.slice(0, shownA), renderedCount: shownA };
|
|
130
|
+
} else {
|
|
131
|
+
sections.actions = { ...actions, renderedCount: 0 };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const proposals = proposalsSection(agentDir);
|
|
135
|
+
sections.proposals = proposals;
|
|
136
|
+
if (proposals.pending > 0) {
|
|
137
|
+
lines.push('## Proposals');
|
|
138
|
+
lines.push(`${proposals.pending} proposal(s) await your approval — run \`zuzuu review\`; approving mints a generation (your checkpoint).`);
|
|
139
|
+
lines.push('');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const guardrails = guardrailsSection(agentDir);
|
|
143
|
+
sections.guardrails = guardrails;
|
|
144
|
+
lines.push('## Guardrails');
|
|
145
|
+
lines.push(guardrails.count ? `${guardrails.count} rule(s) — the enforced gate is on; refusals are policy.` : 'no rules configured.');
|
|
146
|
+
lines.push('');
|
|
147
|
+
|
|
148
|
+
return { text: lines.join('\n').trimEnd() + '\n', sections };
|
|
149
|
+
}
|