@ulysses-ai/create-workspace 0.23.1-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/lib/upgrade.mjs +127 -41
- package/lib/upgrade.test.mjs +138 -14
- package/package.json +1 -1
- package/template/_claude/rules/workspace-structure.md +2 -1
- package/template/_claude/scripts/forges/gitlab.mjs +6 -1
- package/template/_claude/scripts/migrate-sessions.mjs +212 -34
- package/template/_claude/scripts/template-merge.mjs +255 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +17 -3
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
- package/template/_claude/skills/workspace-update/SKILL.md +13 -5
|
@@ -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 };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Three-way merge for the `differs` files of an upgrade payload (gh:193).
|
|
3
|
+
//
|
|
4
|
+
// classify-update.mjs files a payload file as `differs` when the workspace
|
|
5
|
+
// copy and the template's new copy both left the baseline — a local edit
|
|
6
|
+
// meeting a template change. Picking one side whole loses the other, so this
|
|
7
|
+
// script merges instead, using the template files of the version being
|
|
8
|
+
// upgraded FROM as the common ancestor. `--upgrade` stages those at
|
|
9
|
+
// .workspace-update/.template-base/ under live names (lib/upgrade.mjs owns
|
|
10
|
+
// the staging) whenever it could fetch the installed version's npm tarball;
|
|
11
|
+
// offline or unpublished, the base is absent and every file reports noBase.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node template-merge.mjs --root <dir> --payload <dir> [--baseline <file>]
|
|
15
|
+
// [--out <dir>] [--files a,b]
|
|
16
|
+
//
|
|
17
|
+
// --root the workspace root (the update worktree in the remote flow);
|
|
18
|
+
// defaults to the current working directory, never derived from
|
|
19
|
+
// this script's location
|
|
20
|
+
// --payload the staged payload; defaults to <root>/.workspace-update
|
|
21
|
+
// --baseline the baseline base content is validated against; resolves
|
|
22
|
+
// exactly like classify-update's (the root's baseline first,
|
|
23
|
+
// then the payload's reconstructed one) when omitted
|
|
24
|
+
// --out where merged text lands, mirroring each path; defaults to
|
|
25
|
+
// <payload>/.merged — NEVER the workspace file itself
|
|
26
|
+
// --files a comma-separated subset of the differs list to process
|
|
27
|
+
//
|
|
28
|
+
// For each differs file, the base at <payload>/.template-base/<path> must
|
|
29
|
+
// exist and, when the baseline records the path, hash (template-baseline's
|
|
30
|
+
// hashBytes) to the baseline's entry — otherwise the file reports noBase: a
|
|
31
|
+
// base the baseline disproves is not this workspace's ancestor, and merging
|
|
32
|
+
// against it would fabricate conflicts or silently drop local edits. With a
|
|
33
|
+
// base, `git merge-file` merges local vs base vs template; its exit status
|
|
34
|
+
// is the conflict count (0 = clean merge, >0 = conflicts, anything else =
|
|
35
|
+
// error). The merged bytes pass through untouched — a file that isn't
|
|
36
|
+
// valid UTF-8 survives a clean merge byte-exact — and a CRLF local copy is
|
|
37
|
+
// folded to LF for the merge and restored to CRLF after, so a Windows
|
|
38
|
+
// checkout neither conflicts wholesale nor loses its line-ending style.
|
|
39
|
+
//
|
|
40
|
+
// Prints JSON:
|
|
41
|
+
// { "merged": [{ path, conflicts: 0, out }],
|
|
42
|
+
// "conflicted": [{ path, conflicts: N, out }],
|
|
43
|
+
// "noBase": [path],
|
|
44
|
+
// "errors": [{ path, message }],
|
|
45
|
+
// "out": "<resolved output dir>",
|
|
46
|
+
// "templateBase": "<resolved base dir, or null when none was staged>" }
|
|
47
|
+
//
|
|
48
|
+
// Merged text — conflict markers `<<<<<<< local` / `>>>>>>> template`
|
|
49
|
+
// included — lands ONLY in the output dir. Applying an approved result is
|
|
50
|
+
// /workspace-update's job: copying the file into the workspace one
|
|
51
|
+
// operator-approved file at a time, never silently.
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
existsSync,
|
|
55
|
+
mkdirSync,
|
|
56
|
+
mkdtempSync,
|
|
57
|
+
readFileSync,
|
|
58
|
+
realpathSync,
|
|
59
|
+
rmSync,
|
|
60
|
+
writeFileSync,
|
|
61
|
+
} from 'node:fs';
|
|
62
|
+
import { tmpdir } from 'node:os';
|
|
63
|
+
import { dirname, join, resolve } from 'node:path';
|
|
64
|
+
import { fileURLToPath } from 'node:url';
|
|
65
|
+
import { spawnSync } from 'node:child_process';
|
|
66
|
+
import { classifyUpdate, resolveBaseline } from './classify-update.mjs';
|
|
67
|
+
import { hashBytes } from './template-baseline.mjs';
|
|
68
|
+
|
|
69
|
+
export const TEMPLATE_BASE_DIR = '.template-base';
|
|
70
|
+
export const MERGED_DIR = '.merged';
|
|
71
|
+
|
|
72
|
+
function isMainModule(metaUrl) {
|
|
73
|
+
if (!process.argv[1]) return false;
|
|
74
|
+
try {
|
|
75
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseArgs(argv) {
|
|
80
|
+
const args = { root: process.cwd(), payload: null, baseline: null, out: null, files: null };
|
|
81
|
+
for (let i = 2; i < argv.length; i++) {
|
|
82
|
+
const a = argv[i];
|
|
83
|
+
if (a === '--root') args.root = argv[++i];
|
|
84
|
+
else if (a === '--payload') args.payload = argv[++i];
|
|
85
|
+
else if (a === '--baseline') args.baseline = argv[++i];
|
|
86
|
+
else if (a === '--out') args.out = argv[++i];
|
|
87
|
+
else if (a === '--files') args.files = argv[++i];
|
|
88
|
+
else throw new Error(`Unknown arg: ${a}`);
|
|
89
|
+
}
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// git merge-file's exit status is the conflict count, truncated to 127; a
|
|
94
|
+
// status outside 0–127 (or a spawn failure, or binary input git refuses to
|
|
95
|
+
// merge) is an error, not a merge.
|
|
96
|
+
const MAX_CONFLICT_STATUS = 127;
|
|
97
|
+
const MERGE_BUFFER = 16 * 1024 * 1024;
|
|
98
|
+
|
|
99
|
+
function gitMergeFile(local, base, template) {
|
|
100
|
+
// -p prints the merged text to stdout instead of overwriting <local> —
|
|
101
|
+
// the workspace file is never touched. The -L labels name the three sides
|
|
102
|
+
// in the order the files follow, so conflict hunks read
|
|
103
|
+
// `<<<<<<< local` … `>>>>>>> template`. Buffers end to end (no encoding):
|
|
104
|
+
// decoding to a string would turn every non-UTF-8 byte into U+FFFD and
|
|
105
|
+
// corrupt a "clean" merge's output.
|
|
106
|
+
const r = spawnSync('git', [
|
|
107
|
+
'merge-file', '-p',
|
|
108
|
+
'-L', 'local', '-L', 'base', '-L', 'template',
|
|
109
|
+
local, base, template,
|
|
110
|
+
], { maxBuffer: MERGE_BUFFER });
|
|
111
|
+
if (r.error || r.status === null || r.status < 0 || r.status > MAX_CONFLICT_STATUS) {
|
|
112
|
+
const detail = (r.stderr && r.stderr.toString('utf8').trim())
|
|
113
|
+
|| (r.error && r.error.message)
|
|
114
|
+
|| `git merge-file exited with status ${r.status}`;
|
|
115
|
+
return { error: detail };
|
|
116
|
+
}
|
|
117
|
+
return { conflicts: r.status, bytes: r.stdout ?? Buffer.alloc(0) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- line endings ----------
|
|
121
|
+
//
|
|
122
|
+
// The baseline's hashes fold CRLF to LF, but git merge-file compares bytes:
|
|
123
|
+
// a CRLF working copy against LF base and template inputs conflicts on
|
|
124
|
+
// every line of the file. When the local copy is CRLF text, fold all three
|
|
125
|
+
// inputs to LF in temp files for the merge, then restore the local style on
|
|
126
|
+
// the result. Binary (NUL-bearing) and LF-local files take the byte-exact
|
|
127
|
+
// path with no temp files.
|
|
128
|
+
|
|
129
|
+
// latin1 round-trips bytes 1:1 — the same trick hashBytes uses — so the
|
|
130
|
+
// fold never mangles bytes that aren't valid UTF-8.
|
|
131
|
+
function foldCrLf(bytes) {
|
|
132
|
+
if (bytes.includes(0)) return bytes;
|
|
133
|
+
return Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isCrlfText(bytes) {
|
|
137
|
+
return !bytes.includes(0) && bytes.includes('\r\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeTempInput(tmpDir, name, bytes) {
|
|
141
|
+
const p = join(tmpDir, name);
|
|
142
|
+
writeFileSync(p, bytes);
|
|
143
|
+
return p;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Merge one file trio, returning { conflicts, bytes } or { error }. When
|
|
148
|
+
* the local copy is CRLF text the merge runs on LF-folded copies under a
|
|
149
|
+
* temp dir (removed afterwards) and the result comes back in the local
|
|
150
|
+
* CRLF style — every `\n` in the folded output stood for a line the local
|
|
151
|
+
* file ends with CRLF. The payload's base and template files, and the
|
|
152
|
+
* workspace file, are only ever read here.
|
|
153
|
+
*/
|
|
154
|
+
function mergeTrio(local, base, template) {
|
|
155
|
+
const localBytes = readFileSync(local);
|
|
156
|
+
if (!isCrlfText(localBytes)) return gitMergeFile(local, base, template);
|
|
157
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'template-merge-'));
|
|
158
|
+
try {
|
|
159
|
+
const merged = gitMergeFile(
|
|
160
|
+
writeTempInput(tmpDir, 'local', foldCrLf(localBytes)),
|
|
161
|
+
writeTempInput(tmpDir, 'base', foldCrLf(readFileSync(base))),
|
|
162
|
+
writeTempInput(tmpDir, 'template', foldCrLf(readFileSync(template))),
|
|
163
|
+
);
|
|
164
|
+
if (merged.error) return merged;
|
|
165
|
+
const crlf = Buffer.from(merged.bytes.toString('latin1').replace(/\n/g, '\r\n'), 'latin1');
|
|
166
|
+
return { conflicts: merged.conflicts, bytes: crlf };
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Merge the payload's `differs` files three-way. Classification comes from
|
|
174
|
+
* classifyUpdate (imported, not re-derived) so the merge set is exactly what
|
|
175
|
+
* Step 2 of /workspace-update showed the operator. Merged text — clean or
|
|
176
|
+
* conflicted — is written under `out` (default <payload>/.merged/) mirroring
|
|
177
|
+
* each path; the workspace files themselves are never written here.
|
|
178
|
+
*/
|
|
179
|
+
export function mergeTemplateFiles({ root, payload = null, baseline = null, out = null, files = null }) {
|
|
180
|
+
const absRoot = resolve(root);
|
|
181
|
+
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
182
|
+
if (!existsSync(absPayload)) {
|
|
183
|
+
throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const classification = classifyUpdate({ root: absRoot, payload: absPayload, baseline });
|
|
187
|
+
const differsSet = new Set(classification.differs);
|
|
188
|
+
const baseDir = join(absPayload, TEMPLATE_BASE_DIR);
|
|
189
|
+
|
|
190
|
+
const result = {
|
|
191
|
+
merged: [],
|
|
192
|
+
conflicted: [],
|
|
193
|
+
noBase: [],
|
|
194
|
+
errors: [],
|
|
195
|
+
out: resolve(out ?? join(absPayload, MERGED_DIR)),
|
|
196
|
+
templateBase: existsSync(baseDir) ? baseDir : null,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let targets = classification.differs;
|
|
200
|
+
if (files !== null) {
|
|
201
|
+
const wanted = files.split(',').map((s) => s.trim()).filter(Boolean);
|
|
202
|
+
targets = [];
|
|
203
|
+
for (const rel of wanted) {
|
|
204
|
+
if (differsSet.has(rel)) targets.push(rel);
|
|
205
|
+
else result.errors.push({ path: rel, message: 'not classified as differs — only differs files can be merged' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { baseline: resolvedBaseline } = resolveBaseline({ root: absRoot, payload: absPayload, baseline });
|
|
210
|
+
for (const rel of targets) {
|
|
211
|
+
const local = join(absRoot, rel);
|
|
212
|
+
const template = join(absPayload, rel);
|
|
213
|
+
const base = join(baseDir, rel);
|
|
214
|
+
if (!existsSync(local) || !existsSync(template)) {
|
|
215
|
+
result.errors.push({ path: rel, message: 'local or template copy missing' });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(base)) {
|
|
219
|
+
result.noBase.push(rel);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const baselineHash = resolvedBaseline ? resolvedBaseline.files[rel] : undefined;
|
|
223
|
+
if (baselineHash !== undefined && hashBytes(readFileSync(base)) !== baselineHash) {
|
|
224
|
+
result.noBase.push(rel);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const merge = mergeTrio(local, base, template);
|
|
228
|
+
if (merge.error) {
|
|
229
|
+
result.errors.push({ path: rel, message: merge.error });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const outFile = join(result.out, rel);
|
|
233
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
234
|
+
writeFileSync(outFile, merge.bytes);
|
|
235
|
+
const entry = { path: rel, conflicts: merge.conflicts, out: outFile };
|
|
236
|
+
if (merge.conflicts === 0) result.merged.push(entry);
|
|
237
|
+
else result.conflicted.push(entry);
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function main() {
|
|
243
|
+
const args = parseArgs(process.argv);
|
|
244
|
+
const result = mergeTemplateFiles(args);
|
|
245
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isMainModule(import.meta.url)) {
|
|
249
|
+
try {
|
|
250
|
+
main();
|
|
251
|
+
} catch (err) {
|
|
252
|
+
process.stderr.write(`template-merge: ${err.message}\n`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|