@zuzuucodes/cli 1.2.3 → 1.3.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.
@@ -1,7 +1,9 @@
1
1
  // `zuzuu status` — detected hosts + recorded sessions (the git-native index).
2
2
 
3
3
  import { existsSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
4
5
  import { detected } from '../capture/adapters/registry.mjs';
6
+ import { sessionStatus } from '../session-git.mjs';
5
7
  import { readIndex, paths } from '../store.mjs';
6
8
  import { FACULTIES } from '../faculty/contract.mjs';
7
9
  import { listProposals } from '../faculty/proposal.mjs';
@@ -10,8 +12,9 @@ import { detectDrift } from './doctor.mjs';
10
12
 
11
13
  const fmtDur = (ms) => (ms < 60_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 60_000).toFixed(1)}m`);
12
14
 
13
- /** Pure: structured status for a faculty home (the zuzuu-web /status source). Fail-soft per field. */
14
- export function statusData(agentDir, { hosts = detected().map((a) => ({ name: a.name })) } = {}) {
15
+ /** Pure: structured status for a faculty home (the zuzuu-web /status source). Fail-soft per field.
16
+ * `session` is injectable (like hosts) for hermetic tests; default = the repo above the home. */
17
+ export function statusData(agentDir, { hosts = detected().map((a) => ({ name: a.name })), session } = {}) {
15
18
  let active = null, drift = { dirty: false, items: [] };
16
19
  const pending = {};
17
20
  try { active = activeGenerationFn(agentDir); } catch { active = null; }
@@ -23,7 +26,11 @@ export function statusData(agentDir, { hosts = detected().map((a) => ({ name: a.
23
26
  const items = Array.isArray(d?.drifted) ? d.drifted : [];
24
27
  drift = { dirty: items.length > 0, items };
25
28
  } catch { /* fail-soft */ }
26
- return { home: existsSync(agentDir), activeGeneration: active, pending, drift, hosts };
29
+ let sess = session;
30
+ if (sess === undefined) {
31
+ try { sess = sessionStatus(dirname(agentDir)); } catch { sess = null; } // sessionStatus never throws — belt + braces
32
+ }
33
+ return { home: existsSync(agentDir), activeGeneration: active, pending, drift, hosts, session: sess };
27
34
  }
28
35
 
29
36
  /**
@@ -50,6 +50,13 @@ export function touchLive({ id, host, transcriptPath, now }, cwd = process.cwd()
50
50
  return write({ ...existing, lastSeen: now, transcriptPath: transcriptPath ?? existing.transcriptPath }, cwd);
51
51
  }
52
52
 
53
+ /** Merge a patch into an existing live record (no-op when absent). */
54
+ export function updateLive(id, patch, cwd = process.cwd()) {
55
+ const existing = read(id, cwd);
56
+ if (!existing) return null;
57
+ return write({ ...existing, ...patch }, cwd);
58
+ }
59
+
53
60
  /** Remove a live record (its lifecycle has reached a terminal state). */
54
61
  export function closeLive(id, cwd = process.cwd()) {
55
62
  try {
@@ -0,0 +1,392 @@
1
+ // zuzuu/session-git.mjs — invisible session-git: one agent session = one branch.
2
+ //
3
+ // OPEN → create `zz/session-<shortid>` (a branch, never a worktree)
4
+ // TURN → checkpoint commit ON the session branch
5
+ // END → squash-merge to main as ONE commit `session: <title>`, delete branch
6
+ //
7
+ // THE most safety-critical module in the codebase: it runs git mutations inside
8
+ // USERS' repos, triggered from fail-open lifecycle hooks. Therefore:
9
+ // - every exported op is try-wrapped and returns { ok:false, reason } — NEVER throws
10
+ // - all git goes through spawnSync('git', [args], {cwd}) — no shell strings
11
+ // - non-repo / bare / detached HEAD / merge-rebase-in-progress / unborn HEAD → no-op
12
+ // - never push, never touch remotes, never auto-resolve conflicts (conflict →
13
+ // abort the squash, restore the branch, leave the repo exactly as before)
14
+ // - single-working-branch invariant: at most ONE `zz/session-*` branch; a
15
+ // leftover (crashed session) BLOCKS new session branches until continued,
16
+ // merged, or discarded — the next-session prompt is the recovery path.
17
+ // - secrets policy: checkpoints NEVER stage secret material (the same family
18
+ // the seeded no-secret-reads guardrail denies: .env/.env.* at any depth,
19
+ // *.pem, *.key, id_rsa*) — excluded files stay untracked in the worktree
20
+ // and are reported as `excludedSecrets`.
21
+ // - checkpoint history is never destroyed silently: an empty squash that
22
+ // still has checkpoints KEEPS the branch (explicit discard is the only
23
+ // way exploration history is dropped).
24
+
25
+ import { spawnSync } from 'node:child_process';
26
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
27
+ import { join, isAbsolute, resolve } from 'node:path';
28
+
29
+ const PREFIX = 'zz/session-';
30
+
31
+ /** One git call — argv array only (no shell), never throws. */
32
+ function git(args, cwd, input) {
33
+ try {
34
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', input });
35
+ return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
36
+ } catch (e) {
37
+ return { ok: false, out: '', err: String(e) };
38
+ }
39
+ }
40
+
41
+ function gitDir(cwd) {
42
+ const r = git(['rev-parse', '--git-dir'], cwd);
43
+ if (!r.ok || !r.out) return null;
44
+ return isAbsolute(r.out) ? r.out : resolve(cwd, r.out);
45
+ }
46
+
47
+ /** Current branch name, or null when detached / not a repo. */
48
+ function currentBranch(cwd) {
49
+ const r = git(['symbolic-ref', '--short', '-q', 'HEAD'], cwd);
50
+ return r.ok && r.out ? r.out : null;
51
+ }
52
+
53
+ const branchExists = (cwd, name) => git(['rev-parse', '-q', '--verify', `refs/heads/${name}`], cwd).ok;
54
+ const isDirty = (cwd) => !!git(['status', '--porcelain'], cwd).out;
55
+
56
+ /** Why git mutations are unsafe right now, or null when clear. */
57
+ function unsafeReason(cwd) {
58
+ const inside = git(['rev-parse', '--is-inside-work-tree'], cwd);
59
+ if (!inside.ok || inside.out !== 'true') return 'not-a-git-repo';
60
+ if (!git(['rev-parse', '-q', '--verify', 'HEAD'], cwd).ok) return 'no-commits';
61
+ if (!currentBranch(cwd)) return 'detached-head';
62
+ const gd = gitDir(cwd);
63
+ if (gd) {
64
+ // An in-progress merge/rebase/cherry-pick/bisect belongs to the USER — a
65
+ // checkpoint commit here would conclude their operation. Hands off.
66
+ for (const f of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply']) {
67
+ if (existsSync(join(gd, f))) return 'operation-in-progress';
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** Project opt-out: `.zuzuu/agent.json` carrying `"sessionGit": false`. */
74
+ function optedOut(cwd) {
75
+ try {
76
+ const root = git(['rev-parse', '--show-toplevel'], cwd);
77
+ if (!root.ok || !root.out) return false;
78
+ const f = join(root.out, '.zuzuu', 'agent.json');
79
+ if (!existsSync(f)) return false;
80
+ return JSON.parse(readFileSync(f, 'utf8')).sessionGit === false;
81
+ } catch {
82
+ return false; // an unreadable manifest never *enables* danger — but opt-out is explicit
83
+ }
84
+ }
85
+
86
+ function disabledReason(cwd) {
87
+ const r = unsafeReason(cwd);
88
+ if (r) return r;
89
+ if (optedOut(cwd)) return 'opted-out';
90
+ return null;
91
+ }
92
+
93
+ /** True when session-git may act here: a usable git repo, not opted out. */
94
+ export function sessionGitEnabled(cwd) {
95
+ try {
96
+ return !disabledReason(cwd);
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /** `zz/session-` + first 8 of the id, sanitized to [a-z0-9]. */
103
+ export function sessionBranchName(sessionId) {
104
+ const short = String(sessionId ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8);
105
+ return PREFIX + (short || 'unknown');
106
+ }
107
+
108
+ /** All `zz/session-*` branches (there should be at most one — the invariant). */
109
+ export function listSessionBranches(cwd) {
110
+ try {
111
+ const r = git(['for-each-ref', '--format=%(refname:short)', `refs/heads/${PREFIX}*`], cwd);
112
+ return r.ok && r.out ? r.out.split('\n').filter(Boolean) : [];
113
+ } catch {
114
+ return [];
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The branch sessions merge back into. Preference order:
120
+ * 1. the branch HEAD was on when the session opened (recorded as
121
+ * `branch.<session>.zz-base` config at open — survives crashes)
122
+ * 2. origin/HEAD's branch, if it exists locally
123
+ * 3. local `main`, then `master`
124
+ * 4. the current branch, if it isn't itself a session branch
125
+ */
126
+ export function mainBranch(cwd) {
127
+ try {
128
+ for (const b of listSessionBranches(cwd)) {
129
+ const base = git(['config', `branch.${b}.zz-base`], cwd);
130
+ if (base.ok && base.out && branchExists(cwd, base.out)) return base.out;
131
+ }
132
+ const oh = git(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd);
133
+ if (oh.ok && oh.out) {
134
+ const name = oh.out.replace('refs/remotes/origin/', '');
135
+ if (branchExists(cwd, name)) return name;
136
+ }
137
+ if (branchExists(cwd, 'main')) return 'main';
138
+ if (branchExists(cwd, 'master')) return 'master';
139
+ const cur = currentBranch(cwd);
140
+ return cur && !cur.startsWith(PREFIX) ? cur : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /** Commits on the session branch beyond main (best-effort; 0 on any trouble). */
147
+ function countCheckpoints(cwd, branch) {
148
+ const main = mainBranch(cwd);
149
+ if (!main || main === branch) return 0;
150
+ const r = git(['rev-list', '--count', `${main}..${branch}`], cwd);
151
+ const n = r.ok ? Number.parseInt(r.out, 10) : NaN;
152
+ return Number.isFinite(n) ? n : 0;
153
+ }
154
+
155
+ /**
156
+ * OPEN: create the session branch (dirty tree fine — changes ride along).
157
+ * already on this session's branch (host restart) → { ok:true, resumed:true }
158
+ * another session branch exists (leftover) → { ok:false, blocked:true, existing }
159
+ */
160
+ export function openSession(cwd, sessionId) {
161
+ try {
162
+ const reason = disabledReason(cwd);
163
+ if (reason) return { ok: false, reason };
164
+ const target = sessionBranchName(sessionId);
165
+ const cur = currentBranch(cwd);
166
+ if (cur === target) return { ok: true, resumed: true, branch: target };
167
+ const existing = listSessionBranches(cwd);
168
+ if (existing.length) return { ok: false, blocked: true, existing: existing[0] };
169
+ const r = git(['checkout', '-q', '-b', target], cwd);
170
+ if (!r.ok) return { ok: false, reason: r.err || 'checkout-failed' };
171
+ git(['config', `branch.${target}.zz-base`, cur], cwd); // remember where to merge back (best-effort)
172
+ return { ok: true, branch: target };
173
+ } catch (e) {
174
+ return { ok: false, reason: String(e) };
175
+ }
176
+ }
177
+
178
+ // The secret family checkpoints must never commit — mirrors the seeded
179
+ // no-secret-reads guardrail (scaffold.mjs RULES_SEED: .env / id_rsa / .pem).
180
+ const SECRET_GLOBS = ['.env', '.env.*', '**/.env', '**/.env.*', '**/*.pem', '**/*.key', '**/id_rsa*'];
181
+ const SECRET_RE = /(^|\/)\.env(\.|$)|(^|\/)id_rsa[^/]*$|\.pem$|\.key$/;
182
+
183
+ /** How many dirty/untracked paths are secret-family (and so were excluded). */
184
+ function countExcludedSecrets(cwd) {
185
+ const out = git(['status', '--porcelain', '-uall'], cwd).out; // -uall: expand untracked dirs
186
+ if (!out) return 0;
187
+ let n = 0;
188
+ for (const line of out.split('\n')) {
189
+ const p = line.slice(3);
190
+ const path = (p.includes(' -> ') ? p.split(' -> ').pop() : p).replace(/^"|"$/g, '');
191
+ if (SECRET_RE.test(path)) n += 1;
192
+ }
193
+ return n;
194
+ }
195
+
196
+ /** TURN: checkpoint commit — only ON a session branch, only when dirty. NEVER commits on main.
197
+ * Secret-family files are excluded from staging (left untracked/unstaged);
198
+ * the count of excluded paths is returned as `excludedSecrets` when > 0. */
199
+ export function checkpoint(cwd) {
200
+ try {
201
+ const blocked = unsafeReason(cwd);
202
+ if (blocked) return { ok: false, reason: blocked };
203
+ const cur = currentBranch(cwd);
204
+ if (!cur || !cur.startsWith(PREFIX)) return { ok: false, reason: 'not-on-session-branch' };
205
+ const base = countCheckpoints(cwd, cur);
206
+ if (!isDirty(cwd)) return { ok: true, committed: false, n: base };
207
+ const excludedSecrets = countExcludedSecrets(cwd);
208
+ const add = git(['add', '-A', '--', '.', ...SECRET_GLOBS.map((g) => `:(exclude,glob)${g}`)], cwd);
209
+ if (!add.ok) return { ok: false, reason: 'add-failed' };
210
+ if (git(['diff', '--cached', '--quiet'], cwd).ok) {
211
+ // everything dirty was secret material — never an empty commit
212
+ return { ok: true, committed: false, n: base, ...(excludedSecrets ? { excludedSecrets } : {}) };
213
+ }
214
+ const c = git(['commit', '-q', '-m', `zz: checkpoint ${base + 1}`], cwd);
215
+ if (!c.ok) return { ok: false, reason: c.err || 'commit-failed' };
216
+ return { ok: true, committed: true, n: base + 1, ...(excludedSecrets ? { excludedSecrets } : {}) };
217
+ } catch (e) {
218
+ return { ok: false, reason: String(e) };
219
+ }
220
+ }
221
+
222
+ /** The leftover detector: reflects any zz/session-* branch, checked out or not. */
223
+ export function sessionStatus(cwd) {
224
+ try {
225
+ const enabled = sessionGitEnabled(cwd);
226
+ const main = mainBranch(cwd);
227
+ const cur = currentBranch(cwd);
228
+ const onSessionBranch = !!cur && cur.startsWith(PREFIX);
229
+ const branches = listSessionBranches(cwd);
230
+ let active = null;
231
+ if (branches.length) {
232
+ const branch = branches[0];
233
+ const checkpoints = countCheckpoints(cwd, branch);
234
+ active = {
235
+ branch,
236
+ checkpoints,
237
+ dirty: cur === branch && isDirty(cwd),
238
+ // exploration-only session: checkpoints exist but the tree equals main
239
+ // (the empty-squash-with-checkpoints case doctor/status render specially)
240
+ noNetChanges: checkpoints > 0 && !!main && main !== branch && git(['diff', '--quiet', main, branch], cwd).ok,
241
+ };
242
+ }
243
+ return { enabled, mainBranch: main, active, onSessionBranch };
244
+ } catch {
245
+ return { enabled: false, mainBranch: null, active: null, onSessionBranch: false };
246
+ }
247
+ }
248
+
249
+ const defaultTitle = (branch) => `${branch} · ${new Date().toISOString().slice(0, 10)}`;
250
+
251
+ /** Best-effort: drop squash leftovers so they can't leak into the user's next commit. */
252
+ function cleanupSquashState(cwd) {
253
+ const gd = gitDir(cwd);
254
+ if (!gd) return;
255
+ for (const f of ['SQUASH_MSG', 'MERGE_MSG']) {
256
+ try {
257
+ rmSync(join(gd, f), { force: true });
258
+ } catch {
259
+ /* best-effort */
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * END: squash-merge the session branch into main as ONE commit
266
+ * `session: <title>`, then delete the branch. Every ok:true return carries
267
+ * `mergedTo` (the branch actually merged into); when the recorded zz-base
268
+ * branch no longer exists, `warning:'base-branch-missing'` flags the fallback.
269
+ * conflict → abort (reset --merge), restore the prior checkout,
270
+ * { ok:false, conflict:true, branch, restoredTo } —
271
+ * restoredTo:null means the checkout back failed (stranded on main)
272
+ * empty squash → 0 checkpoints: no commit, still cleans up ({ mergedAs:null });
273
+ * WITH checkpoints: the branch is KEPT (history is never
274
+ * destroyed silently) → { ok:false,
275
+ * reason:'empty-squash-with-checkpoints', commits, branch }
276
+ */
277
+ export function closeSession(cwd, { title } = {}) {
278
+ try {
279
+ const blocked = unsafeReason(cwd);
280
+ if (blocked) return { ok: false, reason: blocked };
281
+ const branches = listSessionBranches(cwd);
282
+ if (!branches.length) return { ok: false, reason: 'no-session-branch' };
283
+ const branch = branches[0];
284
+ const cur = currentBranch(cwd);
285
+ const baseCfg = git(['config', `branch.${branch}.zz-base`], cwd).out;
286
+ const main = mainBranch(cwd);
287
+ if (!main || main === branch) return { ok: false, reason: 'no-main-branch' };
288
+ // honesty: the branch we recorded at open is gone → we merge to a fallback
289
+ const baseMissing = !!baseCfg && !branchExists(cwd, baseCfg);
290
+
291
+ let excludedSecrets = 0;
292
+ if (cur === branch) {
293
+ const cp = checkpoint(cwd); // fold any uncommitted work into the squash
294
+ if (!cp.ok) return { ok: false, reason: cp.reason };
295
+ excludedSecrets = cp.excludedSecrets ?? 0;
296
+ } else if (isDirty(cwd)) {
297
+ // Loose changes on another branch are the USER's — never mix them into the squash.
298
+ return { ok: false, reason: 'dirty-worktree' };
299
+ }
300
+
301
+ const commits = countCheckpoints(cwd, branch);
302
+ /** Undo a failed merge and put the user back where they were. Returns the
303
+ * branch we actually landed on — null = the checkout back failed and the
304
+ * user is STRANDED ON MAIN (report it, never pretend otherwise). */
305
+ const restore = () => {
306
+ git(['reset', '--merge'], cwd);
307
+ cleanupSquashState(cwd);
308
+ if (!cur || cur === main) return cur ?? null;
309
+ return git(['checkout', '-q', cur], cwd).ok ? cur : null;
310
+ };
311
+
312
+ const co = git(['checkout', '-q', main], cwd);
313
+ if (!co.ok) return { ok: false, reason: co.err || 'checkout-main-failed' };
314
+ const merge = git(['merge', '--squash', branch], cwd);
315
+ if (!merge.ok) {
316
+ // conflict (or any squash failure): leave the repo exactly as before
317
+ return { ok: false, conflict: true, branch, restoredTo: restore() };
318
+ }
319
+
320
+ const extras = {
321
+ ...(excludedSecrets ? { excludedSecrets } : {}),
322
+ ...(baseMissing ? { warning: 'base-branch-missing' } : {}),
323
+ };
324
+
325
+ let mergedAs = null;
326
+ const nothingStaged = git(['diff', '--cached', '--quiet'], cwd).ok; // exit 1 = staged changes
327
+ if (nothingStaged) {
328
+ cleanupSquashState(cwd); // a stale SQUASH_MSG would hijack the user's next commit
329
+ if (commits > 0) {
330
+ // Exploration-only session: the squash is empty but real checkpoints
331
+ // exist. NEVER delete that history silently — keep the branch, put the
332
+ // user back, and report; `zuzuu session discard --yes` is the drop path.
333
+ if (cur && cur !== main) git(['checkout', '-q', cur], cwd);
334
+ return { ok: false, reason: 'empty-squash-with-checkpoints', commits, branch, ...extras };
335
+ }
336
+ } else {
337
+ const c = git(['commit', '-q', '-m', `session: ${title || defaultTitle(branch)}`], cwd);
338
+ if (!c.ok) {
339
+ return { ok: false, reason: c.err || 'commit-failed', restoredTo: restore() };
340
+ }
341
+ mergedAs = git(['rev-parse', 'HEAD'], cwd).out || null;
342
+ }
343
+
344
+ git(['config', '--unset', `branch.${branch}.zz-base`], cwd); // best-effort
345
+ const del = git(['branch', '-D', branch], cwd);
346
+ if (!del.ok) {
347
+ const warning = extras.warning ? `${extras.warning},branch-delete-failed` : 'branch-delete-failed';
348
+ return { ok: true, mergedAs, mergedTo: main, commits, ...extras, warning };
349
+ }
350
+ return { ok: true, mergedAs, mergedTo: main, commits, ...extras };
351
+ } catch (e) {
352
+ return { ok: false, reason: String(e) };
353
+ }
354
+ }
355
+
356
+ /** Recovery: check the leftover session branch back out and keep working. */
357
+ export function continueSession(cwd) {
358
+ try {
359
+ const blocked = unsafeReason(cwd);
360
+ if (blocked) return { ok: false, reason: blocked };
361
+ const branches = listSessionBranches(cwd);
362
+ if (!branches.length) return { ok: false, reason: 'no-session-branch' };
363
+ const branch = branches[0];
364
+ if (currentBranch(cwd) === branch) return { ok: true, branch };
365
+ const r = git(['checkout', '-q', branch], cwd);
366
+ return r.ok ? { ok: true, branch } : { ok: false, reason: r.err || 'checkout-failed' };
367
+ } catch (e) {
368
+ return { ok: false, reason: String(e) };
369
+ }
370
+ }
371
+
372
+ /** Recovery: drop the session branch and its checkpoints. The CALLER gates confirmation. */
373
+ export function discardSession(cwd) {
374
+ try {
375
+ const blocked = unsafeReason(cwd);
376
+ if (blocked) return { ok: false, reason: blocked };
377
+ const branches = listSessionBranches(cwd);
378
+ if (!branches.length) return { ok: false, reason: 'no-session-branch' };
379
+ const branch = branches[0];
380
+ const main = mainBranch(cwd);
381
+ if (!main || main === branch) return { ok: false, reason: 'no-main-branch' };
382
+ if (currentBranch(cwd) === branch) {
383
+ const co = git(['checkout', '-q', main], cwd);
384
+ if (!co.ok) return { ok: false, reason: co.err || 'checkout-main-failed' };
385
+ }
386
+ git(['config', '--unset', `branch.${branch}.zz-base`], cwd); // best-effort
387
+ const del = git(['branch', '-D', branch], cwd);
388
+ return del.ok ? { ok: true, branch } : { ok: false, reason: del.err || 'branch-delete-failed' };
389
+ } catch (e) {
390
+ return { ok: false, reason: String(e) };
391
+ }
392
+ }