@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.19.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 (117) hide show
  1. package/README.md +3 -3
  2. package/lib/init.mjs +4 -1
  3. package/lib/payload.mjs +18 -1
  4. package/lib/payload.test.mjs +55 -0
  5. package/lib/scaffold.mjs +23 -6
  6. package/lib/scaffold.test.mjs +59 -0
  7. package/package.json +1 -1
  8. package/template/CLAUDE.md.tmpl +19 -2
  9. package/template/{.claude → _claude}/hooks/_utils.mjs +1 -1
  10. package/template/_claude/hooks/repo-write-detection.mjs +204 -0
  11. package/template/{.claude → _claude}/hooks/session-start.mjs +35 -1
  12. package/template/_claude/hooks/subagent-start.mjs +111 -0
  13. package/template/{.claude → _claude}/lib/session-frontmatter.mjs +28 -0
  14. package/template/{.claude → _claude}/rules/coherent-revisions.md +1 -1
  15. package/template/_claude/rules/forge-operations.md +57 -0
  16. package/template/_claude/rules/git-conventions.md +39 -0
  17. package/template/_claude/rules/goal-driven-work.md +24 -0
  18. package/template/_claude/rules/honest-pushback.md +56 -0
  19. package/template/_claude/rules/memory-guidance.md +66 -0
  20. package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +1 -1
  21. package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
  22. package/template/_claude/rules/work-item-tracking.md +48 -0
  23. package/template/_claude/rules/workspace-structure.md +79 -0
  24. package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +86 -30
  25. package/template/_claude/scripts/chat-record.mjs +315 -0
  26. package/template/_claude/scripts/cleanup-work-session.mjs +436 -0
  27. package/template/_claude/scripts/context-footprint.mjs +391 -0
  28. package/template/{.claude → _claude}/scripts/forges/github.mjs +46 -0
  29. package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +3 -2
  30. package/template/{.claude → _claude}/scripts/forges/interface.mjs +13 -0
  31. package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +21 -2
  32. package/template/_claude/scripts/migrate-sessions.mjs +1571 -0
  33. package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +7 -2
  34. package/template/_claude/scripts/task-pr.mjs +447 -0
  35. package/template/_claude/scripts/task-worktree.mjs +525 -0
  36. package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
  37. package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
  38. package/template/_claude/scripts/workspace-diagnostics.mjs +654 -0
  39. package/template/{.claude → _claude}/skills/braindump/SKILL.md +12 -4
  40. package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +5 -5
  41. package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  42. package/template/_claude/skills/complete-work/SKILL.md +452 -0
  43. package/template/_claude/skills/context-placement/SKILL.md +202 -0
  44. package/template/{.claude/rules/goal-driven-work.md → _claude/skills/goal-driven-work/SKILL.md} +46 -19
  45. package/template/{.claude → _claude}/skills/handoff/SKILL.md +12 -4
  46. package/template/{.claude → _claude}/skills/maintenance/SKILL.md +56 -17
  47. package/template/_claude/skills/migrate-sessions/SKILL.md +70 -0
  48. package/template/{.claude → _claude}/skills/pause-work/SKILL.md +9 -1
  49. package/template/_claude/skills/release/SKILL.md +91 -0
  50. package/template/{.claude → _claude}/skills/start-work/SKILL.md +89 -7
  51. package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +3 -1
  52. package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +4 -0
  53. package/template/_gitignore +9 -0
  54. package/template/workspace.json.tmpl +4 -3
  55. package/template/.claude/hooks/repo-write-detection.mjs +0 -107
  56. package/template/.claude/hooks/subagent-start.mjs +0 -44
  57. package/template/.claude/rules/forge-operations.md +0 -107
  58. package/template/.claude/rules/git-conventions.md +0 -34
  59. package/template/.claude/rules/honest-pushback.md +0 -56
  60. package/template/.claude/rules/memory-guidance.md +0 -109
  61. package/template/.claude/rules/work-item-tracking.md +0 -90
  62. package/template/.claude/rules/workspace-structure.md +0 -137
  63. package/template/.claude/scripts/cleanup-work-session.mjs +0 -247
  64. package/template/.claude/skills/complete-work/SKILL.md +0 -498
  65. package/template/.claude/skills/release/SKILL.md +0 -151
  66. /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
  67. /package/template/{.claude → _claude}/agents/implementer.md +0 -0
  68. /package/template/{.claude → _claude}/agents/researcher.md +0 -0
  69. /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
  70. /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
  71. /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
  72. /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
  73. /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
  74. /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
  75. /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
  76. /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
  77. /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
  78. /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
  79. /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
  80. /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
  81. /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
  82. /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
  83. /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
  84. /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
  85. /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
  86. /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
  87. /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
  88. /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
  89. /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
  90. /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
  91. /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
  92. /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
  93. /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
  94. /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
  95. /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
  96. /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
  97. /package/template/{.claude → _claude}/settings.json +0 -0
  98. /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
  99. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
  100. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
  101. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
  102. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
  103. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
  104. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
  105. /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
  106. /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
  107. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
  108. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
  109. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
  110. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
  111. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
  112. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
  113. /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
  114. /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
  115. /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
  116. /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
  117. /package/template/{.mcp.json → _mcp.json} +0 -0
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env node
2
+ // Tear down a work session's worktrees, branches, and folder.
3
+ //
4
+ // Teardown order is MANDATORY:
5
+ // 1. Remove each project worktree from its project repo
6
+ // 2. Remove the workspace worktree from the workspace repo
7
+ // 3. Prune each project repo (belt-and-suspenders)
8
+ // 4. Delete all local branches
9
+ // 5. Remove the whole work-sessions/{name}/ folder
10
+ //
11
+ // Workspace-first removal silently deletes the nested project worktrees'
12
+ // .git files and leaves orphan worktree records in the project repos.
13
+ // The safe order keeps both sides of the relationship in sync.
14
+ //
15
+ // State discovery is defensive: the session tracker (session.md) may have
16
+ // been stripped by /complete-work Step 7 before this script runs, leaving
17
+ // no `repos:` or `branch:` to read. When that happens we discover repos
18
+ // from the work-sessions/{name}/workspace/repos/ directory listing and the
19
+ // branch from `git branch --show-current` on the workspace worktree —
20
+ // BEFORE removing anything. Without that, the per-repo loops silently
21
+ // no-op and the script reports success while leaving orphans behind
22
+ // (gh:119).
23
+ //
24
+ // SECURITY (gh:147/B5): the tracker is session-controlled input. Every
25
+ // git call is an argv array through execFileSync — never a shell string —
26
+ // because a crafted tracker (`branch: 'x"; touch pwn; echo "'`) used to
27
+ // reach execSync as shell text and execute. Beyond injection, the
28
+ // validated values must stay INSIDE this workspace: repos entries must be
29
+ // single path segments resolving under root/repos/ (a tracker claiming
30
+ // `repos: ['../../outside']` used to delete branches in a repo outside
31
+ // the workspace), branch names must pass `git check-ref-format`, and the
32
+ // session name must be a single path segment. All validation runs before
33
+ // anything is touched.
34
+ //
35
+ // `success: true` means VERIFIED: every project worktree record is gone
36
+ // (no prunable entries left over), every local branch is deleted, and the
37
+ // session folder is removed. The script post-verifies all of these and
38
+ // surfaces any leftover state as an error rather than swallowing it. If
39
+ // any step records an error, the session folder is KEPT (N6) — removing
40
+ // it would destroy the tracker and worktrees the next run needs to
41
+ // recover with.
42
+ import '../lib/require-node.mjs';
43
+ import { execFileSync } from 'child_process';
44
+ import { existsSync, readdirSync, statSync, lstatSync, realpathSync } from 'fs';
45
+ import { join, resolve, isAbsolute, sep } from 'path';
46
+ import {
47
+ getWorkspaceRoot,
48
+ readSessionTracker,
49
+ deleteSessionFolder,
50
+ sessionFolderPath,
51
+ normalizeRepos,
52
+ } from '../hooks/_utils.mjs';
53
+
54
+ // No shell anywhere: args go to execFileSync as an array, so metachar-
55
+ // acters in tracker-controlled values are data, never syntax. A failed
56
+ // call returns {ok:false} with git's stderr — the caller decides whether
57
+ // that is a skip or an error.
58
+ function git(cwd, args) {
59
+ try {
60
+ const out = execFileSync('git', ['-C', cwd, ...args], {
61
+ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
62
+ });
63
+ return { ok: true, out };
64
+ } catch (err) {
65
+ return { ok: false, out: String(err.stdout ?? ''), err: String(err.stderr ?? err.message).trim() };
66
+ }
67
+ }
68
+
69
+ // .native resolves Windows 8.3 short names; the plain fallback covers
70
+ // filesystems where the native binding is unavailable.
71
+ function realOf(p) {
72
+ try { return realpathSync.native(p); } catch { /* fall through */ }
73
+ try { return realpathSync(p); } catch { /* fall through */ }
74
+ return resolve(p);
75
+ }
76
+
77
+ // One path segment, not dots, not absolute — the shape of a repo name
78
+ // under root/repos/. "./x", "../y", "a/b", and absolute paths all fail.
79
+ function isRepoSegment(name) {
80
+ if (typeof name !== 'string' || name === '' || isAbsolute(name)) return false;
81
+ const segs = name.split(/[\\/]/);
82
+ if (segs.length !== 1) return false;
83
+ return !/^\.+$/.test(segs[0]);
84
+ }
85
+
86
+ const args = process.argv.slice(2);
87
+ const getArg = (name) => {
88
+ const idx = args.indexOf(`--${name}`);
89
+ return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
90
+ };
91
+
92
+ const sessionName = getArg('session-name');
93
+ if (!sessionName) {
94
+ console.error('Usage: cleanup-work-session.mjs --session-name NAME');
95
+ process.exit(1);
96
+ }
97
+
98
+ // Same segment rule as the repo names: the session name becomes a path
99
+ // under the sessions directory, and traversal out of it is not cleanup.
100
+ const isSessionSegment = (name) => isRepoSegment(name);
101
+ if (!isSessionSegment(sessionName)) {
102
+ console.log(JSON.stringify({
103
+ success: false,
104
+ errors: [`invalid session name "${sessionName}" — must be a single path segment`],
105
+ }));
106
+ process.exit(1);
107
+ }
108
+
109
+ const root = getWorkspaceRoot(import.meta.url);
110
+ const reposDir = join(root, 'repos');
111
+ const sessionFolder = sessionFolderPath(root, sessionName);
112
+ const wsWorktree = join(sessionFolder, 'workspace');
113
+
114
+ const removed = [];
115
+ const skipped = [];
116
+ const errors = [];
117
+
118
+ // The real path of root/repos/ — the hard boundary every repos entry
119
+ // must resolve inside (computed once; root itself is trusted).
120
+ const reposRootReal = realOf(reposDir);
121
+
122
+ // === Discovery: where session.md is silent or missing, fall back to disk ===
123
+ //
124
+ // The tracker may have been stripped before this script runs. Read whatever
125
+ // is still there, then fill gaps from the live worktree state.
126
+
127
+ const tracker = readSessionTracker(root, sessionName);
128
+ let repos = normalizeRepos(tracker?.repos);
129
+ let branch = tracker?.branch || null;
130
+
131
+ // If repos is empty, discover from work-sessions/{name}/workspace/repos/.
132
+ // That directory is the workspace worktree's nested-project-worktrees dir;
133
+ // each entry is one project repo this session checked out.
134
+ let discovered = false;
135
+ if (repos.length === 0) {
136
+ const nestedReposDir = join(wsWorktree, 'repos');
137
+ if (existsSync(nestedReposDir)) {
138
+ try {
139
+ const found = readdirSync(nestedReposDir).filter((entry) => {
140
+ try {
141
+ return statSync(join(nestedReposDir, entry)).isDirectory();
142
+ } catch {
143
+ return false;
144
+ }
145
+ });
146
+ if (found.length > 0) {
147
+ repos = found;
148
+ discovered = true;
149
+ skipped.push({
150
+ step: 'discovery',
151
+ reason: `Tracker missing repos; discovered ${found.length} from ${nestedReposDir}: ${found.join(', ')}`,
152
+ });
153
+ }
154
+ } catch (err) {
155
+ skipped.push({ step: 'discovery', reason: `Failed to list ${nestedReposDir}: ${err.message}` });
156
+ }
157
+ }
158
+ }
159
+
160
+ // Validate every repo entry BEFORE any git runs against it (B5): each
161
+ // must be a single path segment whose real path stays inside root/repos/
162
+ // — a crafted tracker (`repos: ['../../outside']`) must never point this
163
+ // script at a repository outside the workspace. Entries discovered from
164
+ // disk are validated too: they become paths just the same.
165
+ //
166
+ // One supported exception: repos/{name} may be a SYMLINK to the source
167
+ // clone that owns the nested worktree (a layout some machines use to
168
+ // share clones). That is allowed only when the nested worktree's git
169
+ // common dir resolves into the very repository the symlink points at —
170
+ // anything else is an unverified entry and refused.
171
+ const nestedCommonDir = (repo) => {
172
+ const wt = join(wsWorktree, 'repos', repo);
173
+ if (!existsSync(wt)) return null;
174
+ const res = git(wt, ['rev-parse', '--git-common-dir']);
175
+ if (!res.ok) return null;
176
+ return realOf(resolve(wt, res.out.trim()));
177
+ };
178
+ const repoCommonDir = (repoDir) => {
179
+ const res = git(repoDir, ['rev-parse', '--git-common-dir']);
180
+ if (!res.ok) return null;
181
+ return realOf(resolve(repoDir, res.out.trim()));
182
+ };
183
+ for (const repo of repos) {
184
+ const source = discovered ? 'discovered from disk' : 'in the session tracker';
185
+ if (!isRepoSegment(repo)) {
186
+ errors.push(`Invalid repos entry "${repo}" ${source}: must be a single path segment`);
187
+ continue;
188
+ }
189
+ const dir = join(reposDir, repo);
190
+ let st = null;
191
+ try { st = lstatSync(dir); } catch { /* handled below */ }
192
+ if (st && st.isSymbolicLink()) {
193
+ const target = realOf(dir);
194
+ const targetCommon = existsSync(target) ? repoCommonDir(target) : null;
195
+ const nestedCommon = nestedCommonDir(repo);
196
+ if (!targetCommon || !nestedCommon || targetCommon !== nestedCommon) {
197
+ errors.push(`Invalid repos entry "${repo}" ${source}: repos/${repo} is a symlink whose target is not the repository the nested worktree belongs to`);
198
+ }
199
+ continue;
200
+ }
201
+ const dirReal = realOf(dir);
202
+ if (dirReal !== reposRootReal && !dirReal.startsWith(reposRootReal + sep)) {
203
+ errors.push(`Invalid repos entry "${repo}" ${source}: resolves outside ${reposDir}`);
204
+ }
205
+ }
206
+
207
+ // If branch is missing, ask the workspace worktree itself.
208
+ if (!branch && existsSync(wsWorktree)) {
209
+ const res = git(wsWorktree, ['rev-parse', '--abbrev-ref', 'HEAD']);
210
+ if (res.ok && res.out.trim() !== '' && res.out.trim() !== 'HEAD') {
211
+ branch = res.out.trim();
212
+ skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
213
+ }
214
+ }
215
+
216
+ // Removing the workspace worktree (step 2, --force) deletes everything
217
+ // still inside it. So before anything runs, every OTHER thing inside it
218
+ // must be accounted for:
219
+ // - each directory under workspace/repos/ must be a repo this teardown
220
+ // handles (a tracker listing fewer repos than the session holds must
221
+ // not decide it); stray files there are named, with Finder/Explorer
222
+ // junk exempt;
223
+ // - no repository this workspace owns may have a worktree registered
224
+ // anywhere under the workspace worktree except the ones step 1 removes
225
+ // — a worktree nested elsewhere would be deleted with its contents.
226
+ {
227
+ const NOISE = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
228
+ const nestedReposDir = join(wsWorktree, 'repos');
229
+ if (existsSync(nestedReposDir)) {
230
+ let entries = [];
231
+ try { entries = readdirSync(nestedReposDir, { withFileTypes: true }); } catch { /* unreadable: the worktree scan below still runs */ }
232
+ const unlistedDirs = entries.filter((e) => e.isDirectory() && !repos.includes(e.name)).map((e) => e.name);
233
+ const strayFiles = entries.filter((e) => !e.isDirectory() && !NOISE.has(e.name)).map((e) => e.name);
234
+ if (unlistedDirs.length > 0) {
235
+ errors.push(`${nestedReposDir} holds [${unlistedDirs.join(', ')}] that this teardown does not handle — removing the workspace worktree would delete them; add them to the session tracker or move them out first`);
236
+ }
237
+ if (strayFiles.length > 0) {
238
+ errors.push(`${nestedReposDir} holds file(s) [${strayFiles.join(', ')}] that removing the workspace worktree would delete — move or delete them first`);
239
+ }
240
+ }
241
+ const handled = new Set([realOf(wsWorktree), ...repos.map((r) => realOf(join(wsWorktree, 'repos', r)))]);
242
+ const wsReal = realOf(wsWorktree);
243
+ const ownedRepoDirs = [root];
244
+ if (existsSync(reposDir)) {
245
+ try {
246
+ for (const e of readdirSync(reposDir, { withFileTypes: true })) {
247
+ if (e.isDirectory() && isRepoSegment(e.name)) ownedRepoDirs.push(join(reposDir, e.name));
248
+ }
249
+ } catch { /* no repos dir to scan */ }
250
+ }
251
+ for (const repoDir of ownedRepoDirs) {
252
+ const res = git(repoDir, ['worktree', 'list', '--porcelain']);
253
+ if (!res.ok) continue;
254
+ for (const line of res.out.split(/\r?\n/)) {
255
+ if (!line.startsWith('worktree ')) continue;
256
+ const p = realOf(line.slice(9));
257
+ if ((p === wsReal || p.startsWith(wsReal + sep)) && !handled.has(p)) {
258
+ errors.push(`${repoDir} has a worktree at ${p}, inside the workspace worktree but not one this teardown removes — removing the workspace worktree would delete it; move or remove it first`);
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ // Branch names become refs and git command arguments; git's own format
265
+ // check is the authority and also rejects shell-metacharacter payloads
266
+ // long before any later use.
267
+ if (branch) {
268
+ const res = git(root, ['check-ref-format', '--branch', branch]);
269
+ if (!res.ok) {
270
+ errors.push(`Invalid branch "${branch}" in the session tracker: git check-ref-format rejected it`);
271
+ }
272
+ }
273
+
274
+ if (errors.length > 0) {
275
+ console.log(JSON.stringify({
276
+ success: false,
277
+ removed,
278
+ skipped: skipped.length > 0 ? skipped : undefined,
279
+ errors,
280
+ }));
281
+ process.exit(1);
282
+ }
283
+
284
+ // === Step 1: Remove each project worktree FIRST, from its project repo ===
285
+ for (const repo of repos) {
286
+ const projWorktree = join(wsWorktree, 'repos', repo);
287
+ const repoDir = join(reposDir, repo);
288
+ if (!existsSync(projWorktree)) {
289
+ skipped.push({ step: 'remove-project-worktree', repo, reason: `${projWorktree} does not exist` });
290
+ continue;
291
+ }
292
+ if (!existsSync(repoDir)) {
293
+ errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
294
+ continue;
295
+ }
296
+ const res = git(repoDir, ['worktree', 'remove', projWorktree, '--force']);
297
+ if (res.ok) {
298
+ removed.push(`project worktree ${repo}`);
299
+ } else {
300
+ errors.push(`Failed to remove ${repo} worktree: ${res.err || res.out}`);
301
+ }
302
+ }
303
+
304
+ // === HARD STOP if step 1 recorded any error ===
305
+ //
306
+ // A nested entry that could not be removed is an entry whose nature was
307
+ // never verified (a plain directory, a foreign clone, a live worktree in
308
+ // an unexpected state). Continuing would run `branch -D` in repos whose
309
+ // nested entry was not verified as its worktree, and would delete the
310
+ // session folder around whatever survived — the exact data-loss shape
311
+ // the review probes demonstrated. Stop, report, keep everything.
312
+ if (errors.length > 0) {
313
+ errors.push('Stopped after step 1 (nested worktree removal) — steps 2-5 skipped; nothing else was touched');
314
+ console.log(JSON.stringify({
315
+ success: false,
316
+ removed,
317
+ skipped: skipped.length > 0 ? skipped : undefined,
318
+ errors,
319
+ }));
320
+ process.exit(1);
321
+ }
322
+
323
+ // === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
324
+ if (existsSync(wsWorktree)) {
325
+ const res = git(root, ['worktree', 'remove', wsWorktree, '--force']);
326
+ if (res.ok) {
327
+ removed.push('workspace worktree');
328
+ } else {
329
+ errors.push(`Failed to remove workspace worktree: ${res.err || res.out}`);
330
+ }
331
+ } else {
332
+ skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
333
+ }
334
+
335
+ // === Step 3: Prune each project repo to mop up orphans ===
336
+ for (const repo of repos) {
337
+ const repoDir = join(reposDir, repo);
338
+ if (!existsSync(repoDir)) continue;
339
+ const res = git(repoDir, ['worktree', 'prune']);
340
+ if (!res.ok) {
341
+ // Prune is a safety net, but if it fails on a repo we touched, surface
342
+ // it — verification below will catch leftover orphans either way.
343
+ errors.push(`Prune failed in ${repo}: ${res.err || res.out}`);
344
+ }
345
+ }
346
+
347
+ // === Step 4: Delete local branches ===
348
+ if (branch) {
349
+ for (const repo of repos) {
350
+ const repoDir = join(reposDir, repo);
351
+ if (!existsSync(repoDir)) continue;
352
+ const listed = git(repoDir, ['branch', '--list', branch]);
353
+ if (!listed.ok) continue; // Repo broken; verification will catch downstream impact.
354
+ if (listed.out.trim() === '') continue; // Already gone (e.g., gh pr merge --delete-branch did it).
355
+ const del = git(repoDir, ['branch', '-D', branch]);
356
+ if (!del.ok) {
357
+ errors.push(`Failed to delete branch ${branch} in ${repo}: ${del.err || del.out}`);
358
+ }
359
+ }
360
+ // Same for the workspace repo (root).
361
+ const listed = git(root, ['branch', '--list', branch]);
362
+ if (listed.ok && listed.out.trim() !== '') {
363
+ const del = git(root, ['branch', '-D', branch]);
364
+ if (!del.ok) {
365
+ errors.push(`Failed to delete branch ${branch} in workspace repo: ${del.err || del.out}`);
366
+ }
367
+ }
368
+ }
369
+
370
+ // === Post-verification: success means VERIFIED, not "no try/catch threw" ===
371
+ //
372
+ // Without these checks, an empty repos list (the gh:119 root cause) lets
373
+ // every silent skip add up to a "success" output while leaving orphans
374
+ // behind. The verification turns silent skips into honest errors.
375
+
376
+ if (existsSync(sessionFolder)) {
377
+ // The folder is still there — either Step 5 has not run (errors above)
378
+ // or it failed. Only delete it when everything before it succeeded
379
+ // (N6): the folder holds the tracker and any remaining worktrees the
380
+ // next recovery run needs.
381
+ if (errors.length === 0) {
382
+ try {
383
+ deleteSessionFolder(root, sessionName);
384
+ } catch (err) {
385
+ errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
386
+ }
387
+ } else {
388
+ errors.push('Session folder kept — earlier steps failed; reconcile and re-run');
389
+ }
390
+ } else {
391
+ skipped.push({ step: 'delete-session-folder', reason: `${sessionFolder} does not exist` });
392
+ }
393
+
394
+ if (existsSync(sessionFolder)) {
395
+ errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
396
+ }
397
+
398
+ const wsPath = wsWorktree; // canonical path the worktree had
399
+ for (const repo of repos) {
400
+ const repoDir = join(reposDir, repo);
401
+ if (!existsSync(repoDir)) continue;
402
+ const listRes = git(repoDir, ['worktree', 'list', '--porcelain']);
403
+ if (!listRes.ok) {
404
+ errors.push(`Could not list worktrees in ${repo}: ${listRes.err || listRes.out}`);
405
+ continue;
406
+ }
407
+ const wtList = listRes.out;
408
+ if (wtList.includes('prunable')) {
409
+ errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
410
+ }
411
+ if (wtList.includes(wsPath)) {
412
+ errors.push(`${repo} still has a worktree record referencing the session path`);
413
+ }
414
+ if (branch) {
415
+ const stillRes = git(repoDir, ['branch', '--list', branch]);
416
+ if (stillRes.ok && stillRes.out.trim() !== '') {
417
+ errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
418
+ }
419
+ }
420
+ }
421
+
422
+ if (branch) {
423
+ const stillRes = git(root, ['branch', '--list', branch]);
424
+ if (stillRes.ok && stillRes.out.trim() !== '') {
425
+ errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
426
+ }
427
+ }
428
+
429
+ console.log(JSON.stringify({
430
+ success: errors.length === 0,
431
+ removed,
432
+ skipped: skipped.length > 0 ? skipped : undefined,
433
+ errors: errors.length > 0 ? errors : undefined,
434
+ }));
435
+
436
+ if (errors.length > 0) process.exit(1);