@ulysses-ai/create-workspace 0.23.2-beta.0 → 0.24.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +43 -0
  4. package/template/_claude/hooks/session-start.mjs +10 -0
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/migrate-sessions.mjs +212 -34
  15. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  16. package/template/_claude/scripts/task-pr.mjs +316 -72
  17. package/template/_claude/scripts/task-worktree.mjs +9 -1
  18. package/template/_claude/scripts/template-merge.mjs +21 -4
  19. package/template/_claude/scripts/template-modifications.mjs +152 -0
  20. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  21. package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
  22. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  23. package/template/_claude/skills/braindump/SKILL.md +1 -1
  24. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  25. package/template/_claude/skills/handoff/SKILL.md +1 -1
  26. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  27. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
  28. package/template/_claude/skills/release/SKILL.md +20 -6
  29. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  30. package/template/_claude/skills/start-work/SKILL.md +23 -5
  31. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  32. package/template/workspace.json.tmpl +2 -1
  33. package/lib/init.test.mjs +0 -75
  34. package/lib/payload.test.mjs +0 -210
  35. package/lib/scaffold.test.mjs +0 -79
  36. package/lib/upgrade.test.mjs +0 -547
@@ -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 };