@zuzuucodes/cli 1.2.3 → 1.3.1

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 (27) hide show
  1. package/bin/zuzuu.mjs +8 -2
  2. package/package.json +1 -1
  3. package/web-app/dist/index.js +19 -0
  4. package/web-app/dist/instance-file.js +62 -0
  5. package/web-app/dist/server.js +65 -3
  6. package/web-app/dist/sessions.js +42 -8
  7. package/web-app/dist/zuzuu-api.js +24 -3
  8. package/web-app/web-dist/assets/{DiffTab-Clz0uEu_.js → DiffTab-CihRJjzf.js} +1 -1
  9. package/web-app/web-dist/assets/{MonacoFile-DguRe1Rt.js → MonacoFile-DJvpGyW2.js} +1 -1
  10. package/web-app/web-dist/assets/{cssMode-C4OLzNbC.js → cssMode-R1Bks9TO.js} +1 -1
  11. package/web-app/web-dist/assets/{dist-DYKlGApw.js → dist-jCnX6g-O.js} +1 -1
  12. package/web-app/web-dist/assets/{htmlMode-D1O2jo0s.js → htmlMode-Csqnn3yv.js} +1 -1
  13. package/web-app/web-dist/assets/index-D_MPtALn.css +2 -0
  14. package/web-app/web-dist/assets/index-Ye54YyTn.js +267 -0
  15. package/web-app/web-dist/assets/{jsonMode-D8YStjhJ.js → jsonMode-DRBg9jwi.js} +1 -1
  16. package/web-app/web-dist/assets/{monaco-setup-BBNGrQzm.js → monaco-setup-Dszx738Y.js} +3 -3
  17. package/web-app/web-dist/assets/{tsMode-B8P6eQAV.js → tsMode-9YOHYiVQ.js} +1 -1
  18. package/web-app/web-dist/index.html +2 -2
  19. package/zuzuu/commands/doctor.mjs +10 -0
  20. package/zuzuu/commands/hook.mjs +24 -1
  21. package/zuzuu/commands/session.mjs +103 -0
  22. package/zuzuu/commands/status.mjs +10 -3
  23. package/zuzuu/commands/web.mjs +113 -8
  24. package/zuzuu/live/live-store.mjs +7 -0
  25. package/zuzuu/session-git.mjs +392 -0
  26. package/web-app/web-dist/assets/index-Cfwhe1gB.js +0 -270
  27. package/web-app/web-dist/assets/index-RHYMLHDZ.css +0 -2
@@ -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
  /**
@@ -11,14 +11,27 @@
11
11
  // the workbench, never the CLI. `--omit=optional` installs skip the deps; this
12
12
  // command then explains how to repair.
13
13
  //
14
+ // Singleton-per-workspace (2026-06-12): the daemon records itself in
15
+ // ~/.webcode/instances/<sha256(realpath-root).slice(0,16)>.json after listen
16
+ // and removes it on clean shutdown (daemon src/instance-file.ts — the path
17
+ // scheme here MUST stay in sync with it). Before spawning we check that file:
18
+ // a live instance (pid alive + HTTP listener answering) is REUSED — same port,
19
+ // same token, old browser tabs keep working; a stale file is deleted and we
20
+ // spawn fresh. `--stop` / `--status` manage the running instance.
21
+ // (The file carries the auth token: 0600, user's own machine, and the token
22
+ // already appears in the daemon's stdout — acceptable.)
23
+ //
14
24
  // Resolution order:
15
25
  // 1. the bundled web-app/dist next to this package (installed OR repo after build:web)
16
26
  // 2. the nested dev project's built daemon (web/packages/daemon, repo checkout)
17
27
  // 3. a standalone `zuzuu-web` on PATH (legacy/manual installs)
18
28
  // 4. none → repair hint (reinstall, or `npm run build:web` in a checkout)
19
29
 
20
- import { existsSync } from 'node:fs';
30
+ import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs';
21
31
  import { resolve, dirname, join } from 'node:path';
32
+ import { homedir } from 'node:os';
33
+ import { createHash } from 'node:crypto';
34
+ import http from 'node:http';
22
35
  import { spawn } from 'node:child_process';
23
36
  import { spawnSync } from 'node:child_process';
24
37
  import { fileURLToPath } from 'node:url';
@@ -45,25 +58,108 @@ const realLaunch = ({ cwd, entryScript }) => {
45
58
  if (entryScript) spawn(process.execPath, [entryScript, cwd], { detached: true, stdio: 'ignore' }).unref();
46
59
  else spawn('zuzuu-web', [cwd], { detached: true, stdio: 'ignore' }).unref();
47
60
  };
61
+ // Same scheme as the daemon's instance-file.ts: sha256 of the realpath'd root.
62
+ const realInstancePathFor = (dir) => {
63
+ let real = dir;
64
+ try { real = realpathSync(dir); } catch { /* missing dir → daemon will refuse anyway */ }
65
+ const id = createHash('sha256').update(real).digest('hex').slice(0, 16);
66
+ return join(homedir(), '.webcode', 'instances', `${id}.json`);
67
+ };
68
+ const realReadInstance = (file) => {
69
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
70
+ };
71
+ const realRemoveFile = (file) => { try { unlinkSync(file); } catch { /* gone already */ } };
72
+ const realPidAlive = (pid) => {
73
+ try { process.kill(pid, 0); return true; }
74
+ catch (err) { return err?.code === 'EPERM'; } // EPERM = alive, just not ours
75
+ };
76
+ const realKillPid = (pid, signal) => { try { process.kill(pid, signal); } catch { /* gone */ } };
77
+ // Connectivity probe only — ANY HTTP answer (even 401) means a listener is there.
78
+ const realProbe = (port) => new Promise((done) => {
79
+ const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout: 1000 }, (res) => {
80
+ res.resume();
81
+ done(true);
82
+ });
83
+ req.on('timeout', () => { req.destroy(); done(false); });
84
+ req.on('error', () => done(false));
85
+ });
86
+ const realOpenBrowser = (url) => { // fail-soft: a missing opener never breaks the command
87
+ try {
88
+ const cmd = process.platform === 'darwin' ? 'open'
89
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
90
+ spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
91
+ } catch { /* ignore */ }
92
+ };
93
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
94
+
95
+ const urlOf = (inst) => `http://127.0.0.1:${inst.port}/?token=${inst.token}`;
48
96
 
49
97
  /**
50
- * `zuzuu web [dir]`
51
- * Launch the visual workbench for the given directory (default: cwd).
98
+ * `zuzuu web [dir] [--stop|--status]`
99
+ * Launch the visual workbench for the given directory (default: cwd)
100
+ * reusing an already-running daemon for that workspace when there is one.
52
101
  * Bundled-first; never installs anything — the workbench ships in this package.
53
102
  */
54
- export function web(args = {}, deps = {}) {
103
+ export async function web(args = {}, deps = {}) {
55
104
  const d = {
56
105
  resolveBundled: realResolveBundled,
57
106
  detectPath: realDetectPath,
58
107
  launch: realLaunch,
108
+ instancePathFor: realInstancePathFor,
109
+ readInstance: realReadInstance,
110
+ removeFile: realRemoveFile,
111
+ pidAlive: realPidAlive,
112
+ killPid: realKillPid,
113
+ probe: realProbe,
114
+ openBrowser: realOpenBrowser,
115
+ sleep: realSleep,
59
116
  log: (...m) => console.log(...m),
60
117
  ...deps,
61
118
  };
62
119
 
63
- // 1. resolve the target directory
120
+ // 1. resolve the target directory + its instance state
64
121
  const dir = args._?.[0] ? resolve(String(args._[0])) : process.cwd();
122
+ const instanceFile = d.instancePathFor(dir);
123
+ const inst = d.readInstance(instanceFile);
124
+ const isAlive = async (i) =>
125
+ !!(i && Number.isInteger(i.pid) && d.pidAlive(i.pid) && await d.probe(i.port));
126
+
127
+ // --stop: terminate the running daemon for this workspace
128
+ if (args.stop) {
129
+ if (!inst) { d.log(`no workbench running for ${dir}`); return; }
130
+ if (d.pidAlive(inst.pid)) {
131
+ d.killPid(inst.pid, 'SIGTERM');
132
+ for (let i = 0; i < 15 && d.pidAlive(inst.pid); i++) await d.sleep(200); // ~3s grace
133
+ if (d.pidAlive(inst.pid)) d.log(`workbench (pid ${inst.pid}) hasn't exited yet — still shutting down.`);
134
+ else d.log(`stopped workbench for ${dir} (pid ${inst.pid})`);
135
+ } else {
136
+ d.log(`workbench for ${dir} was not running — cleaned up stale state.`);
137
+ }
138
+ d.removeFile(instanceFile); // daemon removes it itself on SIGTERM; this is belt-and-braces
139
+ return;
140
+ }
65
141
 
66
- // 2. find the workbench: bundled PATH → repair hint
142
+ // --status: report without side effects
143
+ if (args.status) {
144
+ if (await isAlive(inst)) {
145
+ d.log(`workbench running for ${dir}`);
146
+ d.log(` pid ${inst.pid} → ${urlOf(inst)}`);
147
+ } else {
148
+ d.log(`no workbench running for ${dir}`);
149
+ }
150
+ return;
151
+ }
152
+
153
+ // 2. reuse a live instance: same port + token, old tabs stay valid
154
+ if (await isAlive(inst)) {
155
+ d.log(`workbench already running for ${dir}`);
156
+ d.log(` → ${urlOf(inst)}`);
157
+ d.openBrowser(urlOf(inst));
158
+ return;
159
+ }
160
+ if (inst) d.removeFile(instanceFile); // stale (dead pid / no listener) → spawn fresh
161
+
162
+ // 3. find the workbench: bundled → PATH → repair hint
67
163
  const entryScript = d.resolveBundled();
68
164
  if (!entryScript && !d.detectPath()) {
69
165
  d.log('the workbench is not available in this install.');
@@ -72,8 +168,17 @@ export function web(args = {}, deps = {}) {
72
168
  return;
73
169
  }
74
170
 
75
- // 3. launch — the workbench opens the browser and prints its URL
171
+ // 4. launch — the daemon opens the browser itself on fresh boot, so we only
172
+ // wait for its instance file to surface the URL here (don't double-open).
76
173
  d.log(`zuzuu web → launching visual workbench in ${dir} …`);
77
- d.log(' it will open your browser and print its URL.');
78
174
  d.launch({ cwd: dir, entryScript });
175
+ for (let i = 0; i < 30; i++) { // ~6s
176
+ await d.sleep(200);
177
+ const fresh = d.readInstance(instanceFile);
178
+ if (fresh && d.pidAlive(fresh.pid)) {
179
+ d.log(` → ${urlOf(fresh)}`);
180
+ return;
181
+ }
182
+ }
183
+ d.log(' it will open your browser and print its URL.');
79
184
  }
@@ -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
+ }