@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.23.2-beta.0",
3
+ "version": "0.23.3-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -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
- // (they exist only on this machine) unless --allow-unbacked
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. Draining a session means taking it out of the
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: patterns silently drop the peeled ^{} lines,
1070
- // which are the ones comparable with commit tips. A failed or timed-out
1071
- // query returns null (unknown) — it proves nothing.
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 — they exist only on this machine, and the
1797
- * archive is safe for them but the moment it is deleted they are gone
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
- * that carried unbacked tips reports them in `unbacked`). Also refused
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 — 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.
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
- if (info.ahead > 0 && !info.backedBy) unbacked.push(info);
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
- `${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`,
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
- 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`),
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 — remainingSessions is null and
1962
- * the note says where the real list comes from.
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
- const linked = isLinkedWorktree(gitFn, rootDir);
1979
- return linked
1980
- ? {
1981
- sessionModel: 'task',
1982
- remainingSessions: null,
1983
- note: 'run from a task worktree — remaining sessions come from --inventory at the launcher root',
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 --archive');
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) throw new Error(`Could not parse issue number from: ${stdout.trim()}`);
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 never deletes anything.
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` refuses while any tip holds commits no remote backs, and proceeds only with `--allow-unbacked`, the operator's recorded no after seeing 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 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 a remote holds the tips (a pushed backup), 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), 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 command re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
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 -e "const fs=require('fs');const p=process.argv[1];const empty=d=>fs.readdirSync(d,{withFileTypes:true}).every(e=>e.isDirectory()&&empty(d+'/'+e.name));if(!empty(p)){console.error(p+' is not empty — left alone');process.exit(1)}fs.rmSync(p,{recursive:true});console.log('removed empty shell '+p)" work-sessions/{name}
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/`, then create, ask before merging, and pull the launcher after the merge:
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, fast-forward the launcher's default branch, remove 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
- The switch output reports `remainingSessions: null` when run from the worktree — the real remaining-sessions list comes from 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.
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