@zuzuucodes/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/bin/zuzuu.mjs +133 -0
  4. package/experiments/experiment-1-trace-capture/adapters/claude-code.mjs +220 -0
  5. package/experiments/experiment-1-trace-capture/adapters/codex.mjs +201 -0
  6. package/experiments/experiment-1-trace-capture/adapters/gemini-cli.mjs +113 -0
  7. package/experiments/experiment-1-trace-capture/adapters/host-adapter.mjs +43 -0
  8. package/experiments/experiment-1-trace-capture/adapters/opencode.mjs +205 -0
  9. package/experiments/experiment-1-trace-capture/adapters/pi.mjs +218 -0
  10. package/experiments/experiment-1-trace-capture/adapters/registry.mjs +20 -0
  11. package/experiments/experiment-1-trace-capture/adapters/signals.mjs +44 -0
  12. package/experiments/experiment-1-trace-capture/core/event.mjs +58 -0
  13. package/experiments/experiment-1-trace-capture/core/ids.mjs +32 -0
  14. package/experiments/experiment-1-trace-capture/core/otlp.mjs +54 -0
  15. package/experiments/experiment-1-trace-capture/core/render.mjs +63 -0
  16. package/experiments/experiment-1-trace-capture/core/spans.mjs +43 -0
  17. package/package.json +56 -0
  18. package/zuzuu/actions/adapter.mjs +130 -0
  19. package/zuzuu/actions/convert.mjs +27 -0
  20. package/zuzuu/actions/dispatch.mjs +87 -0
  21. package/zuzuu/actions/inbox.mjs +56 -0
  22. package/zuzuu/actions/manifest.mjs +72 -0
  23. package/zuzuu/actions/marker.mjs +4 -0
  24. package/zuzuu/actions/runner.mjs +37 -0
  25. package/zuzuu/actions/schema.mjs +73 -0
  26. package/zuzuu/actions/trail.mjs +22 -0
  27. package/zuzuu/capture-core.mjs +49 -0
  28. package/zuzuu/commands/act-author.mjs +72 -0
  29. package/zuzuu/commands/act.mjs +101 -0
  30. package/zuzuu/commands/capture.mjs +32 -0
  31. package/zuzuu/commands/code.mjs +84 -0
  32. package/zuzuu/commands/digest.mjs +23 -0
  33. package/zuzuu/commands/distill.mjs +46 -0
  34. package/zuzuu/commands/doctor.mjs +197 -0
  35. package/zuzuu/commands/enable.mjs +195 -0
  36. package/zuzuu/commands/eval.mjs +101 -0
  37. package/zuzuu/commands/explain.mjs +119 -0
  38. package/zuzuu/commands/generation.mjs +107 -0
  39. package/zuzuu/commands/hook.mjs +209 -0
  40. package/zuzuu/commands/inbox.mjs +73 -0
  41. package/zuzuu/commands/init.mjs +89 -0
  42. package/zuzuu/commands/knowledge.mjs +152 -0
  43. package/zuzuu/commands/migrate.mjs +125 -0
  44. package/zuzuu/commands/review.mjs +299 -0
  45. package/zuzuu/commands/status.mjs +82 -0
  46. package/zuzuu/commands/trace.mjs +19 -0
  47. package/zuzuu/digest.mjs +149 -0
  48. package/zuzuu/eval/rank.mjs +31 -0
  49. package/zuzuu/eval/score.mjs +85 -0
  50. package/zuzuu/eval/signals.mjs +57 -0
  51. package/zuzuu/faculty/contract.mjs +19 -0
  52. package/zuzuu/faculty/gate.mjs +65 -0
  53. package/zuzuu/faculty/generation.mjs +392 -0
  54. package/zuzuu/faculty/proposal.mjs +166 -0
  55. package/zuzuu/faculty/provenance.mjs +35 -0
  56. package/zuzuu/faculty/registry.mjs +33 -0
  57. package/zuzuu/faculty/trail.mjs +27 -0
  58. package/zuzuu/guardrails/adapter.mjs +134 -0
  59. package/zuzuu/guardrails.mjs +89 -0
  60. package/zuzuu/inject.mjs +46 -0
  61. package/zuzuu/instructions/adapter.mjs +93 -0
  62. package/zuzuu/knowledge/adapter.mjs +99 -0
  63. package/zuzuu/knowledge/distill.mjs +237 -0
  64. package/zuzuu/knowledge/embed.mjs +52 -0
  65. package/zuzuu/knowledge/er.mjs +98 -0
  66. package/zuzuu/knowledge/inbox.mjs +43 -0
  67. package/zuzuu/knowledge/index.mjs +194 -0
  68. package/zuzuu/knowledge/items.mjs +154 -0
  69. package/zuzuu/knowledge/proposals.mjs +196 -0
  70. package/zuzuu/knowledge/registry.mjs +115 -0
  71. package/zuzuu/live/install.mjs +76 -0
  72. package/zuzuu/live/live-store.mjs +78 -0
  73. package/zuzuu/live/probe.mjs +55 -0
  74. package/zuzuu/live/reconcile.mjs +33 -0
  75. package/zuzuu/memory/adapter.mjs +121 -0
  76. package/zuzuu/miners/actions.mjs +118 -0
  77. package/zuzuu/miners/guardrails.mjs +174 -0
  78. package/zuzuu/miners/instructions.mjs +152 -0
  79. package/zuzuu/miners/knowledge.mjs +22 -0
  80. package/zuzuu/miners/memory.mjs +27 -0
  81. package/zuzuu/miners/registry.mjs +31 -0
  82. package/zuzuu/scaffold.mjs +213 -0
  83. package/zuzuu/session.mjs +72 -0
  84. package/zuzuu/store.mjs +104 -0
@@ -0,0 +1,152 @@
1
+ // zuzuu/miners/instructions.mjs
2
+ // Instructions miner (WS5-T4) — detect recurring corrective user turns across
3
+ // sessions and propose steering-amendment blocks for the Instructions faculty.
4
+ //
5
+ // Corrective turns are captured by mineTranscript as:
6
+ // correctionTurns: [{ text }] — user turns following an assistant tool action
7
+ // that contain corrective lexicon (e.g. "always", "never", "don't", etc.)
8
+ //
9
+ // Shape: { faculty:'instructions', aggregate(sessions, opts), propose(agentDir, aggregated) }
10
+ // Self-registers on import.
11
+
12
+ import { join } from 'node:path';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { makeProposal, writeProposal, listProposals } from '../faculty/proposal.mjs';
15
+ import { register } from './registry.mjs';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // helpers
19
+
20
+ /**
21
+ * Normalise a correction text for grouping:
22
+ * lowercase, collapse whitespace, truncate to 200 chars.
23
+ *
24
+ * v1 grouping: near-identical normalised text (exact key match). Simple and
25
+ * deterministic; a fuzzy grouper can be earned later.
26
+ *
27
+ * @param {string} text
28
+ * @returns {string}
29
+ */
30
+ function normText(text) {
31
+ return String(text).toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 200);
32
+ }
33
+
34
+ /**
35
+ * Derive a proposal id fragment from a normalised text key.
36
+ * Keep it stable, short, and filesystem-safe.
37
+ */
38
+ function instrId(normKey) {
39
+ // Use a slugified version of the first 60 chars of the normalised text,
40
+ // prefixed to make collisions with other faculties impossible.
41
+ const slug = normKey.slice(0, 60).replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50) || 'instr';
42
+ return 'instr-' + slug;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // aggregate
47
+
48
+ /**
49
+ * Group correctionTurns from mined sessions; propose when a similar correction
50
+ * recurs across ≥minSessions (default 2) distinct sessions.
51
+ *
52
+ * @param {Array<{sessionId:string, correctionTurns:{text:string}[]}>} sessions
53
+ * @param {object} opts
54
+ * @param {number} [opts.minSessions=2] min distinct sessions with the same normalised correction
55
+ * @returns {Array<{payload:{text:string}, evidence:{occurrences:number, sessions:number}}>}
56
+ */
57
+ export function aggregate(sessions, { minSessions = 2 } = {}) {
58
+ // normalised text → { count, sessions: Set<sessionId>, rawText: string }
59
+ const stats = new Map();
60
+
61
+ for (const s of sessions) {
62
+ if (!Array.isArray(s.correctionTurns)) continue;
63
+ // Track distinct normalised texts per session to avoid double-counting
64
+ // the same session for the same correction text.
65
+ const seenInSession = new Set();
66
+ for (const { text } of s.correctionTurns) {
67
+ const key = normText(text);
68
+ if (!key) continue;
69
+ const st = stats.get(key) ?? { count: 0, sessions: new Set(), rawText: text };
70
+ st.count++;
71
+ st.sessions.add(s.sessionId);
72
+ seenInSession.add(key);
73
+ stats.set(key, st);
74
+ }
75
+ }
76
+
77
+ const candidates = [];
78
+ for (const [, st] of stats) {
79
+ if (st.sessions.size < minSessions) continue;
80
+
81
+ // Phrase the raw correction as an instruction for the steering amendment.
82
+ // The corrective turn text already reads like user guidance; use it directly
83
+ // (trimmed to 500 chars to match mineTranscript's cap).
84
+ const amendmentText = st.rawText.slice(0, 500).trim();
85
+ const id = instrId(normText(amendmentText));
86
+
87
+ candidates.push({
88
+ payload: { id, text: amendmentText },
89
+ evidence: { occurrences: st.count, sessions: st.sessions.size },
90
+ });
91
+ }
92
+
93
+ return candidates;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // propose
98
+
99
+ /**
100
+ * Write an instructions proposal into agent/instructions/proposals/ for each
101
+ * candidate.
102
+ *
103
+ * Idempotent:
104
+ * - skips if an instructions proposal with the same derived id already exists
105
+ * - skips if the text is already present in project.md
106
+ *
107
+ * @param {string} agentDir
108
+ * @param {ReturnType<typeof aggregate>} aggregated
109
+ * @returns {number} count of new proposals written
110
+ */
111
+ export function propose(agentDir, aggregated) {
112
+ // Collect ids of existing pending proposals for this faculty.
113
+ const existing = listProposals(agentDir, 'instructions');
114
+ const existingIds = new Set(existing.map((p) => p.payload?.id).filter(Boolean));
115
+
116
+ // Read project.md to skip amendments already applied.
117
+ const projectMdPath = join(agentDir, 'instructions', 'project.md');
118
+ const projectMdContent = existsSync(projectMdPath)
119
+ ? readFileSync(projectMdPath, 'utf8')
120
+ : '';
121
+
122
+ let count = 0;
123
+ for (const c of aggregated) {
124
+ const { payload, evidence } = c;
125
+
126
+ // Idempotent: skip if already proposed.
127
+ if (existingIds.has(payload.id)) continue;
128
+
129
+ // Idempotent: skip if text already present in project.md.
130
+ if (projectMdContent.includes(payload.text)) continue;
131
+
132
+ const proposal = makeProposal({
133
+ faculty: 'instructions',
134
+ kind: 'block',
135
+ source: 'distill',
136
+ payload,
137
+ evidence,
138
+ });
139
+
140
+ writeProposal(agentDir, proposal);
141
+ count++;
142
+ }
143
+
144
+ return count;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // self-register
149
+
150
+ export const miner = { faculty: 'instructions', aggregate, propose };
151
+
152
+ register(miner);
@@ -0,0 +1,22 @@
1
+ // Knowledge miner (WS5-T1) — the existing source-A distill path, wrapped as a
2
+ // registry miner. NO behavior change: `aggregate` is the same function the
3
+ // golden distill test asserts on; `propose` mirrors `distillSessions` (one
4
+ // `createProposal` per aggregated candidate). Self-registers on import.
5
+
6
+ import { aggregate } from '../knowledge/distill.mjs';
7
+ import { createProposal } from '../knowledge/proposals.mjs';
8
+ import { register } from './registry.mjs';
9
+
10
+ /** File one knowledge proposal per aggregated candidate; return the count. */
11
+ export function propose(agentDir, aggregated) {
12
+ for (const c of aggregated) {
13
+ createProposal(agentDir, { candidate: c.candidate, source: 'distill', evidence: c.evidence });
14
+ }
15
+ return aggregated.length;
16
+ }
17
+
18
+ export const miner = { faculty: 'knowledge', aggregate, propose };
19
+
20
+ register(miner);
21
+
22
+ export { aggregate };
@@ -0,0 +1,27 @@
1
+ // zuzuu/miners/memory.mjs
2
+ // Memory miner STUB (WS5-T4) — registered no-op; deferred this pass.
3
+ //
4
+ // WHAT IT WOULD MINE (when implemented):
5
+ // Completed-session episodes — a Run that reached `completed` status in
6
+ // agent/sessions.json — would be distilled into curated episode entries at
7
+ // agent/memory/entries/<id>.md. Each entry captures: what was attempted,
8
+ // key decisions made, outcome, and a set of durable learnings. The miner
9
+ // would emit `memory` proposals of kind 'episode' into
10
+ // agent/memory/proposals/ for human review via `zuzuu review`. Deferred until
11
+ // the Memory substrate (off-edge Postgres/Neon or local Markdown) is
12
+ // established and the session lifecycle state machine is stable enough to
13
+ // reliably produce `completed` runs with rich enough trace data to distill.
14
+ //
15
+ // Shape: { faculty:'memory', aggregate, propose, stub:true }
16
+ // Self-registers on import (no-op).
17
+
18
+ import { register } from './registry.mjs';
19
+
20
+ export const miner = {
21
+ faculty: 'memory',
22
+ stub: true,
23
+ aggregate: () => [],
24
+ propose: () => 0,
25
+ };
26
+
27
+ register(miner);
@@ -0,0 +1,31 @@
1
+ // Miner registry (WS5-T1) — the faculty-mining plugin table.
2
+ //
3
+ // Each miner is `{ faculty, aggregate(sessions, opts) -> candidates,
4
+ // propose(agentDir, candidates) -> count }`. The `--all-faculties` distill driver
5
+ // mines every transcript once into a shared `sessions` array, then runs each
6
+ // registered miner's aggregate + propose. Miners self-register on import.
7
+
8
+ const miners = [];
9
+
10
+ /** Register a miner. Re-registering the same faculty replaces it (idempotent import). */
11
+ export function register(miner) {
12
+ const i = miners.findIndex((m) => m.faculty === miner.faculty);
13
+ if (i >= 0) miners[i] = miner;
14
+ else miners.push(miner);
15
+ return miner;
16
+ }
17
+
18
+ /** All registered miners. */
19
+ export function all() {
20
+ return miners.slice();
21
+ }
22
+
23
+ /** The miner for a faculty, or undefined. */
24
+ export function get(faculty) {
25
+ return miners.find((m) => m.faculty === faculty);
26
+ }
27
+
28
+ /** Clear the registry — tests only. */
29
+ export function reset() {
30
+ miners.length = 0;
31
+ }
@@ -0,0 +1,213 @@
1
+ // The faculty-home scaffold — the layout contract for `zuzuu init`.
2
+ //
3
+ // Git-init discipline: idempotent and never destructive. plan() inspects what
4
+ // exists; apply() creates ONLY what's missing — it never overwrites a file, so
5
+ // user edits to any seeded file always survive a re-init.
6
+ //
7
+ // Layout = the five faculties (docs/DESIGN.md §3①, the 5+3 anatomy):
8
+ // agent/agent.json + knowledge/ memory/ actions/ instructions/ guardrails/
9
+ // Guardrails became first-class (enforced via the PreToolUse gate) on 2026-06-10;
10
+ // the old instructions/guardrails.md advisory seed left the layout (existing
11
+ // projects keep theirs — no-clobber — but new scaffolds get the real faculty).
12
+
13
+ import { join } from 'node:path';
14
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
15
+ import { SEED_TYPES, SEED_ATTRIBUTES, SEED_RELATIONS } from './knowledge/registry.mjs';
16
+
17
+ export const MANIFEST_VERSION = 3;
18
+
19
+ const AGENT_README = `# agent/ — your coding agent's home, in the open
20
+
21
+ This directory is your agent's evolving brain. Five **faculties** grow from how you
22
+ actually work — and **nothing changes without your approval**.
23
+
24
+ ## The five faculties
25
+ - **knowledge/** — what's TRUE (facts about this project)
26
+ - **memory/** — what HAPPENED (curated episodes from past sessions)
27
+ - **actions/** — how to DO things (runbooks the agent can call)
28
+ - **instructions/** — who to BE (steering / project conventions)
29
+ - **guardrails/** — what NOT to do (enforced rules, checked on every tool call)
30
+
31
+ ## How things graduate (you're in the loop)
32
+ a session runs → zuzuu mines candidates → inbox/ → proposals/
33
+ │ you decide
34
+ zuzuu review (y / n / edit)
35
+
36
+ approved → the faculty + a new *generation*
37
+ A **generation** is a pinned checkpoint of every faculty. Approving proposals mints
38
+ one; \`zuzuu generation rollback <id>\` restores any earlier checkpoint.
39
+
40
+ ## Get in the loop
41
+ - \`zuzuu inbox\` — what's waiting for your approval
42
+ - \`zuzuu review\` — approve / reject, one at a time
43
+ - \`zuzuu generation list\` — your checkpoints (· = active)
44
+ - \`zuzuu explain\` — this model, any time
45
+
46
+ ## What to ignore
47
+ \`.traces/\`, \`.live/\`, and \`knowledge/.index.db\` are machine internals (git-ignored).
48
+ Everything else here is yours to read, edit, and version in git.
49
+ `;
50
+
51
+ const KNOWLEDGE_README = `# knowledge/ — the Knowledge faculty (what's TRUE)
52
+
53
+ Items in \`items/\` — one fact/entity per file: prose body + typed attributes +
54
+ typed relations (registry-governed: \`registry/\`) + provenance. The derived
55
+ search index (\`.index.db\`, git-ignored) gives lexical/graph/semantic recall:
56
+ \`zuzuu recall\`. Candidates arrive in \`inbox/\` (from agents or \`zuzuu distill\`),
57
+ become \`proposals/\`, and a human approves via \`zuzuu review\` — never silently.
58
+ \`zuzuu remember\` writes directly (the human IS the gate). \`zuzuu knowledge audit\`
59
+ checks health.
60
+ `;
61
+
62
+ const MEMORY_README = `# memory/ — episodic faculty (what HAPPENED)
63
+
64
+ Curated recollections of past sessions, distilled from the observability traces (\`agent/.traces/\`).
65
+ - **Who writes:** zuzuu (distillation — *not built yet*), human (curation). Raw traces stay in traces/ — this is the *curated* layer.
66
+ - **Where:** one Markdown file per entry under \`entries/\`, named \`<id>.md\`.
67
+
68
+ ## Record schema (Markdown + YAML frontmatter)
69
+ \`\`\`markdown
70
+ ---
71
+ id: mem-2026-06-11-flaky-ci-retry # mem-<YYYY-MM-DD>-<slug>, stable
72
+ date: 2026-06-11 # ISO date the episode occurred
73
+ title: Flaky CI fixed by pinning node 22
74
+ provenance: # links back to observability
75
+ sessions: [ses_abc123] # ids that exist in agent/sessions.json
76
+ hosts: [claude-code]
77
+ tags: [ci, flaky-test] # optional
78
+ status: curated # curated (human) | proposed (reserved — future distiller)
79
+ ---
80
+ ## Attempted
81
+ What was tried.
82
+ ## Resulted
83
+ What happened (outcome / error / fix).
84
+ ## Remember next time
85
+ The durable lesson.
86
+ \`\`\`
87
+ \`status: proposed\` and the distiller→review pipeline are **reserved** (not built this pass).
88
+ `;
89
+
90
+ const ACTIONS_README = `# actions/ — procedural faculty (how to DO things)
91
+
92
+ Named, reusable procedures/skills for this project (scripts, runbooks, tool recipes).
93
+ - **Who writes:** the human; later, zuzuu proposes crystallized actions mined from traces (human-approved).
94
+ - **Contract:** one action per file; state what it does, inputs, and how to invoke it.
95
+ - **Propose a reusable action**: \`zuzuu act propose <slug>\` scaffolds into \`actions/inbox/\` for review. A human approves via \`zuzuu review\` (or \`zuzuu act approve <slug>\`). Never write active actions directly from an agent.
96
+ `;
97
+
98
+ const INSTRUCTIONS_README = `# instructions/ — the Instructions faculty (directive: who the agent is)
99
+
100
+ Cognition steering: identity, conventions, priorities — the project-level seed of
101
+ the pinned system prompt. The host agent reads and follows this.
102
+ - \`project.md\` — project-specific steering (what this is, conventions, priorities).
103
+ - Hard *enforced* rules live in \`../guardrails/\` (a separate faculty), not here.
104
+ `;
105
+
106
+ const PROJECT_SEED = `# Project steering
107
+
108
+ <!-- Fill in: what this project is, conventions, priorities. The host agent reads this. -->
109
+ `;
110
+
111
+ const GUARDRAILS_README = `# guardrails/ — the Guardrails faculty (enforced, not advisory)
112
+
113
+ Declarative rules in \`rules.json\`, evaluated on every tool call by the zuzuu
114
+ PreToolUse gate (installed by \`zuzuu enable\`). Severity wins: deny > ask > allow;
115
+ no match → the host's normal permission flow. The engine FAILS OPEN — a
116
+ guardrail bug can block nothing — and matched decisions are logged for the trace.
117
+
118
+ Rule shape: \`{ id, action: deny|ask|allow, tool: "Bash"|"*", pattern: <regex
119
+ over the tool input>, reason }\`. Edit, commit, done — rules are definitions,
120
+ versioned in git like everything else.
121
+ `;
122
+
123
+ const RULES_SEED =
124
+ JSON.stringify(
125
+ {
126
+ version: 1,
127
+ rules: [
128
+ { id: 'no-root-wipe', action: 'deny', tool: 'Bash', pattern: 'rm\\s+-[a-z]*r[a-z]*\\s+/(\\s|$)', reason: 'destructive delete at filesystem root' },
129
+ { id: 'no-secret-reads', action: 'deny', tool: '*', pattern: '\\.env(\\.|\\b)|id_rsa|\\.pem\\b', reason: 'secret material should not enter the context' },
130
+ // \b.*\bpush, not push adjacent to git: a real session bypassed the
131
+ // adjacent form with `git -C /path push --force-with-lease` (exp-8).
132
+ { id: 'confirm-force-push', action: 'ask', tool: 'Bash', pattern: 'git\\b.*\\bpush\\b.*--force', reason: 'force-push rewrites shared history' },
133
+ ],
134
+ },
135
+ null,
136
+ 2,
137
+ ) + '\n';
138
+
139
+ /** The layout contract: dirs + seed files (relative to the project root). */
140
+ export const LAYOUT = {
141
+ dirs: ['agent', 'agent/knowledge', 'agent/knowledge/registry', 'agent/knowledge/items', 'agent/knowledge/inbox', 'agent/knowledge/proposals', 'agent/memory', 'agent/memory/entries', 'agent/memory/inbox', 'agent/memory/proposals', 'agent/actions', 'agent/actions/inbox', 'agent/instructions', 'agent/instructions/inbox', 'agent/instructions/proposals', 'agent/guardrails', 'agent/guardrails/inbox', 'agent/guardrails/proposals', 'agent/generations', 'agent/generations/snapshots'],
142
+ files: {
143
+ 'agent/README.md': AGENT_README,
144
+ 'agent/knowledge/README.md': KNOWLEDGE_README,
145
+ 'agent/memory/README.md': MEMORY_README,
146
+ 'agent/actions/README.md': ACTIONS_README,
147
+ 'agent/instructions/README.md': INSTRUCTIONS_README,
148
+ 'agent/instructions/project.md': PROJECT_SEED,
149
+ 'agent/guardrails/README.md': GUARDRAILS_README,
150
+ 'agent/guardrails/rules.json': RULES_SEED,
151
+ 'agent/knowledge/registry/types.json': JSON.stringify(SEED_TYPES, null, 2) + '\n',
152
+ 'agent/knowledge/registry/attributes.json': JSON.stringify(SEED_ATTRIBUTES, null, 2) + '\n',
153
+ 'agent/knowledge/registry/relations.json': JSON.stringify(SEED_RELATIONS, null, 2) + '\n',
154
+ },
155
+ };
156
+
157
+ /** Gitignore lines the project needs (trace blobs + liveness state stay local). */
158
+ export const IGNORE_LINES = ['agent/.traces/', 'agent/.live/', 'agent/knowledge/.index.db', '.gemini/settings.json', '.codex/hooks.json', '.pi/extensions/zuzuu.ts'];
159
+
160
+ export function manifest(initializedAt) {
161
+ return {
162
+ version: MANIFEST_VERSION,
163
+ initializedAt,
164
+ layout: ['knowledge', 'memory', 'actions', 'instructions', 'guardrails'],
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Inspect the project: which layout pieces are missing?
170
+ * @returns {{dirs: string[], files: string[], manifestMissing: boolean}}
171
+ */
172
+ export function planScaffold(cwd) {
173
+ const dirs = LAYOUT.dirs.filter((d) => !existsSync(join(cwd, d)));
174
+ const files = Object.keys(LAYOUT.files).filter((f) => !existsSync(join(cwd, f)));
175
+ const manifestMissing = !existsSync(join(cwd, 'agent', 'agent.json'));
176
+ return { dirs, files, manifestMissing };
177
+ }
178
+
179
+ /**
180
+ * Create ONLY the missing pieces (no-clobber). Returns what was created.
181
+ * @param {string} cwd
182
+ * @param {{now?: number}} opts injectable clock for tests
183
+ */
184
+ export function applyScaffold(cwd, { now = Date.now() } = {}) {
185
+ const plan = planScaffold(cwd);
186
+ for (const d of plan.dirs) mkdirSync(join(cwd, d), { recursive: true });
187
+ for (const f of plan.files) writeFileSync(join(cwd, f), LAYOUT.files[f]);
188
+ if (plan.manifestMissing) {
189
+ mkdirSync(join(cwd, 'agent'), { recursive: true });
190
+ writeFileSync(join(cwd, 'agent', 'agent.json'), JSON.stringify(manifest(new Date(now).toISOString()), null, 2) + '\n');
191
+ }
192
+ return plan;
193
+ }
194
+
195
+ /**
196
+ * Ensure the project .gitignore carries our ignore lines (append-only; creates
197
+ * the file if absent). Returns the lines actually added.
198
+ */
199
+ export function ensureGitignore(cwd) {
200
+ const path = join(cwd, '.gitignore');
201
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
202
+ const have = new Set(existing.split('\n').map((l) => l.trim()));
203
+ const missing = IGNORE_LINES.filter((l) => !have.has(l));
204
+ if (!missing.length) return [];
205
+ const block = (existing && !existing.endsWith('\n') ? '\n' : '') + '\n# zuzuu: local-only observability data\n' + missing.join('\n') + '\n';
206
+ writeFileSync(path, existing + block);
207
+ return missing;
208
+ }
209
+
210
+ /** Is there a zuzuu home here already? (the git-detect question) */
211
+ export function homeExists(cwd) {
212
+ return existsSync(join(cwd, 'agent'));
213
+ }
@@ -0,0 +1,72 @@
1
+ // The Session primitive — the lifecycle model for an agent coding session.
2
+ //
3
+ // A Session is the unit motors&sensors tracks: it OPENS when an agent session
4
+ // starts and CLOSES when it ends — cleanly (completed), or by being lost/killed
5
+ // (abandoned/crashed, reconciled after the fact since a killed terminal sends no
6
+ // signal). The state machine below is the contract for that lifecycle.
7
+ //
8
+ // Phase 1 (post-hoc transcript capture) records sessions as `captured` — a
9
+ // lifecycle-unknown snapshot. Phase 2 (live hooks) drives the real transitions:
10
+ // opening → active → completed | abandoned | crashed. The full machine is defined
11
+ // now so the primitive is stable before live wiring lands.
12
+
13
+ export const SessionState = Object.freeze({
14
+ OPENING: 'opening', // hook fired session-start; root span opened
15
+ ACTIVE: 'active', // turns/tools flowing
16
+ COMPLETED: 'completed', // clean end (Stop / explicit close)
17
+ ABANDONED: 'abandoned', // no activity past the liveness window; reconciled
18
+ CRASHED: 'crashed', // process/terminal died mid-session; reconciled
19
+ CAPTURED: 'captured', // Phase 1 post-hoc snapshot; lifecycle not tracked live
20
+ });
21
+
22
+ const TERMINAL = new Set([
23
+ SessionState.COMPLETED,
24
+ SessionState.ABANDONED,
25
+ SessionState.CRASHED,
26
+ SessionState.CAPTURED,
27
+ ]);
28
+
29
+ // Legal live transitions (Phase 2 enforces these). CAPTURED is post-hoc, off-machine.
30
+ const TRANSITIONS = {
31
+ [SessionState.OPENING]: [SessionState.ACTIVE, SessionState.CRASHED, SessionState.ABANDONED],
32
+ [SessionState.ACTIVE]: [SessionState.COMPLETED, SessionState.ABANDONED, SessionState.CRASHED],
33
+ [SessionState.COMPLETED]: [],
34
+ [SessionState.ABANDONED]: [],
35
+ [SessionState.CRASHED]: [],
36
+ [SessionState.CAPTURED]: [],
37
+ };
38
+
39
+ export const isTerminal = (state) => TERMINAL.has(state);
40
+ export const canTransition = (from, to) => (TRANSITIONS[from] || []).includes(to);
41
+
42
+ /** Apply a lifecycle transition, throwing on an illegal one. Returns a new record. */
43
+ export function transition(session, to, at = session.endedAt) {
44
+ if (!canTransition(session.status, to)) {
45
+ throw new Error(`illegal session transition: ${session.status} -> ${to}`);
46
+ }
47
+ const endedAt = isTerminal(to) ? at : session.endedAt;
48
+ const durationMs = endedAt && session.startedAt ? Date.parse(endedAt) - Date.parse(session.startedAt) : session.durationMs;
49
+ return { ...session, status: to, endedAt, durationMs };
50
+ }
51
+
52
+ /**
53
+ * Build a Session record (the git-native index entry).
54
+ * @param {object} s
55
+ * @param {string} s.id host session id
56
+ * @param {string} s.host adapter name
57
+ * @param {string} [s.status] SessionState; default CAPTURED (Phase 1)
58
+ * @param {string} s.startedAt ISO
59
+ * @param {string} s.endedAt ISO
60
+ * @param {string} s.traceId
61
+ * @param {string} s.traceRef path to the OTLP blob (gitignored)
62
+ * @param {{commit:string|null,branch:string|null}} [s.git]
63
+ * @param {{turns:number,tools:number,errors:number}} [s.counts]
64
+ * @param {string|null} [s.generation] active generation id at session open (WS3-T3)
65
+ */
66
+ export function makeSession({ id, host, status = SessionState.CAPTURED, startedAt, endedAt, traceId, traceRef, git = { commit: null, branch: null }, counts = { turns: 0, tools: 0, errors: 0 }, generation = null }) {
67
+ if (!id) throw new Error('makeSession: id required');
68
+ if (!host) throw new Error('makeSession: host required');
69
+ if (!Object.values(SessionState).includes(status)) throw new Error(`makeSession: unknown status ${status}`);
70
+ const durationMs = startedAt && endedAt ? Date.parse(endedAt) - Date.parse(startedAt) : 0;
71
+ return { id: String(id), host, status, startedAt, endedAt, durationMs, traceId, traceRef, git, counts, generation };
72
+ }
@@ -0,0 +1,104 @@
1
+ // The git-native agent/ store (the visible faculty home).
2
+ //
3
+ // Layout (entire.io-style split — linkage in git, blobs out of the diff):
4
+ // agent/sessions.json tracked — the session index (small, diff-friendly,
5
+ // each entry links to a git commit)
6
+ // agent/.traces/<host>-<id>.otlp.jsonl gitignored — the bulky OTLP blobs (dot-prefixed)
7
+ //
8
+ // Trace blobs are git-ignored in Phase 1; Phase 2 moves them to an orphan branch.
9
+
10
+ import { join, relative, resolve, isAbsolute } from 'node:path';
11
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { writeNdjson } from '../experiments/experiment-1-trace-capture/core/otlp.mjs';
14
+
15
+ const INDEX_VERSION = 1;
16
+
17
+ function git(args, cwd) {
18
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
19
+ return r.status === 0 ? r.stdout.trim() : null;
20
+ }
21
+
22
+ /** Repo root via git, falling back to cwd. */
23
+ export function repoRoot(cwd = process.cwd()) {
24
+ return git(['rev-parse', '--show-toplevel'], cwd) || cwd;
25
+ }
26
+
27
+ /** Resolve the faculty home: the visible `agent/`. The single chokepoint for the
28
+ * whole CLI. */
29
+ export function homeDir(root = repoRoot()) {
30
+ return join(root, 'agent');
31
+ }
32
+
33
+ /** Internal liveness dir (git-ignored, dot-prefixed) under the home. */
34
+ export const liveDir = (agentDir) => join(agentDir, '.live');
35
+
36
+ export function paths(cwd = process.cwd()) {
37
+ const root = repoRoot(cwd);
38
+ const dir = homeDir(root);
39
+ return { root, dir, index: join(dir, 'sessions.json'), tracesDir: join(dir, '.traces') };
40
+ }
41
+
42
+ /** Current commit + branch, or nulls if not a git repo. */
43
+ export function gitInfo(cwd = process.cwd()) {
44
+ return { commit: git(['rev-parse', 'HEAD'], cwd), branch: git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) };
45
+ }
46
+
47
+ export function readIndex(cwd = process.cwd()) {
48
+ const { index } = paths(cwd);
49
+ if (!existsSync(index)) return { version: INDEX_VERSION, sessions: [] };
50
+ try {
51
+ const data = JSON.parse(readFileSync(index, 'utf8'));
52
+ return { version: data.version ?? INDEX_VERSION, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
53
+ } catch {
54
+ return { version: INDEX_VERSION, sessions: [] };
55
+ }
56
+ }
57
+
58
+ function writeIndex(idx, cwd = process.cwd()) {
59
+ const { dir, index } = paths(cwd);
60
+ mkdirSync(dir, { recursive: true });
61
+ // stable order: newest first by startedAt
62
+ const sessions = [...idx.sessions].sort((a, b) => Date.parse(b.startedAt || 0) - Date.parse(a.startedAt || 0));
63
+ // Atomic write (tmp + rename) so a concurrent reader never sees a half-written
64
+ // file (which readIndex would silently treat as empty). NOTE: this prevents
65
+ // *corruption*, not the lost-update race when two sessions in the same repo
66
+ // upsert concurrently — Phase 3 should move to per-session record files (no
67
+ // shared index) or file locking. Trace blobs are per-session, so unaffected.
68
+ const tmp = `${index}.tmp`;
69
+ writeFileSync(tmp, JSON.stringify({ version: INDEX_VERSION, sessions }, null, 2) + '\n');
70
+ renameSync(tmp, index);
71
+ }
72
+
73
+ /** Write an OTLP request array as a trace blob; returns the repo-relative ref. */
74
+ export function writeTrace(host, sessionId, requests, cwd = process.cwd()) {
75
+ const { tracesDir, root } = paths(cwd);
76
+ const file = join(tracesDir, `${host}-${sessionId}.otlp.jsonl`);
77
+ writeNdjson(file, requests);
78
+ return relative(root, file);
79
+ }
80
+
81
+ /** Insert-or-replace a session record by id, persist the index. */
82
+ export function upsertSession(record, cwd = process.cwd()) {
83
+ const idx = readIndex(cwd);
84
+ const sessions = idx.sessions.filter((s) => !(s.id === record.id && s.host === record.host));
85
+ sessions.push(record);
86
+ writeIndex({ ...idx, sessions }, cwd);
87
+ return record;
88
+ }
89
+
90
+ /** Resolve a possibly-relative traceRef against the repo root. */
91
+ export function resolveTrace(ref, cwd = process.cwd()) {
92
+ return isAbsolute(ref) ? ref : resolve(repoRoot(cwd), ref);
93
+ }
94
+
95
+ /** Most-recently-modified trace blob, or null. */
96
+ export function lastTrace(cwd = process.cwd()) {
97
+ const { tracesDir } = paths(cwd);
98
+ if (!existsSync(tracesDir)) return null;
99
+ const files = readdirSync(tracesDir)
100
+ .filter((f) => f.endsWith('.otlp.jsonl'))
101
+ .map((f) => ({ f: join(tracesDir, f), m: statSync(join(tracesDir, f)).mtimeMs }))
102
+ .sort((a, b) => b.m - a.m);
103
+ return files.length ? files[0].f : null;
104
+ }