@ulysses-ai/create-workspace 0.23.2-beta.0 → 0.23.3-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/package.json
CHANGED
|
@@ -47,14 +47,24 @@
|
|
|
47
47
|
// worktree holds uncommitted changes (an edited session.md
|
|
48
48
|
// counts) unless --allow-uncommitted says leave them be,
|
|
49
49
|
// and when a worktree tip holds commits no remote backs
|
|
50
|
-
//
|
|
50
|
+
// on a branch or under a drain/{session}/* tag (they
|
|
51
|
+
// exist only on this machine) unless --allow-unbacked
|
|
51
52
|
// records that the operator saw the counts and declined
|
|
52
53
|
// the backup.
|
|
54
|
+
// --remove-shell
|
|
55
|
+
// remove an ORPHAN_SHELL session folder — no worktree,
|
|
56
|
+
// nothing but empty directories, re-verified empty at
|
|
57
|
+
// removal time; anything else refuses, touching nothing
|
|
53
58
|
// --enable-task-model
|
|
54
59
|
// flip workspace.sessionModel to "task" (accepts a task
|
|
55
|
-
// worktree root — the one mode allowed off the launcher
|
|
60
|
+
// worktree root — the one mode allowed off the launcher;
|
|
61
|
+
// --launcher <root> counts the remaining sessions from
|
|
62
|
+
// the launcher when --root is that worktree)
|
|
56
63
|
//
|
|
57
|
-
// NOTHING HERE DELETES
|
|
64
|
+
// NOTHING HERE DELETES — with one deliberate exception: --remove-shell,
|
|
65
|
+
// whose target is a folder provably empty of files and symlinks
|
|
66
|
+
// (re-verified at removal time, so a plain directory tree is all it can
|
|
67
|
+
// ever lose). Draining a session otherwise means taking it out of the
|
|
58
68
|
// active lifecycle, not destroying it. An earlier design tore sessions
|
|
59
69
|
// down behind a "prove it is safe to delete" check; four independent
|
|
60
70
|
// reviews each found a new place git keeps state that the check missed
|
|
@@ -80,7 +90,7 @@
|
|
|
80
90
|
// reasons: [...]} and exits 1; any other error goes to stderr and exits 2.
|
|
81
91
|
|
|
82
92
|
import {
|
|
83
|
-
readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, statSync, mkdirSync, renameSync, readlinkSync,
|
|
93
|
+
readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, statSync, mkdirSync, renameSync, readlinkSync, rmSync,
|
|
84
94
|
} from 'node:fs';
|
|
85
95
|
import { realpathSync } from 'node:fs';
|
|
86
96
|
import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path';
|
|
@@ -633,6 +643,7 @@ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath, fetchState) {
|
|
|
633
643
|
repo,
|
|
634
644
|
path: relative(rootDir, realPath(wtPath)),
|
|
635
645
|
branch,
|
|
646
|
+
head,
|
|
636
647
|
dirty: dirtyRecords.length,
|
|
637
648
|
ahead: aheadCount(gitFn, wtPath, range),
|
|
638
649
|
lastOwnCommit: lastOwnCommitIso(gitFn, wtPath, range),
|
|
@@ -1066,9 +1077,12 @@ function remoteQualifies(safety, repoDir, remote) {
|
|
|
1066
1077
|
|
|
1067
1078
|
// Every branch and tag a remote holds, mapped by commit sha. Other
|
|
1068
1079
|
// advertised refs (HEAD, a forge's refs/pull/N/*) are ephemeral and never
|
|
1069
|
-
// count. No refspec pattern:
|
|
1070
|
-
//
|
|
1071
|
-
//
|
|
1080
|
+
// count. No refspec pattern: some git versions drop the peeled ^{} lines
|
|
1081
|
+
// under a pattern, and those lines are the ones comparable with commit
|
|
1082
|
+
// tips (drainTagSafety below does use one — it must name a single
|
|
1083
|
+
// session's tags — and compensates by peeling locally instead of trusting
|
|
1084
|
+
// the peel line). A failed or timed-out query returns null (unknown) — it
|
|
1085
|
+
// proves nothing.
|
|
1072
1086
|
function remoteRefs(safety, repoDir, remote) {
|
|
1073
1087
|
const key = `${repoDir}\0${remote}`;
|
|
1074
1088
|
if (safety.lsCache.has(key)) return safety.lsCache.get(key);
|
|
@@ -1101,6 +1115,75 @@ function tipSafety(safety, repoDir, sha) {
|
|
|
1101
1115
|
return { safe: false };
|
|
1102
1116
|
}
|
|
1103
1117
|
|
|
1118
|
+
// The archive gate's tag half: --backup --remote is what puts
|
|
1119
|
+
// drain/{session}/{branch-slug} tags on remotes, so the gate must count its
|
|
1120
|
+
// own backup or the documented "backup, then archive" path always refuses.
|
|
1121
|
+
// A tip is backed by tag when a qualifying remote holds a
|
|
1122
|
+
// drain/{session}/* tag whose peeled commit CONTAINS the tip — the tip
|
|
1123
|
+
// itself (what the backup tagged) or any descendant. The query narrows to
|
|
1124
|
+
// the session with a refspec pattern AND every returned line is re-checked
|
|
1125
|
+
// against `refs/tags/drain/{session}/` after stripping a trailing `^{}` —
|
|
1126
|
+
// the pattern is the query, the prefix is the answer's meaning, and a tag
|
|
1127
|
+
// like drain/{session}-suffix/… must never satisfy the other session's
|
|
1128
|
+
// gate. A session name containing glob metacharacters (* ? [ ]) has no
|
|
1129
|
+
// honest pattern — git forbids those characters in ref names anyway, so no
|
|
1130
|
+
// drain tag can exist for it — and is refused up front rather than escaped
|
|
1131
|
+
// into meaning something else. The peel is resolved locally
|
|
1132
|
+
// (cat-file/merge-base peel tag objects; the backup run left the tag
|
|
1133
|
+
// object in the local store), which keeps the check fetch-free and never
|
|
1134
|
+
// dependent on the peeled ^{} line: current git returns it under a
|
|
1135
|
+
// pattern, older gits drop it (the reason remoteRefs above avoids
|
|
1136
|
+
// patterns), and the plain line answers either way once the object is
|
|
1137
|
+
// local. Without the object nothing can be verified and the tip counts as
|
|
1138
|
+
// not backed. A local-only tag proves nothing — the remote is asked
|
|
1139
|
+
// directly — and a failed or timed-out query is reported as a blocker and
|
|
1140
|
+
// counts as not backed (the safe direction: an unreachable remote cannot
|
|
1141
|
+
// vouch for anything).
|
|
1142
|
+
function drainTagSafety(safety, session, wtPath, tip) {
|
|
1143
|
+
const blockers = [];
|
|
1144
|
+
if (/[*?[\]]/.test(session)) {
|
|
1145
|
+
blockers.push(`cannot build a drain-tag pattern for session name "${session}" (it contains glob metacharacters, which git forbids in ref names — no drain tag can exist for it)`);
|
|
1146
|
+
return { backed: false, remote: null, tag: null, blockers };
|
|
1147
|
+
}
|
|
1148
|
+
const out = { backed: false, remote: null, tag: null, blockers };
|
|
1149
|
+
const prefix = `refs/tags/drain/${session}/`;
|
|
1150
|
+
const pattern = `${prefix}*`;
|
|
1151
|
+
for (const remote of safety.remotes(wtPath)) {
|
|
1152
|
+
if (!remoteQualifies(safety, wtPath, remote)) continue;
|
|
1153
|
+
const res = safety.gitFn('git', ['-C', wtPath, 'ls-remote', '--tags', remote, pattern], netOpts(LS_REMOTE_TIMEOUT_MS));
|
|
1154
|
+
if (res.error || res.status !== 0) {
|
|
1155
|
+
// "timed out" only when spawnSync actually timed out; anything else
|
|
1156
|
+
// carries its own message (or git's stderr).
|
|
1157
|
+
const detail = res.error
|
|
1158
|
+
? (res.error.code === 'ETIMEDOUT'
|
|
1159
|
+
? `timed out after ${LS_REMOTE_TIMEOUT_MS / 1000}s`
|
|
1160
|
+
: String(res.error.message || res.error))
|
|
1161
|
+
: (String(res.stderr || '').trim() || 'ls-remote failed');
|
|
1162
|
+
out.blockers.push(`could not reach remote "${remote}" (${detail})`);
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
for (const line of okLines(res)) {
|
|
1166
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
1167
|
+
// Explicit session filter: strip the peel suffix first, so both line
|
|
1168
|
+
// kinds are held to the same `drain/{session}/` prefix.
|
|
1169
|
+
const bare = ref && ref.endsWith('^{}') ? ref.slice(0, -3) : ref;
|
|
1170
|
+
if (!sha || !ref || !bare.startsWith(prefix)) continue;
|
|
1171
|
+
if (sha === tip) {
|
|
1172
|
+
out.backed = true; out.remote = remote; out.tag = bare.slice('refs/tags/'.length);
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
const commit = `${sha}^{commit}`; // no-op for a peel line, peels a plain tag-object sha
|
|
1176
|
+
const known = run(safety.gitFn, wtPath, ['cat-file', '-e', commit]).status === 0;
|
|
1177
|
+
if (known && run(safety.gitFn, wtPath, ['merge-base', '--is-ancestor', tip, commit]).status === 0) {
|
|
1178
|
+
out.backed = true; out.remote = remote; out.tag = bare.slice('refs/tags/'.length);
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (out.backed) break;
|
|
1183
|
+
}
|
|
1184
|
+
return out;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1104
1187
|
function makeSafety(gitFn, rootDir) {
|
|
1105
1188
|
const safety = {
|
|
1106
1189
|
gitFn,
|
|
@@ -1793,11 +1876,13 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
|
|
|
1793
1876
|
* session.md counts (unless allowUncommitted: they would ride along
|
|
1794
1877
|
* fine, but they deserve a decision: commit them to the session branch,
|
|
1795
1878
|
* or explicitly accept archiving them mid-edit); or a worktree tip holds
|
|
1796
|
-
* commits no remote backs —
|
|
1797
|
-
*
|
|
1879
|
+
* commits no remote backs — on a branch, or under a drain/{session}/* tag
|
|
1880
|
+
* the backup step pushed there — so they exist only on this machine, and
|
|
1881
|
+
* the archive is safe for them but the moment it is deleted they are gone
|
|
1798
1882
|
* (unless allowUnbacked, the operator's recorded decline after seeing
|
|
1799
1883
|
* the per-repo counts; the refusal names them, and a successful archive
|
|
1800
|
-
*
|
|
1884
|
+
* reports tips those tags backed in `backedByTag` and tips that rode
|
|
1885
|
+
* along unbacked in `unbacked`). Also refused
|
|
1801
1886
|
* when the archive directory is a symlink or resolves outside the
|
|
1802
1887
|
* workspace. If anything fails after the rename, the folder is renamed
|
|
1803
1888
|
* back and repaired, and the result reports the verified state.
|
|
@@ -1873,10 +1958,15 @@ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked
|
|
|
1873
1958
|
// own call. A tip whose commits no remote backs (the inventory's
|
|
1874
1959
|
// `unbacked` evidence, recomputed here at archive time) exists only on
|
|
1875
1960
|
// this machine, so the archive moves only once a remote holds every
|
|
1876
|
-
// such tip —
|
|
1877
|
-
// after --allow-unbacked records that
|
|
1878
|
-
//
|
|
1961
|
+
// such tip — on a branch, or under a drain/{session}/* tag the backup
|
|
1962
|
+
// step's push mode put there — or after --allow-unbacked records that
|
|
1963
|
+
// the operator saw these counts and declined the backup. allowUnbacked
|
|
1964
|
+
// still reports what rode along.
|
|
1879
1965
|
const unbacked = [];
|
|
1966
|
+
const backedByTag = [];
|
|
1967
|
+
const tagBlockers = [];
|
|
1968
|
+
const wtInfos = [];
|
|
1969
|
+
const safety = makeSafety(gitFn, rootDir);
|
|
1880
1970
|
for (const f of found) {
|
|
1881
1971
|
const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
|
|
1882
1972
|
const info = inspectWorktree(
|
|
@@ -1884,12 +1974,25 @@ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked
|
|
|
1884
1974
|
f.owner.repo === WORKSPACE_REPO ? 'workspace' : 'project',
|
|
1885
1975
|
f.owner.repo, wtPath, null,
|
|
1886
1976
|
);
|
|
1887
|
-
|
|
1977
|
+
wtInfos.push(info);
|
|
1978
|
+
if (!(info.ahead > 0) || info.backedBy) continue;
|
|
1979
|
+
const tag = info.head
|
|
1980
|
+
? drainTagSafety(safety, session, wtPath, info.head)
|
|
1981
|
+
: { backed: false, remote: null, tag: null, blockers: [] };
|
|
1982
|
+
if (tag.backed) {
|
|
1983
|
+
backedByTag.push({ repo: info.repo, branch: info.branch, commits: info.ahead, tag: tag.tag, remote: tag.remote });
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1986
|
+
for (const b of tag.blockers) {
|
|
1987
|
+
tagBlockers.push(`${repoLabel(info)}: the drain-tag check did not clear — ${b} — treated as not backed; resolve it or decide with --allow-unbacked`);
|
|
1988
|
+
}
|
|
1989
|
+
unbacked.push(info);
|
|
1888
1990
|
}
|
|
1889
1991
|
if (unbacked.length > 0 && !allowUnbacked) {
|
|
1890
1992
|
reasons.push(
|
|
1891
1993
|
...unbacked.map((wt) => unbackedMessage(gitFn, rootDir, wt)),
|
|
1892
|
-
|
|
1994
|
+
...tagBlockers,
|
|
1995
|
+
`${unbacked.length} worktree tip(s) above hold commits that exist only on this machine — this clears only when a remote holds them (a pushed branch, or --backup --remote with the operator's allow pushing backup tags there), or re-run with --allow-unbacked once the operator has seen these counts and explicitly declined the backup`,
|
|
1893
1996
|
);
|
|
1894
1997
|
}
|
|
1895
1998
|
if (reasons.length > 0) return { refused: true, reasons };
|
|
@@ -1929,19 +2032,60 @@ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked
|
|
|
1929
2032
|
return { refused: true, reasons: [...problems, state] };
|
|
1930
2033
|
}
|
|
1931
2034
|
|
|
2035
|
+
// The archived worktrees keep their branches checked out, so those names
|
|
2036
|
+
// stay taken until the archive is deleted — the result says so, naming
|
|
2037
|
+
// them, so the next task that wants one of those names learns why not.
|
|
2038
|
+
const heldBranches = [...new Set(wtInfos.map((w) => w.branch).filter(Boolean))];
|
|
1932
2039
|
return {
|
|
1933
2040
|
session,
|
|
1934
2041
|
archived: true,
|
|
1935
2042
|
from: relative(rootDir, folder),
|
|
1936
2043
|
to: relative(rootDir, dest),
|
|
1937
2044
|
worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
|
|
2045
|
+
// Tips the backup's drain tags cover on a remote — backed, just not on
|
|
2046
|
+
// a branch.
|
|
2047
|
+
...(backedByTag.length > 0 ? { backedByTag } : {}),
|
|
1938
2048
|
// What the operator accepted riding along unbacked — the same
|
|
1939
2049
|
// repos and counts the refusal would have named.
|
|
1940
2050
|
...(unbacked.length > 0 ? { unbacked: unbacked.map((wt) => ({ repo: wt.repo, branch: wt.branch, commits: wt.ahead })) } : {}),
|
|
1941
|
-
|
|
2051
|
+
...(heldBranches.length > 0 ? {
|
|
2052
|
+
heldBranches,
|
|
2053
|
+
note: `the archived worktrees keep ${heldBranches.join(', ')} checked out — a new task cannot reuse ${heldBranches.length === 1 ? 'that branch name' : 'those branch names'} until the archive is deleted`,
|
|
2054
|
+
} : {}),
|
|
2055
|
+
warnings: [
|
|
2056
|
+
...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`),
|
|
2057
|
+
...tagBlockers,
|
|
2058
|
+
],
|
|
1942
2059
|
};
|
|
1943
2060
|
}
|
|
1944
2061
|
|
|
2062
|
+
// The one thing this script removes: an ORPHAN_SHELL — a session folder
|
|
2063
|
+
// with no worktree left in it and nothing but empty directories, verified
|
|
2064
|
+
// EMPTY AT REMOVAL TIME, not at inventory time. The inventory's `empty`
|
|
2065
|
+
// flag is evidence a shell was empty when read; anything that appeared
|
|
2066
|
+
// since (a stray file, a symlink, a .git marker) means the folder is
|
|
2067
|
+
// content now, and content is archive's to keep, never removal's to
|
|
2068
|
+
// delete. A refusal touches nothing.
|
|
2069
|
+
function removeShell(root, { session, cwd = process.cwd() } = {}) {
|
|
2070
|
+
const rootDir = resolveRoot(root);
|
|
2071
|
+
if (!isSessionSegment(session)) {
|
|
2072
|
+
throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
|
|
2073
|
+
}
|
|
2074
|
+
const sessionsDir = sessionsDirOf(rootDir);
|
|
2075
|
+
const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
|
|
2076
|
+
if (refusal) return refusal;
|
|
2077
|
+
if (!shellIsEmpty(folder)) {
|
|
2078
|
+
return {
|
|
2079
|
+
refused: true,
|
|
2080
|
+
reasons: [
|
|
2081
|
+
`session "${session}" holds files or symlinks, not just empty directories — nothing was removed; archive it instead (--archive keeps everything) or reconcile manually`,
|
|
2082
|
+
],
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
rmSync(folder, { recursive: true, force: true });
|
|
2086
|
+
return { session, removed: true, from: relative(rootDir, folder) };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1945
2089
|
// The launcher root is the main worktree of the workspace repo; every
|
|
1946
2090
|
// other checkout is a linked worktree. Acting modes require the
|
|
1947
2091
|
// launcher; --enable-task-model is the exception (it edits workspace.json
|
|
@@ -1958,11 +2102,39 @@ function isLinkedWorktree(gitFn, rootDir) {
|
|
|
1958
2102
|
* Switch the workspace to the task model: flip workspace.sessionModel to
|
|
1959
2103
|
* "task", keeping every other key untouched. From the launcher this also
|
|
1960
2104
|
* reports the remaining sessions; from a task worktree (the S6 flow)
|
|
1961
|
-
* there is no sessions directory to read —
|
|
1962
|
-
*
|
|
2105
|
+
* there is no sessions directory to read — pass `launcher` (CLI
|
|
2106
|
+
* `--launcher <root>`) to count them from the launcher anyway, or get
|
|
2107
|
+
* remainingSessions null with a note saying where the real list comes
|
|
2108
|
+
* from. The launcher is validated before anything is edited: it must be
|
|
2109
|
+
* the launcher of the SAME repository as --root (a different workspace's
|
|
2110
|
+
* launcher would happily count that workspace's sessions), and a --root
|
|
2111
|
+
* that is itself the launcher needs no launcher — only the same root
|
|
2112
|
+
* spelled again is accepted.
|
|
1963
2113
|
*/
|
|
1964
|
-
function enableTaskModel(root, { gitFn = spawnSync } = {}) {
|
|
2114
|
+
function enableTaskModel(root, { gitFn = spawnSync, launcher = null } = {}) {
|
|
1965
2115
|
const rootDir = resolveRoot(root);
|
|
2116
|
+
// Validate and resolve the launcher BEFORE editing anything: a wrong
|
|
2117
|
+
// launcher would report a wrong count, and the switch must not land
|
|
2118
|
+
// only to have the run die on the flag afterwards.
|
|
2119
|
+
const linked = isLinkedWorktree(gitFn, rootDir);
|
|
2120
|
+
let launcherDir = null;
|
|
2121
|
+
if (launcher) {
|
|
2122
|
+
launcherDir = resolveRoot(launcher);
|
|
2123
|
+
if (isLinkedWorktree(gitFn, launcherDir)) {
|
|
2124
|
+
throw new Error(`--launcher must be the workspace launcher (its own root), not a linked worktree: ${launcherDir}`);
|
|
2125
|
+
}
|
|
2126
|
+
const rootCommon = commonDirOf(gitFn, rootDir);
|
|
2127
|
+
const launcherCommon = commonDirOf(gitFn, launcherDir);
|
|
2128
|
+
if (!rootCommon || !launcherCommon) {
|
|
2129
|
+
throw new Error(`could not determine the repository of ${!rootCommon ? rootDir : launcherDir} (git rev-parse --git-common-dir failed) — refusing to guess at --launcher`);
|
|
2130
|
+
}
|
|
2131
|
+
if (rootCommon !== launcherCommon) {
|
|
2132
|
+
throw new Error(`--launcher ${launcherDir} belongs to a different repository than --root ${rootDir} — name this workspace's own launcher`);
|
|
2133
|
+
}
|
|
2134
|
+
if (!linked && rootDir !== launcherDir) {
|
|
2135
|
+
throw new Error(`--root ${rootDir} is not a task worktree, so --launcher has nothing to add (got ${launcherDir}) — omit it, or point --root at the switch worktree`);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
1966
2138
|
const cfgPath = join(rootDir, 'workspace.json');
|
|
1967
2139
|
let cfg;
|
|
1968
2140
|
try {
|
|
@@ -1975,14 +2147,13 @@ function enableTaskModel(root, { gitFn = spawnSync } = {}) {
|
|
|
1975
2147
|
// file's house format.
|
|
1976
2148
|
cfg.workspace = { ...(cfg.workspace || {}), sessionModel: 'task' };
|
|
1977
2149
|
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
1978
|
-
|
|
1979
|
-
return
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
: { sessionModel: 'task', remainingSessions: listSessionNames(rootDir) };
|
|
2150
|
+
if (!linked) return { sessionModel: 'task', remainingSessions: listSessionNames(rootDir) };
|
|
2151
|
+
if (launcherDir) return { sessionModel: 'task', remainingSessions: listSessionNames(launcherDir) };
|
|
2152
|
+
return {
|
|
2153
|
+
sessionModel: 'task',
|
|
2154
|
+
remainingSessions: null,
|
|
2155
|
+
note: 'run from a task worktree — pass --launcher <launcher root> to count the remaining sessions from it, or run --inventory at the launcher root',
|
|
2156
|
+
};
|
|
1986
2157
|
}
|
|
1987
2158
|
|
|
1988
2159
|
// Human-readable inventory rendering for stderr — the operator's table;
|
|
@@ -2037,12 +2208,13 @@ function renderTable(result) {
|
|
|
2037
2208
|
return `${lines.join('\n')}\n`;
|
|
2038
2209
|
}
|
|
2039
2210
|
|
|
2040
|
-
const MODE_FLAGS = new Set(['--inventory', '--backup', '--archive', '--enable-task-model']);
|
|
2211
|
+
const MODE_FLAGS = new Set(['--inventory', '--backup', '--archive', '--enable-task-model', '--remove-shell']);
|
|
2041
2212
|
const VALUE_FLAGS = new Map([
|
|
2042
2213
|
['--root', 'root'],
|
|
2043
2214
|
['--session', 'session'],
|
|
2044
2215
|
['--active-days', 'activeDays'],
|
|
2045
2216
|
['--remote-allow', 'remoteAllow'], // repeatable: <repo>=<remote>
|
|
2217
|
+
['--launcher', 'launcher'], // --enable-task-model only: count sessions from here
|
|
2046
2218
|
]);
|
|
2047
2219
|
const BOOL_FLAGS = new Map([
|
|
2048
2220
|
['--dry-run', 'dryRun'],
|
|
@@ -2060,6 +2232,7 @@ function parseArgs(argv) {
|
|
|
2060
2232
|
session: null,
|
|
2061
2233
|
activeDays: null,
|
|
2062
2234
|
remoteAllow: [],
|
|
2235
|
+
launcher: null,
|
|
2063
2236
|
dryRun: false,
|
|
2064
2237
|
remote: false,
|
|
2065
2238
|
remoteAllowAll: false,
|
|
@@ -2088,12 +2261,15 @@ function parseArgs(argv) {
|
|
|
2088
2261
|
}
|
|
2089
2262
|
throw new Error(`unknown argument: ${a}`);
|
|
2090
2263
|
}
|
|
2091
|
-
if (!args.mode) throw new Error('one of --inventory, --backup, --archive, --enable-task-model is required');
|
|
2092
|
-
if ((args.mode === 'backup' || args.mode === 'archive') && !args.session) {
|
|
2264
|
+
if (!args.mode) throw new Error('one of --inventory, --backup, --archive, --enable-task-model, --remove-shell is required');
|
|
2265
|
+
if ((args.mode === 'backup' || args.mode === 'archive' || args.mode === 'remove-shell') && !args.session) {
|
|
2093
2266
|
throw new Error(`--${args.mode} requires --session`);
|
|
2094
2267
|
}
|
|
2095
|
-
if (args.session != null && args.mode !== 'backup' && args.mode !== 'archive') {
|
|
2096
|
-
throw new Error('--session is only valid with --backup or --
|
|
2268
|
+
if (args.session != null && args.mode !== 'backup' && args.mode !== 'archive' && args.mode !== 'remove-shell') {
|
|
2269
|
+
throw new Error('--session is only valid with --backup, --archive or --remove-shell');
|
|
2270
|
+
}
|
|
2271
|
+
if (args.launcher != null && args.mode !== 'enable-task-model') {
|
|
2272
|
+
throw new Error('--launcher is only valid with --enable-task-model');
|
|
2097
2273
|
}
|
|
2098
2274
|
if (args.session != null && !isSessionSegment(args.session)) {
|
|
2099
2275
|
throw new Error(`--session must be a single path segment, got: ${args.session}`);
|
|
@@ -2156,8 +2332,10 @@ function main() {
|
|
|
2156
2332
|
allowUncommitted: args.allowUncommitted,
|
|
2157
2333
|
allowUnbacked: args.allowUnbacked,
|
|
2158
2334
|
});
|
|
2335
|
+
} else if (args.mode === 'remove-shell') {
|
|
2336
|
+
out = removeShell(rootDir, { session: args.session });
|
|
2159
2337
|
} else {
|
|
2160
|
-
out = enableTaskModel(rootDir);
|
|
2338
|
+
out = enableTaskModel(rootDir, { launcher: args.launcher });
|
|
2161
2339
|
}
|
|
2162
2340
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
2163
2341
|
if (out && out.refused) {
|
|
@@ -2181,4 +2359,4 @@ if (isMainModule(import.meta.url)) {
|
|
|
2181
2359
|
}
|
|
2182
2360
|
}
|
|
2183
2361
|
|
|
2184
|
-
export { inventory, backupSession, archiveSession, enableTaskModel, classify, parseArgs };
|
|
2362
|
+
export { inventory, backupSession, archiveSession, removeShell, enableTaskModel, classify, parseArgs };
|
|
@@ -134,13 +134,23 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
134
134
|
return issue;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// `glab issue create` prints the new issue's URL: older GitLab/glab says
|
|
138
|
+
// /-/issues/N, newer says /-/work_items/N (issues became work items
|
|
139
|
+
// under the hood) — the number is the same iid either way. If neither
|
|
140
|
+
// shape appears the issue still WAS created (glab exited 0 and the
|
|
141
|
+
// server accepted it), so the error must say so and carry glab's output
|
|
142
|
+
// — otherwise the caller retries and duplicates the issue.
|
|
137
143
|
async function createIssue({ title, body = '', labels = [], milestone = null }) {
|
|
138
144
|
const args = ['issue', 'create', '--repo', repo, '--title', title, '--description', body, '--yes'];
|
|
139
145
|
if (labels.length > 0) args.push('--label', labels.join(','));
|
|
140
146
|
if (milestone) args.push('--milestone', milestone);
|
|
141
147
|
const stdout = glab(args);
|
|
142
|
-
const m = stdout.match(/\/-\/issues\/(\d+)/);
|
|
143
|
-
if (!m)
|
|
148
|
+
const m = stdout.match(/\/-\/(?:issues|work_items)\/(\d+)/);
|
|
149
|
+
if (!m) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`glab issue create succeeded, but the new issue's number could not be parsed from its output `
|
|
152
|
+
+ `— the issue WAS created; do NOT retry (a retry would create a duplicate). glab output: ${stdout.trim()}`);
|
|
153
|
+
}
|
|
144
154
|
return getIssue(`gl:${m[1]}`);
|
|
145
155
|
}
|
|
146
156
|
|
|
@@ -162,7 +172,11 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
162
172
|
|
|
163
173
|
// The canonical URL for an issue — what a cross-forge reference needs
|
|
164
174
|
// (a `group/sub#N` reference cannot resolve on a GitHub PR), minted from
|
|
165
|
-
// the same host/repo every other URL here is built from.
|
|
175
|
+
// the same host/repo every other URL here is built from. `/-/issues/N`
|
|
176
|
+
// stays the link form even though GitLab now serves issues at work_items
|
|
177
|
+
// URLs — those links still resolve — and every method but createIssue
|
|
178
|
+
// takes `gl:N` ids rather than parsing glab's printed URLs, so this is
|
|
179
|
+
// the only other place the URL shape is chosen, deliberately.
|
|
166
180
|
function issueUrl(issueId) {
|
|
167
181
|
const num = parseIssueNumber(issueId);
|
|
168
182
|
return `https://${host}/${repo}/-/issues/${num}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: migrate-sessions
|
|
3
|
-
description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script
|
|
3
|
+
description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script deletes nothing but verified-empty orphan shells.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Migrate Sessions
|
|
@@ -40,11 +40,11 @@ For each session, lay out its evidence and ask the operator which way to go. Nev
|
|
|
40
40
|
```bash
|
|
41
41
|
node .claude/scripts/migrate-sessions.mjs --backup --session {name} --remote
|
|
42
42
|
```
|
|
43
|
-
With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive`
|
|
44
|
-
3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted. The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when
|
|
45
|
-
- **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The
|
|
43
|
+
With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive` counts what this step pushed (a tip is backed when a remote holds it on a branch or under a `drain/{session}/*` tag), so `--allow-unbacked` is needed only when the operator declined the backup — pass it only on their explicit yes naming the session, after showing them the counts.
|
|
44
|
+
3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted — the result names them (`heldBranches`). The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs on a branch or under a pushed `drain/{session}/*` tag — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when the backup pushed the tips somewhere a remote holds them, or with `--allow-unbacked`. Surface any refusal's reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move; a drain-tag check that did not clear, with the reason — an unreachable remote, a session name the pattern cannot express), report tips the result lists under `backedByTag` as backed by their drain tag, and when the result reports `unbacked` entries, say plainly that those commits now exist only on this machine.
|
|
45
|
+
- **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The scripted removal re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
|
|
46
46
|
```bash
|
|
47
|
-
node
|
|
47
|
+
node .claude/scripts/migrate-sessions.mjs --remove-shell --session {name}
|
|
48
48
|
```
|
|
49
49
|
- **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle. A kept session cannot be converted to a task in place — no converter exists, and the lifecycles keep their state differently (a session folder with a tracker vs. a branch with a chat-record entry). The supported equivalent: finish the session (merge it) and start the remaining work as a task, or keep it under the session lifecycle until it is done. Do not improvise a conversion by hand.
|
|
50
50
|
|
|
@@ -58,26 +58,33 @@ The switch procedure:
|
|
|
58
58
|
```bash
|
|
59
59
|
node .claude/scripts/task-worktree.mjs --root . --create --repo . --branch chore/enable-task-model
|
|
60
60
|
```
|
|
61
|
-
2. Run the switch against that worktree (the one mode that accepts a linked-worktree root — it only edits `workspace.json`):
|
|
61
|
+
2. Run the switch against that worktree (the one mode that accepts a linked-worktree root — it only edits `workspace.json`), passing the launcher so the remaining-session count comes back with the result:
|
|
62
62
|
```bash
|
|
63
|
-
node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model
|
|
63
|
+
node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model --launcher .
|
|
64
64
|
```
|
|
65
|
-
3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`,
|
|
65
|
+
3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`, and create the PR:
|
|
66
66
|
```bash
|
|
67
67
|
node .claude/scripts/task-pr.mjs --create --root . --branch chore/enable-task-model \
|
|
68
68
|
--repo . --body-file .=workspace-scratchpad/switch-pr.md --out workspace-scratchpad/switch-prs.json
|
|
69
|
-
node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
|
|
70
69
|
```
|
|
71
|
-
Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree
|
|
70
|
+
Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree and leave the landing to step 4:
|
|
72
71
|
```bash
|
|
73
72
|
git -C .claude/worktrees/chore-enable-task-model add workspace.json
|
|
74
73
|
git -C .claude/worktrees/chore-enable-task-model commit -m "chore: switch to the task lifecycle"
|
|
75
|
-
git -C . merge --ff-only chore/enable-task-model
|
|
76
|
-
node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
|
|
77
74
|
```
|
|
75
|
+
4. **Ask the operator before merging** — a step of its own, not a clause inside step 3, because a one-line diff is still the launcher's default branch and "it's only a one-line change" is not permission. Ask exactly: "The task-model switch is ready to merge into the launcher's default branch. Merge it now?" Only an explicit yes merges; on yes:
|
|
76
|
+
- forge remote — merge the PR, then pull the launcher:
|
|
77
|
+
```bash
|
|
78
|
+
node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
|
|
79
|
+
```
|
|
80
|
+
- no remote — fast-forward the launcher's default branch, then remove the worktree:
|
|
81
|
+
```bash
|
|
82
|
+
git -C . merge --ff-only chore/enable-task-model
|
|
83
|
+
node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
|
|
84
|
+
```
|
|
78
85
|
Either way the launcher root never commits to its default branch directly. A workspace with neither a remote nor a tracker can use the task model's local mode (gh:173); until that ships, recommend such workspaces stay on sessions.
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
With `--launcher .` the switch output reports `remainingSessions` from the launcher even though `--root` is the switch worktree (which has no `work-sessions/` to read; without the flag the result is `remainingSessions: null` with a note pointing at a separate `--inventory` at the launcher root). Remaining sessions are fine either way: they keep resuming and completing under the session lifecycle after the switch.
|
|
81
88
|
|
|
82
89
|
## 4. Verify
|
|
83
90
|
|