@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.
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +201 -21
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +33 -9
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-update/SKILL.md +34 -22
|
@@ -26,14 +26,97 @@ function readWorkspace(rootDir) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Same remote shapes the forge adapters resolve a repo from
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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 /
|
|
11
|
-
// listing every worktree's remotes by
|
|
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
|
-
|
|
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).
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
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, {
|