@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.1-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.
@@ -7,9 +7,22 @@
7
7
  // some still live. This script is the mechanical half of draining them:
8
8
  //
9
9
  // --inventory read-only evidence + a proposal (ACTIVE / ABANDONED /
10
- // MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session,
11
- // listing every worktree's remotes by name, fetch URL, and
12
- // (when it differs) push URL(s)
10
+ // MERGEABLE / READY_TO_COMPLETE / UNKNOWN / ORPHAN_SHELL /
11
+ // LEAVE) per session, listing every worktree's remotes by
12
+ // name, fetch URL, and (when it differs) push URL(s); the
13
+ // age of each repo's last fetch (FETCH_HEAD mtime) so the
14
+ // operator can tell stale tracking refs from fresh ones;
15
+ // and every worktree the workspace's repos register
16
+ // OUTSIDE the workspace (reported as external — never
17
+ // pruned, moved, or removed by anything here)
18
+ // --inventory --fetch
19
+ // refresh each touched repo's remote-tracking refs with a
20
+ // `git fetch origin` in its source clone before inspecting
21
+ // it. A fetch moves no local branch and touches no
22
+ // worktree — it updates refs/remotes/* and the object
23
+ // store — so the inventory stays read-only with respect
24
+ // to the workspace's own state. Failures (offline, no
25
+ // origin) are recorded, never fatal.
13
26
  // --backup tag each tip the session holds that no remote branch or
14
27
  // tag already points at — LOCAL tags only, nothing pushed
15
28
  // (--dry-run reports the plan with no side effects)
@@ -32,7 +45,11 @@
32
45
  // folder is renamed into {sessions}/.archived/ and git's
33
46
  // worktree links are repaired to follow it. Refuses when a
34
47
  // worktree holds uncommitted changes (an edited session.md
35
- // counts) unless --allow-uncommitted says leave them be.
48
+ // counts) unless --allow-uncommitted says leave them be,
49
+ // and when a worktree tip holds commits no remote backs
50
+ // (they exist only on this machine) unless --allow-unbacked
51
+ // records that the operator saw the counts and declined
52
+ // the backup.
36
53
  // --enable-task-model
37
54
  // flip workspace.sessionModel to "task" (accepts a task
38
55
  // worktree root — the one mode allowed off the launcher)
@@ -86,6 +103,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;
86
103
  // refuse rather than guess.
87
104
  const LS_REMOTE_TIMEOUT_MS = 15000;
88
105
  const PUSH_TIMEOUT_MS = 60000;
106
+ const FETCH_TIMEOUT_MS = 60000;
89
107
 
90
108
  function netOpts(timeoutMs) {
91
109
  return { encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
@@ -512,6 +530,51 @@ function dirtyContentMtimeMs(gitFn, wtPath, kind) {
512
530
  return newest;
513
531
  }
514
532
 
533
+ // === S2(d): fetch freshness ===
534
+ //
535
+ // ahead/behind and the content counts compare against tracking refs
536
+ // (refs/remotes/origin/*) that only move on a fetch — in a quiet workspace
537
+ // they can be weeks stale, and every count derived from them inherits the
538
+ // staleness. FETCH_HEAD's mtime is the cheapest honest proxy for "when did
539
+ // that last happen". FETCH_HEAD is per git-dir (a linked worktree has its
540
+ // own), so this reads the SOURCE CLONE's copy — the same place --fetch
541
+ // refreshes — keeping the reported age and the refresh the same file.
542
+ function lastFetchMs(gitFn, repoDir) {
543
+ const res = run(gitFn, repoDir, ['rev-parse', '--git-path', 'FETCH_HEAD']);
544
+ if (res.status !== 0) return null;
545
+ try {
546
+ return statSync(resolve(repoDir, String(res.stdout).trim())).mtimeMs;
547
+ } catch {
548
+ return null; // never fetched (or an unreadable file) — age unknown
549
+ }
550
+ }
551
+
552
+ function describeFetchAge(ageMs) {
553
+ if (ageMs == null) return 'never (no FETCH_HEAD)';
554
+ const hours = ageMs / 3600000;
555
+ if (hours < 1) return 'less than an hour ago';
556
+ if (hours < 48) return `${Math.max(1, Math.round(hours))} hour(s) ago`;
557
+ return `${Math.max(2, Math.round(hours / 24))} day(s) ago`;
558
+ }
559
+
560
+ // --fetch refreshes one repo's remote-tracking refs: `git fetch origin` in
561
+ // the source clone, nothing else. A fetch moves no local branch and
562
+ // touches no worktree, so the inventory stays read-only with respect to
563
+ // the workspace's own state; a repo without an origin is skipped, and a
564
+ // failure (offline, unreachable, timed out) is a recorded outcome, never
565
+ // a crash — the counts just keep their old staleness warning.
566
+ function runFetch(gitFn, repoDir, repo) {
567
+ if (!remotesOf(gitFn, repoDir).includes('origin')) {
568
+ return { repo, status: 'skipped', detail: 'no origin remote' };
569
+ }
570
+ const res = gitFn('git', ['-C', repoDir, 'fetch', 'origin'], netOpts(FETCH_TIMEOUT_MS));
571
+ if (res.error || res.status !== 0) {
572
+ const detail = res.error ? `timed out after ${FETCH_TIMEOUT_MS / 1000}s` : String(res.stderr || '').trim();
573
+ return { repo, status: 'failed', detail };
574
+ }
575
+ return { repo, status: 'ok' };
576
+ }
577
+
515
578
  // === Session shape ===
516
579
 
517
580
  function readTracker(wsDir) {
@@ -524,12 +587,20 @@ function readTracker(wsDir) {
524
587
  const rawRepos = fields.repos;
525
588
  const repos = Array.isArray(rawRepos) ? rawRepos.map(String)
526
589
  : rawRepos == null || rawRepos === '' ? [] : [String(rawRepos)];
590
+ // A chat session with ended: null may still be open — session-start
591
+ // records one per chat and session-end fills ended in. Counting them
592
+ // is the raw material for the chat-open flag; whether the count means
593
+ // "a chat may be working here right now" is decided against the
594
+ // session's recency in inspectSession, not here.
595
+ const chats = Array.isArray(fields.chatSessions) ? fields.chatSessions : [];
596
+ const openChats = chats.filter((c) => c && typeof c === 'object' && (c.ended == null || c.ended === '')).length;
527
597
  return {
528
598
  status: typeof fields.status === 'string' ? fields.status : null,
529
599
  workItem: typeof fields.workItem === 'string' ? fields.workItem : null,
530
600
  branch: typeof fields.branch === 'string' ? fields.branch : null,
531
601
  updated: fields.updated != null ? String(fields.updated) : null,
532
602
  repos,
603
+ openChats,
533
604
  };
534
605
  } catch {
535
606
  // An unparseable tracker is evidence about the tracker, not the
@@ -541,8 +612,16 @@ function readTracker(wsDir) {
541
612
  // One worktree of a session — the workspace worktree (repo ".") or one
542
613
  // nested project worktree (repo = its directory name under repos/).
543
614
  // A session's workspace branch and its code are different things, so
544
- // each is reported on its own line.
545
- function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
615
+ // each is reported on its own line. fetchState (present only under
616
+ // --fetch) refreshes the repo's tracking refs in its source clone before
617
+ // anything derived from them is computed, once per repo per run.
618
+ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath, fetchState) {
619
+ const repoDir = kind === 'workspace' ? rootDir : join(rootDir, 'repos', repo);
620
+ if (fetchState && !fetchState.attempts.has(repoDir)) {
621
+ fetchState.attempts.set(repoDir, runFetch(gitFn, repoDir, kind === 'workspace' ? WORKSPACE_REPO : repo));
622
+ }
623
+ const now = fetchState ? fetchState.now : Date.now();
624
+ const fetchedMs = lastFetchMs(gitFn, repoDir);
546
625
  const branch = currentBranch(gitFn, wtPath);
547
626
  const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
548
627
  const range = ownRange(gitFn, wtPath, defaultBranch);
@@ -560,6 +639,8 @@ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
560
639
  base,
561
640
  defaultBranch,
562
641
  reflogAt: reflogTs(gitFn, wtPath),
642
+ lastFetch: fetchedMs != null ? new Date(fetchedMs).toISOString() : null,
643
+ fetchAgeMs: fetchedMs != null ? Math.max(0, now - fetchedMs) : null,
563
644
  remotes: {},
564
645
  backedBy: null,
565
646
  };
@@ -607,14 +688,18 @@ function collectSessionWorktrees(gitFn, rootDir, folder) {
607
688
  * Pure classifier: given a session's computed metrics, return its
608
689
  * proposal and the human-readable reasons for it. No activity signal at
609
690
  * all → UNKNOWN (never ABANDONED — absence of evidence is not
610
- * abandonment). Active-ness is lastActivity within N days, or any dirty
611
- * worktree with lastActivity within 2N days. Everything else splits on
612
- * whether real content survives — committed content files, uncommitted
613
- * content paths, or project commits mean MERGEABLE; artifact-only,
614
- * clean, and quiet means ABANDONED. A session that fits neither (e.g.
615
- * uncommitted project changes on a stale session) falls to MERGEABLE —
616
- * real uncommitted work is content, and the dirty warning carries the
617
- * caution.
691
+ * abandonment). A session with activity but no session.md →
692
+ * READY_TO_COMPLETE: /complete-work strips the tracker before its final
693
+ * steps, so live worktrees without one mean the completion stopped
694
+ * partway — finishing it (or archiving it as abandoned mid-flight, the
695
+ * operator's call) beats resuming it as ordinary work. Active-ness is
696
+ * lastActivity within N days, or any dirty worktree with lastActivity
697
+ * within 2N days. Everything else splits on whether real content
698
+ * survives — committed content files, uncommitted content paths, or
699
+ * project commits mean MERGEABLE; artifact-only, clean, and quiet means
700
+ * ABANDONED. A session that fits neither (e.g. uncommitted project
701
+ * changes on a stale session) falls to MERGEABLE — real uncommitted
702
+ * work is content, and the dirty warning carries the caution.
618
703
  */
619
704
  function classify(session, activeDays, now = Date.now()) {
620
705
  const ws = session.worktrees.find((w) => w.kind === 'workspace') || null;
@@ -629,6 +714,16 @@ function classify(session, activeDays, now = Date.now()) {
629
714
  reasons: ['no activity signal — no session commits, no reflog entries, no content-dirty files, no tracker updated date'],
630
715
  };
631
716
  }
717
+ if (session.trackerStripped) {
718
+ return {
719
+ proposal: 'READY_TO_COMPLETE',
720
+ reasons: [
721
+ `last activity ${session.lastActivity}`,
722
+ 'no session.md tracker — /complete-work strips it before its final steps, so live worktrees without one are a session stopped mid-completion',
723
+ 'finish it with /complete-work, or archive it if the operator knows it was abandoned there',
724
+ ],
725
+ };
726
+ }
632
727
  if (within(activeDays)) {
633
728
  return { proposal: 'ACTIVE', reasons: [`last activity ${session.lastActivity} is within ${activeDays} days`] };
634
729
  }
@@ -675,6 +770,7 @@ function classify(session, activeDays, now = Date.now()) {
675
770
 
676
771
  function collectWarnings(gitFn, rootDir, worktrees, active, trackerBranch) {
677
772
  const warnings = [];
773
+ const staleFetchReported = new Set(); // once per repo — one FETCH_HEAD serves every worktree
678
774
  for (const wt of worktrees) {
679
775
  if (wt.kind === 'workspace' && wt.branchDrift) {
680
776
  warnings.push({
@@ -709,25 +805,91 @@ function collectWarnings(gitFn, rootDir, worktrees, active, trackerBranch) {
709
805
  repo: wt.repo,
710
806
  });
711
807
  }
808
+ // The remote states come from live ls-remote queries, but ahead/behind
809
+ // and the content counts ride on tracking refs frozen at the last
810
+ // fetch. Past a day that deserves a flag before anyone trusts the
811
+ // numbers — and the flag names the two ways to refresh them.
812
+ if ((wt.fetchAgeMs == null || wt.fetchAgeMs > DAY_MS) && !staleFetchReported.has(wt.repo)) {
813
+ staleFetchReported.add(wt.repo);
814
+ warnings.push({
815
+ kind: 'stale-fetch',
816
+ message: `${repoLabel(wt)}: last fetch ${describeFetchAge(wt.fetchAgeMs)} — commits-ahead and content counts use tracking refs from then; fetch the repo first, or re-run with --inventory --fetch`,
817
+ repo: wt.repo,
818
+ fetchAgeMs: wt.fetchAgeMs,
819
+ });
820
+ }
712
821
  }
713
822
  return warnings;
714
823
  }
715
824
 
716
- function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
825
+ // Is a shell folder provably empty — every entry a directory, every
826
+ // directory readable, no files and no symlinks anywhere? Symlinks count
827
+ // as content (they may point at anything), and an unreadable directory
828
+ // means emptiness cannot be proven. "Removable" must be provably safe,
829
+ // not probably safe: the inventory answer is what the operator's removal
830
+ // step is checked against, and it re-verifies at removal time anyway.
831
+ function shellIsEmpty(folder) {
832
+ let empty = true;
833
+ const walk = (dir) => {
834
+ let entries;
835
+ try {
836
+ entries = readdirSync(dir, { withFileTypes: true });
837
+ } catch {
838
+ empty = false;
839
+ return;
840
+ }
841
+ for (const entry of entries) {
842
+ if (!entry.isDirectory()) { // files, symlinks, anything else
843
+ empty = false;
844
+ continue;
845
+ }
846
+ walk(join(dir, entry.name));
847
+ }
848
+ };
849
+ walk(folder);
850
+ return empty;
851
+ }
852
+
853
+ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetchState) {
717
854
  const folder = join(sessionsDir, name);
718
855
  const wsDir = join(folder, 'workspace');
719
856
  if (!existsSync(join(wsDir, '.git'))) {
720
- const reason = existsSync(wsDir)
857
+ const shape = existsSync(wsDir)
721
858
  ? `workspace/ at ${relative(rootDir, wsDir)} is not a git worktree (empty shell)`
722
859
  : `no workspace worktree at ${relative(rootDir, folder)}${sep}workspace`;
723
- return { name, kind: 'broken', proposal: 'REMOVE_SHELL', reasons: [reason] };
860
+ const empty = shellIsEmpty(folder);
861
+ return {
862
+ name,
863
+ kind: 'broken',
864
+ empty,
865
+ proposal: 'ORPHAN_SHELL',
866
+ reasons: [
867
+ shape,
868
+ empty
869
+ ? 'the folder holds only empty directories — an orphan shell left by a session whose worktrees were already torn down; removable once re-verified empty at removal time (see the skill)'
870
+ : 'the folder holds files with no worktree left to own them — archive it with --archive (which keeps them) or reconcile manually',
871
+ ],
872
+ };
724
873
  }
725
874
 
726
875
  const tracker = readTracker(wsDir);
876
+ // A missing tracker and an unreadable one are different facts: absence is
877
+ // the /complete-work strip step's signature (a session stopped
878
+ // mid-completion), while an unparseable file is tracker damage the
879
+ // operator should hear about. Neither ever crashes the inspection.
880
+ const trackerStripped = !existsSync(join(wsDir, 'session.md'));
881
+ // The tracker file's own mtime: a chat resuming the session rewrites
882
+ // session.md (the session-start hook registers it in chatSessions), so a
883
+ // fresh mtime is live-chat evidence even when no commit or reflog entry
884
+ // followed. It feeds only the chat-open question below, never
885
+ // lastActivity — an uncommitted tracker edit is bookkeeping, not work
886
+ // (same line dirtyContentMtimeMs draws).
887
+ let trackerMtimeMs = null;
888
+ try { trackerMtimeMs = statSync(join(wsDir, 'session.md')).mtimeMs; } catch { /* absent or unreadable — no signal */ }
727
889
  const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
728
890
  const worktrees = rawWorktrees
729
891
  .filter((w) => w.kind !== 'foreign')
730
- .map((w) => inspectWorktree(gitFn, rootDir, w.kind, w.repo, w.path));
892
+ .map((w) => inspectWorktree(gitFn, rootDir, w.kind, w.repo, w.path, fetchState));
731
893
  const wsWt = worktrees.find((w) => w.kind === 'workspace');
732
894
  if (wsWt && tracker) {
733
895
  wsWt.trackerBranch = tracker.branch;
@@ -754,13 +916,52 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
754
916
  }
755
917
  consider(tracker?.updated ?? null);
756
918
 
757
- const { proposal, reasons } = classify({ name, worktrees, lastActivity }, activeDays, now);
919
+ const { proposal, reasons } = classify({ name, worktrees, lastActivity, trackerStripped }, activeDays, now);
758
920
  const warnings = collectWarnings(gitFn, rootDir, worktrees, proposal === 'ACTIVE', tracker?.branch ?? null);
921
+ // A chat with no recorded end only means "may still be open" while
922
+ // something in the session is also recent: session-end misses often
923
+ // enough (a crashed chat, a skipped hook) that an entry sitting on a
924
+ // session idle for months is a stale record, not a live chat. Recency is
925
+ // the newest of the session's own activity signals (lastActivity —
926
+ // own-branch commits, reflog, dirty content, the tracker's updated
927
+ // field) and the tracker file's mtime. When no signal can establish
928
+ // either answer, the cautious reading stands.
929
+ let chatOpen = false;
930
+ let chatIdleDays = null;
931
+ if (tracker && tracker.openChats > 0) {
932
+ const newestChatSignal = Math.max(
933
+ Number.isFinite(lastMs) ? lastMs : -Infinity,
934
+ trackerMtimeMs ?? -Infinity,
935
+ );
936
+ if (!Number.isFinite(newestChatSignal) || now - newestChatSignal <= activeDays * DAY_MS) {
937
+ chatOpen = true;
938
+ warnings.push({
939
+ kind: 'chat-open',
940
+ message: `${tracker.openChats} chat session(s) recorded with no end time — a chat may still be working in this session; confirm with the operator before archiving it`,
941
+ });
942
+ } else {
943
+ chatIdleDays = Math.floor((now - newestChatSignal) / DAY_MS);
944
+ warnings.push({
945
+ kind: 'chat-open-idle',
946
+ info: true,
947
+ idleDays: chatIdleDays,
948
+ message: `${tracker.openChats} chat session(s) recorded with no end time, but nothing in the session has moved for ${chatIdleDays} day(s) — the end was most likely never recorded; treat the chat as closed unless the operator knows otherwise`,
949
+ });
950
+ }
951
+ }
952
+ if (!trackerStripped && !tracker) {
953
+ warnings.push({
954
+ kind: 'tracker-unreadable',
955
+ message: 'session.md exists but could not be parsed — status, work item and chat evidence are missing; the worktree evidence still stands',
956
+ });
957
+ }
759
958
  return {
760
959
  name,
761
960
  kind: 'session',
762
961
  status: tracker?.status ?? null,
763
962
  workItem: tracker?.workItem ?? null,
963
+ chatOpen,
964
+ ...(chatIdleDays != null ? { chatIdleDays } : {}),
764
965
  lastActivity,
765
966
  proposal,
766
967
  reasons,
@@ -769,15 +970,42 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
769
970
  };
770
971
  }
771
972
 
973
+ // Registered worktrees of the workspace's own repositories that live
974
+ // OUTSIDE the workspace root — a scratch checkout in tmp, a directory on
975
+ // another drive, someone's working copy. They belong to no session, so
976
+ // they are reported at the top level rather than under one, and nothing
977
+ // in this script ever prunes, moves, or removes them: `git worktree
978
+ // prune` is the one command that could silently drop their records, and
979
+ // no mode here runs it for real (archive's prune checks are --dry-run).
980
+ function externalWorktreesOf(gitFn, rootDir) {
981
+ const out = [];
982
+ for (const owned of workspaceRepos(rootDir)) {
983
+ const listed = registeredWorktrees(gitFn, owned.dir);
984
+ if (listed === null) {
985
+ out.push({ repo: owned.repo, error: `could not list worktrees of ${relative(rootDir, owned.dir) || 'the workspace repo'}` });
986
+ continue;
987
+ }
988
+ for (const p of listed) {
989
+ if (!insideRoot(rootDir, p)) out.push({ repo: owned.repo, path: p });
990
+ }
991
+ }
992
+ return out;
993
+ }
994
+
772
995
  /**
773
996
  * Read-only inventory of every session under the workspace's sessions
774
997
  * directory. The proposal each session gets is a proposal — the note in
775
998
  * the result says so, and the skill says so again to the operator.
776
999
  * Symlinked entries are reported as foreign (LEAVE) and never followed.
1000
+ * `fetch: true` (CLI --fetch) refreshes each touched repo's tracking
1001
+ * refs first; the result records what the refresh did per repo. Worktrees
1002
+ * the workspace's repos register outside the root are listed as external
1003
+ * — evidence for the operator, never something to act on.
777
1004
  */
778
- function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now() } = {}) {
1005
+ function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now(), fetch = false } = {}) {
779
1006
  const rootDir = resolveRoot(root);
780
1007
  const sessionsDir = sessionsDirOf(rootDir);
1008
+ const fetchState = fetch ? { attempts: new Map(), now } : null;
781
1009
  const sessions = listSessionEntries(rootDir).map((entry) => (
782
1010
  entry.foreign
783
1011
  ? {
@@ -786,13 +1014,15 @@ function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now()
786
1014
  proposal: 'LEAVE',
787
1015
  reasons: ['entry is a symlink, not a session directory — never followed; reconcile manually'],
788
1016
  }
789
- : inspectSession(gitFn, rootDir, sessionsDir, entry.name, activeDays, now)
1017
+ : inspectSession(gitFn, rootDir, sessionsDir, entry.name, activeDays, now, fetchState)
790
1018
  ));
791
1019
  return {
792
1020
  root: rootDir,
793
1021
  activeDays,
794
1022
  note: 'Proposals are proposals — inventory evidence only; the operator decides each session.',
795
1023
  sessions,
1024
+ externalWorktrees: externalWorktreesOf(gitFn, rootDir),
1025
+ ...(fetch ? { fetch: [...fetchState.attempts.values()] } : {}),
796
1026
  };
797
1027
  }
798
1028
 
@@ -1562,12 +1792,17 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
1562
1792
  * find; a worktree holds uncommitted or untracked changes — an edited
1563
1793
  * session.md counts (unless allowUncommitted: they would ride along
1564
1794
  * fine, but they deserve a decision: commit them to the session branch,
1565
- * or explicitly accept archiving them mid-edit); or the archive
1566
- * directory is a symlink or resolves outside the workspace. If anything
1567
- * fails after the rename, the folder is renamed back and repaired, and
1568
- * the result reports the verified state.
1795
+ * or explicitly accept archiving them mid-edit); or a worktree tip holds
1796
+ * commits no remote backs — they exist only on this machine, and the
1797
+ * archive is safe for them but the moment it is deleted they are gone
1798
+ * (unless allowUnbacked, the operator's recorded decline after seeing
1799
+ * the per-repo counts; the refusal names them, and a successful archive
1800
+ * that carried unbacked tips reports them in `unbacked`). Also refused
1801
+ * when the archive directory is a symlink or resolves outside the
1802
+ * workspace. If anything fails after the rename, the folder is renamed
1803
+ * back and repaired, and the result reports the verified state.
1569
1804
  */
1570
- function archiveSession(root, { session, allowUncommitted = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1805
+ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1571
1806
  const rootDir = resolveRoot(root);
1572
1807
  if (!isSessionSegment(session)) {
1573
1808
  throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
@@ -1633,6 +1868,30 @@ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawn
1633
1868
  }
1634
1869
  }
1635
1870
  }
1871
+ // Unpushed commits ride along safely too — but "safely" holds only as
1872
+ // long as the archive exists, and deleting it later is the operator's
1873
+ // own call. A tip whose commits no remote backs (the inventory's
1874
+ // `unbacked` evidence, recomputed here at archive time) exists only on
1875
+ // this machine, so the archive moves only once a remote holds every
1876
+ // such tip — the backup step's push mode --remote with an allow — or
1877
+ // after --allow-unbacked records that the operator saw these counts and
1878
+ // declined it. allowUnbacked still reports what rode along.
1879
+ const unbacked = [];
1880
+ for (const f of found) {
1881
+ const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
1882
+ const info = inspectWorktree(
1883
+ gitFn, rootDir,
1884
+ f.owner.repo === WORKSPACE_REPO ? 'workspace' : 'project',
1885
+ f.owner.repo, wtPath, null,
1886
+ );
1887
+ if (info.ahead > 0 && !info.backedBy) unbacked.push(info);
1888
+ }
1889
+ if (unbacked.length > 0 && !allowUnbacked) {
1890
+ reasons.push(
1891
+ ...unbacked.map((wt) => unbackedMessage(gitFn, rootDir, wt)),
1892
+ `${unbacked.length} worktree tip(s) above hold commits that exist only on this machine — this clears only when a remote holds them (--backup --remote with the operator's allow pushes backup tags there), or re-run with --allow-unbacked once the operator has seen these counts and explicitly declined the backup`,
1893
+ );
1894
+ }
1636
1895
  if (reasons.length > 0) return { refused: true, reasons };
1637
1896
 
1638
1897
  const archiveDir = join(sessionsDir, '.archived');
@@ -1676,6 +1935,9 @@ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawn
1676
1935
  from: relative(rootDir, folder),
1677
1936
  to: relative(rootDir, dest),
1678
1937
  worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
1938
+ // What the operator accepted riding along unbacked — the same
1939
+ // repos and counts the refusal would have named.
1940
+ ...(unbacked.length > 0 ? { unbacked: unbacked.map((wt) => ({ repo: wt.repo, branch: wt.branch, commits: wt.ahead })) } : {}),
1679
1941
  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`),
1680
1942
  };
1681
1943
  }
@@ -1731,14 +1993,19 @@ function renderTable(result) {
1731
1993
  ];
1732
1994
  for (const s of result.sessions) {
1733
1995
  lines.push('');
1734
- lines.push(`${s.name} ${s.kind === 'broken' ? 'broken shell' : s.kind === 'foreign' ? 'foreign entry' : s.proposal}` +
1735
- (s.kind === 'broken' || s.kind === 'foreign' ? '' : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'})`));
1996
+ const header = s.kind === 'broken'
1997
+ ? `ORPHAN_SHELL — ${s.empty ? 'only empty directories' : 'holds files, no worktree'}`
1998
+ : s.kind === 'foreign' ? 'foreign entry' : s.proposal;
1999
+ const detail = s.kind === 'broken' || s.kind === 'foreign'
2000
+ ? ''
2001
+ : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'}${s.chatOpen ? ', chat open?' : s.chatIdleDays != null ? `, chat: no end recorded (idle ${s.chatIdleDays}d)` : ''})`;
2002
+ lines.push(`${s.name} ${header}${detail}`);
1736
2003
  for (const w of s.worktrees || []) {
1737
2004
  const remotes = Object.entries(w.remotes)
1738
2005
  .map(([r, v]) => describeRemote(r, v))
1739
2006
  .join(' ') || 'no remotes';
1740
2007
  const extra = w.kind === 'workspace' ? ` content:${w.contentFiles}` : '';
1741
- lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} [${remotes}]`);
2008
+ lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} fetch:${describeFetchAge(w.fetchAgeMs)} [${remotes}]`);
1742
2009
  // The state bracket alone is ambiguous — "origin:none" says the remote
1743
2010
  // holds no copy of this branch, not that there is no origin. The URL
1744
2011
  // line settles it: name = exact URL for every configured remote, plus
@@ -1756,7 +2023,16 @@ function renderTable(result) {
1756
2023
  }
1757
2024
  }
1758
2025
  for (const r of s.reasons || []) lines.push(` · ${r}`);
1759
- for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
2026
+ // `!` marks something to act on; an entry carrying info: true is
2027
+ // context (a stale record explained, not a live risk).
2028
+ for (const w of s.warnings || []) lines.push(` ${w.info ? 'i' : '!'} ${w.message}`);
2029
+ }
2030
+ if (Array.isArray(result.externalWorktrees) && result.externalWorktrees.length > 0) {
2031
+ lines.push('');
2032
+ lines.push('external worktrees (registered outside this workspace — never prune, move, or remove them):');
2033
+ for (const e of result.externalWorktrees) {
2034
+ lines.push(e.error ? ` repo "${e.repo}": ${e.error}` : ` repo "${e.repo}" ${e.path}`);
2035
+ }
1760
2036
  }
1761
2037
  return `${lines.join('\n')}\n`;
1762
2038
  }
@@ -1773,6 +2049,8 @@ const BOOL_FLAGS = new Map([
1773
2049
  ['--remote', 'remote'],
1774
2050
  ['--remote-allow-all', 'remoteAllowAll'],
1775
2051
  ['--allow-uncommitted', 'allowUncommitted'],
2052
+ ['--allow-unbacked', 'allowUnbacked'],
2053
+ ['--fetch', 'fetch'],
1776
2054
  ]);
1777
2055
 
1778
2056
  function parseArgs(argv) {
@@ -1786,6 +2064,8 @@ function parseArgs(argv) {
1786
2064
  remote: false,
1787
2065
  remoteAllowAll: false,
1788
2066
  allowUncommitted: false,
2067
+ allowUnbacked: false,
2068
+ fetch: false,
1789
2069
  };
1790
2070
  const rest = argv.slice(2);
1791
2071
  for (let i = 0; i < rest.length; i += 1) {
@@ -1824,6 +2104,9 @@ function parseArgs(argv) {
1824
2104
  if (!Number.isInteger(n) || n <= 0) throw new Error('--active-days must be a positive integer');
1825
2105
  args.activeDays = n;
1826
2106
  }
2107
+ if (args.fetch && args.mode !== 'inventory') {
2108
+ throw new Error('--fetch is only valid with --inventory');
2109
+ }
1827
2110
  if (args.remote && args.mode !== 'backup') {
1828
2111
  throw new Error('--remote is only valid with --backup');
1829
2112
  }
@@ -1837,6 +2120,9 @@ function parseArgs(argv) {
1837
2120
  if (args.allowUncommitted && args.mode !== 'archive') {
1838
2121
  throw new Error('--allow-uncommitted is only valid with --archive');
1839
2122
  }
2123
+ if (args.allowUnbacked && args.mode !== 'archive') {
2124
+ throw new Error('--allow-unbacked is only valid with --archive');
2125
+ }
1840
2126
  if (args.dryRun && args.mode !== 'backup') {
1841
2127
  throw new Error('--dry-run is only valid with --backup');
1842
2128
  }
@@ -1854,7 +2140,7 @@ function main() {
1854
2140
  let out;
1855
2141
  let code = 0;
1856
2142
  if (args.mode === 'inventory') {
1857
- out = inventory(rootDir, { activeDays: args.activeDays ?? 14 });
2143
+ out = inventory(rootDir, { activeDays: args.activeDays ?? 14, fetch: args.fetch });
1858
2144
  process.stderr.write(renderTable(out));
1859
2145
  } else if (args.mode === 'backup') {
1860
2146
  out = backupSession(rootDir, {
@@ -1865,7 +2151,11 @@ function main() {
1865
2151
  dryRun: args.dryRun,
1866
2152
  });
1867
2153
  } else if (args.mode === 'archive') {
1868
- out = archiveSession(rootDir, { session: args.session, allowUncommitted: args.allowUncommitted });
2154
+ out = archiveSession(rootDir, {
2155
+ session: args.session,
2156
+ allowUncommitted: args.allowUncommitted,
2157
+ allowUnbacked: args.allowUnbacked,
2158
+ });
1869
2159
  } else {
1870
2160
  out = enableTaskModel(rootDir);
1871
2161
  }