@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,58 @@
1
+ // The normalized Event — the host-agnostic vocabulary of a run.
2
+ //
3
+ // This is the seam entire.io calls "one Event": every HostAdapter normalizes its
4
+ // native log into Events, and the core turns Events into OTel spans. Nothing here
5
+ // knows about Claude Code, Gemini, or OpenTelemetry wire format — swap either side
6
+ // without touching this. The README calls this normalized Event "the basis of our
7
+ // trace-span schema."
8
+ //
9
+ // An Event is a span-precursor. Its tree position is expressed purely by ids:
10
+ // refId — stable unique id of THIS event within the trace
11
+ // parentRefId — refId of its parent, or null for the root
12
+ // The core wires parent_span_id = spanId(parentRefId); adapters never compute spans.
13
+
14
+ /** Semantic kinds. Adapters express as rich a tree as the host's log allows. */
15
+ export const EventKind = Object.freeze({
16
+ SESSION: 'session', // the root: one host session
17
+ TURN: 'turn', // one user-prompt -> response cycle (a Run/Episode boundary)
18
+ TOOL_CALL: 'tool_call', // one tool invocation
19
+ });
20
+
21
+ export const Status = Object.freeze({ UNSET: 'unset', OK: 'ok', ERROR: 'error' });
22
+
23
+ /**
24
+ * @param {object} e
25
+ * @param {string} e.kind one of EventKind
26
+ * @param {string} e.refId stable unique id within the trace
27
+ * @param {string|null} e.parentRefId parent's refId, or null for root
28
+ * @param {string} e.name span name
29
+ * @param {number} e.startMs epoch ms (span start)
30
+ * @param {number} [e.endMs] epoch ms (span end); defaults to startMs (zero-duration)
31
+ * @param {string} [e.status] one of Status; default UNSET
32
+ * @param {object} [e.attributes] flat key->value (string|number|boolean)
33
+ */
34
+ export function event({ kind, refId, parentRefId = null, name, startMs, endMs, status = Status.UNSET, attributes = {} }) {
35
+ if (!refId) throw new Error(`event: refId required (kind=${kind}, name=${name})`);
36
+ return {
37
+ kind,
38
+ refId: String(refId),
39
+ parentRefId: parentRefId == null ? null : String(parentRefId),
40
+ name: name ?? kind,
41
+ startMs: Number(startMs) || 0,
42
+ endMs: endMs == null ? Number(startMs) || 0 : Number(endMs),
43
+ status,
44
+ attributes,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * A normalized trace as produced by an adapter.
50
+ * @param {object} t
51
+ * @param {string} t.host adapter name, e.g. "claude-code"
52
+ * @param {string} t.sessionId the host's session identifier
53
+ * @param {string} [t.title] human label
54
+ * @param {Event[]} t.events exactly one SESSION root + descendants
55
+ */
56
+ export function trace({ host, sessionId, title = '', events }) {
57
+ return { host, sessionId: String(sessionId), title, events };
58
+ }
@@ -0,0 +1,32 @@
1
+ // W3C TraceContext id generation — host-agnostic.
2
+ //
3
+ // trace_id = 16 bytes (32 hex), span_id = 8 bytes (16 hex). We derive them
4
+ // DETERMINISTICALLY from stable inputs (host+sessionId, and the event's refId)
5
+ // so that re-capturing the same host log yields the same trace — captures are
6
+ // idempotent, and a span's parent_span_id is just `spanId(parentRefId)` with no
7
+ // lookup table. Determinism is a property of static-log parsing; a live tracer
8
+ // would use crypto.randomBytes instead.
9
+
10
+ import { createHash } from 'node:crypto';
11
+
12
+ /** Lowercase hex of the first `bytes` bytes of sha256(parts.join('\0')). */
13
+ function hashHex(bytes, ...parts) {
14
+ return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, bytes * 2);
15
+ }
16
+
17
+ /** 32-hex-char trace id, stable per (host, sessionId). Never all-zero. */
18
+ export function traceId(host, sessionId) {
19
+ const id = hashHex(16, 'trace', host, sessionId);
20
+ return id === '0'.repeat(32) ? '0'.repeat(31) + '1' : id;
21
+ }
22
+
23
+ /** 16-hex-char span id, stable per (traceId, refId). Never all-zero. */
24
+ export function spanId(traceIdHex, refId) {
25
+ const id = hashHex(8, 'span', traceIdHex, refId);
26
+ return id === '0'.repeat(16) ? '0'.repeat(15) + '1' : id;
27
+ }
28
+
29
+ /** W3C `traceparent` header value: version-traceid-spanid-flags (sampled). */
30
+ export function traceparent(traceIdHex, spanIdHex) {
31
+ return `00-${traceIdHex}-${spanIdHex}-01`;
32
+ }
@@ -0,0 +1,54 @@
1
+ // Spans -> OTLP/JSON ExportTraceServiceRequest, and NDJSON writer.
2
+ //
3
+ // Output format: one COMPLETE OTLP/JSON request per line
4
+ // { "resourceSpans": [ { "resource": {...}, "scopeSpans": [ { "scope": {...}, "spans": [...] } ] } ] }
5
+ // This is exactly what the OpenTelemetry Collector `otlpjsonfilereceiver` ingests
6
+ // (newline-delimited ExportTraceServiceRequest objects) — so a trace we write
7
+ // locally today is replayable into any OTel backend later, no converter.
8
+ //
9
+ // NOTE: verify the precise nesting against the receiver spec before claiming
10
+ // drop-in interop for an external consumer (tracked in the experiment README’s Conclusions).
11
+
12
+ import { writeFileSync, mkdirSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+
15
+ const SCOPE = { name: 'zuzuu/trace-capture', version: '0.1.0' };
16
+
17
+ function strAttr(key, value) {
18
+ return { key, value: { stringValue: String(value) } };
19
+ }
20
+
21
+ /**
22
+ * Wrap spans into one ExportTraceServiceRequest.
23
+ * @param {{traceId:string, spans:object[]}} built from eventsToSpans()
24
+ * @param {{host:string, sessionId:string}} meta
25
+ */
26
+ export function toExportRequest(built, meta) {
27
+ return {
28
+ resourceSpans: [
29
+ {
30
+ resource: {
31
+ attributes: [
32
+ strAttr('service.name', 'zuzuu'),
33
+ strAttr('service.version', '0.1.0'),
34
+ strAttr('host.name', meta.host), // which host CLI this trace was observed from
35
+ strAttr('session.id', meta.sessionId),
36
+ ],
37
+ },
38
+ scopeSpans: [{ scope: SCOPE, spans: built.spans }],
39
+ },
40
+ ],
41
+ };
42
+ }
43
+
44
+ /** Serialize one or more export requests as NDJSON (one request per line). */
45
+ export function toNdjson(requests) {
46
+ return requests.map((r) => JSON.stringify(r)).join('\n') + '\n';
47
+ }
48
+
49
+ /** Write NDJSON to disk, creating parent dirs. Returns the path. */
50
+ export function writeNdjson(path, requests) {
51
+ mkdirSync(dirname(path), { recursive: true });
52
+ writeFileSync(path, toNdjson(requests));
53
+ return path;
54
+ }
@@ -0,0 +1,63 @@
1
+ // Render an OTLP/JSON trace file as a span tree. Shared by the experiment's
2
+ // inspect-trace CLI and the app-level `mns trace` command.
3
+
4
+ import { readFileSync } from 'node:fs';
5
+
6
+ const MARK = { 0: '•', 1: '•', 2: '✗' };
7
+
8
+ /** Load all spans (+ first resource attrs) from an OTLP/JSON NDJSON file. */
9
+ export function loadSpans(file) {
10
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
11
+ const spans = [];
12
+ const resources = [];
13
+ for (const line of lines) {
14
+ const req = JSON.parse(line);
15
+ for (const rs of req.resourceSpans || []) {
16
+ resources.push(Object.fromEntries((rs.resource?.attributes || []).map((a) => [a.key, a.value?.stringValue])));
17
+ for (const ss of rs.scopeSpans || []) for (const s of ss.spans || []) spans.push(s);
18
+ }
19
+ }
20
+ return { spans, resource: resources[0] || {} };
21
+ }
22
+
23
+ // nanosecond strings exceed Number.MAX_SAFE_INTEGER — use BigInt for durations.
24
+ const durMs = (s) => Number((BigInt(s.endTimeUnixNano) - BigInt(s.startTimeUnixNano)) / 1_000_000n);
25
+
26
+ function fmtDur(ms) {
27
+ if (ms < 1000) return `${ms}ms`;
28
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
29
+ return `${(ms / 60_000).toFixed(1)}m`;
30
+ }
31
+
32
+ /** Build a printable span-tree string from { spans, resource }. */
33
+ export function renderTree({ spans, resource = {} }) {
34
+ if (!spans.length) return '(no spans)';
35
+
36
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
37
+ const children = new Map();
38
+ const roots = [];
39
+ for (const s of spans) {
40
+ const parent = s.parentSpanId && byId.has(s.parentSpanId) ? s.parentSpanId : null;
41
+ if (parent) {
42
+ if (!children.has(parent)) children.set(parent, []);
43
+ children.get(parent).push(s);
44
+ } else roots.push(s);
45
+ }
46
+ const sortByStart = (a, b) => (BigInt(a.startTimeUnixNano) < BigInt(b.startTimeUnixNano) ? -1 : 1);
47
+
48
+ const lines = [];
49
+ lines.push(`host=${resource['host.name'] || '?'} session=${resource['session.id'] || '?'} trace=${spans[0].traceId}`);
50
+ lines.push(`spans=${spans.length}\n`);
51
+
52
+ function walk(s, depth) {
53
+ const pad = ' '.repeat(depth);
54
+ const err = s.status?.code === 2 ? ' [ERROR]' : '';
55
+ lines.push(`${pad}${MARK[s.status?.code || 0]} ${s.name} ${fmtDur(durMs(s))}${err}`);
56
+ (children.get(s.spanId) || []).sort(sortByStart).forEach((c) => walk(c, depth + 1));
57
+ }
58
+ roots.sort(sortByStart).forEach((r) => walk(r, 0));
59
+
60
+ const errors = spans.filter((s) => s.status?.code === 2).length;
61
+ lines.push(`\n${spans.length} spans, ${errors} error(s)`);
62
+ return lines.join('\n');
63
+ }
@@ -0,0 +1,43 @@
1
+ // Event[] -> OpenTelemetry spans (data model). Host-agnostic: pure id-wiring.
2
+
3
+ import { traceId, spanId } from './ids.mjs';
4
+ import { Status } from './event.mjs';
5
+
6
+ // OTLP status codes: 0 UNSET, 1 OK, 2 ERROR.
7
+ const STATUS_CODE = { [Status.UNSET]: 0, [Status.OK]: 1, [Status.ERROR]: 2 };
8
+ // SpanKind 1 = INTERNAL (we observe, we don't classify client/server here).
9
+ const SPAN_KIND_INTERNAL = 1;
10
+
11
+ const msToUnixNano = (ms) => String(Math.round(ms) * 1_000_000);
12
+
13
+ /** Encode a JS value as an OTLP/JSON AnyValue-wrapped attribute. */
14
+ function attr(key, value) {
15
+ let v;
16
+ if (typeof value === 'boolean') v = { boolValue: value };
17
+ else if (typeof value === 'number') v = Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
18
+ else v = { stringValue: String(value) };
19
+ return { key, value: v };
20
+ }
21
+
22
+ /**
23
+ * @param {import('./event.mjs').trace} t normalized trace from an adapter
24
+ * @returns {{ traceId: string, spans: object[] }} OTLP/JSON span objects
25
+ */
26
+ export function eventsToSpans(t) {
27
+ const tid = traceId(t.host, t.sessionId);
28
+ const spans = t.events.map((e) => {
29
+ const span = {
30
+ traceId: tid,
31
+ spanId: spanId(tid, e.refId),
32
+ name: e.name,
33
+ kind: SPAN_KIND_INTERNAL,
34
+ startTimeUnixNano: msToUnixNano(e.startMs),
35
+ endTimeUnixNano: msToUnixNano(e.endMs),
36
+ attributes: Object.entries(e.attributes).map(([k, v]) => attr(k, v)),
37
+ status: { code: STATUS_CODE[e.status] ?? 0 },
38
+ };
39
+ if (e.parentRefId != null) span.parentSpanId = spanId(tid, e.parentRefId);
40
+ return span;
41
+ });
42
+ return { traceId: tid, spans };
43
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@zuzuucodes/cli",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "description": "Give the coding agent you already run (Claude Code, Codex, Gemini CLI, OpenCode) evolving faculties — knowledge, memory, actions, instructions, guardrails — with host-agnostic OpenTelemetry session tracing. Zero dependencies.",
10
+ "keywords": [
11
+ "ai-agents",
12
+ "claude-code",
13
+ "codex",
14
+ "gemini-cli",
15
+ "opencode",
16
+ "opentelemetry",
17
+ "observability",
18
+ "tracing",
19
+ "guardrails",
20
+ "agent-memory",
21
+ "cli"
22
+ ],
23
+ "homepage": "https://github.com/h1902y/zuzuu#readme",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/h1902y/zuzuu.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/h1902y/zuzuu/issues"
30
+ },
31
+ "license": "MIT",
32
+ "author": "Harshit Krishna Choudhary (https://x.com/h1902y)",
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "bin": {
37
+ "zz": "bin/zuzuu.mjs",
38
+ "zuzuu": "bin/zuzuu.mjs"
39
+ },
40
+ "files": [
41
+ "bin/",
42
+ "zuzuu/",
43
+ "experiments/experiment-1-trace-capture/core/",
44
+ "experiments/experiment-1-trace-capture/adapters/",
45
+ "LICENSE",
46
+ "README.md"
47
+ ],
48
+ "scripts": {
49
+ "zuzuu": "node bin/zuzuu.mjs",
50
+ "capture": "node experiments/experiment-1-trace-capture/bin/capture.mjs",
51
+ "inspect": "node experiments/experiment-1-trace-capture/bin/inspect-trace.mjs",
52
+ "test": "node --test 'tests/**/*.test.mjs'",
53
+ "playground": "node tests/playground/run.mjs",
54
+ "prepublishOnly": "npm test"
55
+ }
56
+ }
@@ -0,0 +1,130 @@
1
+ // zuzuu/actions/adapter.mjs
2
+ // The Actions faculty adapter (WS2-T3). Wraps the EXISTING Actions inbox gate
3
+ // (proposed dirs under agent/actions/inbox/<slug>/) behind the faculty-spine
4
+ // adapter contract — { name, ingest, validate, apply, render } — so the generic
5
+ // `zuzuu review` gate can drive Actions the same way it drives Knowledge.
6
+ //
7
+ // Actions payloads are DIRECTORIES (run.mjs/SKILL.md + action.json), not JSON.
8
+ // Strategy (lowest-risk): the inbox stays a dir; this adapter emits/reads a
9
+ // spine-shaped proposal RECORD that REFERENCES the dir
10
+ // (payload = { slug, kind, dir:'inbox/<slug>' }). The gate resolves a single
11
+ // record via `getProposal`, lists pending via `listProposals`, and — because
12
+ // the payload is dir-shaped — archives rejections via `rejectDir` (a dir move
13
+ // into actions/proposals/archive/, not a JSON archive).
14
+ //
15
+ // Registers itself on import.
16
+
17
+ import { join } from 'node:path';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { listActions, inboxDir, isSafeSlug } from './manifest.mjs';
20
+ import { activateAction, rejectAction } from './inbox.mjs';
21
+ import { validateInputs } from './schema.mjs';
22
+ import * as registry from '../faculty/registry.mjs';
23
+
24
+ const name = 'actions';
25
+
26
+ /** Build a spine-shaped proposal record for one proposed action. */
27
+ function recordFor(a) {
28
+ return {
29
+ id: a.slug,
30
+ faculty: name,
31
+ kind: 'action',
32
+ status: 'pending',
33
+ source: 'agent',
34
+ payload: { slug: a.slug, kind: a.kind, dir: `inbox/${a.slug}` },
35
+ // carry render hints alongside the payload (cheap, dir read already done)
36
+ title: a.title,
37
+ promptSnippet: a.promptSnippet,
38
+ analysis: {},
39
+ evidence: {},
40
+ provenance: [],
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Pending action proposals (dirs in agent/actions/inbox/), surfaced as
46
+ * spine-shaped records so the gate can render/approve/reject them uniformly.
47
+ */
48
+ function listProposals(agentDir) {
49
+ return listActions(inboxDir(agentDir)).map(recordFor);
50
+ }
51
+
52
+ /** Resolve a single proposed action by slug → spine-shaped record, or null. */
53
+ function getProposal(agentDir, slug) {
54
+ if (!isSafeSlug(slug)) return null;
55
+ return listProposals(agentDir).find((p) => p.id === slug) ?? null;
56
+ }
57
+
58
+ /**
59
+ * Ingest is a pass-through for Actions: proposing scaffolds a dir
60
+ * (zuzuu act propose / act-author). Kept for adapter-contract symmetry.
61
+ */
62
+ function ingest(_agentDir, raw) {
63
+ return { payload: raw?.payload ?? raw ?? {}, analysis: {} };
64
+ }
65
+
66
+ /**
67
+ * Validate a proposed action's manifest against the schema subset and confirm
68
+ * the manifest slug matches the dir. Missing manifest → accept (slug fallback).
69
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
70
+ */
71
+ function validate(agentDir, payload) {
72
+ const slug = payload?.slug;
73
+ if (!isSafeSlug(slug)) return { ok: false, errors: [`invalid slug '${slug}'`], warnings: [] };
74
+ const manPath = join(inboxDir(agentDir), slug, 'action.json');
75
+ if (!existsSync(manPath)) return { ok: true, errors: [], warnings: [] };
76
+ let man;
77
+ try { man = JSON.parse(readFileSync(manPath, 'utf8')); }
78
+ catch { return { ok: false, errors: ['manifest is not valid JSON'], warnings: [] }; }
79
+ if (man.slug && man.slug !== slug) return { ok: false, errors: [`manifest slug '${man.slug}' ≠ dir '${slug}'`], warnings: [] };
80
+ const errors = [];
81
+ // the manifest schema is itself JSON-Schema-subset shaped; sanity-check both ends
82
+ if (man.inputs) {
83
+ const vi = validateInputs(man.inputs, man.default_args, {});
84
+ // inputs schema is for caller args, not the manifest — only flag a structurally
85
+ // broken schema (validateInputs is permissive on empty args), so this is a no-op
86
+ // for well-formed manifests. Kept for symmetry with the knowledge adapter.
87
+ if (vi.ok === false && !/required/i.test(vi.error ?? '')) errors.push(vi.error);
88
+ }
89
+ if (man.outputs && typeof man.outputs !== 'object') errors.push('outputs schema must be an object');
90
+ return { ok: errors.length === 0, errors, warnings: [] };
91
+ }
92
+
93
+ /**
94
+ * Apply an approved action proposal: activate it (move inbox/<slug> → <slug>).
95
+ * Preserves the "already exists" guard from activateAction.
96
+ * @returns {{ok:boolean, action:string, itemIds:string[], warnings:string[]}}
97
+ */
98
+ function apply(agentDir, proposal) {
99
+ const slug = proposal?.payload?.slug ?? proposal?.id;
100
+ const r = activateAction(agentDir, slug);
101
+ if (!r.ok) return { ok: false, action: r.error, itemIds: [], warnings: [] };
102
+ return { ok: true, action: `activated ${slug}`, itemIds: [slug], warnings: [] };
103
+ }
104
+
105
+ /**
106
+ * Reject path: dir-shaped, so the gate calls this instead of the JSON archive.
107
+ * Moves inbox/<slug> → actions/proposals/archive/<slug> (archive, not delete).
108
+ */
109
+ function rejectDir(agentDir, slug, _reason = '') {
110
+ return rejectAction(agentDir, slug);
111
+ }
112
+
113
+ /**
114
+ * Render a proposed action for the human gate. `card` mirrors the current review
115
+ * card (slug ── kind, then the prompt snippet); `line` is the one-line list form.
116
+ * @returns {{line:string, card:string}}
117
+ */
118
+ function render(proposal) {
119
+ const slug = proposal?.id ?? proposal?.payload?.slug ?? '';
120
+ const kind = proposal?.payload?.kind ?? proposal?.kind ?? 'action';
121
+ const snippet = proposal?.promptSnippet ?? '';
122
+ return {
123
+ line: `${slug} [${kind}] ${snippet}`,
124
+ card: `${slug} ── ${kind}\n ${snippet}`,
125
+ };
126
+ }
127
+
128
+ export const adapter = { name, ingest, validate, apply, render, listProposals, getProposal, rejectDir };
129
+
130
+ registry.register(adapter);
@@ -0,0 +1,27 @@
1
+ // zuzuu/actions/convert.mjs
2
+ // Pure manifest → tool-definition converters (the _labs tool-definition pattern).
3
+ // The manifest's `inputs` JSON Schema is already the right shape for each format,
4
+ // so conversion is a thin re-wrap.
5
+ //
6
+ // STATUS (2026-06-11): used today only by `zuzuu act schema <slug> [--mcp|--openai|
7
+ // --anthropic]` for inspection. There is NO runtime MCP/native-tool *serving* yet —
8
+ // actions are invoked via `zuzuu act <slug>` from the host shell and surfaced to the
9
+ // agent in the digest. Live "Actions over MCP" serving is DEFERRED (DESIGN §6 /
10
+ // Stage 2 / OpenCode bundle); these converters are the seam for it, not the thing.
11
+
12
+ const desc = (m) => m.description ?? m.title ?? m.slug;
13
+ const inputs = (m) => m.inputs ?? { type: 'object' };
14
+
15
+ export function toMcpTool(m) {
16
+ const t = { name: m.slug, description: desc(m), inputSchema: inputs(m) };
17
+ if (m.outputs) t.outputSchema = m.outputs;
18
+ return t;
19
+ }
20
+
21
+ export function toOpenAITool(m) {
22
+ return { type: 'function', function: { name: m.slug, description: desc(m), parameters: inputs(m) } };
23
+ }
24
+
25
+ export function toAnthropicTool(m) {
26
+ return { name: m.slug, description: desc(m), input_schema: inputs(m) };
27
+ }
@@ -0,0 +1,87 @@
1
+ // zuzuu/actions/dispatch.mjs
2
+ // runAction spawns the runner harness (a fresh node process — isolation + the
3
+ // _labs marker pattern), extracts the single result marker from stdout, and
4
+ // returns { ok, value|error, detail?, logs }. zuzuu act is itself spawned by the
5
+ // host's Bash, so this is observe-not-drive: a CLI the agent calls, never a loop.
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { join, dirname } from 'node:path';
10
+ import { existsSync } from 'node:fs';
11
+ import { loadManifest, actionsDir } from './manifest.mjs';
12
+ import { MARKER } from './marker.mjs';
13
+
14
+ const MAX_DEPTH = 8;
15
+ const MAX_BYTES = 50_000;
16
+ const MAX_LINES = 2000;
17
+ const ACTION_TIMEOUT_MS = 60_000;
18
+ const runnerPath = join(dirname(fileURLToPath(import.meta.url)), 'runner.mjs');
19
+
20
+ function truncate(s) {
21
+ let out = s;
22
+ if (out.length > MAX_BYTES) out = out.slice(0, MAX_BYTES) + '\n…[truncated]';
23
+ const lines = out.split('\n');
24
+ if (lines.length > MAX_LINES) out = lines.slice(0, MAX_LINES).join('\n') + '\n…[truncated]';
25
+ return out;
26
+ }
27
+
28
+ function parseOutput(stdout) {
29
+ const lines = stdout.split('\n');
30
+ let parsed = null;
31
+ const logLines = [];
32
+ for (const line of lines) {
33
+ if (line.startsWith(MARKER)) {
34
+ try { parsed = JSON.parse(line.slice(MARKER.length)); } catch { /* keep last good */ }
35
+ } else {
36
+ logLines.push(line);
37
+ }
38
+ }
39
+ return { parsed, logs: logLines.join('\n').trim() };
40
+ }
41
+
42
+ /**
43
+ * Run an action by slug. Returns:
44
+ * { ok:true, value, logs } | { ok:false, error, detail?, logs }
45
+ * error ∈ depth_exceeded | not_found | not_runnable | invalid_input |
46
+ * invalid_output | script_error | no_result | timeout | spawn_error | killed
47
+ */
48
+ // Synchronous (spawnSync). Returns the result object directly.
49
+ export function runAction(agentDir, slug, callerArgs = {}, { timeoutMs = ACTION_TIMEOUT_MS } = {}) {
50
+ const depth = Number(process.env.ZUZUU_ACT_DEPTH || 0);
51
+ if (depth >= MAX_DEPTH) return { ok: false, error: 'depth_exceeded', detail: `depth ${depth} ≥ ${MAX_DEPTH}`, logs: '' };
52
+
53
+ const manifest = loadManifest(agentDir, slug);
54
+ if (!manifest) return { ok: false, error: 'not_found', detail: `no action '${slug}' (missing action.json)`, logs: '' };
55
+
56
+ const runPath = join(actionsDir(agentDir), slug, 'run.mjs');
57
+ if (!existsSync(runPath)) return { ok: false, error: 'not_runnable', detail: `'${slug}' has no run.mjs`, logs: '' };
58
+
59
+ const payload = JSON.stringify({
60
+ runPath,
61
+ inputs: manifest.inputs ?? { type: 'object' },
62
+ outputs: manifest.outputs ?? { type: 'object' },
63
+ default_args: manifest.default_args ?? {},
64
+ args: callerArgs ?? {},
65
+ });
66
+
67
+ const res = spawnSync(process.execPath, [runnerPath, payload], {
68
+ cwd: agentDir,
69
+ encoding: 'utf8',
70
+ env: { ...process.env, ZUZUU_ACT_DEPTH: String(depth + 1) },
71
+ maxBuffer: 64 * 1024 * 1024,
72
+ timeout: timeoutMs,
73
+ killSignal: 'SIGTERM',
74
+ });
75
+
76
+ if (res.error) {
77
+ const timedOut = res.error.code === 'ETIMEDOUT';
78
+ return { ok: false, error: timedOut ? 'timeout' : 'spawn_error', detail: timedOut ? `action exceeded ${timeoutMs}ms` : res.error.message, logs: '' };
79
+ }
80
+ if (res.signal) return { ok: false, error: 'killed', detail: `child killed by ${res.signal}`, logs: '' };
81
+
82
+ const { parsed, logs: outLogs } = parseOutput(res.stdout || '');
83
+ const errText = (res.stderr || '').trim();
84
+ const logs = truncate([outLogs, errText].filter(Boolean).join('\n'));
85
+ if (!parsed) return { ok: false, error: 'no_result', detail: 'action emitted no result marker', logs };
86
+ return { ...parsed, logs };
87
+ }
@@ -0,0 +1,56 @@
1
+ // zuzuu/actions/inbox.mjs
2
+ // The Actions crystallization gate (the same governed pipeline as Knowledge
3
+ // promotion, kept out of the knowledge ER/registry machinery). A proposed action
4
+ // is a real dir under agent/actions/inbox/<slug>/. A human activates it (move to
5
+ // agent/actions/<slug>/) or rejects it (remove). Never auto-activates.
6
+
7
+ import { join } from 'node:path';
8
+ import { existsSync, readFileSync, renameSync, mkdirSync, rmSync } from 'node:fs';
9
+ import { actionsDir, inboxDir, listActions, isSafeSlug } from './manifest.mjs';
10
+
11
+ /** Archive dir for rejected action proposals: agent/actions/proposals/archive/. */
12
+ const archiveBaseDir = (agentDir) => join(actionsDir(agentDir), 'proposals', 'archive');
13
+
14
+ /** Proposed actions awaiting review (in agent/actions/inbox/). */
15
+ export function listProposedActions(agentDir) {
16
+ return listActions(inboxDir(agentDir));
17
+ }
18
+
19
+ /**
20
+ * Activate a proposed action: validate, then move inbox/<slug> → actions/<slug>.
21
+ * @returns {{ok:true} | {ok:false, error:string}}
22
+ */
23
+ export function activateAction(agentDir, slug) {
24
+ if (!isSafeSlug(slug)) return { ok: false, error: `invalid slug '${slug}'` };
25
+ const from = join(inboxDir(agentDir), slug);
26
+ const to = join(actionsDir(agentDir), slug);
27
+ if (!existsSync(from)) return { ok: false, error: `no proposed action '${slug}'` };
28
+ if (existsSync(to)) return { ok: false, error: `an active action '${slug}' already exists — reject or rename first` };
29
+ const manPath = join(from, 'action.json');
30
+ if (existsSync(manPath)) {
31
+ let man;
32
+ try { man = JSON.parse(readFileSync(manPath, 'utf8')); }
33
+ catch { return { ok: false, error: `manifest is not valid JSON` }; }
34
+ if (man.slug && man.slug !== slug) return { ok: false, error: `manifest slug '${man.slug}' ≠ dir '${slug}'` };
35
+ }
36
+ renameSync(from, to);
37
+ return { ok: true };
38
+ }
39
+
40
+ /**
41
+ * Reject a proposed action: ARCHIVE its dir (move inbox/<slug> →
42
+ * actions/proposals/archive/<slug>), never destroy it (WS2-T3). An auditable
43
+ * history mirrors the Knowledge gate's archive-on-reject.
44
+ */
45
+ export function rejectAction(agentDir, slug) {
46
+ if (!isSafeSlug(slug)) return { ok: false, error: `invalid slug '${slug}'` };
47
+ const from = join(inboxDir(agentDir), slug);
48
+ if (!existsSync(from)) return { ok: false, error: `no proposed action '${slug}'` };
49
+ const archBase = archiveBaseDir(agentDir);
50
+ mkdirSync(archBase, { recursive: true });
51
+ const to = join(archBase, slug);
52
+ // if a prior rejection of the same slug exists, clear it so the move succeeds
53
+ if (existsSync(to)) rmSync(to, { recursive: true, force: true });
54
+ renameSync(from, to);
55
+ return { ok: true };
56
+ }