@zuzuucodes/cli 1.0.1 → 1.1.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 (41) hide show
  1. package/README.md +4 -4
  2. package/bin/zuzuu.mjs +6 -3
  3. package/package.json +1 -1
  4. package/zuzuu/actions/adapter.mjs +2 -2
  5. package/zuzuu/actions/inbox.mjs +4 -4
  6. package/zuzuu/actions/manifest.mjs +3 -3
  7. package/zuzuu/actions/trail.mjs +1 -1
  8. package/zuzuu/commands/act-author.mjs +3 -3
  9. package/zuzuu/commands/act.mjs +58 -13
  10. package/zuzuu/commands/code.mjs +1 -1
  11. package/zuzuu/commands/digest.mjs +1 -1
  12. package/zuzuu/commands/doctor.mjs +3 -3
  13. package/zuzuu/commands/eval.mjs +44 -19
  14. package/zuzuu/commands/generation.mjs +40 -5
  15. package/zuzuu/commands/hook.mjs +6 -6
  16. package/zuzuu/commands/init.mjs +43 -18
  17. package/zuzuu/commands/knowledge.mjs +1 -1
  18. package/zuzuu/commands/migrate.mjs +109 -9
  19. package/zuzuu/commands/review.mjs +72 -5
  20. package/zuzuu/commands/status.mjs +3 -3
  21. package/zuzuu/commands/web.mjs +75 -0
  22. package/zuzuu/digest.mjs +2 -2
  23. package/zuzuu/faculty/generation.mjs +2 -2
  24. package/zuzuu/faculty/proposal.mjs +1 -1
  25. package/zuzuu/faculty/trail.mjs +2 -2
  26. package/zuzuu/guardrails/adapter.mjs +1 -1
  27. package/zuzuu/guardrails.mjs +1 -1
  28. package/zuzuu/inject.mjs +6 -6
  29. package/zuzuu/instructions/adapter.mjs +1 -1
  30. package/zuzuu/knowledge/inbox.mjs +1 -1
  31. package/zuzuu/knowledge/items.mjs +1 -1
  32. package/zuzuu/knowledge/proposals.mjs +1 -1
  33. package/zuzuu/knowledge/registry.mjs +2 -2
  34. package/zuzuu/live/install.mjs +3 -3
  35. package/zuzuu/live/live-store.mjs +2 -2
  36. package/zuzuu/memory/adapter.mjs +1 -1
  37. package/zuzuu/miners/guardrails.mjs +1 -1
  38. package/zuzuu/miners/instructions.mjs +1 -1
  39. package/zuzuu/miners/memory.mjs +3 -3
  40. package/zuzuu/scaffold.mjs +27 -22
  41. package/zuzuu/store.mjs +6 -5
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Your host agent — Claude Code, Codex, Gemini CLI, OpenCode — supplies the *brain* (the reasoning loop + the model). zuzuu wraps the host you already pay for: it **serves** faculties to it, **observes** every session as an OpenTelemetry trace, and (the end-game) **evolves** the faculties from those traces — human-gated, across versioned generations. We never run a competing agent loop and never drive the host headlessly.
8
8
 
9
- > The CLI is `zuzuu` (package `zuzuu`, v1.0.0).
9
+ > Install `npm i -g @zuzuucodes/cli` — the command is **`zz`** (or `zuzuu`). Published with provenance; releases auto-publish from `main` via GitHub OIDC.
10
10
 
11
11
  > **Status (honest):** early build, moving fast. **Observe** works (5 real hosts, verified). **Serve** delivers the faculty home (`zuzuu init`), a session digest to every host, an **enforced guardrails gate** on all 5, and five faculties sharing one proposal/review spine. **Evolve** is now **wired and tested** — trace miners → a mechanical eval lens → human-gated `zuzuu review` → versioned **generations** (mint / rollback / drift-check) — but **not yet proven on a real graduation corpus** (the loop runs + passes hermetic tests; it hasn't yet improved an agent from real sessions end-to-end). Full design: [`docs/DESIGN.md`](docs/DESIGN.md).
12
12
 
@@ -19,7 +19,7 @@ npm install -g @zuzuucodes/cli # zero dependencies — installs the `zuzuu` co
19
19
  zuzuu code # scaffold the faculty home, install + wire OpenCode, launch it (capture + gate + grounding)
20
20
 
21
21
  # already run Claude Code / Gemini / Codex / OpenCode / pi? wrap the one you have:
22
- zuzuu init # scaffold your project's agent home (agent/) — git-style, open
22
+ zuzuu init # scaffold your project's agent home (.zuzuu/) — git-style, hidden like .git
23
23
  zuzuu explain # the 5 faculties + how graduation works (you're always in the loop)
24
24
  zuzuu inbox # what's pending your approval · zuzuu review to approve/reject
25
25
  zuzuu capture # turn your latest agent session into an OpenTelemetry trace
@@ -42,11 +42,11 @@ All five verified against **real sessions** — never fixtures; every host's liv
42
42
 
43
43
  **Prerequisites:** Node ≥ 22 — that's it. You need at least one supported agent you've already used, so a session exists to capture. (Hacking on zuzuu itself? `git clone https://github.com/h1902y/zuzuu && cd zuzuu && npm link`.)
44
44
 
45
- **`zuzuu init`** behaves like `git init`: empty dir → scaffolds the agent home + `AGENTS.md`/`CLAUDE.md`; existing project → adds `agent/` and injects a small delimiter-marked block into your existing instruction files (your text is never touched); already initialized → restores missing pieces only. The home is **open and self-explaining** — a visible `agent/` dir you can read and version in git: `agent/README.md` (the explainer) · `knowledge/` (verified facts) · `memory/` (curated episodes) · `actions/` (runbooks) · `instructions/` (steering) · `guardrails/` (enforced rules), plus `generations/` (your checkpoints). Machine internals are dot-prefixed + git-ignored (`agent/.traces/`, `agent/.live/`).
45
+ **`zuzuu init`** behaves like `git init`: empty dir → scaffolds the agent home + `AGENTS.md`/`CLAUDE.md`; existing project → adds the home and injects a small delimiter-marked block into your existing instruction files (your text is never touched); already initialized → restores missing pieces only. The home is **hidden like `.git` and self-explaining** — a `.zuzuu/` dir you can read and version in git (the only visible footprint is the managed block + three `.gitignore` lines; transparency lives in `zuzuu status` / `explain` / `digest`): `.zuzuu/README.md` (the explainer) · `knowledge/` (verified facts) · `memory/` (curated episodes) · `actions/` (runbooks) · `instructions/` (steering) · `guardrails/` (enforced rules), plus `generations/` (your checkpoints). Machine internals are dot-prefixed + git-ignored (`.zuzuu/.traces/`, `.zuzuu/.live/`). *(Pre-2026-06-12 homes at `agent/` migrate automatically on `zuzuu init`, or via `zuzuu migrate --home`.)*
46
46
 
47
47
  **Live capture** (`zuzuu enable`) is invisible by design: a minimal lifecycle hook set (Claude Code, Gemini CLI, Codex), a bus plugin (OpenCode), or an extension (pi) — each wrapped so it **always exits 0 / fails open — it can never break your agent**. The same hook carries the guardrails gate, applied in each host's own idiom (Claude/Codex `hookSpecificOutput`, Gemini `{decision:"deny"}`, OpenCode throws from `tool.execute.before`, pi returns `{block:true}` from `tool_call`). Most hosts emit no clean end-signal when a terminal is killed, so `zuzuu doctor` *reconciles* lost sessions afterward from the transcript still on disk (nothing lost).
48
48
 
49
- **Where your data lives:** transcripts are read **read-only**; output is git-native in your repo — `agent/sessions.json` (small tracked index, each session linked to a commit) + `agent/.traces/*.otlp.jsonl` (local, git-ignored). **Nothing is uploaded**; no raw tool input/output on the trace (byte sizes only).
49
+ **Where your data lives:** transcripts are read **read-only**; output is git-native in your repo — `.zuzuu/sessions.json` (small tracked index, each session linked to a commit) + `.zuzuu/.traces/*.otlp.jsonl` (local, git-ignored). **Nothing is uploaded**; no raw tool input/output on the trace (byte sizes only).
50
50
 
51
51
  **Verify / troubleshoot:** `npm test` (hermetic) · `npm run playground` (⏭️ skip = that host isn't on *your* machine, not a failure) · `zuzuu doctor` (env + session health). "No host detected" → use a supported agent once in the repo, then retry.
52
52
 
package/bin/zuzuu.mjs CHANGED
@@ -29,6 +29,7 @@ import { migrate } from '../zuzuu/commands/migrate.mjs';
29
29
  import { generation } from '../zuzuu/commands/generation.mjs';
30
30
  import { evalCmd } from '../zuzuu/commands/eval.mjs';
31
31
  import { code } from '../zuzuu/commands/code.mjs';
32
+ import { web } from '../zuzuu/commands/web.mjs';
32
33
  import { explain } from '../zuzuu/commands/explain.mjs';
33
34
  import { inbox } from '../zuzuu/commands/inbox.mjs';
34
35
 
@@ -59,9 +60,10 @@ function help() {
59
60
  usage: zuzuu <command> [options]
60
61
 
61
62
  code [dir] launch OpenCode as the bundled default host (faculty home + capture + gate + digest)
62
- init scaffold the faculty home (agent/) git-style, idempotent
63
+ web [dir] launch the visual workbench (installs @zuzuucodes/web on demand)
64
+ init scaffold the faculty home (.zuzuu/) — git-style, idempotent
63
65
  status detected hosts + recorded sessions
64
- capture [--host NAME] capture a session → agent/.traces + agent/sessions.json
66
+ capture [--host NAME] capture a session → .zuzuu/.traces + .zuzuu/sessions.json
65
67
  [--session ID] [--file PATH]
66
68
  trace [--last | FILE] print a captured trace's span tree
67
69
  remember "fact" [--type t] [--attr k=v] [--rel type=target]
@@ -88,7 +90,7 @@ usage: zuzuu <command> [options]
88
90
  enable background hooks: invisible live capture + guardrails gate
89
91
  disable remove the background hooks
90
92
  eval [--faculty f] rank pending proposals by eval score, highest first
91
- migrate one-time migrator: rewrite legacy candidate/er proposals to new shape
93
+ migrate [--home] one-time migrators: proposal schema · --home moves agent/ → .zuzuu/
92
94
  doctor environment + session health (reconciles lost sessions)
93
95
  explain [topic] the 5 faculties + how graduation works
94
96
  version print version
@@ -103,6 +105,7 @@ const args = parseArgs(rest);
103
105
 
104
106
  switch (cmd) {
105
107
  case 'code': process.exit(code(args)); break;
108
+ case 'web': web(args); break;
106
109
  case 'init': init(args); break;
107
110
  case 'remember': remember(args); break;
108
111
  case 'recall': await recall(args); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zuzuucodes/cli",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,6 +1,6 @@
1
1
  // zuzuu/actions/adapter.mjs
2
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
3
+ // (proposed dirs under .zuzuu/actions/inbox/<slug>/) behind the faculty-spine
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
  //
@@ -42,7 +42,7 @@ function recordFor(a) {
42
42
  }
43
43
 
44
44
  /**
45
- * Pending action proposals (dirs in agent/actions/inbox/), surfaced as
45
+ * Pending action proposals (dirs in .zuzuu/actions/inbox/), surfaced as
46
46
  * spine-shaped records so the gate can render/approve/reject them uniformly.
47
47
  */
48
48
  function listProposals(agentDir) {
@@ -1,17 +1,17 @@
1
1
  // zuzuu/actions/inbox.mjs
2
2
  // The Actions crystallization gate (the same governed pipeline as Knowledge
3
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.
4
+ // is a real dir under .zuzuu/actions/inbox/<slug>/. A human activates it (move to
5
+ // .zuzuu/actions/<slug>/) or rejects it (remove). Never auto-activates.
6
6
 
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
10
 
11
- /** Archive dir for rejected action proposals: agent/actions/proposals/archive/. */
11
+ /** Archive dir for rejected action proposals: .zuzuu/actions/proposals/archive/. */
12
12
  const archiveBaseDir = (agentDir) => join(actionsDir(agentDir), 'proposals', 'archive');
13
13
 
14
- /** Proposed actions awaiting review (in agent/actions/inbox/). */
14
+ /** Proposed actions awaiting review (in .zuzuu/actions/inbox/). */
15
15
  export function listProposedActions(agentDir) {
16
16
  return listActions(inboxDir(agentDir));
17
17
  }
@@ -1,5 +1,5 @@
1
1
  // zuzuu/actions/manifest.mjs
2
- // Reads the Actions faculty off disk: one action per dir under agent/actions/.
2
+ // Reads the Actions faculty off disk: one action per dir under .zuzuu/actions/.
3
3
  // Two kinds — `script` (has run.mjs + action.json) and `runbook` (SKILL.md prose).
4
4
  // Pure-ish: filesystem reads only, no logging, no process control.
5
5
 
@@ -7,7 +7,7 @@ import { join } from 'node:path';
7
7
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
8
8
 
9
9
  // Action slugs: letters/digits start, then letters/digits/-/_. No dots or slashes
10
- // → cannot escape agent/actions/ via path traversal.
10
+ // → cannot escape .zuzuu/actions/ via path traversal.
11
11
  export const SAFE_SLUG = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
12
12
  export function isSafeSlug(slug) {
13
13
  return typeof slug === 'string' && SAFE_SLUG.test(slug);
@@ -66,7 +66,7 @@ export function listActions(baseDir) {
66
66
  return out;
67
67
  }
68
68
 
69
- /** Active actions under agent/actions/ (the inbox subdir is excluded). */
69
+ /** Active actions under .zuzuu/actions/ (the inbox subdir is excluded). */
70
70
  export function allActions(agentDir) {
71
71
  return listActions(actionsDir(agentDir)).filter((a) => a.slug !== 'inbox');
72
72
  }
@@ -1,6 +1,6 @@
1
1
  // zuzuu/actions/trail.mjs
2
2
  // The actions observability trail (A7): every `zuzuu act` run appends an outcome
3
- // record to agent/.live/actions.jsonl. This is the "details" side of the result —
3
+ // record to .zuzuu/.live/actions.jsonl. This is the "details" side of the result —
4
4
  // the agent sees the marker value; the trace keeps the metadata. Fail-soft: a
5
5
  // logging failure must never affect the action (mirrors the guardrails decision log).
6
6
 
@@ -46,12 +46,12 @@ function scaffoldInto(baseDir, slug) {
46
46
  return { created };
47
47
  }
48
48
 
49
- /** Scaffold a live action (agent/actions/<slug>/). Humans author here directly. */
49
+ /** Scaffold a live action (.zuzuu/actions/<slug>/). Humans author here directly. */
50
50
  export function scaffoldAction(agentDir, slug) {
51
51
  return scaffoldInto(actionsDir(agentDir), slug);
52
52
  }
53
53
 
54
- /** Scaffold a PROPOSED action (agent/actions/inbox/<slug>/) — agents propose here. */
54
+ /** Scaffold a PROPOSED action (.zuzuu/actions/inbox/<slug>/) — agents propose here. */
55
55
  export function proposeAction(agentDir, slug) {
56
56
  return scaffoldInto(inboxDir(agentDir), slug);
57
57
  }
@@ -59,7 +59,7 @@ export function proposeAction(agentDir, slug) {
59
59
  export function newAction(agentDir, slug) {
60
60
  if (!slug) { console.error('usage: zuzuu act new <slug>'); process.exit(1); }
61
61
  const { created } = scaffoldAction(agentDir, slug);
62
- if (created.length) console.log(`scaffolded action '${slug}' → ${created.join(', ')} in agent/actions/${slug}/`);
62
+ if (created.length) console.log(`scaffolded action '${slug}' → ${created.join(', ')} in .zuzuu/actions/${slug}/`);
63
63
  else console.log(`action '${slug}' already complete — nothing to do`);
64
64
  }
65
65
 
@@ -60,11 +60,48 @@ function run(agentDir, slug, args) {
60
60
 
61
61
  function propose(agentDir, slug) {
62
62
  const { created } = proposeAction(agentDir, slug);
63
- if (created.length) console.log(`proposed action '${slug}' → ${created.join(', ')} in agent/actions/inbox/${slug}/ (review with \`zuzuu review\`)`);
63
+ if (created.length) console.log(`proposed action '${slug}' → ${created.join(', ')} in .zuzuu/actions/inbox/${slug}/ (review with \`zuzuu review\`)`);
64
64
  else console.log(`proposed action '${slug}' already complete — nothing to do`);
65
65
  }
66
66
 
67
- function inbox(agentDir) {
67
+ /**
68
+ * Pure: data for `act inbox --json`.
69
+ * @param {string} agentDir
70
+ * @returns {{ pending: Array }}
71
+ */
72
+ export function actInboxData(agentDir) {
73
+ return { pending: listProposedActions(agentDir) };
74
+ }
75
+
76
+ /**
77
+ * Pure: data for `act approve --json`.
78
+ * Calls activateAction and returns the printed object.
79
+ * @param {string} agentDir
80
+ * @param {string} slug
81
+ * @returns {{ ok: boolean, action: string, slug: string }}
82
+ */
83
+ export function actApproveData(agentDir, slug) {
84
+ const r = activateAction(agentDir, slug);
85
+ return { ok: r.ok, action: r.ok ? `activated ${slug}` : r.error, slug };
86
+ }
87
+
88
+ /**
89
+ * Pure: data for `act reject --json`.
90
+ * Calls rejectAction and returns the printed object.
91
+ * @param {string} agentDir
92
+ * @param {string} slug
93
+ * @returns {{ ok: boolean, action: string, slug: string }}
94
+ */
95
+ export function actRejectData(agentDir, slug) {
96
+ const r = rejectAction(agentDir, slug);
97
+ return { ok: r.ok, action: r.ok ? `rejected ${slug}` : r.error, slug };
98
+ }
99
+
100
+ function inbox(agentDir, args = {}) {
101
+ if (args.json) {
102
+ console.log(JSON.stringify(actInboxData(agentDir)));
103
+ return;
104
+ }
68
105
  const pending = listProposedActions(agentDir);
69
106
  if (!pending.length) return console.log('no proposed actions — inbox empty');
70
107
  for (const a of pending.sort((x, y) => x.slug.localeCompare(y.slug))) {
@@ -72,16 +109,24 @@ function inbox(agentDir) {
72
109
  }
73
110
  }
74
111
 
75
- function approve(agentDir, slug) {
76
- const r = activateAction(agentDir, slug);
77
- console.log(r.ok ? `✓ activated '${slug}'` : `✗ ${r.error}`);
78
- process.exit(r.ok ? 0 : 1);
112
+ function approve(agentDir, slug, args = {}) {
113
+ const result = actApproveData(agentDir, slug);
114
+ if (args.json) {
115
+ console.log(JSON.stringify(result));
116
+ } else {
117
+ console.log(result.ok ? `✓ activated '${slug}'` : `✗ ${result.action}`);
118
+ }
119
+ process.exit(result.ok ? 0 : 1);
79
120
  }
80
121
 
81
- function reject(agentDir, slug) {
82
- const r = rejectAction(agentDir, slug);
83
- console.log(r.ok ? `✓ rejected '${slug}'` : `✗ ${r.error}`);
84
- process.exit(r.ok ? 0 : 1);
122
+ function reject(agentDir, slug, args = {}) {
123
+ const result = actRejectData(agentDir, slug);
124
+ if (args.json) {
125
+ console.log(JSON.stringify(result));
126
+ } else {
127
+ console.log(result.ok ? `✓ rejected '${slug}'` : `✗ ${result.action}`);
128
+ }
129
+ process.exit(result.ok ? 0 : 1);
85
130
  }
86
131
 
87
132
  export function act(args) {
@@ -92,9 +137,9 @@ export function act(args) {
92
137
  if (sub === 'new') return newAction(agentDir, requireSlug(args._[1], 'usage: zuzuu act new <slug>'));
93
138
  if (sub === 'schema') return schemaCmd(agentDir, requireSlug(args._[1], 'usage: zuzuu act schema <slug> [--openai|--anthropic]'), args);
94
139
  if (sub === 'propose') return propose(agentDir, requireSlug(args._[1], 'usage: zuzuu act propose <slug>'));
95
- if (sub === 'inbox') return inbox(agentDir);
96
- if (sub === 'approve') return approve(agentDir, requireSlug(args._[1], 'usage: zuzuu act approve <slug>'));
97
- if (sub === 'reject') return reject(agentDir, requireSlug(args._[1], 'usage: zuzuu act reject <slug>'));
140
+ if (sub === 'inbox') return inbox(agentDir, args);
141
+ if (sub === 'approve') return approve(agentDir, requireSlug(args._[1], 'usage: zuzuu act approve <slug>'), args);
142
+ if (sub === 'reject') return reject(agentDir, requireSlug(args._[1], 'usage: zuzuu act reject <slug>'), args);
98
143
  // future-reserved guard: extend RESERVED + add a handler above in tandem
99
144
  if (RESERVED.has(sub)) { console.error(`unknown: zuzuu act ${sub}`); process.exit(1); }
100
145
  return run(agentDir, requireSlug(sub, 'usage: zuzuu act <slug> [--args JSON]'), args);
@@ -76,7 +76,7 @@ export function code(args = {}, deps = {}) {
76
76
 
77
77
  // a clean one-screen summary of what the newcomer just got (vs. the verbose enable output)
78
78
  d.log('zuzuu code → OpenCode, faculty-equipped');
79
- d.log(` ✓ faculty home (agent/) ${wired ? '✓ capture + guardrails gate ✓ session grounding' : '⚠ plugin not wired (degraded)'}`);
79
+ d.log(` ✓ faculty home (.zuzuu/) ${wired ? '✓ capture + guardrails gate ✓ session grounding' : '⚠ plugin not wired (degraded)'}`);
80
80
  d.log(` → launching OpenCode in ${dir} …`);
81
81
 
82
82
  // 5. launch the real OpenCode (configure + launch, never drive)
@@ -6,7 +6,7 @@
6
6
  import { paths } from '../store.mjs';
7
7
  import { computeDigest } from '../digest.mjs';
8
8
 
9
- /** Pure: the digest payload — the zuzuu-web /digest source (the daemon also reads agent/.live/digest.md directly). */
9
+ /** Pure: the digest payload — the zuzuu-web /digest source (the daemon also reads .zuzuu/.live/digest.md directly). */
10
10
  export function digestData(agentDir, opts = {}) {
11
11
  const d = computeDigest(agentDir, opts);
12
12
  return { text: d.text ?? '' };
@@ -123,14 +123,14 @@ export async function doctor() {
123
123
  if (commit) ok(`git repo on ${branch} @ ${commit.slice(0, 8)}`);
124
124
  else info("not a git repo — capture works; sessions just won’t link to a commit");
125
125
 
126
- // agent/ writable
126
+ // .zuzuu/ writable
127
127
  const { dir } = paths();
128
128
  try {
129
129
  mkdirSync(dir, { recursive: true });
130
130
  accessSync(dir, constants.W_OK);
131
- ok(`agent/ writable (${dir})`);
131
+ ok(`.zuzuu/ writable (${dir})`);
132
132
  } catch {
133
- bad(`agent/ not writable (${dir})`);
133
+ bad(`.zuzuu/ not writable (${dir})`);
134
134
  }
135
135
 
136
136
  // faculty home (served by `zuzuu init`)
@@ -6,6 +6,7 @@
6
6
  // Also exports `evalLine` — a small pure helper used by `zuzuu review` to render
7
7
  // a one-line eval annotation per proposal card.
8
8
 
9
+ import { join } from 'node:path';
9
10
  import { paths, readIndex } from '../store.mjs';
10
11
  import * as registry from '../faculty/registry.mjs';
11
12
  import { listProposals as spineListProposals } from '../faculty/proposal.mjs';
@@ -58,20 +59,21 @@ function collectProposals(agentDir, adapter) {
58
59
  }
59
60
 
60
61
  /**
61
- * Core of `zuzuu eval` exported so tests can inject a custom log fn.
62
+ * Pure: gather + rank all pending proposals, returning structured data for JSON output.
63
+ * The zuzuu-web /eval source.
64
+ * Touches FS via buildSessionMtimes (fail-open) and collectProposals.
62
65
  *
63
- * @param {object} args Parsed CLI args.
64
- * @param {Function} [log=console.log] Output sink (injectable for tests).
66
+ * @param {string} agentDir Resolved .zuzuu/ path.
67
+ * @param {object} [opts]
68
+ * @param {string} [opts.faculty] Filter to a single faculty name.
69
+ * @returns {{ ranked: Array<{id,faculty,title,score,confidence,rationale}> }}
65
70
  */
66
- export function evalCmd(args, log = console.log) {
67
- const agentDir = paths().dir;
68
- const onlyFaculty = args?.faculty ?? null;
71
+ export function evalData(agentDir, { faculty: onlyFaculty = null } = {}) {
69
72
  const adapters = registry.all();
70
- const sessionMtimes = buildSessionMtimes();
73
+ const sessionMtimes = buildSessionMtimes(join(agentDir, '..'));
71
74
  const now = Date.now();
72
75
  const scorer = getScorer();
73
76
 
74
- // Gather proposals from all (or the one filtered) faculty adapters.
75
77
  const allEntries = [];
76
78
  for (const adapter of adapters) {
77
79
  if (onlyFaculty && adapter.name !== onlyFaculty) continue;
@@ -81,21 +83,44 @@ export function evalCmd(args, log = console.log) {
81
83
  }
82
84
  }
83
85
 
84
- if (!allEntries.length) {
85
- log('no pending proposals');
86
- return;
87
- }
86
+ if (!allEntries.length) return { ranked: [] };
88
87
 
89
- // Rank all entries together.
90
88
  const rawProposals = allEntries.map((e) => e.proposal);
91
- const ranked = rank(rawProposals, scorer, { now, sessionMtimes });
92
-
93
- // Build faculty lookup for display: proposal.id → faculty name.
89
+ const rankResults = rank(rawProposals, scorer, { now, sessionMtimes });
94
90
  const facultyByProposalId = new Map(allEntries.map((e) => [e.proposal.id, e.faculty]));
95
91
 
96
- for (const { proposal, score, confidence, rationale } of ranked) {
97
- const faculty = facultyByProposalId.get(proposal.id) ?? '?';
92
+ const ranked = rankResults.map(({ proposal, score, confidence, rationale }) => {
93
+ const fac = facultyByProposalId.get(proposal.id) ?? '?';
94
+ const title = proposal.title
95
+ ?? proposal.candidate?.body?.slice(0, 80)
96
+ ?? proposal.payload?.body?.slice(0, 80)
97
+ ?? proposal.id;
98
+ return { id: proposal.id, faculty: fac, title, score, confidence, rationale };
99
+ });
100
+
101
+ return { ranked };
102
+ }
103
+
104
+ /**
105
+ * Core of `zuzuu eval` — exported so tests can inject a custom log fn.
106
+ *
107
+ * @param {object} args Parsed CLI args.
108
+ * @param {Function} [log=console.log] Output sink (injectable for tests).
109
+ */
110
+ export function evalCmd(args, log = console.log) {
111
+ const agentDir = paths().dir;
112
+ const onlyFaculty = args?.faculty ?? null;
113
+
114
+ if (args?.json) {
115
+ const d = evalData(agentDir, { faculty: onlyFaculty });
116
+ log(JSON.stringify(d));
117
+ return;
118
+ }
119
+
120
+ const { ranked } = evalData(agentDir, { faculty: onlyFaculty });
121
+ if (!ranked.length) { log('no pending proposals'); return; }
122
+ for (const { id, faculty, score, confidence, rationale } of ranked) {
98
123
  const warn = confidence === 'low' ? ' ⚠' : '';
99
- log(`${String(score).padEnd(6)} [${confidence}] ${faculty}/${proposal.id} — ${rationale}${warn}`);
124
+ log(`${String(score).padEnd(6)} [${confidence}] ${faculty}/${id} — ${rationale}${warn}`);
100
125
  }
101
126
  }
@@ -25,9 +25,39 @@ function list(dir) {
25
25
  }
26
26
  }
27
27
 
28
- function mint(dir) {
28
+ /**
29
+ * Pure: mint a new generation and return structured data.
30
+ * @param {string} dir
31
+ * @param {object} [opts]
32
+ * @param {string[]} [opts.mintedFrom] proposal ids this generation is built from
33
+ * @returns {{ id: string, mintedFrom: string[], forkedFrom: string|null }}
34
+ */
35
+ export function mintGenerationData(dir, { mintedFrom = [] } = {}) {
29
36
  const forkedFrom = activeGeneration(dir);
30
- const lf = mintGeneration(dir, { forkedFrom });
37
+ const lf = mintGeneration(dir, { forkedFrom, mintedFrom });
38
+ return { id: lf.id, mintedFrom: lf.mintedFrom ?? [], forkedFrom: lf.forkedFrom ?? null };
39
+ }
40
+
41
+ /**
42
+ * Pure: rollback to a generation and return structured data.
43
+ * @param {string} dir
44
+ * @param {string} id
45
+ * @returns {{ ok: boolean, restored: number, active: string }}
46
+ */
47
+ export function rollbackData(dir, id) {
48
+ const r = rollback(dir, id);
49
+ return { ok: r.ok, restored: r.restored, active: id };
50
+ }
51
+
52
+ function mint(dir, args = {}) {
53
+ const mintedFrom = args.from ? String(args.from).split(',').map((s) => s.trim()).filter(Boolean) : [];
54
+ if (args.json) {
55
+ const d = mintGenerationData(dir, { mintedFrom });
56
+ console.log(JSON.stringify(d));
57
+ return;
58
+ }
59
+ const forkedFrom = activeGeneration(dir);
60
+ const lf = mintGeneration(dir, { forkedFrom, mintedFrom });
31
61
  console.log(`✓ minted ${lf.id}${forkedFrom ? ` (forkedFrom ${forkedFrom})` : ''} — now active`);
32
62
  }
33
63
 
@@ -78,9 +108,14 @@ function show(dir, id) {
78
108
  console.log(out);
79
109
  }
80
110
 
81
- function doRollback(dir, id) {
111
+ function doRollback(dir, id, args = {}) {
82
112
  if (!id) { console.error('usage: zuzuu generation rollback <id>'); process.exit(1); }
83
113
  if (!readGeneration(dir, id)) { console.error(`no generation '${id}'`); process.exit(1); }
114
+ if (args.json) {
115
+ const d = rollbackData(dir, id);
116
+ console.log(JSON.stringify(d));
117
+ return;
118
+ }
84
119
  const r = rollback(dir, id);
85
120
  console.log(`✓ rolled back to ${id} — restored ${r.restored} item(s); active=${id}`);
86
121
  }
@@ -92,7 +127,7 @@ export function generation(args) {
92
127
  if (args.json) { console.log(JSON.stringify(generationListData(dir))); return; }
93
128
  return list(dir);
94
129
  }
95
- if (sub === 'mint') return mint(dir);
130
+ if (sub === 'mint') return mint(dir, args);
96
131
  if (sub === 'show') {
97
132
  if (args.json) {
98
133
  const d = generationShowData(dir, args._[1]);
@@ -101,7 +136,7 @@ export function generation(args) {
101
136
  }
102
137
  return show(dir, args._[1]);
103
138
  }
104
- if (sub === 'rollback') return doRollback(dir, args._[1]);
139
+ if (sub === 'rollback') return doRollback(dir, args._[1], args);
105
140
  console.error(`unknown: zuzuu generation ${sub}\nusage: zuzuu generation [list|show <id>|mint|rollback <id>]`);
106
141
  process.exit(1);
107
142
  }
@@ -73,7 +73,7 @@ export function handleHook({ event, payload = {}, cwd = process.cwd(), now = Dat
73
73
  openLive({ id, host, transcriptPath: ref, startedAt: new Date(now).toISOString(), now, generation }, cwd);
74
74
  safeCapture(adapter, ref, SessionState.ACTIVE, cwd, generation);
75
75
  } catch { /* live/capture hiccup must not block grounding below */ }
76
- writeLiveDigest(cwd); // universal grounding channel — every host reads agent/.live/digest.md
76
+ writeLiveDigest(cwd); // universal grounding channel — every host reads .zuzuu/.live/digest.md
77
77
  } else if (TURN.has(event)) {
78
78
  touchLive({ id, host, transcriptPath: ref, now }, cwd);
79
79
  safeCapture(adapter, ref, SessionState.ACTIVE, cwd);
@@ -88,7 +88,7 @@ export function handleHook({ event, payload = {}, cwd = process.cwd(), now = Dat
88
88
 
89
89
  /**
90
90
  * The Guardrails gate (PreToolUse). Evaluates the tool call against
91
- * agent/guardrails/rules.json and prints Claude's hookSpecificOutput decision —
91
+ * .zuzuu/guardrails/rules.json and prints Claude's hookSpecificOutput decision —
92
92
  * or NOTHING (exit 0, no JSON = defer to the host's normal permission flow).
93
93
  * That silence is the fail-open: engine errors and rule-file problems can slow
94
94
  * nothing down and block nothing. Matched decisions are logged for the trace.
@@ -132,11 +132,11 @@ export function gateDecision({ host = 'claude-code', payload = {}, cwd = process
132
132
 
133
133
  /**
134
134
  * Universal digest delivery (Design B side effect, not a span builder). Computes
135
- * the faculty digest and writes it to `agent/.live/digest.md` — the ONE channel
135
+ * the faculty digest and writes it to `.zuzuu/.live/digest.md` — the ONE channel
136
136
  * every host can read at session start (the faculty block points here). Claude
137
137
  * also gets it inline via sessionStartContext; the other 4 hosts rely on this
138
138
  * file. Fail-open: any error is swallowed (never break the host).
139
- * @param {string} cwd repo cwd; paths() resolves the agent/ home under it
139
+ * @param {string} cwd repo cwd; paths() resolves the .zuzuu/ home under it
140
140
  */
141
141
  export function writeLiveDigest(cwd = process.cwd()) {
142
142
  try {
@@ -155,7 +155,7 @@ export function writeLiveDigest(cwd = process.cwd()) {
155
155
  * Build Claude Code's SessionStart additionalContext payload from the faculty
156
156
  * digest. Returns null on ANY failure (fail-open: the session proceeds with no
157
157
  * injected context, never a broken hook).
158
- * @param {string} cwd repo cwd; paths() resolves the agent/ home under it
158
+ * @param {string} cwd repo cwd; paths() resolves the .zuzuu/ home under it
159
159
  */
160
160
  export function sessionStartContext(cwd = process.cwd()) {
161
161
  try {
@@ -193,7 +193,7 @@ export function runHook(event, { host = 'claude-code', session } = {}) {
193
193
  } else {
194
194
  try { handleHook({ event, payload, host }); } catch { /* capture failure is silent — never blocks the digest or the host */ }
195
195
  // Claude consumes additionalContext inline; the other hosts read
196
- // agent/.live/digest.md (written by handleHook's OPEN branch). Scoping the
196
+ // .zuzuu/.live/digest.md (written by handleHook's OPEN branch). Scoping the
197
197
  // stdout push to Claude avoids emitting an unread schema to Gemini/Codex.
198
198
  if (event === 'SessionStart' && host === 'claude-code') {
199
199
  try {
@@ -1,10 +1,14 @@
1
1
  // `zuzuu init` — git-style, context-aware, idempotent scaffold of the faculty home.
2
2
  //
3
- // empty dir → greenfield: full scaffold + create AGENTS.md/CLAUDE.md
4
- // non-empty, no agent/ → brownfield: scaffold + inject block into existing
5
- // instruction files (user content untouched)
6
- // agent/ exists → "Reinitialized": create missing pieces only (no-op
7
- // on a complete home; never overwrites anything)
3
+ // empty dir → greenfield: full scaffold + create AGENTS.md/CLAUDE.md
4
+ // non-empty, no .zuzuu/ → brownfield: scaffold + inject block into existing
5
+ // instruction files (user content untouched)
6
+ // .zuzuu/ exists → "Reinitialized": create missing pieces only (no-op
7
+ // on a complete home; never overwrites anything)
8
+ //
9
+ // Onboarding contract (W1, 2026-06-12): the output NARRATES what appeared and
10
+ // why — the home is hidden (.zuzuu/, like .git), so the init message is the
11
+ // user's first and main tour of it.
8
12
 
9
13
  import { join, basename } from 'node:path';
10
14
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -12,6 +16,7 @@ import { applyScaffold, ensureGitignore, homeExists } from '../scaffold.mjs';
12
16
  import { injectBlock, facultiesBlock, hasBlock, BLOCK_VERSION } from '../inject.mjs';
13
17
  import { detected } from '../../experiments/experiment-1-trace-capture/adapters/registry.mjs';
14
18
  import { repoRoot } from '../store.mjs';
19
+ import { migrateHome } from './migrate.mjs';
15
20
 
16
21
  const HOST_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'];
17
22
  // dotfiles/dirs that don't make a directory "a project" for emptiness purposes
@@ -51,11 +56,30 @@ function serveInstructions(cwd, { greenfield }) {
51
56
  return { injected, created };
52
57
  }
53
58
 
59
+ /** The friendly tour of what `init` just created (the home is hidden — narrate it). */
60
+ function narrateHome() {
61
+ console.log('');
62
+ console.log(' .zuzuu/ your agent\'s home — hidden like .git, yours to read & version');
63
+ console.log(' knowledge/ memory/ actions/ instructions/ guardrails/');
64
+ console.log(' the five faculties: what\'s TRUE · what HAPPENED · how to DO · who to BE · what NOT to do');
65
+ console.log(' README.md explains the whole model — start there');
66
+ console.log('');
67
+ }
68
+
54
69
  export function init(args = {}) {
55
70
  // Root at the git toplevel when inside a repo (same base the store uses for
56
- // agent/), falling back to cwd — one project, one home, like .git/.
71
+ // .zuzuu/), falling back to cwd — one project, one home, like .git/.
57
72
  const cwd = repoRoot(process.cwd());
58
73
  if (cwd !== process.cwd()) console.log(`(project root: ${cwd})`);
74
+
75
+ // One-shot home migration: a pre-2026-06-12 visible agent/ home (gated on its
76
+ // agent.json) moves to .zuzuu/. Fail-open — init must never die on migration.
77
+ try {
78
+ if (migrateHome(cwd).migrated) {
79
+ console.log('Migrated agent/ → .zuzuu/ (the faculty home is hidden now, like .git; transparency via `zuzuu status` / `digest` / `explain`)');
80
+ }
81
+ } catch { /* fail-open */ }
82
+
59
83
  const reinit = homeExists(cwd);
60
84
  const greenfield = !reinit && isEmptyDir(cwd);
61
85
 
@@ -66,24 +90,25 @@ export function init(args = {}) {
66
90
  const createdCount = plan.dirs.length + plan.files.length + (plan.manifestMissing ? 1 : 0);
67
91
 
68
92
  if (reinit) {
69
- console.log(`Reinitialized existing zuzuu home in ${join(cwd, 'agent')}/`);
93
+ console.log(`Reinitialized existing zuzuu home in ${join(cwd, '.zuzuu')}/`);
70
94
  if (createdCount) console.log(` restored : ${createdCount} missing piece(s)`);
71
95
  if (injected.length) console.log(` injected : faculty block → ${injected.join(', ')}`);
72
96
  if (!createdCount && !injected.length && !ignoreAdded.length) console.log(' (complete — nothing to do)');
73
- } else if (greenfield) {
74
- console.log(`Initialized empty zuzuu home in ${join(cwd, 'agent')}/`);
75
- console.log(` faculties : knowledge/ memory/ actions/ instructions/ guardrails/ (+ agent.json manifest)`);
76
- console.log(` steering : created ${created.join(' + ')} pointing your agent at its faculties`);
77
- console.log(` next : \`zuzuu enable\` for live capture · \`zuzuu digest\` to preview the grounding your agent opens with · start your agent in ${basename(cwd)}/`);
78
97
  } else {
79
- console.log(`Initialized zuzuu home in existing project ${join(cwd, 'agent')}/`);
80
- console.log(` faculties : knowledge/ memory/ actions/ instructions/ guardrails/ (+ agent.json manifest)`);
98
+ console.log(greenfield
99
+ ? `Initialized empty zuzuu home in ${join(cwd, '.zuzuu')}/`
100
+ : `Initialized zuzuu home in existing project ${basename(cwd)}/`);
101
+ narrateHome();
102
+ // the only visible footprint — name it so nothing feels like it appeared unannounced
103
+ const names = [...injected.map((f) => f.replace(/ \(.*\)$/, '')), ...created];
104
+ console.log(` visible : only a managed zuzuu block in ${names.join(' + ') || 'your instruction files'}${ignoreAdded.length ? ` and ${ignoreAdded.length} .gitignore line(s)` : ''} — everything else lives inside .zuzuu/`);
81
105
  const steer = [];
82
- if (injected.length) steer.push(`injected → ${injected.join(', ')}`);
83
- if (created.length) steer.push(`created ${created.join(' + ')} (read by Codex/OpenCode/pi)`);
84
- if (steer.length) console.log(` steering : ${steer.join(' · ')}`);
106
+ if (injected.length) steer.push(`faculty block → ${injected.join(', ')}`);
107
+ if (created.length) steer.push(`created ${created.join(' + ')}`);
108
+ if (steer.length) console.log(` steering : ${steer.join(' · ')} (read by your agent at session start)`);
85
109
  const hosts = detected().map((a) => a.name).join(', ');
86
- if (hosts) console.log(` hosts : detected ${hosts} — \`zuzuu capture\` works now; \`zuzuu enable\` for live`);
110
+ if (hosts) console.log(` hosts : detected ${hosts}`);
111
+ console.log(' next : `zuzuu enable` (live capture + guardrails gate) → `zuzuu digest` (preview the grounding) → work normally → `zuzuu inbox` / `zuzuu review` when proposals appear');
87
112
  }
88
113
  if (ignoreAdded.length) console.log(` gitignore : +${ignoreAdded.join(' ')}`);
89
114
  }