@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,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);
|