@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,31 @@
|
|
|
1
|
+
// zuzuu/eval/rank.mjs
|
|
2
|
+
// Rank proposals by mechanical score, high→low.
|
|
3
|
+
// Pure — no FS, no side-effects. Input array is never mutated.
|
|
4
|
+
|
|
5
|
+
import { mechanicalScore } from './score.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Rank an array of proposals by score, descending.
|
|
9
|
+
* Stable on ties: proposals with equal scores are ordered by proposal.id (lexicographic ascending).
|
|
10
|
+
*
|
|
11
|
+
* @param {object[]} proposals - Array of unified proposal records.
|
|
12
|
+
* @param {Function} scorer - Scoring function (default: mechanicalScore).
|
|
13
|
+
* @param {object} opts - Options forwarded to the scorer (now, sessionMtimes, thresholds).
|
|
14
|
+
* @returns {Array<{ proposal, score, confidence, rationale, signals }>} - New array, sorted DESC.
|
|
15
|
+
*/
|
|
16
|
+
export function rank(proposals, scorer = mechanicalScore, opts = {}) {
|
|
17
|
+
const scored = proposals.map((proposal) => ({
|
|
18
|
+
proposal,
|
|
19
|
+
...scorer(proposal, opts),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Sort descending by score; stable tie-break by proposal.id ascending.
|
|
23
|
+
scored.sort((a, b) => {
|
|
24
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
25
|
+
const idA = String(a.proposal?.id ?? '');
|
|
26
|
+
const idB = String(b.proposal?.id ?? '');
|
|
27
|
+
return idA < idB ? -1 : idA > idB ? 1 : 0;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return scored;
|
|
31
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// zuzuu/eval/score.mjs
|
|
2
|
+
// Mechanical scorer — weighted sum of normalized signals → { score, confidence, rationale, signals }.
|
|
3
|
+
// Pure; deterministic; no FS, no Date.now(), no Math.random().
|
|
4
|
+
|
|
5
|
+
import { extractSignals } from './signals.mjs';
|
|
6
|
+
|
|
7
|
+
// Weight vector (must sum to 1.0).
|
|
8
|
+
const W = {
|
|
9
|
+
occurrence: 0.30,
|
|
10
|
+
corroboration: 0.30,
|
|
11
|
+
recency: 0.15,
|
|
12
|
+
failureReduction: 0.15,
|
|
13
|
+
erNovelty: 0.10,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a short human-readable rationale from the dominant signals.
|
|
18
|
+
* Deterministic — purely a function of signal values.
|
|
19
|
+
*/
|
|
20
|
+
function buildRationale(s) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
|
|
23
|
+
// Positive signals
|
|
24
|
+
if (s.occurrence >= 0.8 && s.corroboration >= 0.8) {
|
|
25
|
+
parts.push('recurring + cross-session');
|
|
26
|
+
} else if (s.occurrence >= 0.8) {
|
|
27
|
+
parts.push('high occurrence');
|
|
28
|
+
} else if (s.corroboration >= 0.8) {
|
|
29
|
+
parts.push('strong cross-session coverage');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (s.failureReduction >= 0.5) {
|
|
33
|
+
parts.push('addresses repeated failures');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (s.recency >= 0.8) {
|
|
37
|
+
parts.push('recently active');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Novelty framing
|
|
41
|
+
if (s.erNovelty === 0) {
|
|
42
|
+
parts.push('already known');
|
|
43
|
+
} else if (s.erNovelty === 1 && parts.length === 0) {
|
|
44
|
+
parts.push('novel signal');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Low-signal fallback
|
|
48
|
+
if (parts.length === 0) {
|
|
49
|
+
parts.push('weak evidence');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return parts.join('; ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Score a proposal mechanically.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} proposal - Unified proposal record.
|
|
59
|
+
* @param {object} opts - Passed through to extractSignals (now, sessionMtimes, thresholds).
|
|
60
|
+
* @returns {{ score: number, confidence: string, rationale: string, signals: object }}
|
|
61
|
+
*/
|
|
62
|
+
export function mechanicalScore(proposal, opts = {}) {
|
|
63
|
+
const s = extractSignals(proposal, opts);
|
|
64
|
+
|
|
65
|
+
const raw =
|
|
66
|
+
W.occurrence * s.occurrence +
|
|
67
|
+
W.corroboration * s.corroboration +
|
|
68
|
+
W.recency * s.recency +
|
|
69
|
+
W.failureReduction * s.failureReduction +
|
|
70
|
+
W.erNovelty * s.erNovelty;
|
|
71
|
+
|
|
72
|
+
const score = Number(Math.min(Math.max(raw, 0), 1).toFixed(4));
|
|
73
|
+
|
|
74
|
+
const confidence = score >= 0.66 ? 'high' : score >= 0.33 ? 'med' : 'low';
|
|
75
|
+
|
|
76
|
+
const rationale = buildRationale(s);
|
|
77
|
+
|
|
78
|
+
return { score, confidence, rationale, signals: s };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const SCORERS = { mechanical: mechanicalScore };
|
|
82
|
+
|
|
83
|
+
export function getScorer(name = 'mechanical') {
|
|
84
|
+
return SCORERS[name] ?? mechanicalScore;
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// zuzuu/eval/signals.mjs
|
|
2
|
+
// Extract a normalized signal vector from a proposal record.
|
|
3
|
+
// Pure — no FS, no Date.now(), no Math.random(). Inject `now` and `sessionMtimes`.
|
|
4
|
+
|
|
5
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract a normalized signal vector from a proposal.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} proposal - A unified proposal record (payload, analysis, evidence, provenance).
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {number} opts.now - Current epoch ms (injected; default 0).
|
|
13
|
+
* @param {object} opts.sessionMtimes - Map of sessionId → epoch ms of last modification.
|
|
14
|
+
* @param {object} opts.thresholds - Override default normalisation denominators.
|
|
15
|
+
* @returns {{ occurrence, corroboration, recency, failureReduction, erNovelty }} — each in [0,1].
|
|
16
|
+
*/
|
|
17
|
+
export function extractSignals(proposal, { now = 0, sessionMtimes = {}, thresholds = {} } = {}) {
|
|
18
|
+
const evidence = proposal?.evidence ?? {};
|
|
19
|
+
const analysis = proposal?.analysis ?? {};
|
|
20
|
+
const provenance = Array.isArray(proposal?.provenance) ? proposal.provenance : [];
|
|
21
|
+
|
|
22
|
+
const occurrenceThresh = thresholds.occurrence ?? 10;
|
|
23
|
+
const sessionsThresh = thresholds.sessions ?? 3;
|
|
24
|
+
const failuresThresh = thresholds.failures ?? 3;
|
|
25
|
+
const recencyWindowMs = thresholds.recencyWindowMs ?? THIRTY_DAYS_MS;
|
|
26
|
+
|
|
27
|
+
// occurrence: how often did this pattern appear?
|
|
28
|
+
const occurrence = Math.min((evidence.occurrences ?? 0) / occurrenceThresh, 1);
|
|
29
|
+
|
|
30
|
+
// corroboration: how many distinct sessions contributed?
|
|
31
|
+
const corroboration = Math.min((evidence.sessions ?? 0) / sessionsThresh, 1);
|
|
32
|
+
|
|
33
|
+
// recency: find the newest known session mtime among the provenance entries.
|
|
34
|
+
// If no provenance sessions are found in sessionMtimes → neutral 0.5.
|
|
35
|
+
let recency;
|
|
36
|
+
const knownMtimes = provenance
|
|
37
|
+
.map((p) => sessionMtimes[p?.session])
|
|
38
|
+
.filter((ms) => ms !== undefined && ms !== null);
|
|
39
|
+
|
|
40
|
+
if (knownMtimes.length === 0) {
|
|
41
|
+
recency = 0.5;
|
|
42
|
+
} else {
|
|
43
|
+
const newest = Math.max(...knownMtimes);
|
|
44
|
+
const age = now - newest;
|
|
45
|
+
recency = 1 - Math.min(age / recencyWindowMs, 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// failureReduction: a proposal that addresses repeated failures is valuable.
|
|
49
|
+
const failureReduction = Math.min((evidence.failures ?? 0) / failuresThresh, 1);
|
|
50
|
+
|
|
51
|
+
// erNovelty: reward novel / enrich over duplicate.
|
|
52
|
+
const verdictMap = { new: 1, enrich: 0.5, duplicate: 0 };
|
|
53
|
+
const verdict = analysis?.er?.verdict;
|
|
54
|
+
const erNovelty = verdict in verdictMap ? verdictMap[verdict] : 0.5;
|
|
55
|
+
|
|
56
|
+
return { occurrence, corroboration, recency, failureReduction, erNovelty };
|
|
57
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// zuzuu/faculty/contract.mjs
|
|
2
|
+
// Canonical per-faculty paths — single source of truth for the faculty spine.
|
|
3
|
+
// All five us-owned faculties; path helpers are pure (no I/O).
|
|
4
|
+
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const FACULTIES = ['knowledge', 'memory', 'actions', 'instructions', 'guardrails'];
|
|
8
|
+
|
|
9
|
+
/** Root directory for a faculty under agentDir. */
|
|
10
|
+
export const facultyDir = (agentDir, f) => join(agentDir, f);
|
|
11
|
+
|
|
12
|
+
/** Inbox directory (agent-proposed items awaiting review). */
|
|
13
|
+
export const inboxDir = (agentDir, f) => join(agentDir, f, 'inbox');
|
|
14
|
+
|
|
15
|
+
/** Pending proposals directory. */
|
|
16
|
+
export const proposalsDir = (agentDir, f) => join(agentDir, f, 'proposals');
|
|
17
|
+
|
|
18
|
+
/** Archive directory for resolved (approved/rejected) proposals. */
|
|
19
|
+
export const archiveDir = (agentDir, f) => join(agentDir, f, 'proposals', 'archive');
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// zuzuu/faculty/gate.mjs
|
|
2
|
+
// The generic approve/reject orchestrator for the faculty spine (WS2-T3).
|
|
3
|
+
// Replaces the per-faculty inline approve/reject bodies with one adapter-driven
|
|
4
|
+
// path: look up the faculty adapter, validate the payload, apply on approve,
|
|
5
|
+
// archive the proposal record, and record an observability trail entry.
|
|
6
|
+
//
|
|
7
|
+
// Dir-shaped faculties (Actions) carry their payload as a directory, not a JSON
|
|
8
|
+
// blob. Such adapters expose `rejectDir(agentDir, id, reason)` (a dir move into
|
|
9
|
+
// proposals/archive/) which the gate prefers over the JSON archiveProposal.
|
|
10
|
+
//
|
|
11
|
+
// Fail-soft on trail: a logging failure must never affect approve/reject.
|
|
12
|
+
|
|
13
|
+
import * as registry from './registry.mjs';
|
|
14
|
+
import { readProposal, archiveProposal } from './proposal.mjs';
|
|
15
|
+
import { recordTrail } from './trail.mjs';
|
|
16
|
+
|
|
17
|
+
/** Trail is observability only — never let it throw into the caller. */
|
|
18
|
+
function trail(agentDir, faculty, entry) {
|
|
19
|
+
try {
|
|
20
|
+
recordTrail(agentDir, faculty, entry);
|
|
21
|
+
} catch {
|
|
22
|
+
/* fail-soft */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Approve a proposal: validate → apply → archive (status approved) → trail.
|
|
28
|
+
* @returns the adapter's apply result, or {ok:false, errors} on a validation miss.
|
|
29
|
+
*/
|
|
30
|
+
export function approve(agentDir, faculty, id) {
|
|
31
|
+
const a = registry.get(faculty);
|
|
32
|
+
if (!a) return { ok: false, errors: [`no adapter for faculty '${faculty}'`] };
|
|
33
|
+
// dir-shaped faculties (Actions) carry no JSON record — let the adapter resolve.
|
|
34
|
+
const p = (typeof a.getProposal === 'function')
|
|
35
|
+
? a.getProposal(agentDir, id)
|
|
36
|
+
: readProposal(agentDir, faculty, id);
|
|
37
|
+
if (!p) return { ok: false, errors: [`no proposal '${id}' in '${faculty}'`] };
|
|
38
|
+
const v = a.validate(agentDir, p.payload);
|
|
39
|
+
if (!v.ok) return { ok: false, errors: v.errors };
|
|
40
|
+
const r = a.apply(agentDir, p);
|
|
41
|
+
// Only archive as approved if apply actually succeeded — a failed apply (e.g.
|
|
42
|
+
// an action that already exists) must leave the proposal PENDING so it can be
|
|
43
|
+
// retried, never silently archived as "approved" (matches the prior
|
|
44
|
+
// approveProposal `if (!r.ok) return r` guard).
|
|
45
|
+
if (!r || !r.ok) return r || { ok: false, errors: ['apply returned nothing'] };
|
|
46
|
+
archiveProposal(agentDir, faculty, id, { status: 'approved', applied: r.action });
|
|
47
|
+
trail(agentDir, faculty, { kind: 'approve', id, applied: r.action });
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reject a proposal: archive (status rejected) → trail. NEVER a destructive
|
|
53
|
+
* delete — the record (or dir, for dir-shaped faculties) moves to archive/.
|
|
54
|
+
* @returns {{ok:true}}
|
|
55
|
+
*/
|
|
56
|
+
export function reject(agentDir, faculty, id, reason = '') {
|
|
57
|
+
const a = registry.get(faculty);
|
|
58
|
+
if (a && typeof a.rejectDir === 'function') {
|
|
59
|
+
a.rejectDir(agentDir, id, reason);
|
|
60
|
+
} else {
|
|
61
|
+
archiveProposal(agentDir, faculty, id, { status: 'rejected', reason });
|
|
62
|
+
}
|
|
63
|
+
trail(agentDir, faculty, { kind: 'reject', id, reason });
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// zuzuu/faculty/generation.mjs — the generation core (WS3-T1).
|
|
2
|
+
//
|
|
3
|
+
// A *generation* is an immutable, content-addressed snapshot of the agent's
|
|
4
|
+
// pinned faculties (the lockfile). Minting freezes the current faculty state;
|
|
5
|
+
// rollback restores any past generation by *content* (we copy each pinned item's
|
|
6
|
+
// bytes into generations/snapshots/<id>/ at mint time, so a rollback works even
|
|
7
|
+
// for items that were never committed). Identity: Agent → Generation → Run —
|
|
8
|
+
// rollback = flip the active pointer + restore content; never `git revert`.
|
|
9
|
+
//
|
|
10
|
+
// Layout under agent/:
|
|
11
|
+
// generations/active {active: "gen_NNN"} — the live pointer
|
|
12
|
+
// generations/<id>.json the lockfile (content-addressed manifest)
|
|
13
|
+
// generations/snapshots/<id>/<faculty>/... pinned item bytes (rollback source)
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, renameSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { reindex } from '../knowledge/index.mjs';
|
|
21
|
+
|
|
22
|
+
/** Hex sha256 of a string or Buffer. */
|
|
23
|
+
export function sha256(buf) {
|
|
24
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const read = (p) => readFileSync(p, 'utf8');
|
|
28
|
+
const readJson = (p) => JSON.parse(read(p));
|
|
29
|
+
const writeJson = (p, obj) => {
|
|
30
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
31
|
+
writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// --- paths ------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const generationsDir = (agentDir) => join(agentDir, 'generations');
|
|
37
|
+
const snapshotsDir = (agentDir) => join(generationsDir(agentDir), 'snapshots');
|
|
38
|
+
const activePath = (agentDir) => join(generationsDir(agentDir), 'active');
|
|
39
|
+
const lockfilePath = (agentDir, id) => join(generationsDir(agentDir), `${id}.json`);
|
|
40
|
+
const agentJsonPath = (agentDir) => join(agentDir, 'agent.json');
|
|
41
|
+
|
|
42
|
+
// --- faculty file enumeration (the pinned set) ------------------------------
|
|
43
|
+
// Each entry: { id, faculty, src (absolute live path), rel (path under the
|
|
44
|
+
// faculty snapshot dir), hash }. `rel` is what we mirror into snapshots/<id>/.
|
|
45
|
+
|
|
46
|
+
function sortDirents(dir) {
|
|
47
|
+
if (!existsSync(dir)) return [];
|
|
48
|
+
return readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function knowledgeFiles(agentDir) {
|
|
52
|
+
const dir = join(agentDir, 'knowledge', 'items');
|
|
53
|
+
return sortDirents(dir)
|
|
54
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
55
|
+
.map((e) => {
|
|
56
|
+
const src = join(dir, e.name);
|
|
57
|
+
return { id: e.name.replace(/\.md$/, ''), faculty: 'knowledge', src, rel: e.name, hash: sha256(readFileSync(src)) };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function actionFiles(agentDir) {
|
|
62
|
+
const dir = join(agentDir, 'actions');
|
|
63
|
+
return sortDirents(dir)
|
|
64
|
+
.filter((e) => e.isDirectory() && e.name !== 'inbox' && e.name !== 'proposals')
|
|
65
|
+
.map((e) => {
|
|
66
|
+
const adir = join(dir, e.name);
|
|
67
|
+
// Hash the dir's defining files concatenated (action.json + run.mjs/SKILL.md).
|
|
68
|
+
const parts = ['action.json', 'run.mjs', 'SKILL.md']
|
|
69
|
+
.map((f) => join(adir, f))
|
|
70
|
+
.filter((p) => existsSync(p));
|
|
71
|
+
const concat = Buffer.concat(parts.map((p) => readFileSync(p)));
|
|
72
|
+
return {
|
|
73
|
+
id: e.name, faculty: 'actions', files: parts.map((p) => p.slice(adir.length + 1)),
|
|
74
|
+
adir, hash: parts.length ? sha256(concat) : null,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function memoryFiles(agentDir) {
|
|
80
|
+
const dir = join(agentDir, 'memory', 'entries');
|
|
81
|
+
return sortDirents(dir)
|
|
82
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
83
|
+
.map((e) => {
|
|
84
|
+
const src = join(dir, e.name);
|
|
85
|
+
return { id: e.name.replace(/\.md$/, ''), faculty: 'memory', src, rel: e.name, hash: sha256(readFileSync(src)) };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function registryHash(agentDir) {
|
|
90
|
+
const dir = join(agentDir, 'knowledge', 'registry');
|
|
91
|
+
const files = sortDirents(dir).filter((e) => e.isFile() && e.name.endsWith('.json'));
|
|
92
|
+
if (!files.length) return null;
|
|
93
|
+
return sha256(Buffer.concat(files.map((e) => readFileSync(join(dir, e.name)))));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fileHashOrNull(p) {
|
|
97
|
+
return existsSync(p) ? sha256(readFileSync(p)) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Snapshot the current faculty state → the `faculties` manifest object.
|
|
102
|
+
* Tolerates missing files (empty arrays / null hashes).
|
|
103
|
+
*/
|
|
104
|
+
export function snapshotFaculties(agentDir) {
|
|
105
|
+
return {
|
|
106
|
+
knowledge: {
|
|
107
|
+
items: knowledgeFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
108
|
+
registryHash: registryHash(agentDir),
|
|
109
|
+
},
|
|
110
|
+
actions: {
|
|
111
|
+
items: actionFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
112
|
+
},
|
|
113
|
+
guardrails: {
|
|
114
|
+
rulesHash: fileHashOrNull(join(agentDir, 'guardrails', 'rules.json')),
|
|
115
|
+
},
|
|
116
|
+
instructions: {
|
|
117
|
+
projectHash: fileHashOrNull(join(agentDir, 'instructions', 'project.md')),
|
|
118
|
+
},
|
|
119
|
+
memory: {
|
|
120
|
+
items: memoryFiles(agentDir).map(({ id, hash }) => ({ id, hash })),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- agent identity ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** Stable agent id derived from the repo root: agt_<first12 of sha256(root)>. */
|
|
128
|
+
export function agentId(agentDir) {
|
|
129
|
+
// agentDir is the agent/ dir; the repo root is its parent.
|
|
130
|
+
const root = dirname(agentDir);
|
|
131
|
+
return 'agt_' + sha256(root).slice(0, 12);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Add/repair the agent block in agent.json (bump to v2), preserving other fields. */
|
|
135
|
+
export function ensureAgent(agentDir) {
|
|
136
|
+
const path = agentJsonPath(agentDir);
|
|
137
|
+
const m = existsSync(path) ? readJson(path) : {};
|
|
138
|
+
const id = agentId(agentDir);
|
|
139
|
+
if (!m.agent || !m.agent.id) {
|
|
140
|
+
m.agent = { id, createdAt: new Date().toISOString() };
|
|
141
|
+
}
|
|
142
|
+
m.version = 2;
|
|
143
|
+
writeJson(path, m);
|
|
144
|
+
return m.agent;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- generation read/list ---------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/** The active generation id, or null. */
|
|
150
|
+
export function activeGeneration(agentDir) {
|
|
151
|
+
const p = activePath(agentDir);
|
|
152
|
+
if (!existsSync(p)) return null;
|
|
153
|
+
try { return readJson(p).active ?? null; } catch { return null; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** All generation ids in ascending order. */
|
|
157
|
+
export function listGenerations(agentDir) {
|
|
158
|
+
const dir = generationsDir(agentDir);
|
|
159
|
+
if (!existsSync(dir)) return [];
|
|
160
|
+
return readdirSync(dir)
|
|
161
|
+
.filter((f) => /^gen_\d+\.json$/.test(f))
|
|
162
|
+
.map((f) => f.replace(/\.json$/, ''))
|
|
163
|
+
.sort();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Read one lockfile, or null. */
|
|
167
|
+
export function readGeneration(agentDir, id) {
|
|
168
|
+
const p = lockfilePath(agentDir, id);
|
|
169
|
+
return existsSync(p) ? readJson(p) : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Item-list faculties carry {id,hash}[]; single-file faculties a *Hash scalar. */
|
|
173
|
+
const HASH_KEYS = { knowledge: 'registryHash', instructions: 'projectHash', guardrails: 'rulesHash' };
|
|
174
|
+
|
|
175
|
+
/** Diff two item-manifest arrays → {added, changed, removed} (id lists). */
|
|
176
|
+
function diffItems(parentItems = [], childItems = []) {
|
|
177
|
+
const p = new Map(parentItems.map((i) => [i.id, i.hash]));
|
|
178
|
+
const c = new Map(childItems.map((i) => [i.id, i.hash]));
|
|
179
|
+
const added = [], changed = [], removed = [];
|
|
180
|
+
for (const [id, hash] of c) {
|
|
181
|
+
if (!p.has(id)) added.push(id);
|
|
182
|
+
else if (p.get(id) !== hash) changed.push(id);
|
|
183
|
+
}
|
|
184
|
+
for (const id of p.keys()) if (!c.has(id)) removed.push(id);
|
|
185
|
+
return { added: added.sort(), changed: changed.sort(), removed: removed.sort() };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Per-faculty diff of generation `id` against its forkedFrom parent (pure).
|
|
190
|
+
* For item-list faculties (knowledge/actions/memory) reports added/changed/removed
|
|
191
|
+
* id lists. For hash-only faculties (guardrails/instructions, and knowledge's
|
|
192
|
+
* registry) reports a `changed` boolean when the scalar hash differs. When there
|
|
193
|
+
* is no parent (forkedFrom null), everything present counts as added.
|
|
194
|
+
* Returns null for an unknown id.
|
|
195
|
+
*/
|
|
196
|
+
export function diffGenerations(agentDir, id) {
|
|
197
|
+
const child = readGeneration(agentDir, id);
|
|
198
|
+
if (!child) return null;
|
|
199
|
+
const parent = child.forkedFrom ? readGeneration(agentDir, child.forkedFrom) : null;
|
|
200
|
+
const cf = child.faculties || {};
|
|
201
|
+
const pf = parent?.faculties || {};
|
|
202
|
+
const faculties = {};
|
|
203
|
+
for (const f of ['knowledge', 'actions', 'memory']) {
|
|
204
|
+
faculties[f] = diffItems(pf[f]?.items, cf[f]?.items);
|
|
205
|
+
// knowledge also has a registry hash
|
|
206
|
+
if (f === 'knowledge') {
|
|
207
|
+
faculties[f].registryChanged = (cf.knowledge?.registryHash ?? null) !== (pf.knowledge?.registryHash ?? null);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const f of ['guardrails', 'instructions']) {
|
|
211
|
+
const key = HASH_KEYS[f];
|
|
212
|
+
faculties[f] = { changed: (cf[f]?.[key] ?? null) !== (pf[f]?.[key] ?? null) };
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
forkedFrom: child.forkedFrom ?? null,
|
|
217
|
+
mintedFrom: Array.isArray(child.mintedFrom) ? child.mintedFrom : [],
|
|
218
|
+
mintedAt: child.mintedAt ?? null,
|
|
219
|
+
faculties,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function nextGenId(agentDir) {
|
|
224
|
+
const ids = listGenerations(agentDir);
|
|
225
|
+
const max = ids.reduce((m, id) => Math.max(m, parseInt(id.slice(4), 10) || 0), 0);
|
|
226
|
+
return 'gen_' + String(max + 1).padStart(3, '0');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- mint -------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function copySnapshot(agentDir, id) {
|
|
232
|
+
const base = join(snapshotsDir(agentDir), id);
|
|
233
|
+
for (const it of knowledgeFiles(agentDir)) {
|
|
234
|
+
const dest = join(base, 'knowledge', it.rel);
|
|
235
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
236
|
+
writeFileSync(dest, readFileSync(it.src));
|
|
237
|
+
}
|
|
238
|
+
for (const it of memoryFiles(agentDir)) {
|
|
239
|
+
const dest = join(base, 'memory', it.rel);
|
|
240
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
241
|
+
writeFileSync(dest, readFileSync(it.src));
|
|
242
|
+
}
|
|
243
|
+
for (const a of actionFiles(agentDir)) {
|
|
244
|
+
for (const rel of a.files) {
|
|
245
|
+
const dest = join(base, 'actions', a.id, rel);
|
|
246
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
247
|
+
writeFileSync(dest, readFileSync(join(a.adir, rel)));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// single-file faculties
|
|
251
|
+
const rules = join(agentDir, 'guardrails', 'rules.json');
|
|
252
|
+
if (existsSync(rules)) {
|
|
253
|
+
const dest = join(base, 'guardrails', 'rules.json');
|
|
254
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
255
|
+
writeFileSync(dest, readFileSync(rules));
|
|
256
|
+
}
|
|
257
|
+
const proj = join(agentDir, 'instructions', 'project.md');
|
|
258
|
+
if (existsSync(proj)) {
|
|
259
|
+
const dest = join(base, 'instructions', 'project.md');
|
|
260
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
261
|
+
writeFileSync(dest, readFileSync(proj));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Mint a new generation: freeze the current faculty state into a content-addressed
|
|
267
|
+
* lockfile + a byte-for-byte snapshot, and make it active.
|
|
268
|
+
*/
|
|
269
|
+
export function mintGeneration(agentDir, { forkedFrom = null, mintedFrom = [] } = {}) {
|
|
270
|
+
const agent = ensureAgent(agentDir).id;
|
|
271
|
+
const id = nextGenId(agentDir);
|
|
272
|
+
const lockfile = {
|
|
273
|
+
id,
|
|
274
|
+
agent,
|
|
275
|
+
mintedAt: new Date().toISOString(),
|
|
276
|
+
forkedFrom,
|
|
277
|
+
mintedFrom,
|
|
278
|
+
faculties: snapshotFaculties(agentDir),
|
|
279
|
+
};
|
|
280
|
+
copySnapshot(agentDir, id);
|
|
281
|
+
writeJson(lockfilePath(agentDir, id), lockfile);
|
|
282
|
+
writeJson(activePath(agentDir), { active: id });
|
|
283
|
+
return lockfile;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- rollback ---------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
function archive(agentDir, faculty, src) {
|
|
289
|
+
// Park (never delete) under <faculty>/_rolledback/<basename> — by basename so
|
|
290
|
+
// a restore is a simple, flat audit trail of what the rollback displaced.
|
|
291
|
+
const dest = join(agentDir, faculty, '_rolledback', src.slice(dirname(src).length + 1));
|
|
292
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
293
|
+
renameSync(src, dest);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Restore a past generation by content: write each snapshotted item back to its
|
|
298
|
+
* live faculty path; MOVE (never delete) active items absent from the target into
|
|
299
|
+
* <faculty>/_rolledback/; reindex knowledge; flip the active pointer.
|
|
300
|
+
*/
|
|
301
|
+
export function rollback(agentDir, id) {
|
|
302
|
+
const target = readGeneration(agentDir, id);
|
|
303
|
+
if (!target) throw new Error(`no generation '${id}'`);
|
|
304
|
+
const base = join(snapshotsDir(agentDir), id);
|
|
305
|
+
let restored = 0;
|
|
306
|
+
|
|
307
|
+
// 1) restore snapshotted knowledge items
|
|
308
|
+
const targetKnowledge = new Set((target.faculties.knowledge?.items ?? []).map((i) => i.id));
|
|
309
|
+
for (const i of target.faculties.knowledge?.items ?? []) {
|
|
310
|
+
const snap = join(base, 'knowledge', `${i.id}.md`);
|
|
311
|
+
if (existsSync(snap)) {
|
|
312
|
+
const dest = join(agentDir, 'knowledge', 'items', `${i.id}.md`);
|
|
313
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
314
|
+
writeFileSync(dest, readFileSync(snap));
|
|
315
|
+
restored++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// archive live knowledge items not in the target
|
|
319
|
+
const kdir = join(agentDir, 'knowledge', 'items');
|
|
320
|
+
if (existsSync(kdir)) {
|
|
321
|
+
for (const e of readdirSync(kdir, { withFileTypes: true })) {
|
|
322
|
+
if (e.isFile() && e.name.endsWith('.md') && !targetKnowledge.has(e.name.replace(/\.md$/, ''))) {
|
|
323
|
+
archive(agentDir, 'knowledge', join(kdir, e.name));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 2) restore snapshotted memory items + archive extras
|
|
329
|
+
const targetMemory = new Set((target.faculties.memory?.items ?? []).map((i) => i.id));
|
|
330
|
+
for (const i of target.faculties.memory?.items ?? []) {
|
|
331
|
+
const snap = join(base, 'memory', `${i.id}.md`);
|
|
332
|
+
if (existsSync(snap)) {
|
|
333
|
+
const dest = join(agentDir, 'memory', 'entries', `${i.id}.md`);
|
|
334
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
335
|
+
writeFileSync(dest, readFileSync(snap));
|
|
336
|
+
restored++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const mdir = join(agentDir, 'memory', 'entries');
|
|
340
|
+
if (existsSync(mdir)) {
|
|
341
|
+
for (const e of readdirSync(mdir, { withFileTypes: true })) {
|
|
342
|
+
if (e.isFile() && e.name.endsWith('.md') && !targetMemory.has(e.name.replace(/\.md$/, ''))) {
|
|
343
|
+
archive(agentDir, 'memory', join(mdir, e.name));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 3) restore snapshotted actions + archive extras
|
|
349
|
+
const targetActions = new Set((target.faculties.actions?.items ?? []).map((i) => i.id));
|
|
350
|
+
const asnap = join(base, 'actions');
|
|
351
|
+
if (existsSync(asnap)) {
|
|
352
|
+
for (const slugEnt of readdirSync(asnap, { withFileTypes: true })) {
|
|
353
|
+
if (!slugEnt.isDirectory()) continue;
|
|
354
|
+
const sdir = join(asnap, slugEnt.name);
|
|
355
|
+
for (const f of readdirSync(sdir)) {
|
|
356
|
+
const dest = join(agentDir, 'actions', slugEnt.name, f);
|
|
357
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
358
|
+
writeFileSync(dest, readFileSync(join(sdir, f)));
|
|
359
|
+
}
|
|
360
|
+
restored++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const adir = join(agentDir, 'actions');
|
|
364
|
+
if (existsSync(adir)) {
|
|
365
|
+
for (const e of readdirSync(adir, { withFileTypes: true })) {
|
|
366
|
+
if (e.isDirectory() && e.name !== 'inbox' && e.name !== 'proposals' && e.name !== '_rolledback' && !targetActions.has(e.name)) {
|
|
367
|
+
archive(agentDir, 'actions', join(adir, e.name));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 4) restore single-file faculties from the snapshot
|
|
373
|
+
const grules = join(base, 'guardrails', 'rules.json');
|
|
374
|
+
if (existsSync(grules)) {
|
|
375
|
+
const dest = join(agentDir, 'guardrails', 'rules.json');
|
|
376
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
377
|
+
writeFileSync(dest, readFileSync(grules));
|
|
378
|
+
restored++;
|
|
379
|
+
}
|
|
380
|
+
const proj = join(base, 'instructions', 'project.md');
|
|
381
|
+
if (existsSync(proj)) {
|
|
382
|
+
const dest = join(agentDir, 'instructions', 'project.md');
|
|
383
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
384
|
+
writeFileSync(dest, readFileSync(proj));
|
|
385
|
+
restored++;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 5) regenerate the derived knowledge index + flip the pointer
|
|
389
|
+
try { reindex(agentDir); } catch { /* derived index; tolerate absence of node:sqlite features */ }
|
|
390
|
+
writeJson(activePath(agentDir), { active: id });
|
|
391
|
+
return { ok: true, restored };
|
|
392
|
+
}
|