@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,76 @@
1
+ // Pure add/remove of zuzuu's hook block in a Claude Code settings.json object.
2
+ //
3
+ // settings.json is JSON (not line-based), so we can't use supermemory's HTML
4
+ // delimiter blocks. Instead we tag our entries by a stable command SIGNATURE and
5
+ // add/remove only those — never clobbering the user's own hooks. Idempotent.
6
+
7
+ export const SIGNATURE = 'zuzuu.mjs'; // appears in every zuzuu hook command path, quote-agnostic
8
+ const tagged = (cmd) => String(cmd).includes(SIGNATURE);
9
+ // entire-style: agent can't read its own observability output (feedback loop) —
10
+ // but ONLY that. The faculty home (agent/knowledge etc., served by `zuzuu init`)
11
+ // must stay readable, so the deny is narrowed to .traces/ + .live/.
12
+ const DENY_RULES = ['Read(./agent/.traces/**)', 'Read(./agent/.live/**)'];
13
+
14
+ // Minimal hook set: lifecycle (Design B re-captures the transcript — no
15
+ // PostToolUse needed) + the PreToolUse Guardrails GATE (the one place we *do*
16
+ // sit on the hot path: it evaluates agent/guardrails/rules.json per tool call,
17
+ // fails open, and stays silent unless a rule matches).
18
+ export const LIFECYCLE_EVENTS = ['SessionStart', 'Stop', 'SessionEnd'];
19
+ export const GATE_EVENTS = ['PreToolUse'];
20
+ const ALL_EVENTS = [...LIFECYCLE_EVENTS, ...GATE_EVENTS];
21
+
22
+ const clone = (o) => JSON.parse(JSON.stringify(o ?? {}));
23
+ const hasOurs = (matchers) => (matchers || []).some((m) => (m.hooks || []).some((h) => tagged(h.command)));
24
+
25
+ /** Pure hook add for ANY host's {hooks:{Event:[{hooks:[…]}]}} config. No permissions. */
26
+ export function addHookEntries(settings, commandFor, events) {
27
+ const s = clone(settings);
28
+ s.hooks ||= {};
29
+ for (const ev of events) {
30
+ s.hooks[ev] ||= [];
31
+ if (!hasOurs(s.hooks[ev])) s.hooks[ev].push({ hooks: [{ type: 'command', command: commandFor(ev) }] });
32
+ }
33
+ return s;
34
+ }
35
+
36
+ /** Pure hook remove (only zuzuu entries) for ANY host. */
37
+ export function removeHookEntries(settings) {
38
+ const s = clone(settings);
39
+ if (s.hooks) {
40
+ for (const ev of Object.keys(s.hooks)) {
41
+ s.hooks[ev] = (s.hooks[ev] || []).filter((m) => !(m.hooks || []).some((h) => tagged(h.command)));
42
+ if (!s.hooks[ev].length) delete s.hooks[ev];
43
+ }
44
+ if (!Object.keys(s.hooks).length) delete s.hooks;
45
+ }
46
+ return s;
47
+ }
48
+
49
+ /**
50
+ * Return a new settings object with zuzuu lifecycle hooks + the deny rule added.
51
+ * @param {object} settings existing settings.json contents
52
+ * @param {(event:string)=>string} commandFor builds the command string per event
53
+ */
54
+ export function addHooks(settings, commandFor, events = ALL_EVENTS) {
55
+ const s = addHookEntries(settings, commandFor, events);
56
+ s.permissions ||= {};
57
+ s.permissions.deny ||= [];
58
+ for (const rule of DENY_RULES) if (!s.permissions.deny.includes(rule)) s.permissions.deny.push(rule);
59
+ return s;
60
+ }
61
+
62
+ /** Return a new settings object with only zuzuu's entries removed (others kept). */
63
+ export function removeHooks(settings) {
64
+ const s = removeHookEntries(settings);
65
+ if (s.permissions?.deny) {
66
+ s.permissions.deny = s.permissions.deny.filter((r) => !DENY_RULES.includes(r));
67
+ if (!s.permissions.deny.length) delete s.permissions.deny;
68
+ if (s.permissions && !Object.keys(s.permissions).length) delete s.permissions;
69
+ }
70
+ return s;
71
+ }
72
+
73
+ /** True if zuzuu hooks are present for all lifecycle + gate events. */
74
+ export function isInstalled(settings) {
75
+ return ALL_EVENTS.every((ev) => hasOurs(settings?.hooks?.[ev]));
76
+ }
@@ -0,0 +1,78 @@
1
+ // Liveness store for in-flight sessions: thin lifecycle records under agent/.live/.
2
+ // Holds NO spans (Design B — spans come from re-parsing the transcript). Just
3
+ // enough to know a session is open and when it was last seen, so a killed
4
+ // terminal (which sends no SessionEnd) can be reconciled later.
5
+ //
6
+ // agent/.live/ is git-ignored (transient machine state, like .git/ session state).
7
+
8
+ import { join } from 'node:path';
9
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
10
+ import { paths, liveDir as liveDirOf } from '../store.mjs';
11
+ import { SessionState } from '../session.mjs';
12
+
13
+ const liveDir = (cwd) => liveDirOf(paths(cwd).dir);
14
+ // Some hosts pass a file PATH as the session id (pi → the session-file path).
15
+ // Sanitize for the record filename (the real id is preserved inside the JSON),
16
+ // or the write fails into a non-existent nested dir. read/write/close all route
17
+ // through here, so the key stays consistent.
18
+ const recFile = (id) => `${String(id ?? 'unknown').replace(/[^A-Za-z0-9._-]/g, '_').slice(-120) || 'unknown'}.json`;
19
+ const recPath = (id, cwd) => join(liveDir(cwd), recFile(id));
20
+
21
+ function read(id, cwd) {
22
+ try {
23
+ return JSON.parse(readFileSync(recPath(id, cwd), 'utf8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function write(rec, cwd) {
30
+ mkdirSync(liveDir(cwd), { recursive: true });
31
+ writeFileSync(recPath(rec.id, cwd), JSON.stringify(rec, null, 2) + '\n');
32
+ return rec;
33
+ }
34
+
35
+ /** Open (or refresh) a live session record. `generation` is the active gen id (WS3-T3). */
36
+ export function openLive({ id, host, transcriptPath, startedAt, now, generation = null }, cwd = process.cwd()) {
37
+ const existing = read(id, cwd);
38
+ return write(
39
+ existing
40
+ ? { ...existing, lastSeen: now, transcriptPath: transcriptPath ?? existing.transcriptPath, generation: existing.generation ?? generation }
41
+ : { id, host, status: SessionState.ACTIVE, startedAt, lastSeen: now, transcriptPath, generation },
42
+ cwd,
43
+ );
44
+ }
45
+
46
+ /** Bump the heartbeat; create the record if a signal arrives before SessionStart. */
47
+ export function touchLive({ id, host, transcriptPath, now }, cwd = process.cwd()) {
48
+ const existing = read(id, cwd);
49
+ if (!existing) return openLive({ id, host, transcriptPath, startedAt: new Date(now).toISOString(), now }, cwd);
50
+ return write({ ...existing, lastSeen: now, transcriptPath: transcriptPath ?? existing.transcriptPath }, cwd);
51
+ }
52
+
53
+ /** Remove a live record (its lifecycle has reached a terminal state). */
54
+ export function closeLive(id, cwd = process.cwd()) {
55
+ try {
56
+ rmSync(recPath(id, cwd), { force: true });
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }
61
+
62
+ export function listLive(cwd = process.cwd()) {
63
+ const dir = liveDir(cwd);
64
+ if (!existsSync(dir)) return [];
65
+ return readdirSync(dir)
66
+ .filter((f) => f.endsWith('.json'))
67
+ .map((f) => {
68
+ try {
69
+ return JSON.parse(readFileSync(join(dir, f), 'utf8'));
70
+ } catch {
71
+ return null;
72
+ }
73
+ })
74
+ .filter(Boolean);
75
+ }
76
+
77
+ /** A live record is stale if its heartbeat is older than the window. */
78
+ export const isStale = (rec, now, thresholdMs) => now - (rec.lastSeen || 0) > thresholdMs;
@@ -0,0 +1,55 @@
1
+ // zuzuu/live/probe.mjs — Phase-0 observation tool (the real-wire-data rule).
2
+ //
3
+ // Records EXACTLY what a host hands a hook so we can wire the real mapping
4
+ // instead of trusting docs. Installed as a throwaway hook command per candidate
5
+ // event; the user runs a real session; we then read the capture file to learn
6
+ // which events fire, how the payload arrives (argv vs stdin), and its shape.
7
+ //
8
+ // Contract: never throws, always exits 0 (must not disturb the host session).
9
+ //
10
+ // usage (as a hook command):
11
+ // node probe.mjs --host <h> --event <EV> --out <abs path to .jsonl> [host's own args…]
12
+
13
+ import { appendFileSync, mkdirSync, readFileSync, fstatSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+
16
+ const argOf = (k, d = null) => {
17
+ const i = process.argv.indexOf(k);
18
+ return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : d;
19
+ };
20
+
21
+ const host = argOf('--host', 'unknown');
22
+ const event = argOf('--event', 'unknown');
23
+ const out = argOf('--out', null);
24
+
25
+ // Read the host's stdin payload — but ONLY when fd 0 is a pipe or file. A blocking
26
+ // readFileSync(0) on an inherited TTY/open fd hangs the hook forever (Gemini closes
27
+ // stdin so it returned; Codex left it open → a 36-min hang). Never block.
28
+ let stdin = null;
29
+ try {
30
+ const st = fstatSync(0);
31
+ if (st.isFIFO() || st.isFile()) stdin = readFileSync(0, 'utf8');
32
+ } catch {
33
+ /* no/closed/unreadable stdin */
34
+ }
35
+
36
+ const record = {
37
+ at: new Date().toISOString(),
38
+ host,
39
+ event,
40
+ argv: process.argv.slice(2), // exactly how the host invoked us
41
+ stdin: stdin && stdin.length ? stdin : null,
42
+ cwd: process.cwd(),
43
+ env: { GEMINI_SESSION: process.env.GEMINI_SESSION_ID ?? null, CODEX: process.env.CODEX_SESSION_ID ?? null },
44
+ };
45
+
46
+ try {
47
+ if (out) {
48
+ mkdirSync(dirname(out), { recursive: true });
49
+ appendFileSync(out, JSON.stringify(record) + '\n');
50
+ }
51
+ } catch {
52
+ /* observation must never break the host */
53
+ }
54
+
55
+ process.exit(0);
@@ -0,0 +1,33 @@
1
+ // Reconcile lost sessions. A killed terminal sends no SessionEnd, so an `active`
2
+ // live record just stops getting heartbeats. We detect that lazily (on `zuzuu
3
+ // doctor`/`status`), and — because the transcript is still on disk — do a FULL,
4
+ // correct capture of the abandoned session before closing it. Nothing is lost.
5
+
6
+ import { listLive, isStale, closeLive } from './live-store.mjs';
7
+ import { byName } from '../../experiments/experiment-1-trace-capture/adapters/registry.mjs';
8
+ import { captureTrace } from '../capture-core.mjs';
9
+ import { SessionState } from '../session.mjs';
10
+
11
+ export const DEFAULT_STALE_MS = 15 * 60 * 1000; // 15 min without a heartbeat → abandoned
12
+
13
+ /**
14
+ * Close out stale live sessions as `abandoned` (full transcript capture).
15
+ * @returns {Array<{id, host, action}>} what was reconciled
16
+ */
17
+ export function reconcile({ now = Date.now(), thresholdMs = DEFAULT_STALE_MS, cwd = process.cwd() } = {}) {
18
+ const actions = [];
19
+ for (const rec of listLive(cwd)) {
20
+ if (!isStale(rec, now, thresholdMs)) continue;
21
+ const adapter = byName(rec.host);
22
+ if (adapter && rec.transcriptPath) {
23
+ try {
24
+ captureTrace({ adapter, ref: rec.transcriptPath, status: SessionState.ABANDONED, cwd });
25
+ } catch {
26
+ /* transcript gone/unreadable — still close the record below */
27
+ }
28
+ }
29
+ closeLive(rec.id, cwd);
30
+ actions.push({ id: rec.id, host: rec.host, action: 'abandoned' });
31
+ }
32
+ return actions;
33
+ }
@@ -0,0 +1,121 @@
1
+ // zuzuu/memory/adapter.mjs
2
+ // The Memory faculty adapter (WS2-T4). Wraps episode proposals behind the
3
+ // faculty-spine adapter contract — { name, ingest, validate, apply, render } —
4
+ // so `zuzuu review` can surface and approve memory entries uniformly.
5
+ //
6
+ // A memory proposal payload is an episode record matching the WS1 Memory schema:
7
+ // { id, date, title, provenance, body }
8
+ // id format: mem-<YYYY-MM-DD>-<slug>
9
+ //
10
+ // apply: writes agent/memory/entries/<id>.md with YAML frontmatter (status: curated)
11
+ // and the body sections (Attempted / Resulted / Remember next time).
12
+ //
13
+ // Registers itself on import.
14
+
15
+ import { join } from 'node:path';
16
+ import { writeFileSync, mkdirSync } from 'node:fs';
17
+ import * as registry from '../faculty/registry.mjs';
18
+
19
+ const name = 'memory';
20
+
21
+ // mem-<YYYY-MM-DD>-<slug>: the id must START with "mem-"
22
+ const MEM_ID_RE = /^mem-/;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function entriesDir(agentDir) {
29
+ return join(agentDir, 'memory', 'entries');
30
+ }
31
+
32
+ function entryPath(agentDir, id) {
33
+ return join(entriesDir(agentDir), `${id}.md`);
34
+ }
35
+
36
+ /** Render YAML frontmatter block from the payload fields. */
37
+ function renderFrontmatter(payload) {
38
+ const lines = ['---'];
39
+ lines.push(`id: ${payload.id}`);
40
+ if (payload.date) lines.push(`date: ${payload.date}`);
41
+ if (payload.title) lines.push(`title: ${payload.title}`);
42
+ if (payload.provenance) {
43
+ lines.push('provenance:');
44
+ const p = payload.provenance;
45
+ if (Array.isArray(p.sessions)) lines.push(` sessions: [${p.sessions.join(', ')}]`);
46
+ if (Array.isArray(p.hosts)) lines.push(` hosts: [${p.hosts.join(', ')}]`);
47
+ }
48
+ if (Array.isArray(payload.tags) && payload.tags.length) {
49
+ lines.push(`tags: [${payload.tags.join(', ')}]`);
50
+ }
51
+ lines.push('status: curated');
52
+ lines.push('---');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // adapter contract
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Ingest a raw episode. Pass-through: the payload IS the episode.
62
+ */
63
+ function ingest(_agentDir, raw) {
64
+ const payload = raw?.payload ?? raw ?? {};
65
+ return { payload, analysis: {}, dedupeKey: payload.id };
66
+ }
67
+
68
+ /**
69
+ * Validate an episode payload.
70
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
71
+ */
72
+ function validate(_agentDir, payload) {
73
+ const errors = [];
74
+ if (!payload?.id || typeof payload.id !== 'string') {
75
+ errors.push('id is required');
76
+ } else if (!MEM_ID_RE.test(payload.id)) {
77
+ errors.push(`id must match mem-<YYYY-MM-DD>-<slug> format (got '${payload.id}')`);
78
+ }
79
+ if (!payload?.title || !String(payload.title).trim()) {
80
+ errors.push('title is required');
81
+ }
82
+ return { ok: errors.length === 0, errors, warnings: [] };
83
+ }
84
+
85
+ /**
86
+ * Apply an approved episode proposal: write the entry Markdown file.
87
+ * @returns {{ok:boolean, action:string, itemIds:string[]}}
88
+ */
89
+ function apply(agentDir, proposal) {
90
+ const payload = proposal?.payload ?? {};
91
+ const id = payload.id;
92
+
93
+ mkdirSync(entriesDir(agentDir), { recursive: true });
94
+
95
+ const frontmatter = renderFrontmatter(payload);
96
+ const body = payload.body ?? '';
97
+ const content = frontmatter + '\n' + body + (body.endsWith('\n') ? '' : '\n');
98
+
99
+ writeFileSync(entryPath(agentDir, id), content);
100
+
101
+ return { ok: true, action: `wrote memory ${id}`, itemIds: [id] };
102
+ }
103
+
104
+ /**
105
+ * Render an episode proposal for the human gate.
106
+ * @returns {{line:string, card:string}}
107
+ */
108
+ function render(proposal) {
109
+ const p = proposal?.payload ?? {};
110
+ const title = p.title ?? '';
111
+ const date = p.date ?? '';
112
+ const id = p.id ?? '';
113
+ return {
114
+ line: `${id} [episode] ${title} (${date})`,
115
+ card: `${title}\n id: ${id} date: ${date}`,
116
+ };
117
+ }
118
+
119
+ export const adapter = { name, ingest, validate, apply, render };
120
+
121
+ registry.register(adapter);
@@ -0,0 +1,118 @@
1
+ // zuzuu/miners/actions.mjs
2
+ // Actions miner (WS5-T2) — detect recurring Bash 2-gram sequences across
3
+ // sessions and scaffold runbook proposals into actions/inbox/<slug>/.
4
+ //
5
+ // Shape: { faculty:'actions', aggregate(sessions, opts), propose(agentDir, aggregated) }
6
+ // Self-registers on import.
7
+
8
+ import { join } from 'node:path';
9
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { slugify } from '../knowledge/items.mjs';
11
+ import { isSafeSlug, actionsDir, inboxDir } from '../actions/manifest.mjs';
12
+ import { register } from './registry.mjs';
13
+
14
+ // Must match the constant in knowledge/distill.mjs (adjacent Bash separator).
15
+ const SEQ_SEP = ' && ';
16
+
17
+ /**
18
+ * Derive a safe slug from a raw sequence string (bounded, safe chars only).
19
+ * e.g. "npm ci && npm test" → "npm-ci-npm-test" (max 50 chars).
20
+ */
21
+ function slugFromSequence(seq) {
22
+ const raw = slugify(seq.replace(/ && /g, ' '), 50);
23
+ // slugify already returns safe chars [a-z0-9-]; isSafeSlug allows upper too,
24
+ // but we keep lower for readability. Force-safe just in case.
25
+ return raw || 'action-sequence';
26
+ }
27
+
28
+ /**
29
+ * Aggregate recurring Bash 2-gram sequences from mined sessions.
30
+ *
31
+ * @param {Array<{sessionId:string, sequences:string[]}>} sessions
32
+ * The per-session mineTranscript output array.
33
+ * @param {object} opts
34
+ * @param {number} [opts.minSeqCount=3] min total occurrences across all sessions
35
+ * @param {number} [opts.minSeqSessions=2] min distinct sessions the sequence appears in
36
+ * @returns {Array<{payload:{slug,title,steps,promptSnippet,sequence}, evidence:{occurrences,sessions,sequence}}>}
37
+ */
38
+ export function aggregate(sessions, { minSeqCount = 3, minSeqSessions = 2 } = {}) {
39
+ // Count occurrences per sequence string, tracking distinct session ids.
40
+ const stats = new Map(); // rawSeq → { count, sessions: Set<sessionId> }
41
+ for (const s of sessions) {
42
+ if (!Array.isArray(s.sequences)) continue;
43
+ for (const seq of s.sequences) {
44
+ const st = stats.get(seq) ?? { count: 0, sessions: new Set() };
45
+ st.count++;
46
+ st.sessions.add(s.sessionId);
47
+ stats.set(seq, st);
48
+ }
49
+ }
50
+
51
+ const candidates = [];
52
+ for (const [seq, st] of stats) {
53
+ if (st.count < minSeqCount || st.sessions.size < minSeqSessions) continue;
54
+ const steps = seq.split(SEQ_SEP);
55
+ const slug = slugFromSequence(seq);
56
+ // Make sure the slug is safe; if not, skip rather than emit a bad slug.
57
+ if (!isSafeSlug(slug)) continue;
58
+ const title = `Run sequence: ${steps.join(' → ')}`;
59
+ const promptSnippet = `Runs: ${steps.join(' then ')}`;
60
+ candidates.push({
61
+ payload: { slug, title, steps, promptSnippet, sequence: seq },
62
+ evidence: { occurrences: st.count, sessions: st.sessions.size, sequence: seq },
63
+ });
64
+ }
65
+ return candidates;
66
+ }
67
+
68
+ /**
69
+ * Write a runbook action proposal into actions/inbox/<slug>/ for each candidate.
70
+ * Idempotent: skips if inbox/<slug>/ OR active actions/<slug>/ already exists.
71
+ *
72
+ * @param {string} agentDir
73
+ * @param {ReturnType<typeof aggregate>} aggregated
74
+ * @returns {number} count of new proposals written
75
+ */
76
+ export function propose(agentDir, aggregated) {
77
+ const actDir = actionsDir(agentDir);
78
+ const ibDir = inboxDir(agentDir);
79
+ let count = 0;
80
+ for (const c of aggregated) {
81
+ const { slug, title, steps, promptSnippet } = c.payload;
82
+ const inboxSlug = join(ibDir, slug);
83
+ const activeSlug = join(actDir, slug);
84
+ // Idempotent: skip if already proposed or already active.
85
+ if (existsSync(inboxSlug) || existsSync(activeSlug)) continue;
86
+
87
+ mkdirSync(inboxSlug, { recursive: true });
88
+
89
+ // action.json — minimal manifest (no run.mjs; this is a runbook action).
90
+ const manifest = {
91
+ slug,
92
+ title,
93
+ description: `Recurring command sequence detected from session traces: ${steps.join(' → ')}.`,
94
+ promptSnippet,
95
+ };
96
+ writeFileSync(join(inboxSlug, 'action.json'), JSON.stringify(manifest, null, 2) + '\n');
97
+
98
+ // SKILL.md — numbered runbook steps.
99
+ const stepsBlock = steps.map((cmd, i) => `${i + 1}. \`${cmd}\``).join('\n');
100
+ const skillMd = `---
101
+ name: ${title}
102
+ description: ${manifest.description}
103
+ ---
104
+
105
+ ## Steps
106
+
107
+ ${stepsBlock}
108
+ `;
109
+ writeFileSync(join(inboxSlug, 'SKILL.md'), skillMd);
110
+
111
+ count++;
112
+ }
113
+ return count;
114
+ }
115
+
116
+ export const miner = { faculty: 'actions', aggregate, propose };
117
+
118
+ register(miner);
@@ -0,0 +1,174 @@
1
+ // zuzuu/miners/guardrails.mjs
2
+ // Guardrails miner (WS5-T3) — detect repeated destructive-command failures
3
+ // across sessions and propose ask-only guardrail rules.
4
+ //
5
+ // MANDATORY SAFETY PROPERTIES (enforced in aggregate):
6
+ // 1. action is ALWAYS 'ask' — never 'deny'. Auto-proposed rules only escalate
7
+ // to the human prompt, they never hard-block.
8
+ // 2. Patterns are LITERAL-ESCAPED from the observed command — never a broad/
9
+ // free regex. escapeRegex() handles this.
10
+ // 3. Cross-session corroboration required — a destructive command must fail
11
+ // ≄minFailures (default 3) times across ≄minSessions (default 2) DISTINCT
12
+ // sessions. A single session — no matter how many failures — produces
13
+ // NOTHING.
14
+ //
15
+ // Shape: { faculty:'guardrails', aggregate(sessions, opts), propose(agentDir, aggregated) }
16
+ // Self-registers on import.
17
+
18
+ import { join } from 'node:path';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { slugify } from '../knowledge/items.mjs';
21
+ import { makeProposal, writeProposal, listProposals } from '../faculty/proposal.mjs';
22
+ import { register } from './registry.mjs';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // escapeRegex — the ONLY safe way to build a pattern from a literal command.
26
+ // Escapes all RegExp metacharacters so the pattern matches the exact string.
27
+
28
+ /**
29
+ * Escape all regex metacharacters in `s` so that `new RegExp(escapeRegex(s))`
30
+ * matches exactly the string `s` and nothing broader.
31
+ *
32
+ * @param {string} s
33
+ * @returns {string}
34
+ */
35
+ export function escapeRegex(s) {
36
+ // Standard set of regex metacharacters that need escaping.
37
+ return String(s).replace(/[.*+?^${}()|[\]\\\/\-]/g, '\\$&');
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // helpers
42
+
43
+ /** Normalise a command string (trim + collapse whitespace). */
44
+ const norm = (cmd) => String(cmd).trim().replace(/\s+/g, ' ').slice(0, 200);
45
+
46
+ /** Derive a guardrails-miner id for a command. */
47
+ function guardId(cmd) {
48
+ return 'guard-' + slugify(cmd, 50);
49
+ }
50
+
51
+ /** Load rules.json; returns { version, rules:[] } if absent/unreadable. */
52
+ function loadRules(agentDir) {
53
+ const path = join(agentDir, 'guardrails', 'rules.json');
54
+ if (!existsSync(path)) return { version: 1, rules: [] };
55
+ try {
56
+ return JSON.parse(readFileSync(path, 'utf8'));
57
+ } catch {
58
+ return { version: 1, rules: [] };
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // aggregate
64
+
65
+ /**
66
+ * Group destructiveFailures by normalised command; emit a candidate ONLY when
67
+ * both the occurrence count and distinct-session count meet their thresholds.
68
+ *
69
+ * SAFETY: a single-session cluster, no matter how large, produces NOTHING.
70
+ *
71
+ * @param {Array<{sessionId:string, destructiveFailures:{cmd:string,tool:string}[]}>} sessions
72
+ * @param {object} opts
73
+ * @param {number} [opts.minFailures=3] min total failures across all sessions
74
+ * @param {number} [opts.minSessions=2] min distinct sessions with ≄1 failure each
75
+ * @returns {Array<{payload:{id,action,tool,pattern,reason}, evidence:{occurrences,sessions}}>}
76
+ */
77
+ export function aggregate(sessions, { minFailures = 3, minSessions = 2 } = {}) {
78
+ // cmd (normalized) → { count: number, sessions: Set<sessionId>, tool: string }
79
+ const stats = new Map();
80
+
81
+ for (const s of sessions) {
82
+ if (!Array.isArray(s.destructiveFailures)) continue;
83
+ for (const { cmd, tool } of s.destructiveFailures) {
84
+ const key = norm(cmd);
85
+ const st = stats.get(key) ?? { count: 0, sessions: new Set(), tool: tool ?? 'Bash' };
86
+ st.count++;
87
+ st.sessions.add(s.sessionId);
88
+ // Keep first observed tool name (they should all be 'Bash' for destructive cmds).
89
+ stats.set(key, st);
90
+ }
91
+ }
92
+
93
+ const candidates = [];
94
+ for (const [cmd, st] of stats) {
95
+ // SAFETY: enforce BOTH thresholds — cross-session gate is the key one.
96
+ if (st.count < minFailures) continue;
97
+ if (st.sessions.size < minSessions) continue; // ← single-session always rejected here
98
+
99
+ const id = guardId(cmd);
100
+ const pattern = escapeRegex(cmd);
101
+ const tool = st.tool ?? 'Bash';
102
+
103
+ candidates.push({
104
+ payload: {
105
+ id,
106
+ // SAFETY: ALWAYS 'ask', never 'deny'.
107
+ action: 'ask',
108
+ tool,
109
+ pattern,
110
+ reason: `auto-proposed: '${cmd}' failed repeatedly across sessions — confirm before running`,
111
+ },
112
+ evidence: {
113
+ occurrences: st.count,
114
+ sessions: st.sessions.size,
115
+ },
116
+ });
117
+ }
118
+
119
+ return candidates;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // propose
124
+
125
+ /**
126
+ * Write a guardrails proposal into agent/guardrails/proposals/ for each candidate.
127
+ * Idempotent:
128
+ * - skips if a guardrails proposal with the same payload.id already exists
129
+ * - skips if rules.json already has a rule with that id
130
+ *
131
+ * The proposals flow through `zuzuu review` → guardrails adapter on approval.
132
+ *
133
+ * @param {string} agentDir
134
+ * @param {ReturnType<typeof aggregate>} aggregated
135
+ * @returns {number} count of new proposals written
136
+ */
137
+ export function propose(agentDir, aggregated) {
138
+ // Load existing proposals (ids already pending).
139
+ const existing = listProposals(agentDir, 'guardrails');
140
+ const existingIds = new Set(existing.map((p) => p.payload?.id).filter(Boolean));
141
+
142
+ // Load existing rules (ids already applied).
143
+ const rulesData = loadRules(agentDir);
144
+ const rulesIds = new Set((rulesData.rules ?? []).map((r) => r.id).filter(Boolean));
145
+
146
+ let count = 0;
147
+ for (const c of aggregated) {
148
+ const { payload, evidence } = c;
149
+
150
+ // Idempotent: skip if already proposed or already a live rule.
151
+ if (existingIds.has(payload.id)) continue;
152
+ if (rulesIds.has(payload.id)) continue;
153
+
154
+ const proposal = makeProposal({
155
+ faculty: 'guardrails',
156
+ kind: 'rule',
157
+ source: 'distill',
158
+ payload,
159
+ evidence,
160
+ });
161
+
162
+ writeProposal(agentDir, proposal);
163
+ count++;
164
+ }
165
+
166
+ return count;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // self-register
171
+
172
+ export const miner = { faculty: 'guardrails', aggregate, propose };
173
+
174
+ register(miner);