@zuzuucodes/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/bin/zuzuu.mjs +133 -0
  4. package/experiments/experiment-1-trace-capture/adapters/claude-code.mjs +220 -0
  5. package/experiments/experiment-1-trace-capture/adapters/codex.mjs +201 -0
  6. package/experiments/experiment-1-trace-capture/adapters/gemini-cli.mjs +113 -0
  7. package/experiments/experiment-1-trace-capture/adapters/host-adapter.mjs +43 -0
  8. package/experiments/experiment-1-trace-capture/adapters/opencode.mjs +205 -0
  9. package/experiments/experiment-1-trace-capture/adapters/pi.mjs +218 -0
  10. package/experiments/experiment-1-trace-capture/adapters/registry.mjs +20 -0
  11. package/experiments/experiment-1-trace-capture/adapters/signals.mjs +44 -0
  12. package/experiments/experiment-1-trace-capture/core/event.mjs +58 -0
  13. package/experiments/experiment-1-trace-capture/core/ids.mjs +32 -0
  14. package/experiments/experiment-1-trace-capture/core/otlp.mjs +54 -0
  15. package/experiments/experiment-1-trace-capture/core/render.mjs +63 -0
  16. package/experiments/experiment-1-trace-capture/core/spans.mjs +43 -0
  17. package/package.json +56 -0
  18. package/zuzuu/actions/adapter.mjs +130 -0
  19. package/zuzuu/actions/convert.mjs +27 -0
  20. package/zuzuu/actions/dispatch.mjs +87 -0
  21. package/zuzuu/actions/inbox.mjs +56 -0
  22. package/zuzuu/actions/manifest.mjs +72 -0
  23. package/zuzuu/actions/marker.mjs +4 -0
  24. package/zuzuu/actions/runner.mjs +37 -0
  25. package/zuzuu/actions/schema.mjs +73 -0
  26. package/zuzuu/actions/trail.mjs +22 -0
  27. package/zuzuu/capture-core.mjs +49 -0
  28. package/zuzuu/commands/act-author.mjs +72 -0
  29. package/zuzuu/commands/act.mjs +101 -0
  30. package/zuzuu/commands/capture.mjs +32 -0
  31. package/zuzuu/commands/code.mjs +84 -0
  32. package/zuzuu/commands/digest.mjs +23 -0
  33. package/zuzuu/commands/distill.mjs +46 -0
  34. package/zuzuu/commands/doctor.mjs +197 -0
  35. package/zuzuu/commands/enable.mjs +195 -0
  36. package/zuzuu/commands/eval.mjs +101 -0
  37. package/zuzuu/commands/explain.mjs +119 -0
  38. package/zuzuu/commands/generation.mjs +107 -0
  39. package/zuzuu/commands/hook.mjs +209 -0
  40. package/zuzuu/commands/inbox.mjs +73 -0
  41. package/zuzuu/commands/init.mjs +89 -0
  42. package/zuzuu/commands/knowledge.mjs +152 -0
  43. package/zuzuu/commands/migrate.mjs +125 -0
  44. package/zuzuu/commands/review.mjs +299 -0
  45. package/zuzuu/commands/status.mjs +82 -0
  46. package/zuzuu/commands/trace.mjs +19 -0
  47. package/zuzuu/digest.mjs +149 -0
  48. package/zuzuu/eval/rank.mjs +31 -0
  49. package/zuzuu/eval/score.mjs +85 -0
  50. package/zuzuu/eval/signals.mjs +57 -0
  51. package/zuzuu/faculty/contract.mjs +19 -0
  52. package/zuzuu/faculty/gate.mjs +65 -0
  53. package/zuzuu/faculty/generation.mjs +392 -0
  54. package/zuzuu/faculty/proposal.mjs +166 -0
  55. package/zuzuu/faculty/provenance.mjs +35 -0
  56. package/zuzuu/faculty/registry.mjs +33 -0
  57. package/zuzuu/faculty/trail.mjs +27 -0
  58. package/zuzuu/guardrails/adapter.mjs +134 -0
  59. package/zuzuu/guardrails.mjs +89 -0
  60. package/zuzuu/inject.mjs +46 -0
  61. package/zuzuu/instructions/adapter.mjs +93 -0
  62. package/zuzuu/knowledge/adapter.mjs +99 -0
  63. package/zuzuu/knowledge/distill.mjs +237 -0
  64. package/zuzuu/knowledge/embed.mjs +52 -0
  65. package/zuzuu/knowledge/er.mjs +98 -0
  66. package/zuzuu/knowledge/inbox.mjs +43 -0
  67. package/zuzuu/knowledge/index.mjs +194 -0
  68. package/zuzuu/knowledge/items.mjs +154 -0
  69. package/zuzuu/knowledge/proposals.mjs +196 -0
  70. package/zuzuu/knowledge/registry.mjs +115 -0
  71. package/zuzuu/live/install.mjs +76 -0
  72. package/zuzuu/live/live-store.mjs +78 -0
  73. package/zuzuu/live/probe.mjs +55 -0
  74. package/zuzuu/live/reconcile.mjs +33 -0
  75. package/zuzuu/memory/adapter.mjs +121 -0
  76. package/zuzuu/miners/actions.mjs +118 -0
  77. package/zuzuu/miners/guardrails.mjs +174 -0
  78. package/zuzuu/miners/instructions.mjs +152 -0
  79. package/zuzuu/miners/knowledge.mjs +22 -0
  80. package/zuzuu/miners/memory.mjs +27 -0
  81. package/zuzuu/miners/registry.mjs +31 -0
  82. package/zuzuu/scaffold.mjs +213 -0
  83. package/zuzuu/session.mjs +72 -0
  84. package/zuzuu/store.mjs +104 -0
@@ -0,0 +1,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
+ }