@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.
Files changed (44) hide show
  1. package/bin/zuzuu.mjs +8 -1
  2. package/package.json +1 -1
  3. package/web-app/dist/zuzuu-api.js +122 -27
  4. package/web-app/web-dist/assets/{DiffTab-BuWonUNJ.js → DiffTab-BpGp1akx.js} +1 -1
  5. package/web-app/web-dist/assets/{MonacoFile-CL3DhFKG.js → MonacoFile-CqbVacUZ.js} +1 -1
  6. package/web-app/web-dist/assets/{cssMode-B9jnrWOz.js → cssMode-Dx3ub8Pk.js} +1 -1
  7. package/web-app/web-dist/assets/{dist-ChcDQ_7s.js → dist-C6R6xoyX.js} +1 -1
  8. package/web-app/web-dist/assets/{htmlMode-Bi8vSvwb.js → htmlMode-DM6oHc7c.js} +1 -1
  9. package/web-app/web-dist/assets/{index--5yy8RbA.js → index-DHpC851f.js} +25 -24
  10. package/web-app/web-dist/assets/index-O-t1gyMG.css +2 -0
  11. package/web-app/web-dist/assets/{jsonMode-C6ELX5GM.js → jsonMode-DflaUwqW.js} +1 -1
  12. package/web-app/web-dist/assets/{monaco-setup-CsR6EfHe.js → monaco-setup-wbBeb0oN.js} +3 -3
  13. package/web-app/web-dist/assets/{tsMode-a8OvovQd.js → tsMode-DRwkDcoK.js} +1 -1
  14. package/web-app/web-dist/index.html +2 -2
  15. package/zuzuu/actions/adapter.mjs +12 -20
  16. package/zuzuu/actions/convert.mjs +10 -9
  17. package/zuzuu/actions/dispatch.mjs +12 -7
  18. package/zuzuu/actions/inbox.mjs +5 -5
  19. package/zuzuu/actions/manifest.mjs +48 -30
  20. package/zuzuu/actions/schema.mjs +9 -3
  21. package/zuzuu/commands/act-author.mjs +23 -13
  22. package/zuzuu/commands/act.mjs +3 -5
  23. package/zuzuu/commands/doctor.mjs +2 -15
  24. package/zuzuu/commands/explain.mjs +4 -4
  25. package/zuzuu/commands/faculty.mjs +75 -0
  26. package/zuzuu/commands/generation.mjs +2 -4
  27. package/zuzuu/commands/hook.mjs +7 -5
  28. package/zuzuu/commands/init.mjs +14 -1
  29. package/zuzuu/commands/migrate.mjs +348 -1
  30. package/zuzuu/digest.mjs +18 -13
  31. package/zuzuu/faculty/envelope.mjs +290 -0
  32. package/zuzuu/faculty/generation.mjs +53 -47
  33. package/zuzuu/faculty/items.mjs +75 -0
  34. package/zuzuu/guardrails/adapter.mjs +18 -49
  35. package/zuzuu/guardrails.mjs +72 -24
  36. package/zuzuu/instructions/adapter.mjs +30 -30
  37. package/zuzuu/knowledge/items.mjs +56 -91
  38. package/zuzuu/live/install.mjs +1 -1
  39. package/zuzuu/memory/adapter.mjs +27 -52
  40. package/zuzuu/miners/actions.mjs +14 -20
  41. package/zuzuu/miners/guardrails.mjs +8 -11
  42. package/zuzuu/miners/instructions.mjs +10 -10
  43. package/zuzuu/scaffold.mjs +99 -38
  44. 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 (run.mjs/SKILL.md + action.json), not JSON.
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 { validateInputs } from './schema.mjs';
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 manifest against the schema subset and confirm
68
- * the manifest slug matches the dir. Missing manifest → accept (slug fallback).
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, 'action.json');
75
+ const manPath = join(inboxDir(agentDir), slug, 'ACTION.md');
75
76
  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: [] };
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
- // The manifest's `inputs` JSON Schema is already the right shape for each format,
4
- // so conversion is a thin re-wrap.
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 desc = (m) => m.description ?? m.title ?? m.slug;
13
- const inputs = (m) => m.inputs ?? { type: 'object' };
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
- const t = { name: m.slug, description: desc(m), inputSchema: inputs(m) };
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.slug, description: desc(m), parameters: inputs(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.slug, description: desc(m), input_schema: inputs(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 action.json)`, logs: '' };
54
+ if (!manifest) return { ok: false, error: 'not_found', detail: `no action '${slug}' (missing ACTION.md)`, logs: '' };
55
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: '' };
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: manifest.inputs ?? { type: 'object' },
62
- outputs: manifest.outputs ?? { type: 'object' },
63
- default_args: manifest.default_args ?? {},
66
+ inputs: { type: 'object' },
67
+ outputs: { type: 'object' },
68
+ default_args: manifest.payload?.args ?? {},
64
69
  args: callerArgs ?? {},
65
70
  });
66
71
 
@@ -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, 'action.json');
30
+ const manPath = join(from, 'ACTION.md');
30
31
  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}'` };
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
- // Two kinds `script` (has run.mjs + action.json) and `runbook` (SKILL.md prose).
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
- /** Read action.json for a slug object, or null if absent/unparseable. */
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), 'action.json');
42
+ const path = join(actionDir(agentDir, slug), 'ACTION.md');
23
43
  try {
24
- return JSON.parse(readFileSync(path, 'utf8'));
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
- /** Pull `name` / `description` from a SKILL.md YAML-ish frontmatter (best-effort). */
31
- function skillFrontmatter(text) {
32
- const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
33
- const fm = {};
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
- * `script` = dir has run.mjs; `runbook` = dir has SKILL.md; other entries skipped.
46
- * Reads the manifest directly from each entry dir (works for any baseDir, e.g. the inbox).
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
- if (existsSync(join(d, 'run.mjs'))) {
57
- let man = {};
58
- try { man = JSON.parse(readFileSync(join(d, 'action.json'), 'utf8')); } catch { /* slug fallback */ }
59
- out.push({ slug: name, kind: 'script', title: man.title ?? name, promptSnippet: man.promptSnippet ?? man.description ?? name });
60
- } else if (existsSync(join(d, 'SKILL.md'))) {
61
- let fm = {};
62
- try { fm = skillFrontmatter(readFileSync(join(d, 'SKILL.md'), 'utf8')); } catch { /* slug fallback */ }
63
- out.push({ slug: name, kind: 'runbook', title: fm.name ?? name, promptSnippet: fm.description ?? name });
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/ (the inbox subdir is excluded). */
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
  }
@@ -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 length/
5
- // range constraints. Returns an array of error strings ([] = valid). No coercion:
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 manifestStub(slug) {
11
- return JSON.stringify({
12
- slug,
12
+ function actionMdStub(slug) {
13
+ return serializeEnvelope({
14
+ id: slug,
15
+ faculty: 'actions',
16
+ kind: 'script',
13
17
  title: slug,
14
- description: 'what this action does',
15
- promptSnippet: `one line the digest shows for ${slug}`,
16
- inputs: { type: 'object', properties: {}, required: [] },
17
- outputs: { type: 'object', properties: {} },
18
- default_args: {},
19
- requires: [],
20
- }, null, 2) + '\n';
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 is validated against action.json "inputs"; return must match "outputs".
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('action.json', manifestStub(slug));
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 action.json)`); process.exit(1); }
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
  }
@@ -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, loadManifest, actionsDir, isSafeSlug } from '../actions/manifest.mjs';
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 man = loadManifest(agentDir, slug);
39
- if (man) return console.log(JSON.stringify(man, null, 2));
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 (knowledge, actions, memory).
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 artifact ' +
30
- '(instructions/project.md) that grounds every session. Empty by default — the ' +
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. Rules in ' +
34
- 'guardrails/rules.json gate tool calls (deny > ask > allow) before they run. ' +
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
- for (const f of ['knowledge', 'actions', 'memory']) {
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
 
@@ -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/rules.json and prints Claude's hookSpecificOutput decision —
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 rules.json and return the host's block decision
131
- * (or null = fail-open / no match → host's normal flow). Logs matched decisions.
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', 'rules.json'));
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) {
@@ -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