@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.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.
@@ -26,14 +26,97 @@ function readWorkspace(rootDir) {
26
26
  }
27
27
  }
28
28
 
29
- // Same remote shapes the forge adapters resolve a repo from. A URL that
30
- // does not match is not forge-hosted, and the forge path supports
31
- // forge-hosted repos only — a local/bare remote has no PR concept to aim at.
32
- const FORGE_REMOTE_RE = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/;
29
+ // Same remote shapes the forge adapters resolve a repo from: scp-style SSH
30
+ // (`git@github.com:owner/name.git`, `git@gitlab.com:group/sub/name.git`),
31
+ // scheme URLs (`ssh://git@host/...`, `https://host/...`, optional port), with
32
+ // or without a trailing `.git`. A URL that does not match is not
33
+ // forge-hosted, and the forge path supports forge-hosted repos only — a
34
+ // local/bare remote has no PR concept to aim at.
35
+ //
36
+ // GitLab repos may sit in nested groups (`group/sub/project` at any depth),
37
+ // so the parser returns the full `slug` plus `host` and `forge` type; GitHub
38
+ // remains exactly two segments (owner/name). `hosts` names additional
39
+ // self-managed GitLab hosts (workspace.forge.host) — gitlab.com is always
40
+ // recognised, and a URL on any other host parses as nothing.
41
+ function parseForgeRemote(url, { hosts = [] } = {}) {
42
+ const parsed = parseRemoteUrl(url);
43
+ if (!parsed) return null;
44
+ const { host, path: segments } = parsed;
45
+ if (segments.length < 2) return null;
46
+ if (host === 'github.com') {
47
+ if (segments.length !== 2) return null;
48
+ return { owner: segments[0], name: segments[1], slug: segments.join('/'), host, forge: 'github' };
49
+ }
50
+ const gitlabHosts = new Set(['gitlab.com', ...hosts.map((h) => String(h).toLowerCase())]);
51
+ if (gitlabHosts.has(host)) {
52
+ return {
53
+ owner: segments.slice(0, -1).join('/'),
54
+ name: segments[segments.length - 1],
55
+ slug: segments.join('/'),
56
+ host,
57
+ forge: 'gitlab',
58
+ };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // Split any remote URL into host + path segments, or null when it is not a
64
+ // forge-shaped remote at all (local path, file:// URL, …).
65
+ const DEFAULT_PORTS = { https: 443, http: 80, ssh: 22, git: 9418 };
66
+ const SCHEME_URL_RE = /^(?<scheme>https?|ssh|git):\/\/(?:[^@/]+@)?(?<host>[^:/]+)(?::(?<port>\d+))?\/(?<path>.+)$/;
33
67
 
34
- function parseForgeRemote(url) {
35
- const m = String(url).trim().match(FORGE_REMOTE_RE);
36
- return m ? { owner: m[1], name: m[2] } : null;
68
+ function parseRemoteUrl(url) {
69
+ const s = String(url).trim().replace(/\/+$/, '');
70
+ // scp-style: user@host:path — the colon separator, no scheme.
71
+ const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/);
72
+ if (scp) return { host: scp[1].toLowerCase(), path: splitRemotePath(scp[2]) };
73
+ // scheme://[user@]host[:port]/path — an explicit non-default port stays
74
+ // part of the host so a configured self-managed host can carry it
75
+ // (gitlab.example.com:8443); a default port drops, so github.com:443
76
+ // still reads as github.com.
77
+ const m = s.match(SCHEME_URL_RE);
78
+ if (m) {
79
+ const { scheme, host, port, path } = m.groups;
80
+ const hostWithPort = port && Number(port) !== DEFAULT_PORTS[scheme] ? `${host}:${port}` : host;
81
+ return { host: hostWithPort.toLowerCase(), path: splitRemotePath(path) };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function splitRemotePath(p) {
87
+ return p.replace(/\.git$/, '').split('/').filter(Boolean);
88
+ }
89
+
90
+ // Self-managed GitLab hosts configured for this workspace — the value of
91
+ // workspace.forge.host, when set, is a GitLab host however the forge `type`
92
+ // reads (a mixed workspace may leave type unset entirely).
93
+ function forgeHosts(ws) {
94
+ return [ws?.workspace?.forge?.host].filter(Boolean);
95
+ }
96
+
97
+ // The per-repo forge config: the workspace block (host, any shared
98
+ // settings) with the repo's own identity layered on. The origin's host and
99
+ // slug win over any workspace-level `type` — a workspace may mix GitHub
100
+ // and GitLab repos, and each repo's origin names where its PRs live, so
101
+ // one global type cannot speak for both. `parsed` is parseForgeRemote's
102
+ // result; a null (unparseable origin) yields the workspace block as-is.
103
+ function perRepoForge(ws, parsed) {
104
+ if (!parsed) return { ...(ws?.workspace?.forge ?? {}) };
105
+ return { ...(ws?.workspace?.forge ?? {}), type: parsed.forge, host: parsed.host, repo: parsed.slug };
106
+ }
107
+
108
+ // Everything a caller outside task-pr needs to build one repo's forge:
109
+ // read the repo's origin, parse it, and hand back a config createForge can
110
+ // take. The adapter type comes from the repo's own host, so a GitLab repo
111
+ // gets the gitlab adapter even when workspace.forge names no type (or a
112
+ // different one) — this is the helper the /release skill uses, so its
113
+ // forge always matches the repo being released.
114
+ function forgeConfigForRepo(rootDir, repo, ws, deps = {}) {
115
+ const gitFn = deps.gitFn ?? spawnSync;
116
+ const res = gitFn('git', ['-C', repoDirFor(rootDir, repo), 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
117
+ const origin = res.error || res.status !== 0 ? null : String(res.stdout || '').trim();
118
+ const parsed = origin ? parseForgeRemote(origin, { hosts: forgeHosts(ws) }) : null;
119
+ return perRepoForge(ws, parsed);
37
120
  }
38
121
 
39
122
  /**
@@ -43,9 +126,10 @@ function parseForgeRemote(url) {
43
126
  * right call for a clone whose origin is a third-party upstream nobody
44
127
  * here may push to) or when the repo has no origin remote at all;
45
128
  * "forge" — pushed and PR'd — when its origin parses as a forge-hosted
46
- * owner/name. An origin that is neither (say a local bare mirror with no
47
- * override) resolves to null: the caller stops with the override spelled
48
- * out rather than pushing somewhere that cannot host a PR.
129
+ * repo (github.com, gitlab.com, or the configured self-managed host). An
130
+ * origin that is neither (say a local bare mirror with no override)
131
+ * resolves to null: the caller stops with the override spelled out rather
132
+ * than pushing somewhere that cannot host a PR.
49
133
  */
50
134
  function mergeModeFor(root, repo, deps = {}) {
51
135
  const gitFn = deps.gitFn ?? spawnSync;
@@ -55,7 +139,7 @@ function mergeModeFor(root, repo, deps = {}) {
55
139
  if (override === 'local') return 'local';
56
140
  const res = gitFn('git', ['-C', repoDirFor(rootDir, repo), 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
57
141
  if (res.error || res.status !== 0) return 'local'; // no origin — nowhere to push
58
- return parseForgeRemote(String(res.stdout || '').trim()) ? 'forge' : null;
142
+ return parseForgeRemote(String(res.stdout || '').trim(), { hosts: forgeHosts(ws) }) ? 'forge' : null;
59
143
  }
60
144
 
61
- export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor };
145
+ export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, forgeConfigForRepo, mergeModeFor };
@@ -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)
@@ -86,6 +99,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;
86
99
  // refuse rather than guess.
87
100
  const LS_REMOTE_TIMEOUT_MS = 15000;
88
101
  const PUSH_TIMEOUT_MS = 60000;
102
+ const FETCH_TIMEOUT_MS = 60000;
89
103
 
90
104
  function netOpts(timeoutMs) {
91
105
  return { encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
@@ -512,6 +526,51 @@ function dirtyContentMtimeMs(gitFn, wtPath, kind) {
512
526
  return newest;
513
527
  }
514
528
 
529
+ // === S2(d): fetch freshness ===
530
+ //
531
+ // ahead/behind and the content counts compare against tracking refs
532
+ // (refs/remotes/origin/*) that only move on a fetch — in a quiet workspace
533
+ // they can be weeks stale, and every count derived from them inherits the
534
+ // staleness. FETCH_HEAD's mtime is the cheapest honest proxy for "when did
535
+ // that last happen". FETCH_HEAD is per git-dir (a linked worktree has its
536
+ // own), so this reads the SOURCE CLONE's copy — the same place --fetch
537
+ // refreshes — keeping the reported age and the refresh the same file.
538
+ function lastFetchMs(gitFn, repoDir) {
539
+ const res = run(gitFn, repoDir, ['rev-parse', '--git-path', 'FETCH_HEAD']);
540
+ if (res.status !== 0) return null;
541
+ try {
542
+ return statSync(resolve(repoDir, String(res.stdout).trim())).mtimeMs;
543
+ } catch {
544
+ return null; // never fetched (or an unreadable file) — age unknown
545
+ }
546
+ }
547
+
548
+ function describeFetchAge(ageMs) {
549
+ if (ageMs == null) return 'never (no FETCH_HEAD)';
550
+ const hours = ageMs / 3600000;
551
+ if (hours < 1) return 'less than an hour ago';
552
+ if (hours < 48) return `${Math.max(1, Math.round(hours))} hour(s) ago`;
553
+ return `${Math.max(2, Math.round(hours / 24))} day(s) ago`;
554
+ }
555
+
556
+ // --fetch refreshes one repo's remote-tracking refs: `git fetch origin` in
557
+ // the source clone, nothing else. A fetch moves no local branch and
558
+ // touches no worktree, so the inventory stays read-only with respect to
559
+ // the workspace's own state; a repo without an origin is skipped, and a
560
+ // failure (offline, unreachable, timed out) is a recorded outcome, never
561
+ // a crash — the counts just keep their old staleness warning.
562
+ function runFetch(gitFn, repoDir, repo) {
563
+ if (!remotesOf(gitFn, repoDir).includes('origin')) {
564
+ return { repo, status: 'skipped', detail: 'no origin remote' };
565
+ }
566
+ const res = gitFn('git', ['-C', repoDir, 'fetch', 'origin'], netOpts(FETCH_TIMEOUT_MS));
567
+ if (res.error || res.status !== 0) {
568
+ const detail = res.error ? `timed out after ${FETCH_TIMEOUT_MS / 1000}s` : String(res.stderr || '').trim();
569
+ return { repo, status: 'failed', detail };
570
+ }
571
+ return { repo, status: 'ok' };
572
+ }
573
+
515
574
  // === Session shape ===
516
575
 
517
576
  function readTracker(wsDir) {
@@ -524,12 +583,19 @@ function readTracker(wsDir) {
524
583
  const rawRepos = fields.repos;
525
584
  const repos = Array.isArray(rawRepos) ? rawRepos.map(String)
526
585
  : rawRepos == null || rawRepos === '' ? [] : [String(rawRepos)];
586
+ // A chat session with ended: null may still be open — session-start
587
+ // records one per chat and session-end fills ended in. Counting them
588
+ // is how the inventory flags "a chat may be working in this session
589
+ // right now" before anyone proposes archiving it.
590
+ const chats = Array.isArray(fields.chatSessions) ? fields.chatSessions : [];
591
+ const openChats = chats.filter((c) => c && typeof c === 'object' && (c.ended == null || c.ended === '')).length;
527
592
  return {
528
593
  status: typeof fields.status === 'string' ? fields.status : null,
529
594
  workItem: typeof fields.workItem === 'string' ? fields.workItem : null,
530
595
  branch: typeof fields.branch === 'string' ? fields.branch : null,
531
596
  updated: fields.updated != null ? String(fields.updated) : null,
532
597
  repos,
598
+ openChats,
533
599
  };
534
600
  } catch {
535
601
  // An unparseable tracker is evidence about the tracker, not the
@@ -541,8 +607,16 @@ function readTracker(wsDir) {
541
607
  // One worktree of a session — the workspace worktree (repo ".") or one
542
608
  // nested project worktree (repo = its directory name under repos/).
543
609
  // 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) {
610
+ // each is reported on its own line. fetchState (present only under
611
+ // --fetch) refreshes the repo's tracking refs in its source clone before
612
+ // anything derived from them is computed, once per repo per run.
613
+ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath, fetchState) {
614
+ const repoDir = kind === 'workspace' ? rootDir : join(rootDir, 'repos', repo);
615
+ if (fetchState && !fetchState.attempts.has(repoDir)) {
616
+ fetchState.attempts.set(repoDir, runFetch(gitFn, repoDir, kind === 'workspace' ? WORKSPACE_REPO : repo));
617
+ }
618
+ const now = fetchState ? fetchState.now : Date.now();
619
+ const fetchedMs = lastFetchMs(gitFn, repoDir);
546
620
  const branch = currentBranch(gitFn, wtPath);
547
621
  const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
548
622
  const range = ownRange(gitFn, wtPath, defaultBranch);
@@ -560,6 +634,8 @@ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
560
634
  base,
561
635
  defaultBranch,
562
636
  reflogAt: reflogTs(gitFn, wtPath),
637
+ lastFetch: fetchedMs != null ? new Date(fetchedMs).toISOString() : null,
638
+ fetchAgeMs: fetchedMs != null ? Math.max(0, now - fetchedMs) : null,
563
639
  remotes: {},
564
640
  backedBy: null,
565
641
  };
@@ -607,14 +683,18 @@ function collectSessionWorktrees(gitFn, rootDir, folder) {
607
683
  * Pure classifier: given a session's computed metrics, return its
608
684
  * proposal and the human-readable reasons for it. No activity signal at
609
685
  * 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.
686
+ * abandonment). A session with activity but no session.md →
687
+ * READY_TO_COMPLETE: /complete-work strips the tracker before its final
688
+ * steps, so live worktrees without one mean the completion stopped
689
+ * partway — finishing it (or archiving it as abandoned mid-flight, the
690
+ * operator's call) beats resuming it as ordinary work. Active-ness is
691
+ * lastActivity within N days, or any dirty worktree with lastActivity
692
+ * within 2N days. Everything else splits on whether real content
693
+ * survives — committed content files, uncommitted content paths, or
694
+ * project commits mean MERGEABLE; artifact-only, clean, and quiet means
695
+ * ABANDONED. A session that fits neither (e.g. uncommitted project
696
+ * changes on a stale session) falls to MERGEABLE — real uncommitted
697
+ * work is content, and the dirty warning carries the caution.
618
698
  */
619
699
  function classify(session, activeDays, now = Date.now()) {
620
700
  const ws = session.worktrees.find((w) => w.kind === 'workspace') || null;
@@ -629,6 +709,16 @@ function classify(session, activeDays, now = Date.now()) {
629
709
  reasons: ['no activity signal — no session commits, no reflog entries, no content-dirty files, no tracker updated date'],
630
710
  };
631
711
  }
712
+ if (session.trackerStripped) {
713
+ return {
714
+ proposal: 'READY_TO_COMPLETE',
715
+ reasons: [
716
+ `last activity ${session.lastActivity}`,
717
+ 'no session.md tracker — /complete-work strips it before its final steps, so live worktrees without one are a session stopped mid-completion',
718
+ 'finish it with /complete-work, or archive it if the operator knows it was abandoned there',
719
+ ],
720
+ };
721
+ }
632
722
  if (within(activeDays)) {
633
723
  return { proposal: 'ACTIVE', reasons: [`last activity ${session.lastActivity} is within ${activeDays} days`] };
634
724
  }
@@ -675,6 +765,7 @@ function classify(session, activeDays, now = Date.now()) {
675
765
 
676
766
  function collectWarnings(gitFn, rootDir, worktrees, active, trackerBranch) {
677
767
  const warnings = [];
768
+ const staleFetchReported = new Set(); // once per repo — one FETCH_HEAD serves every worktree
678
769
  for (const wt of worktrees) {
679
770
  if (wt.kind === 'workspace' && wt.branchDrift) {
680
771
  warnings.push({
@@ -709,25 +800,83 @@ function collectWarnings(gitFn, rootDir, worktrees, active, trackerBranch) {
709
800
  repo: wt.repo,
710
801
  });
711
802
  }
803
+ // The remote states come from live ls-remote queries, but ahead/behind
804
+ // and the content counts ride on tracking refs frozen at the last
805
+ // fetch. Past a day that deserves a flag before anyone trusts the
806
+ // numbers — and the flag names the two ways to refresh them.
807
+ if ((wt.fetchAgeMs == null || wt.fetchAgeMs > DAY_MS) && !staleFetchReported.has(wt.repo)) {
808
+ staleFetchReported.add(wt.repo);
809
+ warnings.push({
810
+ kind: 'stale-fetch',
811
+ 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`,
812
+ repo: wt.repo,
813
+ fetchAgeMs: wt.fetchAgeMs,
814
+ });
815
+ }
712
816
  }
713
817
  return warnings;
714
818
  }
715
819
 
716
- function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
820
+ // Is a shell folder provably empty — every entry a directory, every
821
+ // directory readable, no files and no symlinks anywhere? Symlinks count
822
+ // as content (they may point at anything), and an unreadable directory
823
+ // means emptiness cannot be proven. "Removable" must be provably safe,
824
+ // not probably safe: the inventory answer is what the operator's removal
825
+ // step is checked against, and it re-verifies at removal time anyway.
826
+ function shellIsEmpty(folder) {
827
+ let empty = true;
828
+ const walk = (dir) => {
829
+ let entries;
830
+ try {
831
+ entries = readdirSync(dir, { withFileTypes: true });
832
+ } catch {
833
+ empty = false;
834
+ return;
835
+ }
836
+ for (const entry of entries) {
837
+ if (!entry.isDirectory()) { // files, symlinks, anything else
838
+ empty = false;
839
+ continue;
840
+ }
841
+ walk(join(dir, entry.name));
842
+ }
843
+ };
844
+ walk(folder);
845
+ return empty;
846
+ }
847
+
848
+ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetchState) {
717
849
  const folder = join(sessionsDir, name);
718
850
  const wsDir = join(folder, 'workspace');
719
851
  if (!existsSync(join(wsDir, '.git'))) {
720
- const reason = existsSync(wsDir)
852
+ const shape = existsSync(wsDir)
721
853
  ? `workspace/ at ${relative(rootDir, wsDir)} is not a git worktree (empty shell)`
722
854
  : `no workspace worktree at ${relative(rootDir, folder)}${sep}workspace`;
723
- return { name, kind: 'broken', proposal: 'REMOVE_SHELL', reasons: [reason] };
855
+ const empty = shellIsEmpty(folder);
856
+ return {
857
+ name,
858
+ kind: 'broken',
859
+ empty,
860
+ proposal: 'ORPHAN_SHELL',
861
+ reasons: [
862
+ shape,
863
+ empty
864
+ ? '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)'
865
+ : 'the folder holds files with no worktree left to own them — archive it with --archive (which keeps them) or reconcile manually',
866
+ ],
867
+ };
724
868
  }
725
869
 
726
870
  const tracker = readTracker(wsDir);
871
+ // A missing tracker and an unreadable one are different facts: absence is
872
+ // the /complete-work strip step's signature (a session stopped
873
+ // mid-completion), while an unparseable file is tracker damage the
874
+ // operator should hear about. Neither ever crashes the inspection.
875
+ const trackerStripped = !existsSync(join(wsDir, 'session.md'));
727
876
  const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
728
877
  const worktrees = rawWorktrees
729
878
  .filter((w) => w.kind !== 'foreign')
730
- .map((w) => inspectWorktree(gitFn, rootDir, w.kind, w.repo, w.path));
879
+ .map((w) => inspectWorktree(gitFn, rootDir, w.kind, w.repo, w.path, fetchState));
731
880
  const wsWt = worktrees.find((w) => w.kind === 'workspace');
732
881
  if (wsWt && tracker) {
733
882
  wsWt.trackerBranch = tracker.branch;
@@ -754,13 +903,26 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
754
903
  }
755
904
  consider(tracker?.updated ?? null);
756
905
 
757
- const { proposal, reasons } = classify({ name, worktrees, lastActivity }, activeDays, now);
906
+ const { proposal, reasons } = classify({ name, worktrees, lastActivity, trackerStripped }, activeDays, now);
758
907
  const warnings = collectWarnings(gitFn, rootDir, worktrees, proposal === 'ACTIVE', tracker?.branch ?? null);
908
+ if (tracker && tracker.openChats > 0) {
909
+ warnings.push({
910
+ kind: 'chat-open',
911
+ 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`,
912
+ });
913
+ }
914
+ if (!trackerStripped && !tracker) {
915
+ warnings.push({
916
+ kind: 'tracker-unreadable',
917
+ message: 'session.md exists but could not be parsed — status, work item and chat evidence are missing; the worktree evidence still stands',
918
+ });
919
+ }
759
920
  return {
760
921
  name,
761
922
  kind: 'session',
762
923
  status: tracker?.status ?? null,
763
924
  workItem: tracker?.workItem ?? null,
925
+ chatOpen: tracker ? tracker.openChats > 0 : false,
764
926
  lastActivity,
765
927
  proposal,
766
928
  reasons,
@@ -769,15 +931,42 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
769
931
  };
770
932
  }
771
933
 
934
+ // Registered worktrees of the workspace's own repositories that live
935
+ // OUTSIDE the workspace root — a scratch checkout in tmp, a directory on
936
+ // another drive, someone's working copy. They belong to no session, so
937
+ // they are reported at the top level rather than under one, and nothing
938
+ // in this script ever prunes, moves, or removes them: `git worktree
939
+ // prune` is the one command that could silently drop their records, and
940
+ // no mode here runs it for real (archive's prune checks are --dry-run).
941
+ function externalWorktreesOf(gitFn, rootDir) {
942
+ const out = [];
943
+ for (const owned of workspaceRepos(rootDir)) {
944
+ const listed = registeredWorktrees(gitFn, owned.dir);
945
+ if (listed === null) {
946
+ out.push({ repo: owned.repo, error: `could not list worktrees of ${relative(rootDir, owned.dir) || 'the workspace repo'}` });
947
+ continue;
948
+ }
949
+ for (const p of listed) {
950
+ if (!insideRoot(rootDir, p)) out.push({ repo: owned.repo, path: p });
951
+ }
952
+ }
953
+ return out;
954
+ }
955
+
772
956
  /**
773
957
  * Read-only inventory of every session under the workspace's sessions
774
958
  * directory. The proposal each session gets is a proposal — the note in
775
959
  * the result says so, and the skill says so again to the operator.
776
960
  * Symlinked entries are reported as foreign (LEAVE) and never followed.
961
+ * `fetch: true` (CLI --fetch) refreshes each touched repo's tracking
962
+ * refs first; the result records what the refresh did per repo. Worktrees
963
+ * the workspace's repos register outside the root are listed as external
964
+ * — evidence for the operator, never something to act on.
777
965
  */
778
- function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now() } = {}) {
966
+ function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now(), fetch = false } = {}) {
779
967
  const rootDir = resolveRoot(root);
780
968
  const sessionsDir = sessionsDirOf(rootDir);
969
+ const fetchState = fetch ? { attempts: new Map(), now } : null;
781
970
  const sessions = listSessionEntries(rootDir).map((entry) => (
782
971
  entry.foreign
783
972
  ? {
@@ -786,13 +975,15 @@ function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now()
786
975
  proposal: 'LEAVE',
787
976
  reasons: ['entry is a symlink, not a session directory — never followed; reconcile manually'],
788
977
  }
789
- : inspectSession(gitFn, rootDir, sessionsDir, entry.name, activeDays, now)
978
+ : inspectSession(gitFn, rootDir, sessionsDir, entry.name, activeDays, now, fetchState)
790
979
  ));
791
980
  return {
792
981
  root: rootDir,
793
982
  activeDays,
794
983
  note: 'Proposals are proposals — inventory evidence only; the operator decides each session.',
795
984
  sessions,
985
+ externalWorktrees: externalWorktreesOf(gitFn, rootDir),
986
+ ...(fetch ? { fetch: [...fetchState.attempts.values()] } : {}),
796
987
  };
797
988
  }
798
989
 
@@ -1731,14 +1922,19 @@ function renderTable(result) {
1731
1922
  ];
1732
1923
  for (const s of result.sessions) {
1733
1924
  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 ?? '—'})`));
1925
+ const header = s.kind === 'broken'
1926
+ ? `ORPHAN_SHELL — ${s.empty ? 'only empty directories' : 'holds files, no worktree'}`
1927
+ : s.kind === 'foreign' ? 'foreign entry' : s.proposal;
1928
+ const detail = s.kind === 'broken' || s.kind === 'foreign'
1929
+ ? ''
1930
+ : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'}${s.chatOpen ? ', chat open?' : ''})`;
1931
+ lines.push(`${s.name} ${header}${detail}`);
1736
1932
  for (const w of s.worktrees || []) {
1737
1933
  const remotes = Object.entries(w.remotes)
1738
1934
  .map(([r, v]) => describeRemote(r, v))
1739
1935
  .join(' ') || 'no remotes';
1740
1936
  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}]`);
1937
+ lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} fetch:${describeFetchAge(w.fetchAgeMs)} [${remotes}]`);
1742
1938
  // The state bracket alone is ambiguous — "origin:none" says the remote
1743
1939
  // holds no copy of this branch, not that there is no origin. The URL
1744
1940
  // line settles it: name = exact URL for every configured remote, plus
@@ -1758,6 +1954,13 @@ function renderTable(result) {
1758
1954
  for (const r of s.reasons || []) lines.push(` · ${r}`);
1759
1955
  for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
1760
1956
  }
1957
+ if (Array.isArray(result.externalWorktrees) && result.externalWorktrees.length > 0) {
1958
+ lines.push('');
1959
+ lines.push('external worktrees (registered outside this workspace — never prune, move, or remove them):');
1960
+ for (const e of result.externalWorktrees) {
1961
+ lines.push(e.error ? ` repo "${e.repo}": ${e.error}` : ` repo "${e.repo}" ${e.path}`);
1962
+ }
1963
+ }
1761
1964
  return `${lines.join('\n')}\n`;
1762
1965
  }
1763
1966
 
@@ -1773,6 +1976,7 @@ const BOOL_FLAGS = new Map([
1773
1976
  ['--remote', 'remote'],
1774
1977
  ['--remote-allow-all', 'remoteAllowAll'],
1775
1978
  ['--allow-uncommitted', 'allowUncommitted'],
1979
+ ['--fetch', 'fetch'],
1776
1980
  ]);
1777
1981
 
1778
1982
  function parseArgs(argv) {
@@ -1786,6 +1990,7 @@ function parseArgs(argv) {
1786
1990
  remote: false,
1787
1991
  remoteAllowAll: false,
1788
1992
  allowUncommitted: false,
1993
+ fetch: false,
1789
1994
  };
1790
1995
  const rest = argv.slice(2);
1791
1996
  for (let i = 0; i < rest.length; i += 1) {
@@ -1824,6 +2029,9 @@ function parseArgs(argv) {
1824
2029
  if (!Number.isInteger(n) || n <= 0) throw new Error('--active-days must be a positive integer');
1825
2030
  args.activeDays = n;
1826
2031
  }
2032
+ if (args.fetch && args.mode !== 'inventory') {
2033
+ throw new Error('--fetch is only valid with --inventory');
2034
+ }
1827
2035
  if (args.remote && args.mode !== 'backup') {
1828
2036
  throw new Error('--remote is only valid with --backup');
1829
2037
  }
@@ -1854,7 +2062,7 @@ function main() {
1854
2062
  let out;
1855
2063
  let code = 0;
1856
2064
  if (args.mode === 'inventory') {
1857
- out = inventory(rootDir, { activeDays: args.activeDays ?? 14 });
2065
+ out = inventory(rootDir, { activeDays: args.activeDays ?? 14, fetch: args.fetch });
1858
2066
  process.stderr.write(renderTable(out));
1859
2067
  } else if (args.mode === 'backup') {
1860
2068
  out = backupSession(rootDir, {