@zuzuucodes/cli 1.4.0 → 1.5.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/bin/zuzuu.mjs +8 -1
- package/package.json +1 -1
- package/web-app/dist/zuzuu-api.js +122 -27
- package/web-app/web-dist/assets/{DiffTab-BuWonUNJ.js → DiffTab-BpGp1akx.js} +1 -1
- package/web-app/web-dist/assets/{MonacoFile-CL3DhFKG.js → MonacoFile-CqbVacUZ.js} +1 -1
- package/web-app/web-dist/assets/{cssMode-B9jnrWOz.js → cssMode-Dx3ub8Pk.js} +1 -1
- package/web-app/web-dist/assets/{dist-ChcDQ_7s.js → dist-C6R6xoyX.js} +1 -1
- package/web-app/web-dist/assets/{htmlMode-Bi8vSvwb.js → htmlMode-DM6oHc7c.js} +1 -1
- package/web-app/web-dist/assets/{index--5yy8RbA.js → index-DHpC851f.js} +25 -24
- package/web-app/web-dist/assets/index-O-t1gyMG.css +2 -0
- package/web-app/web-dist/assets/{jsonMode-C6ELX5GM.js → jsonMode-DflaUwqW.js} +1 -1
- package/web-app/web-dist/assets/{monaco-setup-CsR6EfHe.js → monaco-setup-wbBeb0oN.js} +3 -3
- package/web-app/web-dist/assets/{tsMode-a8OvovQd.js → tsMode-DRwkDcoK.js} +1 -1
- package/web-app/web-dist/index.html +2 -2
- package/zuzuu/actions/adapter.mjs +12 -20
- package/zuzuu/actions/convert.mjs +10 -9
- package/zuzuu/actions/dispatch.mjs +12 -7
- package/zuzuu/actions/inbox.mjs +5 -5
- package/zuzuu/actions/manifest.mjs +48 -30
- package/zuzuu/actions/schema.mjs +9 -3
- package/zuzuu/commands/act-author.mjs +23 -13
- package/zuzuu/commands/act.mjs +3 -5
- package/zuzuu/commands/doctor.mjs +2 -15
- package/zuzuu/commands/explain.mjs +4 -4
- package/zuzuu/commands/faculty.mjs +75 -0
- package/zuzuu/commands/generation.mjs +2 -4
- package/zuzuu/commands/hook.mjs +7 -5
- package/zuzuu/commands/init.mjs +14 -1
- package/zuzuu/commands/migrate.mjs +348 -1
- package/zuzuu/digest.mjs +18 -13
- package/zuzuu/faculty/envelope.mjs +290 -0
- package/zuzuu/faculty/generation.mjs +53 -47
- package/zuzuu/faculty/items.mjs +75 -0
- package/zuzuu/guardrails/adapter.mjs +18 -49
- package/zuzuu/guardrails.mjs +72 -24
- package/zuzuu/instructions/adapter.mjs +30 -30
- package/zuzuu/knowledge/items.mjs +56 -91
- package/zuzuu/live/install.mjs +1 -1
- package/zuzuu/memory/adapter.mjs +27 -52
- package/zuzuu/miners/actions.mjs +14 -20
- package/zuzuu/miners/guardrails.mjs +8 -11
- package/zuzuu/miners/instructions.mjs +10 -10
- package/zuzuu/scaffold.mjs +99 -38
- package/web-app/web-dist/assets/index-BVG4hgk7.css +0 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// adapter contract — { name, ingest, validate, apply, render } — so the generic
|
|
5
5
|
// `zuzuu review` gate can drive Actions the same way it drives Knowledge.
|
|
6
6
|
//
|
|
7
|
-
// Actions payloads are DIRECTORIES (
|
|
7
|
+
// Actions payloads are DIRECTORIES (ACTION.md + sibling scripts), not JSON.
|
|
8
8
|
// Strategy (lowest-risk): the inbox stays a dir; this adapter emits/reads a
|
|
9
9
|
// spine-shaped proposal RECORD that REFERENCES the dir
|
|
10
10
|
// (payload = { slug, kind, dir:'inbox/<slug>' }). The gate resolves a single
|
|
@@ -18,7 +18,7 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
19
19
|
import { listActions, inboxDir, isSafeSlug } from './manifest.mjs';
|
|
20
20
|
import { activateAction, rejectAction } from './inbox.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { parseEnvelope, validateEnvelope, PAYLOAD_SCHEMAS } from '../faculty/envelope.mjs';
|
|
22
22
|
import * as registry from '../faculty/registry.mjs';
|
|
23
23
|
|
|
24
24
|
const name = 'actions';
|
|
@@ -64,30 +64,22 @@ function ingest(_agentDir, raw) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Validate a proposed action's
|
|
68
|
-
*
|
|
67
|
+
* Validate a proposed action's ACTION.md envelope (id matches the dir; the
|
|
68
|
+
* payload validates against the actions schema). Missing ACTION.md → accept
|
|
69
|
+
* (slug fallback, mirrors the historical missing-manifest tolerance).
|
|
69
70
|
* @returns {{ok:boolean, errors:string[], warnings:string[]}}
|
|
70
71
|
*/
|
|
71
72
|
function validate(agentDir, payload) {
|
|
72
73
|
const slug = payload?.slug;
|
|
73
74
|
if (!isSafeSlug(slug)) return { ok: false, errors: [`invalid slug '${slug}'`], warnings: [] };
|
|
74
|
-
const manPath = join(inboxDir(agentDir), slug, '
|
|
75
|
+
const manPath = join(inboxDir(agentDir), slug, 'ACTION.md');
|
|
75
76
|
if (!existsSync(manPath)) return { ok: true, errors: [], warnings: [] };
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
const
|
|
81
|
-
|
|
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: [] };
|
|
77
|
+
const { ok, item, errors: parseErrors } = parseEnvelope(readFileSync(manPath, 'utf8'));
|
|
78
|
+
if (!ok) return { ok: false, errors: [`ACTION.md is not a valid envelope: ${parseErrors[0]}`], warnings: [] };
|
|
79
|
+
if (item.id && item.id !== slug) return { ok: false, errors: [`ACTION.md id '${item.id}' ≠ dir '${slug}'`], warnings: [] };
|
|
80
|
+
if (item.faculty !== 'actions') return { ok: false, errors: [`ACTION.md faculty must be 'actions' (got '${item.faculty}')`], warnings: [] };
|
|
81
|
+
const v = validateEnvelope(item, PAYLOAD_SCHEMAS.actions);
|
|
82
|
+
return { ok: v.ok, errors: v.errors, warnings: [] };
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
/**
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// zuzuu/actions/convert.mjs
|
|
2
2
|
// Pure manifest → tool-definition converters (the _labs tool-definition pattern).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Manifests are ACTION.md envelopes (W24): name = the envelope id, description =
|
|
4
|
+
// the prompt snippet (body first line) or title. The envelope carries no
|
|
5
|
+
// inputs/outputs JSON-schemas (clean break) — tool definitions expose the
|
|
6
|
+
// permissive object schema; the runner validates the same way.
|
|
5
7
|
//
|
|
6
8
|
// STATUS (2026-06-11): used today only by `zuzuu act schema <slug> [--mcp|--openai|
|
|
7
9
|
// --anthropic]` for inspection. There is NO runtime MCP/native-tool *serving* yet —
|
|
@@ -9,19 +11,18 @@
|
|
|
9
11
|
// agent in the digest. Live "Actions over MCP" serving is DEFERRED (DESIGN §6 /
|
|
10
12
|
// Stage 2 / OpenCode bundle); these converters are the seam for it, not the thing.
|
|
11
13
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
+
const name = (m) => m.id ?? m.slug;
|
|
15
|
+
const desc = (m) => m.promptSnippet ?? m.title ?? name(m);
|
|
16
|
+
const inputs = () => ({ type: 'object' });
|
|
14
17
|
|
|
15
18
|
export function toMcpTool(m) {
|
|
16
|
-
|
|
17
|
-
if (m.outputs) t.outputSchema = m.outputs;
|
|
18
|
-
return t;
|
|
19
|
+
return { name: name(m), description: desc(m), inputSchema: inputs() };
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function toOpenAITool(m) {
|
|
22
|
-
return { type: 'function', function: { name: m
|
|
23
|
+
return { type: 'function', function: { name: name(m), description: desc(m), parameters: inputs() } };
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function toAnthropicTool(m) {
|
|
26
|
-
return { name: m
|
|
27
|
+
return { name: name(m), description: desc(m), input_schema: inputs() };
|
|
27
28
|
}
|
|
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { join, dirname } from 'node:path';
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
|
-
import { loadManifest, actionsDir } from './manifest.mjs';
|
|
11
|
+
import { loadManifest, execOf, actionsDir } from './manifest.mjs';
|
|
12
12
|
import { MARKER } from './marker.mjs';
|
|
13
13
|
|
|
14
14
|
const MAX_DEPTH = 8;
|
|
@@ -51,16 +51,21 @@ export function runAction(agentDir, slug, callerArgs = {}, { timeoutMs = ACTION_
|
|
|
51
51
|
if (depth >= MAX_DEPTH) return { ok: false, error: 'depth_exceeded', detail: `depth ${depth} ≥ ${MAX_DEPTH}`, logs: '' };
|
|
52
52
|
|
|
53
53
|
const manifest = loadManifest(agentDir, slug);
|
|
54
|
-
if (!manifest) return { ok: false, error: 'not_found', detail: `no action '${slug}' (missing
|
|
54
|
+
if (!manifest) return { ok: false, error: 'not_found', detail: `no action '${slug}' (missing ACTION.md)`, logs: '' };
|
|
55
55
|
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
56
|
+
const exec = execOf(manifest);
|
|
57
|
+
if (!exec) return { ok: false, error: 'not_runnable', detail: `'${slug}' has an unsafe exec entry`, logs: '' };
|
|
58
|
+
const runPath = join(actionsDir(agentDir), slug, exec);
|
|
59
|
+
if (!existsSync(runPath)) return { ok: false, error: 'not_runnable', detail: `'${slug}' has no ${exec}`, logs: '' };
|
|
58
60
|
|
|
61
|
+
// The envelope carries no inputs/outputs JSON-schemas (clean break, W24) —
|
|
62
|
+
// the runner validates against the permissive object schema; payload.args
|
|
63
|
+
// are the default args (caller args win).
|
|
59
64
|
const payload = JSON.stringify({
|
|
60
65
|
runPath,
|
|
61
|
-
inputs:
|
|
62
|
-
outputs:
|
|
63
|
-
default_args: manifest.
|
|
66
|
+
inputs: { type: 'object' },
|
|
67
|
+
outputs: { type: 'object' },
|
|
68
|
+
default_args: manifest.payload?.args ?? {},
|
|
64
69
|
args: callerArgs ?? {},
|
|
65
70
|
});
|
|
66
71
|
|
package/zuzuu/actions/inbox.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { existsSync, readFileSync, renameSync, mkdirSync, rmSync } from 'node:fs';
|
|
9
9
|
import { actionsDir, inboxDir, listActions, isSafeSlug } from './manifest.mjs';
|
|
10
|
+
import { parseEnvelope } from '../faculty/envelope.mjs';
|
|
10
11
|
|
|
11
12
|
/** Archive dir for rejected action proposals: .zuzuu/actions/proposals/archive/. */
|
|
12
13
|
const archiveBaseDir = (agentDir) => join(actionsDir(agentDir), 'proposals', 'archive');
|
|
@@ -26,12 +27,11 @@ export function activateAction(agentDir, slug) {
|
|
|
26
27
|
const to = join(actionsDir(agentDir), slug);
|
|
27
28
|
if (!existsSync(from)) return { ok: false, error: `no proposed action '${slug}'` };
|
|
28
29
|
if (existsSync(to)) return { ok: false, error: `an active action '${slug}' already exists — reject or rename first` };
|
|
29
|
-
const manPath = join(from, '
|
|
30
|
+
const manPath = join(from, 'ACTION.md');
|
|
30
31
|
if (existsSync(manPath)) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (man.slug && man.slug !== slug) return { ok: false, error: `manifest slug '${man.slug}' ≠ dir '${slug}'` };
|
|
32
|
+
const { ok, item } = parseEnvelope(readFileSync(manPath, 'utf8'));
|
|
33
|
+
if (!ok) return { ok: false, error: 'ACTION.md is not a valid envelope' };
|
|
34
|
+
if (item.id && item.id !== slug) return { ok: false, error: `ACTION.md id '${item.id}' ≠ dir '${slug}'` };
|
|
35
35
|
}
|
|
36
36
|
renameSync(from, to);
|
|
37
37
|
return { ok: true };
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// zuzuu/actions/manifest.mjs
|
|
2
|
-
// Reads the Actions faculty off disk: one action per dir under .zuzuu/actions
|
|
3
|
-
//
|
|
2
|
+
// Reads the Actions faculty off disk: one action per dir under .zuzuu/actions/,
|
|
3
|
+
// described by an ACTION.md envelope (the Faculty Standard, W24 — SKILL.md-shaped:
|
|
4
|
+
// frontmatter + instruction prose body; scripts stay siblings):
|
|
5
|
+
//
|
|
6
|
+
// actions/<slug>/ACTION.md kind: script|runbook; payload.exec (script entry,
|
|
7
|
+
// default run.mjs) + payload.args (default args)
|
|
8
|
+
// actions/<slug>/run.mjs the script (kind: script)
|
|
9
|
+
//
|
|
4
10
|
// Pure-ish: filesystem reads only, no logging, no process control.
|
|
5
11
|
|
|
6
12
|
import { join } from 'node:path';
|
|
7
13
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { parseEnvelope, deriveTitle } from '../faculty/envelope.mjs';
|
|
8
15
|
|
|
9
16
|
// Action slugs: letters/digits start, then letters/digits/-/_. No dots or slashes
|
|
10
17
|
// → cannot escape .zuzuu/actions/ via path traversal.
|
|
@@ -13,60 +20,71 @@ export function isSafeSlug(slug) {
|
|
|
13
20
|
return typeof slug === 'string' && SAFE_SLUG.test(slug);
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
// payload.exec must be a plain sibling filename — never a path.
|
|
24
|
+
const SAFE_EXEC = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
25
|
+
|
|
16
26
|
export const actionsDir = (agentDir) => join(agentDir, 'actions');
|
|
17
27
|
export const inboxDir = (agentDir) => join(actionsDir(agentDir), 'inbox');
|
|
18
28
|
const actionDir = (agentDir, slug) => join(actionsDir(agentDir), slug);
|
|
19
29
|
|
|
20
|
-
/**
|
|
30
|
+
/** First non-empty body line — the one-liner the digest shows. */
|
|
31
|
+
function snippetOf(item) {
|
|
32
|
+
const first = String(item.body ?? '').split('\n').map((l) => l.trim()).find(Boolean);
|
|
33
|
+
return first || item.title || item.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read and parse ACTION.md for a slug → envelope manifest, or null if
|
|
38
|
+
* absent/unparseable. Shape: { id, kind, title, status, created_at,
|
|
39
|
+
* payload: {exec?, args?}, body } + promptSnippet derived from the body.
|
|
40
|
+
*/
|
|
21
41
|
export function loadManifest(agentDir, slug) {
|
|
22
|
-
const path = join(actionDir(agentDir, slug), '
|
|
42
|
+
const path = join(actionDir(agentDir, slug), 'ACTION.md');
|
|
23
43
|
try {
|
|
24
|
-
|
|
44
|
+
const { ok, item } = parseEnvelope(readFileSync(path, 'utf8'));
|
|
45
|
+
if (!ok || item.faculty !== 'actions') return null;
|
|
46
|
+
item.title = item.title ?? deriveTitle(item.body, item.id);
|
|
47
|
+
item.promptSnippet = snippetOf(item);
|
|
48
|
+
return item;
|
|
25
49
|
} catch {
|
|
26
50
|
return null;
|
|
27
51
|
}
|
|
28
52
|
}
|
|
29
53
|
|
|
30
|
-
/**
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
if (m) {
|
|
35
|
-
for (const line of m[1].split('\n')) {
|
|
36
|
-
const kv = line.match(/^(\w+):\s*(.*)$/);
|
|
37
|
-
if (kv) fm[kv[1]] = kv[2].trim();
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return fm;
|
|
54
|
+
/** The script entry file for a manifest (validated sibling name), or null. */
|
|
55
|
+
export function execOf(manifest) {
|
|
56
|
+
const exec = manifest?.payload?.exec ?? 'run.mjs';
|
|
57
|
+
return SAFE_EXEC.test(exec) ? exec : null;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
/**
|
|
44
61
|
* List actions in a base dir as {slug, kind, title, promptSnippet}.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
62
|
+
* An action is a dir carrying ACTION.md; kind comes from its envelope
|
|
63
|
+
* (`script` | `runbook`). Other entries are skipped (READMEs, stray files,
|
|
64
|
+
* unparseable envelopes — the latter surface via `zuzuu faculty items actions`).
|
|
65
|
+
* Reads directly from each entry dir (works for any baseDir, e.g. the inbox).
|
|
47
66
|
*/
|
|
48
67
|
export function listActions(baseDir) {
|
|
49
68
|
if (!existsSync(baseDir)) return [];
|
|
50
69
|
const out = [];
|
|
51
|
-
for (const name of readdirSync(baseDir)) {
|
|
70
|
+
for (const name of readdirSync(baseDir).sort()) {
|
|
52
71
|
const d = join(baseDir, name);
|
|
53
72
|
let isDir = false;
|
|
54
73
|
try { isDir = statSync(d).isDirectory(); } catch { /* skip */ }
|
|
55
74
|
if (!isDir) continue; // ignores README.md and any stray files
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
75
|
+
const actionMd = join(d, 'ACTION.md');
|
|
76
|
+
if (!existsSync(actionMd)) continue;
|
|
77
|
+
try {
|
|
78
|
+
const { ok, item } = parseEnvelope(readFileSync(actionMd, 'utf8'));
|
|
79
|
+
if (!ok) continue;
|
|
80
|
+
const kind = item.kind === 'script' ? 'script' : 'runbook';
|
|
81
|
+
out.push({ slug: name, kind, title: item.title ?? name, promptSnippet: snippetOf(item) });
|
|
82
|
+
} catch { /* unreadable → skip */ }
|
|
65
83
|
}
|
|
66
84
|
return out;
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
/** Active actions under .zuzuu/actions/ (
|
|
87
|
+
/** Active actions under .zuzuu/actions/ (inbox/proposals subdirs excluded). */
|
|
70
88
|
export function allActions(agentDir) {
|
|
71
|
-
return listActions(actionsDir(agentDir)).filter((a) => a.slug !== 'inbox');
|
|
89
|
+
return listActions(actionsDir(agentDir)).filter((a) => a.slug !== 'inbox' && a.slug !== 'proposals' && a.slug !== '_rolledback');
|
|
72
90
|
}
|
package/zuzuu/actions/schema.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// zuzuu/actions/schema.mjs
|
|
2
2
|
// A hand-rolled JSON-Schema *subset* validator — zero-dep (no Ajv), matching the
|
|
3
3
|
// project's node-builtins-only policy. Supports: object (properties, required),
|
|
4
|
-
// array (items), string/number/integer/boolean scalars, enum, and basic
|
|
5
|
-
// range constraints. Returns an array of error strings ([] = valid). No
|
|
6
|
-
// values are expected to already carry real JSON types.
|
|
4
|
+
// array (items), string/number/integer/boolean scalars, enum, pattern, and basic
|
|
5
|
+
// length/range constraints. Returns an array of error strings ([] = valid). No
|
|
6
|
+
// coercion: values are expected to already carry real JSON types. Shared by the
|
|
7
|
+
// actions runner AND the faculty envelope (payload validation — W24).
|
|
7
8
|
|
|
8
9
|
function isPlainObject(v) {
|
|
9
10
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -41,6 +42,11 @@ export function validate(schema, value, path = '$') {
|
|
|
41
42
|
if (typeof value === 'string') {
|
|
42
43
|
if (schema.minLength != null && value.length < schema.minLength) errors.push(`${path}: shorter than minLength ${schema.minLength}`);
|
|
43
44
|
if (schema.maxLength != null && value.length > schema.maxLength) errors.push(`${path}: longer than maxLength ${schema.maxLength}`);
|
|
45
|
+
if (schema.pattern != null) {
|
|
46
|
+
try {
|
|
47
|
+
if (!new RegExp(schema.pattern).test(value)) errors.push(`${path}: does not match pattern ${schema.pattern}`);
|
|
48
|
+
} catch { /* an unparseable authored pattern never blocks (fail-open) */ }
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
if (typeof value === 'number') {
|
|
46
52
|
if (schema.minimum != null && value < schema.minimum) errors.push(`${path}: below minimum ${schema.minimum}`);
|
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
// zuzuu/commands/act-author.mjs
|
|
2
2
|
// `zuzuu act new <slug>` — scaffold a script action (idempotent, no-clobber).
|
|
3
3
|
// `zuzuu act schema <slug> [--mcp|--openai|--anthropic]` — convert the manifest.
|
|
4
|
+
// Actions are described by ACTION.md (the Faculty Standard envelope, W24).
|
|
4
5
|
|
|
5
6
|
import { mkdirSync, existsSync, writeFileSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { actionsDir, inboxDir, loadManifest, isSafeSlug } from '../actions/manifest.mjs';
|
|
8
9
|
import { toMcpTool, toOpenAITool, toAnthropicTool } from '../actions/convert.mjs';
|
|
10
|
+
import { serializeEnvelope } from '../faculty/envelope.mjs';
|
|
9
11
|
|
|
10
|
-
function
|
|
11
|
-
return
|
|
12
|
-
slug,
|
|
12
|
+
function actionMdStub(slug) {
|
|
13
|
+
return serializeEnvelope({
|
|
14
|
+
id: slug,
|
|
15
|
+
faculty: 'actions',
|
|
16
|
+
kind: 'script',
|
|
13
17
|
title: slug,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
status: 'active',
|
|
19
|
+
created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
20
|
+
payload: { exec: 'run.mjs' },
|
|
21
|
+
body: [
|
|
22
|
+
`What ${slug} does — the first line is the one-liner the digest shows.`,
|
|
23
|
+
'',
|
|
24
|
+
'## Usage',
|
|
25
|
+
'',
|
|
26
|
+
`\`zuzuu act ${slug} --args '{"key":"value"}'\``,
|
|
27
|
+
'',
|
|
28
|
+
'Describe inputs, outputs, and when to reach for this action here.',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
});
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
const RUN_TEMPLATE = `// run.mjs — implement the action. Export async main(args) → a JSON object.
|
|
@@ -26,7 +36,7 @@ const RUN_TEMPLATE = `// run.mjs — implement the action. Export async main(arg
|
|
|
26
36
|
// export function prepareArguments(args) { return args; }
|
|
27
37
|
|
|
28
38
|
export async function main(args) {
|
|
29
|
-
// args
|
|
39
|
+
// args = ACTION.md payload.args defaults merged with caller --args (caller wins).
|
|
30
40
|
return { ok: true };
|
|
31
41
|
}
|
|
32
42
|
`;
|
|
@@ -41,7 +51,7 @@ function scaffoldInto(baseDir, slug) {
|
|
|
41
51
|
const p = join(dir, name);
|
|
42
52
|
if (!existsSync(p)) { writeFileSync(p, body); created.push(name); }
|
|
43
53
|
};
|
|
44
|
-
write('
|
|
54
|
+
write('ACTION.md', actionMdStub(slug));
|
|
45
55
|
write('run.mjs', RUN_TEMPLATE);
|
|
46
56
|
return { created };
|
|
47
57
|
}
|
|
@@ -66,7 +76,7 @@ export function newAction(agentDir, slug) {
|
|
|
66
76
|
export function schema(agentDir, slug, args = {}) {
|
|
67
77
|
if (!slug) { console.error('usage: zuzuu act schema <slug> [--mcp|--openai|--anthropic]'); process.exit(1); }
|
|
68
78
|
const man = loadManifest(agentDir, slug);
|
|
69
|
-
if (!man) { console.error(`no action '${slug}' (missing
|
|
79
|
+
if (!man) { console.error(`no action '${slug}' (missing ACTION.md)`); process.exit(1); }
|
|
70
80
|
const def = args.openai ? toOpenAITool(man) : args.anthropic ? toAnthropicTool(man) : toMcpTool(man);
|
|
71
81
|
console.log(JSON.stringify(def, null, 2));
|
|
72
82
|
}
|
package/zuzuu/commands/act.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { paths } from '../store.mjs';
|
|
13
|
-
import { allActions,
|
|
13
|
+
import { allActions, actionsDir, isSafeSlug } from '../actions/manifest.mjs';
|
|
14
14
|
import { runAction } from '../actions/dispatch.mjs';
|
|
15
15
|
import { MARKER } from '../actions/marker.mjs';
|
|
16
16
|
import { newAction, schema as schemaCmd, proposeAction } from './act-author.mjs';
|
|
@@ -35,10 +35,8 @@ function list(agentDir) {
|
|
|
35
35
|
|
|
36
36
|
function show(agentDir, slug) {
|
|
37
37
|
if (!slug) { console.error('usage: zuzuu act show <slug>'); process.exit(1); }
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
const skill = join(actionsDir(agentDir), slug, 'SKILL.md');
|
|
41
|
-
if (existsSync(skill)) return process.stdout.write(readFileSync(skill, 'utf8'));
|
|
38
|
+
const actionMd = join(actionsDir(agentDir), slug, 'ACTION.md');
|
|
39
|
+
if (existsSync(actionMd)) return process.stdout.write(readFileSync(actionMd, 'utf8'));
|
|
42
40
|
console.error(`no action '${slug}'`);
|
|
43
41
|
process.exit(1);
|
|
44
42
|
}
|
|
@@ -40,8 +40,8 @@ export function detectDrift(agentDir) {
|
|
|
40
40
|
const pinned = lockfile.faculties || {};
|
|
41
41
|
const drifted = [];
|
|
42
42
|
|
|
43
|
-
// Compare per-faculty item arrays
|
|
44
|
-
for (const faculty of ['knowledge', 'actions', 'memory']) {
|
|
43
|
+
// Compare per-faculty item arrays — all five are envelope-item lists (W24).
|
|
44
|
+
for (const faculty of ['knowledge', 'actions', 'memory', 'guardrails', 'instructions']) {
|
|
45
45
|
const pinnedItems = (pinned[faculty]?.items ?? []);
|
|
46
46
|
const currentItems = (current[faculty]?.items ?? []);
|
|
47
47
|
|
|
@@ -65,19 +65,6 @@ export function detectDrift(agentDir) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// Compare single-file faculties (guardrails.rulesHash, instructions.projectHash)
|
|
69
|
-
const singleFile = [
|
|
70
|
-
{ faculty: 'guardrails', field: 'rulesHash' },
|
|
71
|
-
{ faculty: 'instructions', field: 'projectHash' },
|
|
72
|
-
];
|
|
73
|
-
for (const { faculty, field } of singleFile) {
|
|
74
|
-
const pinnedHash = pinned[faculty]?.[field] ?? null;
|
|
75
|
-
const currentHash = current[faculty]?.[field] ?? null;
|
|
76
|
-
if (pinnedHash !== currentHash) {
|
|
77
|
-
drifted.push({ id: field, faculty, reason: 'hash_changed', pinned: pinnedHash, current: currentHash });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
68
|
// Compare knowledge.registryHash
|
|
82
69
|
const pinnedReg = pinned.knowledge?.registryHash ?? null;
|
|
83
70
|
const currentReg = current.knowledge?.registryHash ?? null;
|
|
@@ -26,12 +26,12 @@ const FACULTY_CONTRACTS = {
|
|
|
26
26
|
'Propose one with `zuzuu act propose <slug>` (lands in actions/inbox/); on approval ' +
|
|
27
27
|
'it becomes an active action you can run with `zuzuu act <slug>`.',
|
|
28
28
|
instructions:
|
|
29
|
-
'instructions — the directive faculty: who to BE. The pinned steering
|
|
30
|
-
'(instructions/
|
|
29
|
+
'instructions — the directive faculty: who to BE. The pinned steering item ' +
|
|
30
|
+
'(instructions/items/steering.md) that grounds every session. Empty by default — the ' +
|
|
31
31
|
'digest tells the agent to interview you and draft it for your approval.',
|
|
32
32
|
guardrails:
|
|
33
|
-
'guardrails — the protective faculty: what NOT to do, ENFORCED.
|
|
34
|
-
'guardrails/
|
|
33
|
+
'guardrails — the protective faculty: what NOT to do, ENFORCED. One rule per item in ' +
|
|
34
|
+
'guardrails/items/ gates tool calls (deny > ask > allow) before they run. ' +
|
|
35
35
|
'A refusal here is policy, not preference. The gate fails open — never breaks the host.',
|
|
36
36
|
};
|
|
37
37
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// zuzuu/commands/faculty.mjs — `zuzuu faculty` (W24, the Faculty Standard).
|
|
2
|
+
//
|
|
3
|
+
// The read surface over the one envelope format:
|
|
4
|
+
// zuzuu faculty items <f> [--json|--jsonl] list a faculty's envelope items
|
|
5
|
+
// zuzuu faculty schema <f> [--json] print its payload schema
|
|
6
|
+
//
|
|
7
|
+
// `--json` = one document; `--jsonl` = one item per line (streaming consumers).
|
|
8
|
+
// Fail-soft like everything on the serve path: unparseable items are listed as
|
|
9
|
+
// errors next to the good ones, never thrown.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { paths } from '../store.mjs';
|
|
14
|
+
import { FACULTIES } from '../faculty/contract.mjs';
|
|
15
|
+
import { listFacultyItems } from '../faculty/items.mjs';
|
|
16
|
+
import { PAYLOAD_SCHEMAS, FACULTY_KINDS } from '../faculty/envelope.mjs';
|
|
17
|
+
|
|
18
|
+
/** Pure: one faculty's envelope items + parse errors (the --json document). */
|
|
19
|
+
export function facultyItemsData(agentDir, faculty) {
|
|
20
|
+
const { items, errors } = listFacultyItems(agentDir, faculty);
|
|
21
|
+
return { faculty, count: items.length, items, errors };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure: the payload schema served for a faculty — the home's seeded
|
|
26
|
+
* `<faculty>/schema.json` when present and parseable (humans may extend it),
|
|
27
|
+
* else the built-in default. Never throws.
|
|
28
|
+
*/
|
|
29
|
+
export function facultySchemaData(agentDir, faculty) {
|
|
30
|
+
const seeded = join(agentDir, faculty, 'schema.json');
|
|
31
|
+
if (existsSync(seeded)) {
|
|
32
|
+
try { return { faculty, source: 'home', schema: JSON.parse(readFileSync(seeded, 'utf8')) }; }
|
|
33
|
+
catch { /* fall through to the built-in */ }
|
|
34
|
+
}
|
|
35
|
+
return { faculty, source: 'builtin', schema: PAYLOAD_SCHEMAS[faculty] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `zuzuu faculty <sub> <f>` — items | schema. */
|
|
39
|
+
export function faculty(args = {}, log = console.log) {
|
|
40
|
+
const [sub, f] = args._ ?? [];
|
|
41
|
+
if (!sub || !['items', 'schema'].includes(sub)) {
|
|
42
|
+
console.error('usage: zuzuu faculty items <faculty> [--json|--jsonl] · faculty schema <faculty> [--json]');
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!FACULTIES.includes(f)) {
|
|
47
|
+
console.error(`unknown faculty: ${f ?? '(none)'} — one of ${FACULTIES.join(' · ')}`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const agentDir = paths().dir;
|
|
52
|
+
|
|
53
|
+
if (sub === 'schema') {
|
|
54
|
+
const { schema } = facultySchemaData(agentDir, f);
|
|
55
|
+
log(JSON.stringify(schema, null, 2));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = facultyItemsData(agentDir, f);
|
|
60
|
+
if (args.jsonl) {
|
|
61
|
+
for (const item of data.items) log(JSON.stringify(item));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (args.json) {
|
|
65
|
+
log(JSON.stringify(data, null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const kinds = FACULTY_KINDS[f];
|
|
69
|
+
log(`${f} — ${data.count} item(s)${kinds ? ` [${kinds.join('|')}]` : ''}`);
|
|
70
|
+
for (const it of data.items) {
|
|
71
|
+
log(` ${it.id} ${it.kind} · ${it.status ?? 'active'} — ${it.title}`);
|
|
72
|
+
}
|
|
73
|
+
for (const e of data.errors) log(` ✗ ${e.file}: ${e.error}`);
|
|
74
|
+
if (!data.count && !data.errors.length) log(' (none yet)');
|
|
75
|
+
}
|
|
@@ -86,7 +86,8 @@ export function showLines(dir, id) {
|
|
|
86
86
|
lines.push(` forkedFrom: ${d.forkedFrom ?? '(none — first generation)'}`);
|
|
87
87
|
lines.push(` mintedFrom: ${d.mintedFrom.length} proposal(s)`);
|
|
88
88
|
lines.push(' changes vs parent:');
|
|
89
|
-
|
|
89
|
+
// all five faculties are envelope-item lists under the Faculty Standard (W24)
|
|
90
|
+
for (const f of ['knowledge', 'actions', 'memory', 'guardrails', 'instructions']) {
|
|
90
91
|
const x = d.faculties[f] || { added: [], changed: [], removed: [] };
|
|
91
92
|
const parts = [];
|
|
92
93
|
if (x.added.length) parts.push(`+${x.added.length} added`);
|
|
@@ -95,9 +96,6 @@ export function showLines(dir, id) {
|
|
|
95
96
|
if (f === 'knowledge' && x.registryChanged) parts.push('registry changed');
|
|
96
97
|
lines.push(` ${f}: ${parts.length ? parts.join(' · ') : 'no change'}`);
|
|
97
98
|
}
|
|
98
|
-
for (const f of ['guardrails', 'instructions']) {
|
|
99
|
-
lines.push(` ${f}: ${d.faculties[f]?.changed ? 'changed' : 'no change'}`);
|
|
100
|
-
}
|
|
101
99
|
return lines.join('\n');
|
|
102
100
|
}
|
|
103
101
|
|
package/zuzuu/commands/hook.mjs
CHANGED
|
@@ -110,8 +110,9 @@ export function handleHook({ event, payload = {}, cwd = process.cwd(), now = Dat
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* The Guardrails gate (PreToolUse). Evaluates the tool call against
|
|
114
|
-
* .zuzuu/guardrails/
|
|
113
|
+
* The Guardrails gate (PreToolUse). Evaluates the tool call against the
|
|
114
|
+
* envelope rule items in .zuzuu/guardrails/items/ and prints Claude's
|
|
115
|
+
* hookSpecificOutput decision —
|
|
115
116
|
* or NOTHING (exit 0, no JSON = defer to the host's normal permission flow).
|
|
116
117
|
* That silence is the fail-open: engine errors and rule-file problems can slow
|
|
117
118
|
* nothing down and block nothing. Matched decisions are logged for the trace.
|
|
@@ -127,14 +128,15 @@ function guardrailsLogName(sessionId) {
|
|
|
127
128
|
const GATE_EVENTS = new Set(['PreToolUse', 'BeforeTool']);
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
|
-
* Evaluate a tool call against
|
|
131
|
-
* (or null = fail-open / no match → host's normal flow). Logs
|
|
131
|
+
* Evaluate a tool call against the guardrail rule items and return the host's
|
|
132
|
+
* block decision (or null = fail-open / no match → host's normal flow). Logs
|
|
133
|
+
* matched decisions.
|
|
132
134
|
* codex + claude-code → hookSpecificOutput · gemini-cli → {decision,reason}
|
|
133
135
|
*/
|
|
134
136
|
export function gateDecision({ host = 'claude-code', payload = {}, cwd = process.cwd() } = {}) {
|
|
135
137
|
try {
|
|
136
138
|
const { dir } = paths(cwd);
|
|
137
|
-
const loaded = loadRules(join(dir, 'guardrails'
|
|
139
|
+
const loaded = loadRules(join(dir, 'guardrails'));
|
|
138
140
|
if (!loaded.ok) return null;
|
|
139
141
|
const verdict = evaluate(loaded.rules, { tool: payload.tool_name, input: payload.tool_input });
|
|
140
142
|
if (verdict) {
|
package/zuzuu/commands/init.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { applyScaffold, ensureGitignore, homeExists } from '../scaffold.mjs';
|
|
|
16
16
|
import { injectBlock, facultiesBlock, hasBlock, BLOCK_VERSION } from '../inject.mjs';
|
|
17
17
|
import { detected } from '../capture/adapters/registry.mjs';
|
|
18
18
|
import { repoRoot } from '../store.mjs';
|
|
19
|
-
import { migrateHome } from './migrate.mjs';
|
|
19
|
+
import { migrateHome, migrateItems, needsItemsMigration } from './migrate.mjs';
|
|
20
20
|
|
|
21
21
|
const HOST_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'];
|
|
22
22
|
// dotfiles/dirs that don't make a directory "a project" for emptiness purposes
|
|
@@ -80,6 +80,19 @@ export function init(args = {}) {
|
|
|
80
80
|
}
|
|
81
81
|
} catch { /* fail-open */ }
|
|
82
82
|
|
|
83
|
+
// One-shot items migration (W24): pre-standard faculty shapes (rules.json /
|
|
84
|
+
// project.md / action.json / legacy item frontmatter) become Faculty Standard
|
|
85
|
+
// envelopes. Gated on detection, idempotent, fail-open — like migrateHome.
|
|
86
|
+
try {
|
|
87
|
+
const home = join(cwd, '.zuzuu');
|
|
88
|
+
if (existsSync(home) && needsItemsMigration(home)) {
|
|
89
|
+
const r = migrateItems(home);
|
|
90
|
+
const total = r.knowledge + r.memory + r.guardrails + r.actions + r.instructions;
|
|
91
|
+
if (total) console.log(`Migrated ${total} faculty item(s) to the envelope standard (knowledge ${r.knowledge} · memory ${r.memory} · guardrails ${r.guardrails} · actions ${r.actions} · instructions ${r.instructions})`);
|
|
92
|
+
for (const e of r.errors) console.log(` ✗ ${e.file}: ${e.error} — left in place; fix and rerun \`zuzuu migrate --items\``);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* fail-open */ }
|
|
95
|
+
|
|
83
96
|
const reinit = homeExists(cwd);
|
|
84
97
|
const greenfield = !reinit && isEmptyDir(cwd);
|
|
85
98
|
|