@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.18.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 (45) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/template/.claude/hooks/_utils.mjs +1 -1
  4. package/template/.claude/hooks/repo-write-detection.mjs +161 -64
  5. package/template/.claude/hooks/session-start.mjs +35 -1
  6. package/template/.claude/hooks/subagent-start.mjs +89 -22
  7. package/template/.claude/lib/session-frontmatter.mjs +28 -0
  8. package/template/.claude/rules/coherent-revisions.md +1 -1
  9. package/template/.claude/rules/forge-operations.md +37 -93
  10. package/template/.claude/rules/git-conventions.md +16 -11
  11. package/template/.claude/rules/goal-driven-work.md +8 -416
  12. package/template/.claude/rules/honest-pushback.md +37 -37
  13. package/template/.claude/rules/memory-guidance.md +43 -90
  14. package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
  15. package/template/.claude/rules/work-item-tracking.md +30 -72
  16. package/template/.claude/rules/workspace-structure.md +36 -94
  17. package/template/.claude/scripts/build-workspace-context.mjs +61 -16
  18. package/template/.claude/scripts/chat-record.mjs +282 -0
  19. package/template/.claude/scripts/cleanup-work-session.mjs +257 -68
  20. package/template/.claude/scripts/context-footprint.mjs +282 -0
  21. package/template/.claude/scripts/forges/github.mjs +45 -0
  22. package/template/.claude/scripts/forges/gitlab.mjs +3 -2
  23. package/template/.claude/scripts/forges/interface.mjs +12 -0
  24. package/template/.claude/scripts/generate-claude-local.mjs +21 -2
  25. package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
  26. package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
  27. package/template/.claude/scripts/task-worktree.mjs +525 -0
  28. package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
  29. package/template/.claude/skills/braindump/SKILL.md +11 -4
  30. package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
  31. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  32. package/template/.claude/skills/complete-work/SKILL.md +229 -219
  33. package/template/.claude/skills/context-placement/SKILL.md +199 -0
  34. package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
  35. package/template/.claude/skills/handoff/SKILL.md +11 -4
  36. package/template/.claude/skills/maintenance/SKILL.md +7 -0
  37. package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
  38. package/template/.claude/skills/pause-work/SKILL.md +9 -1
  39. package/template/.claude/skills/release/SKILL.md +44 -108
  40. package/template/.claude/skills/start-work/SKILL.md +89 -7
  41. package/template/.claude/skills/workspace-init/SKILL.md +3 -1
  42. package/template/.claude/skills/workspace-update/SKILL.md +4 -0
  43. package/template/CLAUDE.md.tmpl +19 -2
  44. package/template/_gitignore +9 -0
  45. package/template/workspace.json.tmpl +3 -2
@@ -21,14 +21,28 @@
21
21
  // no-op and the script reports success while leaving orphans behind
22
22
  // (gh:119).
23
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
+ //
24
35
  // `success: true` means VERIFIED: every project worktree record is gone
25
36
  // (no prunable entries left over), every local branch is deleted, and the
26
37
  // session folder is removed. The script post-verifies all of these and
27
- // surfaces any leftover state as an error rather than swallowing it.
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.
28
42
  import '../lib/require-node.mjs';
29
- import { execSync } from 'child_process';
30
- import { existsSync, readdirSync, statSync } from 'fs';
31
- import { join } from 'path';
43
+ import { execFileSync } from 'child_process';
44
+ import { existsSync, readdirSync, statSync, lstatSync, realpathSync } from 'fs';
45
+ import { join, resolve, isAbsolute, sep } from 'path';
32
46
  import {
33
47
  getWorkspaceRoot,
34
48
  readSessionTracker,
@@ -37,6 +51,38 @@ import {
37
51
  normalizeRepos,
38
52
  } from '../hooks/_utils.mjs';
39
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
+
40
86
  const args = process.argv.slice(2);
41
87
  const getArg = (name) => {
42
88
  const idx = args.indexOf(`--${name}`);
@@ -49,6 +95,17 @@ if (!sessionName) {
49
95
  process.exit(1);
50
96
  }
51
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
+
52
109
  const root = getWorkspaceRoot(import.meta.url);
53
110
  const reposDir = join(root, 'repos');
54
111
  const sessionFolder = sessionFolderPath(root, sessionName);
@@ -58,6 +115,10 @@ const removed = [];
58
115
  const skipped = [];
59
116
  const errors = [];
60
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
+
61
122
  // === Discovery: where session.md is silent or missing, fall back to disk ===
62
123
  //
63
124
  // The tracker may have been stripped before this script runs. Read whatever
@@ -70,22 +131,24 @@ let branch = tracker?.branch || null;
70
131
  // If repos is empty, discover from work-sessions/{name}/workspace/repos/.
71
132
  // That directory is the workspace worktree's nested-project-worktrees dir;
72
133
  // each entry is one project repo this session checked out.
134
+ let discovered = false;
73
135
  if (repos.length === 0) {
74
136
  const nestedReposDir = join(wsWorktree, 'repos');
75
137
  if (existsSync(nestedReposDir)) {
76
138
  try {
77
- const discovered = readdirSync(nestedReposDir).filter((entry) => {
139
+ const found = readdirSync(nestedReposDir).filter((entry) => {
78
140
  try {
79
141
  return statSync(join(nestedReposDir, entry)).isDirectory();
80
142
  } catch {
81
143
  return false;
82
144
  }
83
145
  });
84
- if (discovered.length > 0) {
85
- repos = discovered;
146
+ if (found.length > 0) {
147
+ repos = found;
148
+ discovered = true;
86
149
  skipped.push({
87
150
  step: 'discovery',
88
- reason: `Tracker missing repos; discovered ${discovered.length} from ${nestedReposDir}: ${discovered.join(', ')}`,
151
+ reason: `Tracker missing repos; discovered ${found.length} from ${nestedReposDir}: ${found.join(', ')}`,
89
152
  });
90
153
  }
91
154
  } catch (err) {
@@ -94,18 +157,130 @@ if (repos.length === 0) {
94
157
  }
95
158
  }
96
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
+
97
207
  // If branch is missing, ask the workspace worktree itself.
98
208
  if (!branch && existsSync(wsWorktree)) {
99
- try {
100
- branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: wsWorktree, stdio: 'pipe', encoding: 'utf-8' }).trim();
101
- if (branch) {
102
- skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
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`);
103
239
  }
104
- } catch (err) {
105
- skipped.push({ step: 'discovery', reason: `Failed to read branch from ${wsWorktree}: ${err.message}` });
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`);
106
271
  }
107
272
  }
108
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
+
109
284
  // === Step 1: Remove each project worktree FIRST, from its project repo ===
110
285
  for (const repo of repos) {
111
286
  const projWorktree = join(wsWorktree, 'repos', repo);
@@ -118,21 +293,40 @@ for (const repo of repos) {
118
293
  errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
119
294
  continue;
120
295
  }
121
- try {
122
- execSync(`git worktree remove "${projWorktree}" --force`, { cwd: repoDir, stdio: 'pipe' });
296
+ const res = git(repoDir, ['worktree', 'remove', projWorktree, '--force']);
297
+ if (res.ok) {
123
298
  removed.push(`project worktree ${repo}`);
124
- } catch (err) {
125
- errors.push(`Failed to remove ${repo} worktree: ${err.message.trim()}`);
299
+ } else {
300
+ errors.push(`Failed to remove ${repo} worktree: ${res.err || res.out}`);
126
301
  }
127
302
  }
128
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
+
129
323
  // === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
130
324
  if (existsSync(wsWorktree)) {
131
- try {
132
- execSync(`git worktree remove "${wsWorktree}" --force`, { cwd: root, stdio: 'pipe' });
325
+ const res = git(root, ['worktree', 'remove', wsWorktree, '--force']);
326
+ if (res.ok) {
133
327
  removed.push('workspace worktree');
134
- } catch (err) {
135
- errors.push(`Failed to remove workspace worktree: ${err.message.trim()}`);
328
+ } else {
329
+ errors.push(`Failed to remove workspace worktree: ${res.err || res.out}`);
136
330
  }
137
331
  } else {
138
332
  skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
@@ -142,12 +336,11 @@ if (existsSync(wsWorktree)) {
142
336
  for (const repo of repos) {
143
337
  const repoDir = join(reposDir, repo);
144
338
  if (!existsSync(repoDir)) continue;
145
- try {
146
- execSync('git worktree prune', { cwd: repoDir, stdio: 'pipe' });
147
- } catch (err) {
339
+ const res = git(repoDir, ['worktree', 'prune']);
340
+ if (!res.ok) {
148
341
  // Prune is a safety net, but if it fails on a repo we touched, surface
149
342
  // it — verification below will catch leftover orphans either way.
150
- errors.push(`Prune failed in ${repo}: ${err.message.trim()}`);
343
+ errors.push(`Prune failed in ${repo}: ${res.err || res.out}`);
151
344
  }
152
345
  }
153
346
 
@@ -156,47 +349,48 @@ if (branch) {
156
349
  for (const repo of repos) {
157
350
  const repoDir = join(reposDir, repo);
158
351
  if (!existsSync(repoDir)) continue;
159
- try {
160
- execSync(`git branch --list "${branch}"`, { cwd: repoDir, stdio: 'pipe', encoding: 'utf-8' });
161
- } catch {
162
- continue; // Repo broken; verification will catch downstream impact.
163
- }
164
- const exists = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
165
- if (!exists) continue; // Already gone (e.g., gh pr merge --delete-branch did it).
166
- try {
167
- execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
168
- } catch (err) {
169
- errors.push(`Failed to delete branch ${branch} in ${repo}: ${err.message.trim()}`);
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}`);
170
358
  }
171
359
  }
172
360
  // Same for the workspace repo (root).
173
- try {
174
- const exists = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
175
- if (exists) {
176
- try {
177
- execSync(`git branch -D "${branch}"`, { cwd: root, stdio: 'pipe' });
178
- } catch (err) {
179
- errors.push(`Failed to delete branch ${branch} in workspace repo: ${err.message.trim()}`);
180
- }
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}`);
181
366
  }
182
- } catch {
183
- // workspace root not a git repo — unusual, skip.
184
367
  }
185
368
  }
186
369
 
187
- // === Step 5: Delete the whole work-sessions/{name}/ folder ===
188
- try {
189
- deleteSessionFolder(root, sessionName);
190
- } catch (err) {
191
- errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
192
- }
193
-
194
370
  // === Post-verification: success means VERIFIED, not "no try/catch threw" ===
195
371
  //
196
372
  // Without these checks, an empty repos list (the gh:119 root cause) lets
197
373
  // every silent skip add up to a "success" output while leaving orphans
198
374
  // behind. The verification turns silent skips into honest errors.
199
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
+
200
394
  if (existsSync(sessionFolder)) {
201
395
  errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
202
396
  }
@@ -205,13 +399,12 @@ const wsPath = wsWorktree; // canonical path the worktree had
205
399
  for (const repo of repos) {
206
400
  const repoDir = join(reposDir, repo);
207
401
  if (!existsSync(repoDir)) continue;
208
- let wtList = '';
209
- try {
210
- wtList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf-8' });
211
- } catch (err) {
212
- errors.push(`Could not list worktrees in ${repo}: ${err.message.trim()}`);
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}`);
213
405
  continue;
214
406
  }
407
+ const wtList = listRes.out;
215
408
  if (wtList.includes('prunable')) {
216
409
  errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
217
410
  }
@@ -219,21 +412,17 @@ for (const repo of repos) {
219
412
  errors.push(`${repo} still has a worktree record referencing the session path`);
220
413
  }
221
414
  if (branch) {
222
- const branchStill = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
223
- if (branchStill) {
415
+ const stillRes = git(repoDir, ['branch', '--list', branch]);
416
+ if (stillRes.ok && stillRes.out.trim() !== '') {
224
417
  errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
225
418
  }
226
419
  }
227
420
  }
228
421
 
229
422
  if (branch) {
230
- try {
231
- const branchStill = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
232
- if (branchStill) {
233
- errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
234
- }
235
- } catch {
236
- // not a git repo — already noted
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`);
237
426
  }
238
427
  }
239
428