@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.
- 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 +356 -28
- 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/maintenance-audit.mjs +47 -8
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +322 -32
- 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 +28 -8
- 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 +51 -26
|
@@ -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)
|
|
@@ -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
|
-
|
|
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).
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
* the
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
}
|