@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.18.0-beta.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 (45) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/template/.claude/hooks/_utils.mjs +1 -1
  4. package/template/.claude/hooks/repo-write-detection.mjs +161 -64
  5. package/template/.claude/hooks/session-start.mjs +35 -1
  6. package/template/.claude/hooks/subagent-start.mjs +89 -22
  7. package/template/.claude/lib/session-frontmatter.mjs +28 -0
  8. package/template/.claude/rules/coherent-revisions.md +1 -1
  9. package/template/.claude/rules/forge-operations.md +37 -93
  10. package/template/.claude/rules/git-conventions.md +16 -11
  11. package/template/.claude/rules/goal-driven-work.md +8 -416
  12. package/template/.claude/rules/honest-pushback.md +37 -37
  13. package/template/.claude/rules/memory-guidance.md +43 -90
  14. package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
  15. package/template/.claude/rules/work-item-tracking.md +30 -72
  16. package/template/.claude/rules/workspace-structure.md +36 -94
  17. package/template/.claude/scripts/build-workspace-context.mjs +61 -16
  18. package/template/.claude/scripts/chat-record.mjs +282 -0
  19. package/template/.claude/scripts/cleanup-work-session.mjs +257 -68
  20. package/template/.claude/scripts/context-footprint.mjs +282 -0
  21. package/template/.claude/scripts/forges/github.mjs +45 -0
  22. package/template/.claude/scripts/forges/gitlab.mjs +3 -2
  23. package/template/.claude/scripts/forges/interface.mjs +12 -0
  24. package/template/.claude/scripts/generate-claude-local.mjs +21 -2
  25. package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
  26. package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
  27. package/template/.claude/scripts/task-worktree.mjs +525 -0
  28. package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
  29. package/template/.claude/skills/braindump/SKILL.md +11 -4
  30. package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
  31. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  32. package/template/.claude/skills/complete-work/SKILL.md +229 -219
  33. package/template/.claude/skills/context-placement/SKILL.md +199 -0
  34. package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
  35. package/template/.claude/skills/handoff/SKILL.md +11 -4
  36. package/template/.claude/skills/maintenance/SKILL.md +7 -0
  37. package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
  38. package/template/.claude/skills/pause-work/SKILL.md +9 -1
  39. package/template/.claude/skills/release/SKILL.md +44 -108
  40. package/template/.claude/skills/start-work/SKILL.md +89 -7
  41. package/template/.claude/skills/workspace-init/SKILL.md +3 -1
  42. package/template/.claude/skills/workspace-update/SKILL.md +4 -0
  43. package/template/CLAUDE.md.tmpl +19 -2
  44. package/template/_gitignore +9 -0
  45. package/template/workspace.json.tmpl +3 -2
@@ -0,0 +1,1571 @@
1
+ #!/usr/bin/env node
2
+ // Per-workspace migration from the session lifecycle to the task model
3
+ // (gh:147).
4
+ //
5
+ // Workspaces that predate the task model accumulate entries under
6
+ // work-sessions/ — some finished-but-never-completed, some abandoned,
7
+ // some still live. This script is the mechanical half of draining them:
8
+ //
9
+ // --inventory read-only evidence + a proposal (ACTIVE / ABANDONED /
10
+ // MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session
11
+ // --backup tag each tip the session holds that no remote branch or
12
+ // tag already points at, push the tag, verify it
13
+ // (--dry-run reports the plan with no side effects)
14
+ // --archive move the session out of the active lifecycle — the whole
15
+ // folder is renamed into {sessions}/.archived/ and git's
16
+ // worktree links are repaired to follow it
17
+ // --enable-task-model
18
+ // flip workspace.sessionModel to "task" (accepts a task
19
+ // worktree root — the one mode allowed off the launcher)
20
+ //
21
+ // NOTHING HERE DELETES. Draining a session means taking it out of the
22
+ // active lifecycle, not destroying it. An earlier design tore sessions
23
+ // down behind a "prove it is safe to delete" check; four independent
24
+ // reviews each found a new place git keeps state that the check missed
25
+ // (tracker drift, ignored files, stale tracking refs, submodules,
26
+ // per-worktree refs, assume-unchanged edits, embedded repositories). A
27
+ // rename keeps all of it by construction: every file, every ref, every
28
+ // index flag travels with the folder, and `git worktree repair` keeps the
29
+ // repositories pointing at it. Deleting an archive is a separate, manual
30
+ // decision the operator makes with their own eyes on it.
31
+ //
32
+ // HARD BOUNDARY: the process only ever touches the workspace it is run
33
+ // in. --root must resolve (real path) to a directory containing
34
+ // workspace.json; every path read or acted on must resolve inside it; a
35
+ // --session name must be a single path segment; and git is only ever run
36
+ // in the workspace repo at the root, the repos under root/repos/, and
37
+ // worktrees under the sessions directory. A session holding a worktree
38
+ // of any other repository is refused, never touched. Remote contact
39
+ // (ls-remote, push) verifies and creates backup tags — it never deletes a
40
+ // remote branch or tag.
41
+ //
42
+ // Output contract: JSON on stdout. Inventory also prints a human-readable
43
+ // table to stderr. A precondition refusal prints {refused: true,
44
+ // reasons: [...]} and exits 1; any other error goes to stderr and exits 2.
45
+
46
+ import {
47
+ readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, statSync, mkdirSync, renameSync, readlinkSync,
48
+ } from 'node:fs';
49
+ import { realpathSync } from 'node:fs';
50
+ import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path';
51
+ import { spawnSync } from 'node:child_process';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { readSessionFields } from '../lib/session-frontmatter.mjs';
54
+ import { defaultBranchFor, slugForBranch, WORKSPACE_REPO } from './task-worktree.mjs';
55
+
56
+ function isMainModule(metaUrl) {
57
+ if (!process.argv[1]) return false;
58
+ try {
59
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
60
+ } catch { return false; }
61
+ }
62
+
63
+ const DAY_MS = 24 * 60 * 60 * 1000;
64
+ // Remote contact must never hang or prompt an operator who is not there:
65
+ // prompts are disabled and both commands get hard timeouts. A timed-out
66
+ // ls-remote degrades the inventory answer to "unknown" and makes backup
67
+ // refuse rather than guess.
68
+ const LS_REMOTE_TIMEOUT_MS = 15000;
69
+ const PUSH_TIMEOUT_MS = 60000;
70
+
71
+ function netOpts(timeoutMs) {
72
+ return { encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
73
+ }
74
+
75
+ // Session artifacts live at the top of the workspace worktree on the
76
+ // session branch (workspace-structure.md). They are process output, not
77
+ // content — a branch whose only diff is these files carries nothing
78
+ // worth merging, and a dirty artifact is not dirty content.
79
+ const SESSION_ARTIFACT_PATTERNS = [
80
+ /^session\.md$/,
81
+ /^design-.*\.md$/,
82
+ /^plan-.*\.md$/,
83
+ /^goal-.*\.md$/,
84
+ /^research-.*\.md$/,
85
+ /^crossref-.*\.md$/,
86
+ ];
87
+
88
+ function isSessionArtifact(file) {
89
+ // "at the worktree top" — the path has no separator at all.
90
+ if (file.includes('/')) return false;
91
+ return SESSION_ARTIFACT_PATTERNS.some((re) => re.test(file));
92
+ }
93
+
94
+ // .native resolves Windows 8.3 short names; the plain fallback covers
95
+ // filesystems where the native binding is unavailable. Same contract as
96
+ // task-worktree.mjs.
97
+ function realPath(p) {
98
+ try { return realpathSync.native(p); } catch { /* fall through */ }
99
+ try { return realpathSync(p); } catch { /* fall through */ }
100
+ return resolve(p);
101
+ }
102
+
103
+ // gitFn is injectable so tests can observe or fake git; the default is
104
+ // spawnSync itself, called as (command, args, options).
105
+ function run(gitFn, cwd, args, opts = {}) {
106
+ const res = gitFn('git', ['-C', cwd, ...args], { encoding: 'utf8', ...opts });
107
+ if (res.error) throw new Error(`git: ${res.error.message}`);
108
+ return res;
109
+ }
110
+
111
+ function okLines(res) {
112
+ return String(res.stdout || '').split(/\r?\n/).filter((l) => l.trim() !== '');
113
+ }
114
+
115
+ // A session name becomes a path segment under the sessions directory —
116
+ // one segment only, no separators or absolute paths, and no leading dot:
117
+ // dot entries are the script's own (.archived/) and discovery never
118
+ // lists them as sessions, so no mode may act on one either.
119
+ function isSessionSegment(name) {
120
+ if (typeof name !== 'string' || name === '' || isAbsolute(name)) return false;
121
+ const segs = name.split(/[\\/]/);
122
+ if (segs.length !== 1) return false;
123
+ return !segs[0].startsWith('.');
124
+ }
125
+
126
+ // A project repo name: one path segment under repos/. This is the guard
127
+ // that keeps a crafted tracker (repos: ['../../elsewhere']) from pointing
128
+ // any git command at a repo outside the workspace.
129
+ function isRepoSegment(name) {
130
+ if (typeof name !== 'string' || name === '' || isAbsolute(name)) return false;
131
+ const segs = name.split(/[\\/]/);
132
+ if (segs.length !== 1) return false;
133
+ return !/^\.+$/.test(segs[0]);
134
+ }
135
+
136
+ // The root must be a workspace root — otherwise the script would be
137
+ // operating on (and writing into) an arbitrary directory, which is the
138
+ // one thing the hard boundary forbids.
139
+ function resolveRoot(root) {
140
+ const rootDir = realPath(resolve(root));
141
+ if (!existsSync(join(rootDir, 'workspace.json'))) {
142
+ throw new Error(`no workspace.json at ${rootDir} — not a workspace root`);
143
+ }
144
+ return rootDir;
145
+ }
146
+
147
+ function insideRoot(rootDir, p) {
148
+ const rp = realPath(p);
149
+ return rp === rootDir || rp.startsWith(rootDir + sep);
150
+ }
151
+
152
+ function insideDir(dir, p) {
153
+ const rp = realPath(p);
154
+ return rp === realPath(dir) || rp.startsWith(realPath(dir) + sep);
155
+ }
156
+
157
+ function readConfig(rootDir) {
158
+ try {
159
+ return JSON.parse(readFileSync(join(rootDir, 'workspace.json'), 'utf-8'));
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ // The sessions directory is configurable, but a configuration pointing
166
+ // outside the root would violate the boundary — refuse it outright.
167
+ function sessionsDirOf(rootDir) {
168
+ const dir = readConfig(rootDir)?.workspace?.workSessionsDir;
169
+ const name = typeof dir === 'string' && dir !== '' ? dir : 'work-sessions';
170
+ const path = resolve(rootDir, name);
171
+ if (!insideRoot(rootDir, path)) {
172
+ throw new Error(`workspace.workSessionsDir (${name}) resolves outside the workspace root`);
173
+ }
174
+ return path;
175
+ }
176
+
177
+ // Session entries with their nature: a symlinked entry is foreign — it
178
+ // may point anywhere, so it is reported and never followed (N3).
179
+ function listSessionEntries(rootDir) {
180
+ const dir = sessionsDirOf(rootDir);
181
+ if (!existsSync(dir)) return [];
182
+ try {
183
+ return readdirSync(dir, { withFileTypes: true })
184
+ // Dot entries are the script's own (.archived/) or tooling noise —
185
+ // never sessions.
186
+ .filter((e) => !e.name.startsWith('.'))
187
+ .map((e) => ({ name: e.name, foreign: e.isSymbolicLink() }))
188
+ .filter((e) => e.foreign || lstatSync(join(dir, e.name)).isDirectory())
189
+ .sort((a, b) => a.name.localeCompare(b.name));
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ function listSessionNames(rootDir) {
196
+ return listSessionEntries(rootDir).filter((e) => !e.foreign).map((e) => e.name);
197
+ }
198
+
199
+ function currentBranch(gitFn, path) {
200
+ try {
201
+ const res = gitFn('git', ['-C', path, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' });
202
+ if (!res || res.status !== 0) return null;
203
+ const name = String(res.stdout).trim();
204
+ return name === 'HEAD' ? null : name; // "HEAD" means detached
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ function headSha(gitFn, path) {
211
+ try {
212
+ const res = run(gitFn, path, ['rev-parse', 'HEAD']);
213
+ return res.status === 0 ? String(res.stdout).trim() : null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ // Porcelain v1 -z records: "XY <path>", NUL-separated, with the rename
220
+ // source in a second NUL field. Ignored entries only appear when
221
+ // --ignored=matching is requested. Null return = git failed, which every
222
+ // caller treats as fail-closed.
223
+ function statusRecords(gitFn, path, extraArgs = []) {
224
+ const res = run(gitFn, path, ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...extraArgs]);
225
+ if (res.status !== 0) return null;
226
+ const parts = String(res.stdout || '').split('\0');
227
+ const records = [];
228
+ for (let i = 0; i < parts.length; i += 1) {
229
+ const p = parts[i];
230
+ if (p === '') continue;
231
+ const xy = p.slice(0, 2);
232
+ const entryPath = p.slice(3);
233
+ if (xy[0] === 'R' || xy[0] === 'C') i += 1; // the next NUL field is the rename source
234
+ records.push({ xy, path: entryPath });
235
+ }
236
+ return records;
237
+ }
238
+
239
+ // The base the session branched from, as a ref: origin/{default} when
240
+ // the remote ref exists (the truth about the integration branch), else
241
+ // the local {default}.
242
+ function baseRef(gitFn, path, defaultBranch) {
243
+ const hasOrigin = run(gitFn, path, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${defaultBranch}`]).status === 0;
244
+ return hasOrigin ? `origin/${defaultBranch}` : defaultBranch;
245
+ }
246
+
247
+ function ownRange(gitFn, path, defaultBranch) {
248
+ return `${baseRef(gitFn, path, defaultBranch)}..HEAD`;
249
+ }
250
+
251
+ // Commits not on the repo's default branch, or null when the question
252
+ // cannot be answered (e.g. the configured default branch does not exist
253
+ // in this repo — B3). null is "unknown", never "zero": the unbacked and
254
+ // classification logic treats it with suspicion.
255
+ function aheadCount(gitFn, path, range) {
256
+ const res = run(gitFn, path, ['rev-list', '--count', range]);
257
+ if (res.status !== 0) return null;
258
+ const n = parseInt(String(res.stdout).trim(), 10);
259
+ return Number.isFinite(n) ? n : null;
260
+ }
261
+
262
+ // When this worktree last got session work of its own: the committer
263
+ // date of the newest commit not on the default branch. An ahead=0
264
+ // worktree's HEAD is just the base tip — its date describes the repo,
265
+ // not the session, and counting it would make every stale session look
266
+ // active whenever the default branch moves. Null = no session commits.
267
+ function lastOwnCommitIso(gitFn, path, range) {
268
+ const res = run(gitFn, path, ['log', '-1', '--format=%cI', range]);
269
+ if (res.status !== 0) return null;
270
+ const s = String(res.stdout).trim();
271
+ return s === '' ? null : s;
272
+ }
273
+
274
+ // Files changed vs the default branch minus session artifacts — the
275
+ // number that answers "is there real content on this branch?".
276
+ function countContentFiles(gitFn, path, defaultBranch) {
277
+ const base = baseRef(gitFn, path, defaultBranch);
278
+ const res = run(gitFn, path, ['diff', '--name-only', `${base}...HEAD`]);
279
+ if (res.status !== 0) return 0;
280
+ const files = okLines(res).filter((f) => !isSessionArtifact(f));
281
+ return files.length;
282
+ }
283
+
284
+ function remotesOf(gitFn, path) {
285
+ const res = run(gitFn, path, ['remote']);
286
+ if (res.status !== 0) return [];
287
+ return okLines(res);
288
+ }
289
+
290
+ // Does {branch} exist on {remote}, and at what commit? Local tracking
291
+ // refs prove nothing (they are stale the moment anything fetches), so
292
+ // the remote is asked directly. Timeouts and failures degrade to
293
+ // 'unknown' — an inventory must report, not crash, and never hang.
294
+ function lsRemoteBranch(gitFn, cwd, remote, branch) {
295
+ const res = gitFn('git', ['-C', cwd, 'ls-remote', '--heads', remote, branch], netOpts(LS_REMOTE_TIMEOUT_MS));
296
+ if (res.error || res.status !== 0) return { exists: 'unknown', sha: null };
297
+ const line = okLines(res)[0];
298
+ if (!line) return { exists: false, sha: null };
299
+ const [sha] = line.trim().split(/\s+/);
300
+ return { exists: true, sha };
301
+ }
302
+
303
+ // === S1: remote state per remote ===
304
+ //
305
+ // For each remote holding the branch, record how the local tip relates:
306
+ // same / local-ahead / local-behind / diverged (with counts) when the
307
+ // remote commit is known locally, not-fetched when it is not. This is
308
+ // what tells the operator "finishing needs a force-push decision" BEFORE
309
+ // they choose Finish, rather than after /complete-work's push bounces.
310
+ function remoteStatesFor(gitFn, wtPath, branch, head) {
311
+ const out = {};
312
+ if (!branch) return out;
313
+ for (const remote of remotesOf(gitFn, wtPath)) {
314
+ const probe = lsRemoteBranch(gitFn, wtPath, remote, branch);
315
+ if (probe.exists === 'unknown') {
316
+ out[remote] = { exists: false, sha: null, state: 'unknown' };
317
+ continue;
318
+ }
319
+ if (!probe.exists) {
320
+ out[remote] = { exists: false, sha: null, state: 'none' };
321
+ continue;
322
+ }
323
+ const { sha } = probe;
324
+ if (sha === head) {
325
+ out[remote] = { exists: true, sha, state: 'same' };
326
+ continue;
327
+ }
328
+ const known = run(gitFn, wtPath, ['cat-file', '-e', `${sha}^{commit}`]).status === 0;
329
+ if (!known) {
330
+ out[remote] = { exists: true, sha, state: 'not-fetched' };
331
+ continue;
332
+ }
333
+ const lr = run(gitFn, wtPath, ['rev-list', '--left-right', '--count', `${sha}...HEAD`]);
334
+ if (lr.status !== 0) {
335
+ out[remote] = { exists: true, sha, state: 'unknown' };
336
+ continue;
337
+ }
338
+ const counts = String(lr.stdout).trim().split(/\s+/).map(Number);
339
+ const remoteOnly = counts[0]; // left side: commits only the remote has
340
+ const localOnly = counts[1]; // right side: commits only HEAD has
341
+ if (remoteOnly === 0 && localOnly === 0) out[remote] = { exists: true, sha, state: 'same' };
342
+ else if (localOnly > 0 && remoteOnly === 0) out[remote] = { exists: true, sha, state: 'local-ahead', ahead: localOnly };
343
+ else if (localOnly === 0 && remoteOnly > 0) out[remote] = { exists: true, sha, state: 'local-behind', behind: remoteOnly };
344
+ else out[remote] = { exists: true, sha, state: 'diverged', ahead: localOnly, behind: remoteOnly };
345
+ }
346
+ return out;
347
+ }
348
+
349
+ function describeRemote(name, r) {
350
+ switch (r.state) {
351
+ case 'same': return `${name}:same`;
352
+ case 'local-ahead': return `${name}:ahead +${r.ahead}`;
353
+ case 'local-behind': return `${name}:behind -${r.behind}`;
354
+ case 'diverged': return `${name}:diverged +${r.ahead}/-${r.behind}`;
355
+ default: return `${name}:${r.state}`;
356
+ }
357
+ }
358
+
359
+ // The tip is machine-safe on a remote when the remote's copy leaves
360
+ // nothing local-only (same, or local purely behind). Any other state —
361
+ // or no remote holding the branch at all — leaves local-only commits.
362
+ function backedByRemote(remotes) {
363
+ for (const [name, r] of Object.entries(remotes)) {
364
+ if (r.exists && (r.state === 'same' || r.state === 'local-behind')) return name;
365
+ }
366
+ return null;
367
+ }
368
+
369
+ // The unbacked message names the repo (two worktrees on the same branch
370
+ // would otherwise print identical lines) and counts the commits that are
371
+ // neither on the primary holding remote nor on the default branch —
372
+ // `rev-list --count HEAD ^<remoteSha> ^<base>` when the base resolves.
373
+ function unbackedMessage(gitFn, rootDir, wt) {
374
+ const parts = Object.entries(wt.remotes).map(([name, r]) => describeRemote(name, r)).join(', ');
375
+ const diverged = Object.values(wt.remotes).some((r) => r.state === 'diverged');
376
+ const holderEntry = Object.entries(wt.remotes).find(([, r]) => r.exists) ?? Object.entries(wt.remotes)[0];
377
+ let count = null;
378
+ if (wt.base) {
379
+ const abs = join(rootDir, wt.path);
380
+ if (holderEntry && holderEntry[1].sha) {
381
+ const res = run(gitFn, abs, ['rev-list', '--count', 'HEAD', `^${holderEntry[1].sha}`, `^${wt.base}`]);
382
+ if (res.status === 0) count = Number(String(res.stdout).trim());
383
+ }
384
+ if (count == null || !Number.isFinite(count)) {
385
+ const res = run(gitFn, abs, ['rev-list', '--count', `${wt.base}..HEAD`]);
386
+ count = res.status === 0 ? Number(String(res.stdout).trim()) : null;
387
+ }
388
+ }
389
+ const label = repoLabel(wt);
390
+ const head = count != null && Number.isFinite(count)
391
+ ? `${label}: ${count} commit(s) on ${wt.branch} are on no remote and not on ${wt.base} (${parts})`
392
+ : `${label}: commit(s) on ${wt.branch} are on no remote (${parts})`;
393
+ // A diverged remote means a rebase rewrote history that was already
394
+ // pushed: a plain push will be rejected, and deciding to force is the
395
+ // operator's, never the script's.
396
+ return diverged ? `${head} — diverged from a remote; finishing needs a force-push decision` : head;
397
+ }
398
+
399
+ // === S2(c): activity signals beyond commits ===
400
+
401
+ // Newest HEAD-reflog timestamp for a worktree (unix seconds), read from
402
+ // the reflog ENTRY's own time. `--date=unix --format=%gd` renders the
403
+ // selector as HEAD@{<unix-ts>} — the entry timestamp, not %ct (which is
404
+ // the referenced commit's date and reads "old" for a fresh worktree
405
+ // checked out on an old branch). Absence is normal and tolerated.
406
+ function reflogTs(gitFn, wtPath) {
407
+ try {
408
+ const res = gitFn('git', ['-C', wtPath, 'log', '-g', '-1', '--date=unix', '--format=%gd'], { encoding: 'utf8' });
409
+ if (!res || res.status !== 0) return null;
410
+ const m = String(res.stdout).trim().match(/@\{(\d+)\}$/);
411
+ return m ? Number(m[1]) : null;
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ // Newest mtime among content (non-artifact) dirty paths — a session with
418
+ // no commits and no reflog can still show fresh uncommitted work.
419
+ // Artifact paths are excluded: an uncommitted session.md edit is bookkeeping,
420
+ // not work, and would otherwise make UNKNOWN unreachable.
421
+ function dirtyContentMtimeMs(gitFn, wtPath, kind) {
422
+ const records = statusRecords(gitFn, wtPath);
423
+ if (!records) return null;
424
+ let newest = null;
425
+ for (const r of records) {
426
+ if (r.xy === '!!') continue; // statusRecords without --ignored never yields these; guard anyway
427
+ if (kind === 'workspace' && isSessionArtifact(r.path)) continue;
428
+ try {
429
+ const m = statSync(join(wtPath, r.path)).mtimeMs;
430
+ if (newest === null || m > newest) newest = m;
431
+ } catch { /* deleted/renamed away between status and stat — skip */ }
432
+ }
433
+ return newest;
434
+ }
435
+
436
+ // === Session shape ===
437
+
438
+ function readTracker(wsDir) {
439
+ const trackerPath = join(wsDir, 'session.md');
440
+ if (!existsSync(trackerPath)) return null;
441
+ try {
442
+ const fields = readSessionFields(trackerPath);
443
+ // normalizeRepos parity with cleanup-work-session.mjs: a scalar or
444
+ // null repos field must never iterate as characters.
445
+ const rawRepos = fields.repos;
446
+ const repos = Array.isArray(rawRepos) ? rawRepos.map(String)
447
+ : rawRepos == null || rawRepos === '' ? [] : [String(rawRepos)];
448
+ return {
449
+ status: typeof fields.status === 'string' ? fields.status : null,
450
+ workItem: typeof fields.workItem === 'string' ? fields.workItem : null,
451
+ branch: typeof fields.branch === 'string' ? fields.branch : null,
452
+ updated: fields.updated != null ? String(fields.updated) : null,
453
+ repos,
454
+ };
455
+ } catch {
456
+ // An unparseable tracker is evidence about the tracker, not the
457
+ // session; the worktrees below still carry the real state.
458
+ return null;
459
+ }
460
+ }
461
+
462
+ // One worktree of a session — the workspace worktree (repo ".") or one
463
+ // nested project worktree (repo = its directory name under repos/).
464
+ // A session's workspace branch and its code are different things, so
465
+ // each is reported on its own line.
466
+ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
467
+ const branch = currentBranch(gitFn, wtPath);
468
+ const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
469
+ const range = ownRange(gitFn, wtPath, defaultBranch);
470
+ const base = range.slice(0, -('..HEAD'.length)); // the surviving-ref side of the range
471
+ const head = headSha(gitFn, wtPath);
472
+ const dirtyRecords = statusRecords(gitFn, wtPath) || [];
473
+ const info = {
474
+ kind,
475
+ repo,
476
+ path: relative(rootDir, realPath(wtPath)),
477
+ branch,
478
+ dirty: dirtyRecords.length,
479
+ ahead: aheadCount(gitFn, wtPath, range),
480
+ lastOwnCommit: lastOwnCommitIso(gitFn, wtPath, range),
481
+ base,
482
+ defaultBranch,
483
+ reflogAt: reflogTs(gitFn, wtPath),
484
+ remotes: {},
485
+ backedBy: null,
486
+ };
487
+ if (kind === 'workspace') {
488
+ info.trackerBranch = null; // filled by the caller when the tracker is read
489
+ info.contentFiles = countContentFiles(gitFn, wtPath, defaultBranch);
490
+ info.dirtyContent = dirtyRecords.filter((r) => !isSessionArtifact(r.path)).length;
491
+ }
492
+ info.remotes = remoteStatesFor(gitFn, wtPath, branch, head);
493
+ info.backedBy = backedByRemote(info.remotes);
494
+ return info;
495
+ }
496
+
497
+ // The session's worktrees as they exist right now — nested project
498
+ // worktrees discovered from the directory listing (the tracker's repos
499
+ // list can drift or be missing), each entry recognized as a worktree by
500
+ // its .git link so plain directories are skipped, symlinked entries
501
+ // surfaced as foreign rather than followed.
502
+ function collectSessionWorktrees(gitFn, rootDir, folder) {
503
+ const out = [];
504
+ const wsDir = join(folder, 'workspace');
505
+ if (existsSync(join(wsDir, '.git'))) {
506
+ out.push({ kind: 'workspace', repo: WORKSPACE_REPO, path: wsDir });
507
+ }
508
+ const nested = join(wsDir, 'repos');
509
+ if (existsSync(nested)) {
510
+ for (const entry of readdirSync(nested, { withFileTypes: true })) {
511
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
512
+ const p = join(nested, entry.name);
513
+ if (entry.isSymbolicLink()) {
514
+ out.push({ kind: 'foreign', repo: entry.name, path: p });
515
+ continue;
516
+ }
517
+ if (!existsSync(join(p, '.git'))) continue;
518
+ out.push({ kind: 'project', repo: entry.name, path: p });
519
+ }
520
+ }
521
+ return out;
522
+ }
523
+
524
+ /**
525
+ * Pure classifier: given a session's computed metrics, return its
526
+ * proposal and the human-readable reasons for it. No activity signal at
527
+ * all → UNKNOWN (never ABANDONED — absence of evidence is not
528
+ * abandonment). Active-ness is lastActivity within N days, or any dirty
529
+ * worktree with lastActivity within 2N days. Everything else splits on
530
+ * whether real content survives — committed content files, uncommitted
531
+ * content paths, or project commits mean MERGEABLE; artifact-only,
532
+ * clean, and quiet means ABANDONED. A session that fits neither (e.g.
533
+ * uncommitted project changes on a stale session) falls to MERGEABLE —
534
+ * real uncommitted work is content, and the dirty warning carries the
535
+ * caution.
536
+ */
537
+ function classify(session, activeDays, now = Date.now()) {
538
+ const ws = session.worktrees.find((w) => w.kind === 'workspace') || null;
539
+ const projects = session.worktrees.filter((w) => w.kind === 'project');
540
+ const lastMs = session.lastActivity != null ? Date.parse(session.lastActivity) : NaN;
541
+ const within = (days) => Number.isFinite(lastMs) && now - lastMs <= days * DAY_MS;
542
+ const anyDirty = session.worktrees.some((w) => w.dirty > 0);
543
+
544
+ if (session.lastActivity == null) {
545
+ return {
546
+ proposal: 'UNKNOWN',
547
+ reasons: ['no activity signal — no session commits, no reflog entries, no content-dirty files, no tracker updated date'],
548
+ };
549
+ }
550
+ if (within(activeDays)) {
551
+ return { proposal: 'ACTIVE', reasons: [`last activity ${session.lastActivity} is within ${activeDays} days`] };
552
+ }
553
+ if (anyDirty && within(activeDays * 2)) {
554
+ return {
555
+ proposal: 'ACTIVE',
556
+ reasons: [`dirty worktree(s) with last activity ${session.lastActivity} within ${activeDays * 2} days`],
557
+ };
558
+ }
559
+
560
+ const content = ws ? ws.contentFiles : 0;
561
+ const dirtyContent = ws ? ws.dirtyContent : 0;
562
+ const aheadProjects = projects.filter((p) => p.ahead > 0);
563
+ const dirtyProjects = projects.filter((p) => p.dirty > 0);
564
+ // An unresolvable default branch means "no content" is UNPROVEN, not
565
+ // established — never ABANDONED on the back of an unknown count.
566
+ const unknownBase = session.worktrees.some((w) => w.ahead == null);
567
+ if (unknownBase) {
568
+ return {
569
+ proposal: 'MERGEABLE',
570
+ reasons: [
571
+ `last activity ${session.lastActivity} is older than ${activeDays} days`,
572
+ 'commits-ahead could not be verified (a default branch does not resolve) — content is unknown, so abandonment is not provable',
573
+ ],
574
+ };
575
+ }
576
+ if (content === 0 && dirtyContent === 0 && aheadProjects.length === 0 && dirtyProjects.length === 0) {
577
+ return {
578
+ proposal: 'ABANDONED',
579
+ reasons: [
580
+ `last activity ${session.lastActivity} is older than ${activeDays} days`,
581
+ ...(ws ? [`workspace branch carries only session artifacts (${ws.ahead ?? '?'} commit(s), no content files, no uncommitted content)`] : []),
582
+ 'no project worktree has commits ahead or uncommitted changes',
583
+ ],
584
+ };
585
+ }
586
+ const reasons = [];
587
+ if (content > 0) reasons.push(`${content} content file(s) beyond session artifacts on the workspace branch`);
588
+ if (dirtyContent > 0) reasons.push(`${dirtyContent} uncommitted content path(s) in the workspace worktree`);
589
+ for (const p of aheadProjects) reasons.push(`repo "${p.repo}" is ${p.ahead} commit(s) ahead of its default branch`);
590
+ for (const p of dirtyProjects) reasons.push(`repo "${p.repo}" has ${p.dirty} uncommitted change(s)`);
591
+ return { proposal: 'MERGEABLE', reasons };
592
+ }
593
+
594
+ function collectWarnings(gitFn, rootDir, worktrees, active, trackerBranch) {
595
+ const warnings = [];
596
+ for (const wt of worktrees) {
597
+ if (wt.kind === 'workspace' && wt.branchDrift) {
598
+ warnings.push({
599
+ kind: 'branch-drift',
600
+ message: `tracker says branch ${wt.trackerBranch} but the workspace worktree is on ${wt.branch}`,
601
+ branch: wt.branch,
602
+ trackerBranch: wt.trackerBranch,
603
+ });
604
+ }
605
+ if (!active && wt.dirty > 0) {
606
+ warnings.push({
607
+ kind: 'dirty',
608
+ message: `${repoLabel({ kind: wt.kind, repo: wt.repo })} has ${wt.dirty} uncommitted change(s) on a session that is not active`,
609
+ repo: wt.repo,
610
+ files: wt.dirty,
611
+ });
612
+ }
613
+ if (wt.ahead > 0 && !wt.backedBy) {
614
+ warnings.push({
615
+ kind: 'unbacked',
616
+ unbacked: true,
617
+ message: unbackedMessage(gitFn, rootDir, wt),
618
+ repo: wt.repo,
619
+ branch: wt.branch,
620
+ ahead: wt.ahead,
621
+ });
622
+ }
623
+ if (wt.ahead == null) {
624
+ warnings.push({
625
+ kind: 'unknown-base',
626
+ message: `${repoLabel({ kind: wt.kind, repo: wt.repo })}: commits-ahead could not be counted (default branch "${wt.defaultBranch}" does not resolve here) — treat every count as unknown and the content as unverifiable`,
627
+ repo: wt.repo,
628
+ });
629
+ }
630
+ }
631
+ return warnings;
632
+ }
633
+
634
+ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
635
+ const folder = join(sessionsDir, name);
636
+ const wsDir = join(folder, 'workspace');
637
+ if (!existsSync(join(wsDir, '.git'))) {
638
+ const reason = existsSync(wsDir)
639
+ ? `workspace/ at ${relative(rootDir, wsDir)} is not a git worktree (empty shell)`
640
+ : `no workspace worktree at ${relative(rootDir, folder)}${sep}workspace`;
641
+ return { name, kind: 'broken', proposal: 'REMOVE_SHELL', reasons: [reason] };
642
+ }
643
+
644
+ const tracker = readTracker(wsDir);
645
+ const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
646
+ const worktrees = rawWorktrees
647
+ .filter((w) => w.kind !== 'foreign')
648
+ .map((w) => inspectWorktree(gitFn, rootDir, w.kind, w.repo, w.path));
649
+ const wsWt = worktrees.find((w) => w.kind === 'workspace');
650
+ if (wsWt && tracker) {
651
+ wsWt.trackerBranch = tracker.branch;
652
+ wsWt.branchDrift = Boolean(tracker.branch && wsWt.branch && tracker.branch !== wsWt.branch);
653
+ }
654
+
655
+ // lastActivity: the newest fact we have — own commits, reflog entries,
656
+ // content-dirty file mtimes, or the tracker's updated field.
657
+ let lastMs = NaN;
658
+ let lastActivity = null;
659
+ const consider = (value) => {
660
+ if (value == null) return;
661
+ const t = typeof value === 'number' ? value : Date.parse(value);
662
+ if (!Number.isFinite(t)) return;
663
+ if (!Number.isFinite(lastMs) || t > lastMs) {
664
+ lastMs = t;
665
+ lastActivity = new Date(t).toISOString();
666
+ }
667
+ };
668
+ for (const wt of worktrees) {
669
+ consider(wt.lastOwnCommit);
670
+ consider(wt.reflogAt != null ? wt.reflogAt * 1000 : null);
671
+ consider(dirtyContentMtimeMs(gitFn, join(rootDir, wt.path), wt.kind));
672
+ }
673
+ consider(tracker?.updated ?? null);
674
+
675
+ const { proposal, reasons } = classify({ name, worktrees, lastActivity }, activeDays, now);
676
+ const warnings = collectWarnings(gitFn, rootDir, worktrees, proposal === 'ACTIVE', tracker?.branch ?? null);
677
+ return {
678
+ name,
679
+ kind: 'session',
680
+ status: tracker?.status ?? null,
681
+ workItem: tracker?.workItem ?? null,
682
+ lastActivity,
683
+ proposal,
684
+ reasons,
685
+ warnings,
686
+ worktrees,
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Read-only inventory of every session under the workspace's sessions
692
+ * directory. The proposal each session gets is a proposal — the note in
693
+ * the result says so, and the skill says so again to the operator.
694
+ * Symlinked entries are reported as foreign (LEAVE) and never followed.
695
+ */
696
+ function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now() } = {}) {
697
+ const rootDir = resolveRoot(root);
698
+ const sessionsDir = sessionsDirOf(rootDir);
699
+ const sessions = listSessionEntries(rootDir).map((entry) => (
700
+ entry.foreign
701
+ ? {
702
+ name: entry.name,
703
+ kind: 'foreign',
704
+ proposal: 'LEAVE',
705
+ reasons: ['entry is a symlink, not a session directory — never followed; reconcile manually'],
706
+ }
707
+ : inspectSession(gitFn, rootDir, sessionsDir, entry.name, activeDays, now)
708
+ ));
709
+ return {
710
+ root: rootDir,
711
+ activeDays,
712
+ note: 'Proposals are proposals — inventory evidence only; the operator decides each session.',
713
+ sessions,
714
+ };
715
+ }
716
+
717
+ // === Backup ===
718
+ //
719
+ // A backup tag is worth pushing only where no remote already holds the
720
+ // commit. Local refs never count as "already held": a stale
721
+ // refs/remotes/* entry or a local tag whose push failed prove nothing.
722
+
723
+ function repoLabel(wt) {
724
+ return wt.kind === 'workspace' ? 'the workspace repo' : `repo "${wt.repo}"`;
725
+ }
726
+
727
+ // Does a remote's URL point somewhere OTHER than the workspace itself? A
728
+ // remote whose URL resolves to a local path inside root shares its fate
729
+ // with the thing being deleted (a remote pointing at the repo, or at a
730
+ // sibling repo under root, holds the same objects in the same store), so
731
+ // "the remote has it" proves nothing. file:// URLs and plain paths are
732
+ // local; scheme URLs and scp-style git@host:path are real remotes.
733
+ function parseRemoteUrl(url) {
734
+ if (url.startsWith('file://')) return { local: true, path: fileURLToPath(url) };
735
+ if (/:\/\//.test(url)) return { local: false };
736
+ if (/^[^:@/\s]+@[^:\s]+:/.test(url)) return { local: false };
737
+ return { local: true, path: url };
738
+ }
739
+
740
+ function remoteQualifies(safety, repoDir, remote) {
741
+ const key = `${repoDir}\0${remote}`;
742
+ if (safety.qualCache.has(key)) return safety.qualCache.get(key);
743
+ let qualifies = false;
744
+ const res = run(safety.gitFn, repoDir, ['remote', 'get-url', remote]);
745
+ if (res.status === 0) {
746
+ const parsed = parseRemoteUrl(String(res.stdout).trim());
747
+ // Relative local paths resolve against the repo, the way git does.
748
+ qualifies = !parsed.local || !insideRoot(safety.rootDir, realPath(resolve(repoDir, parsed.path)));
749
+ }
750
+ // An unreadable URL proves nothing (fail closed).
751
+ safety.qualCache.set(key, qualifies);
752
+ return qualifies;
753
+ }
754
+
755
+ // Every branch and tag a remote holds, mapped by commit sha. Other
756
+ // advertised refs (HEAD, a forge's refs/pull/N/*) are ephemeral and never
757
+ // count. No refspec pattern: patterns silently drop the peeled ^{} lines,
758
+ // which are the ones comparable with commit tips. A failed or timed-out
759
+ // query returns null (unknown) — it proves nothing.
760
+ function remoteRefs(safety, repoDir, remote) {
761
+ const key = `${repoDir}\0${remote}`;
762
+ if (safety.lsCache.has(key)) return safety.lsCache.get(key);
763
+ let map = null;
764
+ const res = safety.gitFn('git', ['-C', repoDir, 'ls-remote', remote], netOpts(LS_REMOTE_TIMEOUT_MS));
765
+ if (!res.error && res.status === 0) {
766
+ map = new Map();
767
+ for (const line of okLines(res)) {
768
+ const [sha, ref] = line.trim().split(/\s+/);
769
+ if (!ref || !/^refs\/(heads|tags)\//.test(ref)) continue;
770
+ if (!map.has(sha)) map.set(sha, ref);
771
+ }
772
+ }
773
+ safety.lsCache.set(key, map);
774
+ return map;
775
+ }
776
+
777
+ // Does a qualifying remote hold a branch or tag at exactly this commit,
778
+ // right now? This only decides whether --backup can skip a tag; archive
779
+ // never depends on it, so it stays deliberately simple — no ancestry
780
+ // walk, no fetch, nothing written locally.
781
+ function tipSafety(safety, repoDir, sha) {
782
+ for (const remote of safety.remotes(repoDir)) {
783
+ if (!remoteQualifies(safety, repoDir, remote)) continue;
784
+ const refs = remoteRefs(safety, repoDir, remote);
785
+ if (!refs) continue;
786
+ const exact = refs.get(sha);
787
+ if (exact) return { safe: true, by: 'remote', remote, ref: exact };
788
+ }
789
+ return { safe: false };
790
+ }
791
+
792
+ function makeSafety(gitFn, rootDir) {
793
+ const safety = {
794
+ gitFn,
795
+ rootDir,
796
+ lsCache: new Map(),
797
+ qualCache: new Map(),
798
+ remotesCache: new Map(),
799
+ remotes(repoDir) {
800
+ if (!safety.remotesCache.has(repoDir)) {
801
+ const res = run(gitFn, repoDir, ['remote']);
802
+ safety.remotesCache.set(repoDir, res.status === 0 ? okLines(res) : []);
803
+ }
804
+ return safety.remotesCache.get(repoDir);
805
+ },
806
+ };
807
+ return safety;
808
+ }
809
+
810
+ // The real path of a worktree's shared git directory — which repository
811
+ // it belongs to.
812
+ function commonDirOf(gitFn, wtPath) {
813
+ const res = run(gitFn, wtPath, ['rev-parse', '--git-common-dir']);
814
+ if (res.status !== 0) return null;
815
+ return realPath(resolve(wtPath, String(res.stdout).trim()));
816
+ }
817
+
818
+ // The tips a session holds, for --backup: the session branch in the
819
+ // workspace repo and in every project repo it names (the tracker's
820
+ // branch: and repos:, falling back to the workspace worktree's branch and
821
+ // the directories under workspace/repos/), plus every worktree's current
822
+ // HEAD, branch or detached. Repo names that are not single in-root
823
+ // segments are skipped here — git is never run in them — and reported by
824
+ // validateTrackerShape.
825
+ function sessionTips(gitFn, rootDir, folder) {
826
+ const wsDir = join(folder, 'workspace');
827
+ const tracker = readTracker(wsDir);
828
+ const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
829
+ const worktrees = rawWorktrees.map((w) => ({
830
+ ...w,
831
+ rootDir,
832
+ branch: w.kind === 'foreign' ? null : currentBranch(gitFn, w.path),
833
+ head: w.kind === 'foreign' ? null : headSha(gitFn, w.path),
834
+ commonDir: w.kind === 'foreign' ? null : commonDirOf(gitFn, w.path),
835
+ }));
836
+
837
+ let branch = tracker?.branch || null;
838
+ let repos = tracker ? [...tracker.repos] : [];
839
+ const discovered = repos.length === 0;
840
+ if (discovered) {
841
+ const nested = join(wsDir, 'repos');
842
+ if (existsSync(nested)) {
843
+ // Mirror cleanup's discovery: every entry that statSync reports as
844
+ // a directory (symlinks to directories included). Entries that are
845
+ // not really worktrees are caught by the structure allowlist, but
846
+ // the deletion set stays a superset either way.
847
+ repos = readdirSync(nested).filter((n) => {
848
+ try {
849
+ return statSync(join(nested, n)).isDirectory();
850
+ } catch {
851
+ return false;
852
+ }
853
+ });
854
+ }
855
+ }
856
+ if (!branch) {
857
+ const wsWt = worktrees.find((w) => w.kind === 'workspace');
858
+ branch = wsWt ? wsWt.branch : null;
859
+ }
860
+
861
+ // Tips: the branch ref in every repo cleanup will delete it from, plus
862
+ // every worktree HEAD. Deduped per repo+sha, branch names preferred.
863
+ const tips = [];
864
+ const seen = new Map();
865
+ const addTip = (repoDir, repo, kind, ref, sha) => {
866
+ if (!sha || !existsSync(repoDir)) return;
867
+ const key = `${repoDir}\0${sha}`;
868
+ const prev = seen.get(key);
869
+ if (prev) {
870
+ if (!prev.ref && ref) prev.ref = ref;
871
+ return;
872
+ }
873
+ const tip = { repoDir, repo, kind, ref: ref ?? null, sha };
874
+ seen.set(key, tip);
875
+ tips.push(tip);
876
+ };
877
+ if (branch) {
878
+ addTip(rootDir, WORKSPACE_REPO, 'workspace', branch, branchTip(gitFn, rootDir, branch));
879
+ const reposRoot = realPath(join(rootDir, 'repos'));
880
+ for (const repo of repos) {
881
+ if (!isRepoSegment(repo)) continue;
882
+ const repoDir = join(rootDir, 'repos', repo);
883
+ if (!insideDir(reposRoot, repoDir)) continue;
884
+ addTip(repoDir, repo, 'project', branch, branchTip(gitFn, repoDir, branch));
885
+ }
886
+ }
887
+ for (const wt of worktrees) {
888
+ if (wt.kind === 'foreign') continue;
889
+ addTip(realPath(wt.commonDir ? dirname(wt.commonDir) : wt.path), wt.repo, wt.kind, wt.branch, wt.head);
890
+ }
891
+
892
+ return { folder, branch, repos, discovered, tracker, worktrees, tips };
893
+ }
894
+
895
+ function branchTip(gitFn, repoDir, branch) {
896
+ const res = run(gitFn, repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
897
+ return res.status === 0 ? String(res.stdout).trim() : null;
898
+ }
899
+
900
+ // The tracker is data from a branch — never trusted as a path or a ref
901
+ // name. Repo names must be single in-root segments, and the branch must
902
+ // satisfy git's own ref format, before --backup acts on either.
903
+ function validateTrackerShape(gitFn, rootDir, del) {
904
+ const reasons = [];
905
+ if (del.branch) {
906
+ const res = gitFn('git', ['check-ref-format', '--branch', del.branch], { encoding: 'utf8' });
907
+ if (res.error || res.status !== 0) {
908
+ reasons.push(`tracker branch "${del.branch}" is not a valid branch name — reconcile the tracker first`);
909
+ }
910
+ }
911
+ const reposRoot = realPath(join(rootDir, 'repos'));
912
+ for (const repo of del.repos) {
913
+ if (!isRepoSegment(repo)) {
914
+ reasons.push(`tracker repos entry "${repo}" is not a single path segment — reconcile the tracker first`);
915
+ continue;
916
+ }
917
+ if (!insideDir(reposRoot, join(rootDir, 'repos', repo))) {
918
+ reasons.push(`tracker repos entry "${repo}" escapes ${relative(rootDir, join(rootDir, 'repos'))}/ — reconcile the tracker first`);
919
+ }
920
+ }
921
+ return reasons;
922
+ }
923
+
924
+ // --backup acts only on a session of the known shape: workspace/ a
925
+ // worktree of the workspace repo, each workspace/repos/{name} a worktree
926
+ // of root/repos/{name}, tracker consistent with both. Anything else is
927
+ // refused by name and left for the operator (archive still works — it
928
+ // keeps everything regardless of shape).
929
+ function structureReasons(gitFn, rootDir, folder, del) {
930
+ const reasons = [];
931
+ const wsDir = join(folder, 'workspace');
932
+
933
+ for (const entry of readdirSync(folder)) {
934
+ if (entry !== 'workspace') {
935
+ reasons.push(`unexpected entry "${entry}" beside workspace/ — a session folder holds only workspace/; reconcile first`);
936
+ }
937
+ }
938
+
939
+ const wsWt = del.worktrees.find((w) => w.kind === 'workspace');
940
+ if (!wsWt) {
941
+ reasons.push('workspace/ is not a git worktree — reconcile first');
942
+ } else if (!wsWt.commonDir || wsWt.commonDir !== realPath(join(rootDir, '.git'))) {
943
+ reasons.push(`workspace/ is not a worktree of the workspace repo (common dir ${wsWt.commonDir ?? 'unknown'}) — reconcile first`);
944
+ }
945
+
946
+ const nestedDir = join(wsDir, 'repos');
947
+ const nestedNames = [];
948
+ if (existsSync(nestedDir)) {
949
+ for (const entry of readdirSync(nestedDir, { withFileTypes: true })) {
950
+ const p = join(nestedDir, entry.name);
951
+ if (entry.isSymbolicLink()) {
952
+ reasons.push(`repos/${entry.name} is a symlink, not a worktree — reconcile first`);
953
+ continue;
954
+ }
955
+ if (entry.isFile()) {
956
+ reasons.push(`repos/${entry.name} is a file, not a worktree — reconcile first`);
957
+ continue;
958
+ }
959
+ if (!entry.isDirectory()) continue; // sockets/fifos and the like: unreadable, refuse via the file branch if git ever lists them
960
+ if (!existsSync(join(p, '.git'))) {
961
+ reasons.push(`repos/${entry.name} is a plain directory, not a worktree — reconcile first`);
962
+ continue;
963
+ }
964
+ const expected = realPath(join(rootDir, 'repos', entry.name, '.git'));
965
+ const common = commonDirOf(gitFn, p);
966
+ if (!common || common !== expected) {
967
+ reasons.push(`repos/${entry.name} is not a worktree of repos/${entry.name} (plain clone or foreign repository; common dir ${common ?? 'unknown'}) — reconcile first`);
968
+ continue;
969
+ }
970
+ nestedNames.push(entry.name);
971
+ }
972
+ }
973
+
974
+ if (del.tracker && del.tracker.repos.length > 0) {
975
+ const tracked = [...del.tracker.repos].map(String).sort();
976
+ if (JSON.stringify(tracked) !== JSON.stringify([...nestedNames].sort())) {
977
+ reasons.push(`tracker repos [${tracked.join(', ')}] do not match the nested worktrees [${[...nestedNames].sort().join(', ')}] — reconcile the tracker first`);
978
+ }
979
+ }
980
+
981
+ if (del.tracker?.branch) {
982
+ for (const w of del.worktrees) {
983
+ if (w.kind !== 'foreign' && w.branch && w.branch !== del.tracker.branch) {
984
+ reasons.push(`tracker says branch ${del.tracker.branch} but the ${w.kind} worktree is on ${w.branch} (drift) — reconcile the tracker first`);
985
+ break;
986
+ }
987
+ }
988
+ }
989
+ return reasons;
990
+ }
991
+
992
+ // Guards shared by every acting mode: the session folder must be a real
993
+ // directory inside the root (a symlinked entry may point anywhere), and
994
+ // the session hosting the current chat is never acted on from inside
995
+ // itself — on Windows a directory that is a live process's cwd cannot
996
+ // even be renamed.
997
+ function sessionFolderGuards(rootDir, sessionsDir, session, cwd) {
998
+ const folder = join(sessionsDir, session);
999
+ if (!existsSync(folder)) {
1000
+ return { folder, refusal: { refused: true, reasons: [`no session named "${session}" under ${relative(rootDir, sessionsDir)}`] } };
1001
+ }
1002
+ let st = null;
1003
+ try { st = lstatSync(folder); } catch { /* handled below */ }
1004
+ if (!st || !st.isDirectory() || st.isSymbolicLink()) {
1005
+ return { folder, refusal: { refused: true, reasons: [`session entry "${session}" is not a real directory (symlink or missing) — never followed; reconcile manually`] } };
1006
+ }
1007
+ if (!insideRoot(rootDir, folder)) {
1008
+ return { folder, refusal: { refused: true, reasons: [`session folder "${session}" resolves outside the workspace root — refusing`] } };
1009
+ }
1010
+ const cwdReal = realPath(resolve(cwd || '.'));
1011
+ if (cwdReal.startsWith(realPath(folder) + sep) || cwdReal === realPath(folder)) {
1012
+ return { folder, refusal: { refused: true, reasons: [`session "${session}" hosts the current chat — run its backup/archive from the workspace root, not from inside the session`] } };
1013
+ }
1014
+ return { folder, refusal: null };
1015
+ }
1016
+
1017
+ // The local peeled SHA of an existing tag, or null when the tag is
1018
+ // absent. Refnames cannot contain ^{ (check-ref-format forbids ^), so
1019
+ // appending ^{} to interpolate the peel is safe.
1020
+ function peeledTagSha(gitFn, cwd, tag) {
1021
+ const res = run(gitFn, cwd, ['rev-parse', '-q', '--verify', `refs/tags/${tag}^{}`]);
1022
+ return res.status === 0 ? String(res.stdout).trim() : null;
1023
+ }
1024
+
1025
+ // Does {tag} exist on {remote} at exactly {commit}? Deliberately no
1026
+ // refspec pattern: a pattern filters out the peeled `^{}` line (the
1027
+ // tag object's sha is not the commit's), and the peeled line is exactly
1028
+ // what "at this commit" needs. An annotated tag answers via the peel; a
1029
+ // lightweight tag's only line already is the commit.
1030
+ function tagOnRemoteAt(gitFn, cwd, remote, tag, commit) {
1031
+ const res = gitFn('git', ['-C', cwd, 'ls-remote', '--tags', remote], netOpts(LS_REMOTE_TIMEOUT_MS));
1032
+ if (res.error || res.status !== 0) return false;
1033
+ const peeledRef = `refs/tags/${tag}^{}`;
1034
+ const plainRef = `refs/tags/${tag}`;
1035
+ let plainSha = false;
1036
+ for (const line of okLines(res)) {
1037
+ const [sha, ref] = line.trim().split(/\s+/);
1038
+ if (ref === peeledRef) return sha === commit;
1039
+ if (ref === plainRef) plainSha = sha;
1040
+ }
1041
+ return plainSha === commit;
1042
+ }
1043
+
1044
+ // S4: where a backup tag is pushed — branch.<b>.pushRemote, then
1045
+ // remote.pushDefault, then branch.<b>.remote, then origin, then the
1046
+ // first configured remote. An explicit --remote overrides everything.
1047
+ function resolvePushRemote(gitFn, repoDir, branch, override, remotes) {
1048
+ if (override) {
1049
+ return remotes.includes(override)
1050
+ ? { remote: override }
1051
+ : { remote: null, reason: `--remote ${override} is not configured for this repo (has: ${remotes.join(', ') || 'none'})` };
1052
+ }
1053
+ const cfg = (key) => {
1054
+ const res = run(gitFn, repoDir, ['config', '--get', key]);
1055
+ return res.status === 0 ? String(res.stdout).trim() : null;
1056
+ };
1057
+ const chain = branch
1058
+ ? [`branch.${branch}.pushRemote`, 'remote.pushDefault', `branch.${branch}.remote`]
1059
+ : ['remote.pushDefault'];
1060
+ for (const key of chain) {
1061
+ const value = cfg(key);
1062
+ if (value && remotes.includes(value)) return { remote: value };
1063
+ }
1064
+ if (remotes.includes('origin')) return { remote: 'origin' };
1065
+ if (remotes.length > 0) return { remote: remotes[0] };
1066
+ return { remote: null };
1067
+ }
1068
+
1069
+ /**
1070
+ * Back up every tip the session holds that no qualifying remote already
1071
+ * has as a branch or tag: an annotated, session-scoped `drain/{session}/…`
1072
+ * tag at the tip, pushed to the resolved remote and verified there — an
1073
+ * off-machine copy of the session's commits before it is archived, and
1074
+ * the thing to check before the operator ever deletes an archive. With dryRun the
1075
+ * plan is reported per tip (remote, tag, already-safe) with no side
1076
+ * effects — backup is a decision, and the operator sees exactly what
1077
+ * would be pushed where before saying yes. Idempotent — an existing tag
1078
+ * at the same commit is fine; at a different commit (the branch moved)
1079
+ * the operator must decide, so it refuses.
1080
+ */
1081
+ function backupSession(root, { session, remote: remoteOverride = null, dryRun = false, gitFn = spawnSync, cwd = process.cwd() } = {}) {
1082
+ const rootDir = resolveRoot(root);
1083
+ if (!isSessionSegment(session)) {
1084
+ throw new Error(`session name must be a single path segment, got: ${session}`);
1085
+ }
1086
+ const sessionsDir = sessionsDirOf(rootDir);
1087
+ const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
1088
+ if (refusal) return refusal;
1089
+
1090
+ const del = sessionTips(gitFn, rootDir, folder);
1091
+ const reasons = [...validateTrackerShape(gitFn, rootDir, del)];
1092
+ if (existsSync(join(folder, 'workspace', '.git'))) {
1093
+ reasons.push(...structureReasons(gitFn, rootDir, folder, del));
1094
+ }
1095
+ const branches = [];
1096
+ const skipped = [];
1097
+ const planned = [];
1098
+ if (reasons.length === 0 && existsSync(join(folder, 'workspace', '.git'))) {
1099
+ const safety = makeSafety(gitFn, rootDir);
1100
+ for (const tip of del.tips) {
1101
+ const verdict = tipSafety(safety, tip.repoDir, tip.sha);
1102
+ if (verdict.safe) {
1103
+ // Already provably on a qualifying remote — pushing a tag for it
1104
+ // would only add noise (and possibly land in a public repo).
1105
+ skipped.push({ repo: tip.repo, ref: tip.ref ?? 'HEAD', commit: tip.sha, safeOn: verdict.remote, safeRef: verdict.ref });
1106
+ continue;
1107
+ }
1108
+ const shortSha = tip.sha.slice(0, 10);
1109
+ const tagName = tip.ref
1110
+ ? `drain/${session}/${slugForBranch(tip.ref)}`
1111
+ : `drain/${session}/${tip.kind === 'workspace' ? 'workspace' : tip.repo}-detached-${shortSha}`;
1112
+ const tagDir = tip.repoDir;
1113
+ const remotes = safety.remotes(tagDir);
1114
+ if (remotes.length === 0) {
1115
+ reasons.push(`${repoLabel(tip)} has no remote to push the backup tag ${tagName} to — decide manually where to back up ${tip.ref ?? shortSha}`);
1116
+ continue;
1117
+ }
1118
+ const { remote, reason } = resolvePushRemote(gitFn, tagDir, tip.ref, remoteOverride, remotes);
1119
+ if (!remote) {
1120
+ reasons.push(reason || `${repoLabel(tip)}: no push remote resolves — decide manually`);
1121
+ continue;
1122
+ }
1123
+ const existing = peeledTagSha(gitFn, tagDir, tagName);
1124
+ if (existing && existing !== tip.sha) {
1125
+ reasons.push(`${repoLabel(tip)}: tag ${tagName} already points at ${existing.slice(0, 10)}…, not the current tip (${shortSha}…); the branch moved — decide manually`);
1126
+ continue;
1127
+ }
1128
+ if (dryRun) {
1129
+ planned.push({
1130
+ repo: tip.repo,
1131
+ branch: tip.ref,
1132
+ detached: !tip.ref,
1133
+ commit: tip.sha,
1134
+ tag: tagName,
1135
+ remote,
1136
+ alreadySafe: false,
1137
+ wouldCreate: !existing,
1138
+ });
1139
+ continue;
1140
+ }
1141
+ let createdThisRun = false;
1142
+ if (!existing) {
1143
+ const created = run(gitFn, tagDir, ['tag', '-a', tagName, '-m', `backup before draining session ${session}`, tip.sha]);
1144
+ if (created.status !== 0) {
1145
+ reasons.push(`${repoLabel(tip)}: git tag ${tagName} failed: ${String(created.stderr || '').trim()}`);
1146
+ continue;
1147
+ }
1148
+ createdThisRun = true;
1149
+ }
1150
+ const pushed = gitFn('git', ['-C', tagDir, 'push', remote, `refs/tags/${tagName}`], netOpts(PUSH_TIMEOUT_MS));
1151
+ if (pushed.error || pushed.status !== 0) {
1152
+ // A local tag that never reached a remote masquerades as a
1153
+ // backup (and local refs prove nothing) — remove the one we made.
1154
+ if (createdThisRun) run(gitFn, tagDir, ['tag', '-d', tagName]);
1155
+ const detail = pushed.error ? `timed out after ${PUSH_TIMEOUT_MS / 1000}s` : String(pushed.stderr || '').trim();
1156
+ reasons.push(`${repoLabel(tip)}: pushing ${tagName} to ${remote} failed (${detail}) — treat ${tip.ref ?? shortSha} as unbacked`);
1157
+ continue;
1158
+ }
1159
+ const verified = tagOnRemoteAt(gitFn, tagDir, remote, tagName, tip.sha);
1160
+ if (!verified) {
1161
+ if (createdThisRun) run(gitFn, tagDir, ['tag', '-d', tagName]);
1162
+ reasons.push(`${repoLabel(tip)}: tag ${tagName} not found on ${remote} after pushing — treat ${tip.ref ?? shortSha} as unbacked`);
1163
+ continue;
1164
+ }
1165
+ branches.push({
1166
+ repo: tip.repo,
1167
+ branch: tip.ref,
1168
+ detached: !tip.ref,
1169
+ tag: tagName,
1170
+ commit: tip.sha,
1171
+ remote,
1172
+ pushed: true,
1173
+ verified: true,
1174
+ });
1175
+ }
1176
+ }
1177
+ if (reasons.length > 0) return { refused: true, reasons };
1178
+ if (dryRun) return { session, dryRun: true, tips: planned, skipped };
1179
+ return { session, branches, skipped };
1180
+ }
1181
+
1182
+ // Walk a session folder, never following a symlink, and report what
1183
+ // archive needs to know: every `.git` FILE (a linked worktree or a
1184
+ // submodule checkout — its gitdir: line names the owner), every relative
1185
+ // symlink whose target leaves the folder (it will dangle once the folder
1186
+ // sits one level deeper), and every directory it could not read. An
1187
+ // unread directory could hide a worktree, so it refuses the archive
1188
+ // rather than being skipped. Embedded repositories (`.git` DIRECTORIES)
1189
+ // need no bookkeeping — they move with the folder.
1190
+ function scanSessionFolder(folder) {
1191
+ const out = { markers: [], unreadable: [], outwardLinks: [] };
1192
+ const walk = (dir) => {
1193
+ let entries;
1194
+ try {
1195
+ entries = readdirSync(dir, { withFileTypes: true });
1196
+ } catch (err) {
1197
+ out.unreadable.push({ dir, code: err.code || err.message });
1198
+ return;
1199
+ }
1200
+ for (const entry of entries) {
1201
+ const p = join(dir, entry.name);
1202
+ if (entry.isSymbolicLink()) {
1203
+ try {
1204
+ const target = readlinkSync(p);
1205
+ if (!isAbsolute(target) && !insideDir(realPath(folder), resolve(dir, target))) out.outwardLinks.push(p);
1206
+ } catch { /* unreadable link: it moves as-is */ }
1207
+ continue;
1208
+ }
1209
+ if (entry.name === '.git') {
1210
+ if (entry.isFile()) out.markers.push(p);
1211
+ continue; // never descend into a git directory
1212
+ }
1213
+ if (entry.isDirectory()) walk(p);
1214
+ }
1215
+ };
1216
+ walk(folder);
1217
+ return out;
1218
+ }
1219
+
1220
+ // The admin directory a `.git` file points at, or null.
1221
+ function gitdirOfMarker(markerPath) {
1222
+ try {
1223
+ const m = /^gitdir:\s*(.+)\s*$/m.exec(readFileSync(markerPath, 'utf8'));
1224
+ return m ? realPath(resolve(dirname(markerPath), m[1].trim())) : null;
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1229
+
1230
+ // The repositories this workspace owns: the workspace repo and every
1231
+ // root/repos/{name} clone. Only these are ever repaired.
1232
+ function workspaceRepos(rootDir) {
1233
+ const repos = [{ repo: WORKSPACE_REPO, dir: rootDir, gitDir: realPath(join(rootDir, '.git')) }];
1234
+ const reposRoot = join(rootDir, 'repos');
1235
+ if (existsSync(reposRoot)) {
1236
+ for (const entry of readdirSync(reposRoot, { withFileTypes: true })) {
1237
+ if (!entry.isDirectory() || !isRepoSegment(entry.name)) continue;
1238
+ const dir = join(reposRoot, entry.name);
1239
+ if (existsSync(join(dir, '.git'))) repos.push({ repo: entry.name, dir, gitDir: realPath(join(dir, '.git')) });
1240
+ }
1241
+ }
1242
+ return repos;
1243
+ }
1244
+
1245
+ // Every worktree path a repository has registered, from git itself —
1246
+ // the authority the folder walk is checked against.
1247
+ function registeredWorktrees(gitFn, repoDir) {
1248
+ const res = run(gitFn, repoDir, ['worktree', 'list', '--porcelain']);
1249
+ if (res.status !== 0) return null;
1250
+ return okLines(res).filter((l) => l.startsWith('worktree ')).map((l) => realPath(l.slice(9)));
1251
+ }
1252
+
1253
+ // Registered worktrees git would prune — their recorded path no longer
1254
+ // exists. Compared before and after a move, a new entry means a link the
1255
+ // move broke.
1256
+ function prunable(gitFn, repoDir) {
1257
+ const res = run(gitFn, repoDir, ['worktree', 'prune', '--dry-run', '-v']);
1258
+ if (res.status !== 0) return null;
1259
+ return okLines(res);
1260
+ }
1261
+
1262
+ function archiveStamp(now) {
1263
+ return new Date(now).toISOString().replace(/[-:]/g, '').replace(/\..*$/, '');
1264
+ }
1265
+
1266
+ // Point each owning repository at its worktrees' current location, then
1267
+ // prove it: every worktree resolves to itself, its owner lists it there,
1268
+ // and no owned repository has a newly prunable entry. Git failures of
1269
+ // any kind — including a thrown spawn error — become problems, never
1270
+ // exceptions, so the caller can always roll back.
1271
+ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
1272
+ const problems = [];
1273
+ try {
1274
+ const byRepo = new Map();
1275
+ for (const w of worktreePaths) {
1276
+ if (!byRepo.has(w.owner.dir)) byRepo.set(w.owner.dir, []);
1277
+ byRepo.get(w.owner.dir).push(w.path);
1278
+ }
1279
+ for (const [repoDir, paths] of byRepo) {
1280
+ const res = run(gitFn, repoDir, ['worktree', 'repair', ...paths]);
1281
+ if (res.error || res.status !== 0) problems.push(`git worktree repair in ${repoDir} failed: ${String(res.stderr || res.error?.message || '').trim()}`);
1282
+ }
1283
+ for (const w of worktreePaths) {
1284
+ const top = run(gitFn, w.path, ['rev-parse', '--show-toplevel']);
1285
+ if (top.error || top.status !== 0 || realPath(String(top.stdout).trim()) !== realPath(w.path)) {
1286
+ problems.push(`worktree at ${w.path} does not resolve to itself after repair`);
1287
+ continue;
1288
+ }
1289
+ const listed = registeredWorktrees(gitFn, w.owner.dir);
1290
+ if (!listed || !listed.includes(realPath(w.path))) problems.push(`${w.owner.dir} does not list the worktree at ${w.path}`);
1291
+ }
1292
+ for (const o of owned) {
1293
+ const after = prunable(gitFn, o.dir);
1294
+ if (after === null) { problems.push(`could not check ${o.dir} for broken worktree links`); continue; }
1295
+ const before = new Set(prunableBefore.get(o.dir) || []);
1296
+ const fresh = after.filter((l) => !before.has(l));
1297
+ if (fresh.length > 0) problems.push(`${o.dir} has broken worktree links: ${fresh.join('; ')}`);
1298
+ }
1299
+ } catch (err) {
1300
+ problems.push(`git failed while repairing: ${err.message}`);
1301
+ }
1302
+ return problems;
1303
+ }
1304
+
1305
+ /**
1306
+ * Take a session out of the active lifecycle without destroying anything:
1307
+ * rename its folder into {sessions}/.archived/{session}--{stamp}/ and
1308
+ * repair git's worktree links so every repository follows it. Every
1309
+ * file, ref, stash, index flag and embedded repository is kept, because
1310
+ * nothing is deleted. The session's branches stay checked out in the
1311
+ * archived worktrees until the operator removes them.
1312
+ *
1313
+ * Refused, with nothing moved, when: a directory in the folder cannot be
1314
+ * read (it could hide a worktree); a worktree belongs to a repository
1315
+ * outside this workspace; a submodule checkout is present (its link is
1316
+ * not a worktree link and `worktree repair` cannot fix it); git's own
1317
+ * list of worktrees names one under the folder that the walk did not
1318
+ * find; or the archive directory is a symlink or resolves outside the
1319
+ * workspace. If anything fails after the rename, the folder is renamed
1320
+ * back and repaired, and the result reports the verified state.
1321
+ */
1322
+ function archiveSession(root, { session, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1323
+ const rootDir = resolveRoot(root);
1324
+ if (!isSessionSegment(session)) {
1325
+ throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
1326
+ }
1327
+ const sessionsDir = sessionsDirOf(rootDir);
1328
+ const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
1329
+ if (refusal) return refusal;
1330
+
1331
+ const owned = workspaceRepos(rootDir);
1332
+ const ownedByGitDir = new Map(owned.map((o) => [o.gitDir, o]));
1333
+ const scan = scanSessionFolder(folder);
1334
+ const reasons = scan.unreadable.map((u) => `cannot read ${relative(rootDir, u.dir)} (${u.code}) — it could hide a worktree; fix its permissions and retry`);
1335
+ const found = [];
1336
+ for (const marker of scan.markers) {
1337
+ const admin = gitdirOfMarker(marker);
1338
+ const wtPath = dirname(marker);
1339
+ if (!admin) {
1340
+ reasons.push(`${relative(rootDir, marker)} has no readable gitdir — reconcile it manually first`);
1341
+ continue;
1342
+ }
1343
+ // A linked worktree's admin dir is exactly {gitDir}/worktrees/{id}.
1344
+ const owner = ownedByGitDir.get(dirname(dirname(admin)));
1345
+ if (owner && dirname(admin) === join(owner.gitDir, 'worktrees')) {
1346
+ found.push({ rel: relative(folder, wtPath) || '.', owner });
1347
+ continue;
1348
+ }
1349
+ const insideOwned = owned.some((o) => insideDir(o.gitDir, admin));
1350
+ reasons.push(insideOwned
1351
+ ? `${relative(rootDir, wtPath)} is a submodule checkout — moving it would break its link, and worktree repair cannot fix that; finish or keep this session instead`
1352
+ : `${relative(rootDir, wtPath)} is a worktree of a repository outside this workspace (${admin}) — refusing to touch it; move or remove it manually`);
1353
+ }
1354
+ // Git is the authority on which worktrees exist: every one it has
1355
+ // registered under this folder must be one the walk found.
1356
+ const folderReal = realPath(folder);
1357
+ const foundPaths = new Set(found.map((f) => realPath(f.rel === '.' ? folder : join(folder, f.rel))));
1358
+ for (const o of owned) {
1359
+ const listed = registeredWorktrees(gitFn, o.dir);
1360
+ if (listed === null) { reasons.push(`could not list worktrees of ${relative(rootDir, o.dir) || '.'} — refusing rather than guessing`); continue; }
1361
+ for (const p of listed) {
1362
+ if ((p === folderReal || p.startsWith(folderReal + sep)) && !foundPaths.has(p)) {
1363
+ reasons.push(`${relative(rootDir, o.dir) || 'the workspace repo'} has a worktree at ${relative(rootDir, p)} that the folder scan could not see — reconcile it first`);
1364
+ }
1365
+ }
1366
+ }
1367
+ if (reasons.length > 0) return { refused: true, reasons };
1368
+
1369
+ const archiveDir = join(sessionsDir, '.archived');
1370
+ let ast = null;
1371
+ try { ast = lstatSync(archiveDir); } catch { /* absent: created below */ }
1372
+ if (ast && (ast.isSymbolicLink() || !ast.isDirectory())) {
1373
+ return { refused: true, reasons: [`${relative(rootDir, archiveDir)} exists but is not a real directory (symlink or file) — archives must stay inside the workspace; move it aside first`] };
1374
+ }
1375
+ mkdirSync(archiveDir, { recursive: true });
1376
+ if (realPath(archiveDir) !== join(realPath(sessionsDir), '.archived') || !insideRoot(rootDir, archiveDir)) {
1377
+ return { refused: true, reasons: [`${relative(rootDir, archiveDir)} resolves outside the workspace — refusing`] };
1378
+ }
1379
+ const dest = join(archiveDir, `${session}--${archiveStamp(now)}`);
1380
+ if (existsSync(dest)) {
1381
+ return { refused: true, reasons: [`${relative(rootDir, dest)} already exists — wait a second and retry`] };
1382
+ }
1383
+
1384
+ const prunableBefore = new Map(owned.map((o) => [o.dir, prunable(gitFn, o.dir) || []]));
1385
+ try {
1386
+ renameSync(folder, dest);
1387
+ } catch (err) {
1388
+ return { refused: true, reasons: [`could not move ${relative(rootDir, folder)}: ${err.code || err.message} — close anything using files in it (editors, terminals, dev servers) and retry`] };
1389
+ }
1390
+
1391
+ const at = (base) => found.map((f) => ({ owner: f.owner, path: f.rel === '.' ? base : join(base, f.rel) }));
1392
+ const problems = repairAndVerify(gitFn, owned, at(dest), prunableBefore);
1393
+ if (problems.length > 0) {
1394
+ let restored = false;
1395
+ try { renameSync(dest, folder); restored = true; } catch { /* reported below */ }
1396
+ const where = restored ? folder : dest;
1397
+ const after = repairAndVerify(gitFn, owned, at(where), prunableBefore);
1398
+ const state = after.length === 0
1399
+ ? `the session is ${restored ? 'back at' : 'still at'} ${relative(rootDir, where)} with every worktree link verified; nothing was lost`
1400
+ : `the session is at ${relative(rootDir, where)}, but some worktree links are still broken (${after.join('; ')}); nothing has been deleted — do NOT run \`git worktree prune\`; run \`git -C <repo> worktree repair <path>\` for each worktree under ${relative(rootDir, where)}`;
1401
+ return { refused: true, reasons: [...problems, state] };
1402
+ }
1403
+
1404
+ return {
1405
+ session,
1406
+ archived: true,
1407
+ from: relative(rootDir, folder),
1408
+ to: relative(rootDir, dest),
1409
+ worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
1410
+ warnings: scan.outwardLinks.map((l) => `relative symlink ${relative(rootDir, join(dest, relative(folder, l)))} pointed outside the session and no longer resolves after the move — it was kept as-is`),
1411
+ };
1412
+ }
1413
+
1414
+ // The launcher root is the main worktree of the workspace repo; every
1415
+ // other checkout is a linked worktree. Acting modes require the
1416
+ // launcher; --enable-task-model is the exception (it edits workspace.json
1417
+ // inside a task worktree by design — S6).
1418
+ function isLinkedWorktree(gitFn, rootDir) {
1419
+ const gitDir = run(gitFn, rootDir, ['rev-parse', '--git-dir']);
1420
+ const commonDir = run(gitFn, rootDir, ['rev-parse', '--git-common-dir']);
1421
+ if (gitDir.status !== 0 || commonDir.status !== 0) return false; // not a git repo at all; other checks will fail loudly
1422
+ return realPath(resolve(rootDir, String(gitDir.stdout).trim()))
1423
+ !== realPath(resolve(rootDir, String(commonDir.stdout).trim()));
1424
+ }
1425
+
1426
+ /**
1427
+ * Switch the workspace to the task model: flip workspace.sessionModel to
1428
+ * "task", keeping every other key untouched. From the launcher this also
1429
+ * reports the remaining sessions; from a task worktree (the S6 flow)
1430
+ * there is no sessions directory to read — remainingSessions is null and
1431
+ * the note says where the real list comes from.
1432
+ */
1433
+ function enableTaskModel(root, { gitFn = spawnSync } = {}) {
1434
+ const rootDir = resolveRoot(root);
1435
+ const cfgPath = join(rootDir, 'workspace.json');
1436
+ let cfg;
1437
+ try {
1438
+ cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
1439
+ } catch (err) {
1440
+ throw new Error(`workspace.json unreadable: ${err.message}`);
1441
+ }
1442
+ // Spread-then-set keeps an existing sessionModel in place and every
1443
+ // other key untouched; 2-space JSON with a trailing newline is the
1444
+ // file's house format.
1445
+ cfg.workspace = { ...(cfg.workspace || {}), sessionModel: 'task' };
1446
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
1447
+ const linked = isLinkedWorktree(gitFn, rootDir);
1448
+ return linked
1449
+ ? {
1450
+ sessionModel: 'task',
1451
+ remainingSessions: null,
1452
+ note: 'run from a task worktree — remaining sessions come from --inventory at the launcher root',
1453
+ }
1454
+ : { sessionModel: 'task', remainingSessions: listSessionNames(rootDir) };
1455
+ }
1456
+
1457
+ // Human-readable inventory rendering for stderr — the operator's table;
1458
+ // the JSON on stdout is the machine copy.
1459
+ function renderTable(result) {
1460
+ const lines = [
1461
+ `${result.sessions.length} session(s); proposals are proposals — the operator decides each one.`,
1462
+ ];
1463
+ for (const s of result.sessions) {
1464
+ lines.push('');
1465
+ lines.push(`${s.name} ${s.kind === 'broken' ? 'broken shell' : s.kind === 'foreign' ? 'foreign entry' : s.proposal}` +
1466
+ (s.kind === 'broken' || s.kind === 'foreign' ? '' : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'})`));
1467
+ for (const w of s.worktrees || []) {
1468
+ const remotes = Object.entries(w.remotes)
1469
+ .map(([r, v]) => describeRemote(r, v))
1470
+ .join(' ') || 'no remotes';
1471
+ const extra = w.kind === 'workspace' ? ` content:${w.contentFiles}` : '';
1472
+ lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} [${remotes}]`);
1473
+ }
1474
+ for (const r of s.reasons || []) lines.push(` · ${r}`);
1475
+ for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
1476
+ }
1477
+ return `${lines.join('\n')}\n`;
1478
+ }
1479
+
1480
+ const MODE_FLAGS = new Set(['--inventory', '--backup', '--archive', '--enable-task-model']);
1481
+ const VALUE_FLAGS = new Map([
1482
+ ['--root', 'root'],
1483
+ ['--session', 'session'],
1484
+ ['--active-days', 'activeDays'],
1485
+ ['--remote', 'remote'],
1486
+ ]);
1487
+
1488
+ function parseArgs(argv) {
1489
+ const args = { root: '.', mode: null, session: null, activeDays: null, remote: null, dryRun: false };
1490
+ const rest = argv.slice(2);
1491
+ for (let i = 0; i < rest.length; i += 1) {
1492
+ const a = rest[i];
1493
+ if (MODE_FLAGS.has(a)) {
1494
+ if (args.mode) throw new Error(`only one mode flag may be given (already have --${args.mode})`);
1495
+ args.mode = a.slice(2);
1496
+ continue;
1497
+ }
1498
+ if (a === '--dry-run') { args.dryRun = true; continue; }
1499
+ const key = VALUE_FLAGS.get(a);
1500
+ if (key) {
1501
+ const v = rest[i + 1];
1502
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value`);
1503
+ args[key] = v;
1504
+ i += 1;
1505
+ continue;
1506
+ }
1507
+ throw new Error(`unknown argument: ${a}`);
1508
+ }
1509
+ if (!args.mode) throw new Error('one of --inventory, --backup, --archive, --enable-task-model is required');
1510
+ if ((args.mode === 'backup' || args.mode === 'archive') && !args.session) {
1511
+ throw new Error(`--${args.mode} requires --session`);
1512
+ }
1513
+ if (args.session != null && args.mode !== 'backup' && args.mode !== 'archive') {
1514
+ throw new Error('--session is only valid with --backup or --archive');
1515
+ }
1516
+ if (args.session != null && !isSessionSegment(args.session)) {
1517
+ throw new Error(`--session must be a single path segment, got: ${args.session}`);
1518
+ }
1519
+ if (args.activeDays != null) {
1520
+ if (args.mode !== 'inventory') throw new Error('--active-days is only valid with --inventory');
1521
+ const n = Number(args.activeDays);
1522
+ if (!Number.isInteger(n) || n <= 0) throw new Error('--active-days must be a positive integer');
1523
+ args.activeDays = n;
1524
+ }
1525
+ if (args.remote != null && args.mode !== 'backup') {
1526
+ throw new Error('--remote is only valid with --backup');
1527
+ }
1528
+ if (args.dryRun && args.mode !== 'backup') {
1529
+ throw new Error('--dry-run is only valid with --backup');
1530
+ }
1531
+ return args;
1532
+ }
1533
+
1534
+ function main() {
1535
+ const args = parseArgs(process.argv);
1536
+ const rootDir = resolveRoot(args.root);
1537
+ // S5: every acting mode runs from the launcher; only the task-model
1538
+ // switch is allowed to aim at a (task) worktree root.
1539
+ if (args.mode !== 'enable-task-model' && isLinkedWorktree(spawnSync, rootDir)) {
1540
+ throw new Error(`--root ${rootDir} is a linked worktree — run from the workspace root (the launcher)`);
1541
+ }
1542
+ let out;
1543
+ let code = 0;
1544
+ if (args.mode === 'inventory') {
1545
+ out = inventory(rootDir, { activeDays: args.activeDays ?? 14 });
1546
+ process.stderr.write(renderTable(out));
1547
+ } else if (args.mode === 'backup') {
1548
+ out = backupSession(rootDir, { session: args.session, remote: args.remote, dryRun: args.dryRun });
1549
+ } else if (args.mode === 'archive') {
1550
+ out = archiveSession(rootDir, { session: args.session });
1551
+ } else {
1552
+ out = enableTaskModel(rootDir);
1553
+ }
1554
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
1555
+ if (out && out.refused) {
1556
+ process.stderr.write(`migrate-sessions: refused — ${out.reasons.join('; ')}\n`);
1557
+ code = 1;
1558
+ }
1559
+ return code;
1560
+ }
1561
+
1562
+ if (isMainModule(import.meta.url)) {
1563
+ try {
1564
+ process.exit(main());
1565
+ } catch (err) {
1566
+ process.stderr.write(`migrate-sessions: ${err.message}\n`);
1567
+ process.exit(2);
1568
+ }
1569
+ }
1570
+
1571
+ export { inventory, backupSession, archiveSession, enableTaskModel, classify, parseArgs };