@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
@@ -91,14 +91,19 @@ function isGitRepo(root) {
91
91
 
92
92
  function gitMv(root, src, dst, dryRun) {
93
93
  if (dryRun) return { ok: true, dry: true };
94
+ // src and dst are relative to root. git resolves them against cwd: root, but the
95
+ // filesystem calls below do not — resolve explicitly or they act on process.cwd(),
96
+ // scattering directories wherever the caller happened to be standing.
97
+ const srcAbs = resolve(root, src);
98
+ const dstAbs = resolve(root, dst);
94
99
  // git mv refuses if dst dir doesn't exist; ensure parent
95
- mkdirSync(dirname(dst), { recursive: true });
100
+ mkdirSync(dirname(dstAbs), { recursive: true });
96
101
  if (isGitRepo(root)) {
97
102
  const r = spawnSync('git', ['mv', src, dst], { cwd: root, encoding: 'utf-8' });
98
103
  if (r.status === 0) return { ok: true };
99
104
  // fall through to plain rename — happens for untracked files
100
105
  }
101
- renameSync(src, dst);
106
+ renameSync(srcAbs, dstAbs);
102
107
  return { ok: true };
103
108
  }
104
109
 
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env node
2
+ // Task worktrees — the lightweight second lifecycle for work (gh:132).
3
+ //
4
+ // A work session is the right shape for a multi-chat effort: a folder, a
5
+ // workspace worktree, a tracker file. It is ceremony for the other common
6
+ // case — one issue, one branch, done in a chat. A task is exactly that: a
7
+ // tracker issue plus a branch plus one worktree per repo it touches,
8
+ // recorded on the chat record instead of in a session.md.
9
+ //
10
+ // Worktrees live at repos/{repo}/.claude/worktrees/{slug}/ — the same
11
+ // location Claude Code's native worktree feature uses — so a task created
12
+ // here and one created natively converge on a single layout instead of
13
+ // forking it.
14
+ //
15
+ // The workspace repo itself is addressed as "." everywhere a repo name is
16
+ // accepted (gh:146). Its worktree lives at {root}/.claude/worktrees/{slug}/
17
+ // — exactly where EnterWorktree puts worktrees of the repo Claude Code
18
+ // launched in — so the two converge there too, and the workspace's
19
+ // .gitignore covers the path for both.
20
+ //
21
+ // In the task model the chat runs at the workspace root (the launcher),
22
+ // not inside a worktree, so detection cannot rely on cwd alone: given a
23
+ // chat name it also consults the chat record, which is the only place
24
+ // open tasks are listed. /complete-work uses that to pick its flow.
25
+ //
26
+ // Usage:
27
+ // node task-worktree.mjs --root <dir> --create --repo <r> --branch <b> [--base <ref>]
28
+ // node task-worktree.mjs --root <dir> --remove --repo <r> --branch <b> [--force] [--delete-branch]
29
+ // node task-worktree.mjs --root <dir> --detect [--cwd <dir>] [--chat <name>]
30
+
31
+ import {
32
+ readFileSync, writeFileSync, existsSync, mkdirSync, statSync,
33
+ } from 'node:fs';
34
+ import { realpathSync } from 'node:fs';
35
+ import { join, resolve, relative, sep, isAbsolute, basename, dirname } from 'node:path';
36
+ import { spawnSync } from 'node:child_process';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { readRecord } from './chat-record.mjs';
39
+
40
+ function isMainModule(metaUrl) {
41
+ if (!process.argv[1]) return false;
42
+ try {
43
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
44
+ } catch { return false; }
45
+ }
46
+
47
+ const WORKTREES_DIR = join('.claude', 'worktrees');
48
+ // The workspace repo (the launcher) is addressed as "." — the one repo
49
+ // name that is not a directory under repos/.
50
+ const WORKSPACE_REPO = '.';
51
+ // Exclude patterns are git syntax: forward slashes on every platform.
52
+ const EXCLUDE_LINE = '.claude/worktrees/';
53
+
54
+ function isWorkspaceRepo(repo) {
55
+ return repo === WORKSPACE_REPO;
56
+ }
57
+
58
+ function readConfig(root) {
59
+ try {
60
+ return JSON.parse(readFileSync(join(resolve(root), 'workspace.json'), 'utf-8'));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // The workspace repo has no workspace.json entry naming its default branch
67
+ // — origin's HEAD is the authority, with "main" as the fallback a fresh
68
+ // clone would get. Project repos keep their configured branch.
69
+ function defaultBranchFor(root, repo, gitFn = spawnSync) {
70
+ if (isWorkspaceRepo(repo)) {
71
+ const res = gitFn('git', ['-C', resolve(root), 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { encoding: 'utf8' });
72
+ const name = res && res.status === 0 ? String(res.stdout).trim().replace(/^origin\//, '') : '';
73
+ return name || 'main';
74
+ }
75
+ const branch = readConfig(root)?.repos?.[repo]?.branch;
76
+ return typeof branch === 'string' && branch ? branch : 'main';
77
+ }
78
+
79
+ // resolve() rather than join(), so an absolute configured dir is honored.
80
+ function sessionsDirFor(root) {
81
+ const dir = readConfig(root)?.workspace?.workSessionsDir;
82
+ const name = typeof dir === 'string' && dir ? dir : 'work-sessions';
83
+ return resolve(root, name);
84
+ }
85
+
86
+ // gitFn is injectable so callers (or tests) can observe or fake git; the
87
+ // default is spawnSync itself, called as (command, args, options).
88
+ function run(gitFn, cwd, args, opts = {}) {
89
+ const res = gitFn('git', ['-C', cwd, ...args], { encoding: 'utf8', ...opts });
90
+ if (res.error) throw new Error(`git: ${res.error.message}`);
91
+ return res;
92
+ }
93
+
94
+ function refExists(gitFn, cwd, ref) {
95
+ return run(gitFn, cwd, ['rev-parse', '--verify', '--quiet', ref]).status === 0;
96
+ }
97
+
98
+ // Branch names become paths and refs, so git's own check is the authority:
99
+ // --branch also rejects names ambiguous with other ref namespaces and names
100
+ // containing "..". It needs no repository, so it runs before anything else.
101
+ function assertBranchName(gitFn, branch) {
102
+ const res = gitFn('git', ['check-ref-format', '--branch', branch], { encoding: 'utf8' });
103
+ if (res.error || res.status !== 0) throw new Error(`invalid branch name: ${branch}`);
104
+ }
105
+
106
+ function slugForBranch(branch) {
107
+ return branch.split('/').join('-');
108
+ }
109
+
110
+ function taskWorktreePath(root, repo, branch) {
111
+ return join(repoDirFor(resolve(root), repo), WORKTREES_DIR, slugForBranch(branch));
112
+ }
113
+
114
+ // "." is the workspace repo itself — the git repo at the root. Every other
115
+ // name is a directory under repos/.
116
+ function repoDirFor(rootDir, repo) {
117
+ return isWorkspaceRepo(repo) ? rootDir : join(rootDir, 'repos', repo);
118
+ }
119
+
120
+ // .native resolves Windows 8.3 short names; the plain fallback covers
121
+ // filesystems where the native binding is unavailable.
122
+ function realPath(p) {
123
+ try { return realpathSync.native(p); } catch { /* fall through */ }
124
+ try { return realpathSync(p); } catch { /* fall through */ }
125
+ return resolve(p);
126
+ }
127
+
128
+ // Normalize both sides to forward slashes before comparing, so a root that
129
+ // reached us through a symlink or a Windows drive still matches the path
130
+ // git recorded at creation time.
131
+ function samePath(a, b) {
132
+ return realPath(a).split(sep).join('/') === realPath(b).split(sep).join('/');
133
+ }
134
+
135
+ function listWorktrees(gitFn, repoDir) {
136
+ const res = run(gitFn, repoDir, ['worktree', 'list', '--porcelain']);
137
+ if (res.status !== 0) return [];
138
+ const out = [];
139
+ let cur = null;
140
+ for (const line of String(res.stdout).split(/\r?\n/)) {
141
+ if (!line) { cur = null; continue; }
142
+ if (line.startsWith('worktree ')) {
143
+ cur = { path: line.slice('worktree '.length), branch: null };
144
+ out.push(cur);
145
+ } else if (cur && line.startsWith('branch ')) {
146
+ cur.branch = line.slice('branch '.length);
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // A branch checked out in any worktree cannot be deleted; report rather
153
+ // than error, so the partial-teardown retry can return a clean false.
154
+ function deleteLocalBranch(gitFn, repoDir, branch) {
155
+ if (!refExists(gitFn, repoDir, `refs/heads/${branch}`)) return false;
156
+ if (listWorktrees(gitFn, repoDir).some((w) => w.branch === `refs/heads/${branch}`)) return false;
157
+ return run(gitFn, repoDir, ['branch', '-D', branch]).status === 0;
158
+ }
159
+
160
+ // A worktree inside the source clone's working tree would show up as
161
+ // untracked noise on every git status. The clone's local exclude file is
162
+ // the place to hide it: machine-local, so nothing is imposed on the shared
163
+ // .gitignore, and append-once so repeated creates cannot duplicate it.
164
+ function ensureExcluded(repoDir) {
165
+ const gitDir = join(repoDir, '.git');
166
+ let content = '';
167
+ try {
168
+ if (!statSync(gitDir).isDirectory()) return; // a linked worktree — no local exclude to write
169
+ content = readFileSync(join(gitDir, 'info', 'exclude'), 'utf-8');
170
+ } catch { /* no exclude file yet */ }
171
+ if (content.split(/\r?\n/).includes(EXCLUDE_LINE)) return;
172
+ const excludePath = join(gitDir, 'info', 'exclude');
173
+ mkdirSync(dirname(excludePath), { recursive: true });
174
+ const gap = content === '' || content.endsWith('\n') ? '' : '\n';
175
+ writeFileSync(excludePath, `${content}${gap}${EXCLUDE_LINE}\n`);
176
+ }
177
+
178
+ // The workspace's .gitignore normally covers .claude/worktrees/ (the
179
+ // template ships the line), so no machine-local exclude is needed — but a
180
+ // workspace whose .gitignore predates the line would stage the worktree as
181
+ // an embedded repo on the next `git add -A` at the launcher. Trust
182
+ // .gitignore only after git itself confirms the path is ignored.
183
+ function ensureWorkspaceExcluded(gitFn, rootDir) {
184
+ const res = gitFn('git', ['-C', rootDir, 'check-ignore', '-q', `${EXCLUDE_LINE}probe`], { encoding: 'utf8' });
185
+ if (res && res.status === 0) return;
186
+ ensureExcluded(rootDir);
187
+ }
188
+
189
+ /**
190
+ * Create (or return) the task worktree for {branch} in {repo}.
191
+ *
192
+ * Idempotent: a worktree already at the path on the same branch is a
193
+ * success — /start-work is not guaranteed to run exactly once per task. A
194
+ * path held by anything else is a collision and refuses rather than
195
+ * guessing. Three creation cases, in order:
196
+ * - the local branch exists (a prior remove kept it) → check it out,
197
+ * keeping its commits;
198
+ * - only refs/remotes/origin/{branch} exists → the task was started on
199
+ * another machine; create a tracking branch from the remote;
200
+ * - neither → new branch from the base, --no-track so the default
201
+ * branch is never accidentally the push target.
202
+ */
203
+ function createTaskWorktree(root, { repo, branch, base = null, gitFn = spawnSync } = {}) {
204
+ if (!repo) throw new Error('createTaskWorktree: repo is required');
205
+ if (!branch) throw new Error('createTaskWorktree: branch is required');
206
+ assertBranchName(gitFn, branch);
207
+ const rootDir = resolve(root);
208
+ const repoDir = repoDirFor(rootDir, repo);
209
+ if (!isWorkspaceRepo(repo) && !existsSync(repoDir)) {
210
+ throw new Error(`createTaskWorktree: no repo "${repo}" under ${join(rootDir, 'repos')}`);
211
+ }
212
+ const path = taskWorktreePath(rootDir, repo, branch);
213
+ const branchRef = `refs/heads/${branch}`;
214
+
215
+ if (existsSync(path)) {
216
+ const existing = listWorktrees(gitFn, repoDir).find((w) => samePath(w.path, path));
217
+ if (!existing) throw new Error(`createTaskWorktree: ${path} exists but is not a git worktree`);
218
+ if (existing.branch !== branchRef) {
219
+ throw new Error(`createTaskWorktree: ${path} is on ${existing.branch ?? 'a detached HEAD'}, not ${branch}`);
220
+ }
221
+ // Project clones always get the machine-local exclude; the workspace
222
+ // only when its .gitignore does not already cover the path.
223
+ if (isWorkspaceRepo(repo)) ensureWorkspaceExcluded(gitFn, repoDir);
224
+ else ensureExcluded(repoDir);
225
+ return { repo, branch, path, created: false };
226
+ }
227
+
228
+ // Best-effort refresh before choosing anything from origin — deliberately
229
+ // NOT via run(): a hung remote makes spawnSync return an ETIMEDOUT error
230
+ // object, and run() throws on any error, which would turn "merely slow"
231
+ // into a hard failure. The 10 s ceiling matches the session-start hook's
232
+ // fetch budget; a failed or timed-out fetch just means a staler base.
233
+ gitFn('git', ['-C', repoDir, 'fetch', 'origin'], { encoding: 'utf8', timeout: 10000 });
234
+ run(gitFn, repoDir, ['worktree', 'prune']);
235
+
236
+ // The base is a starting point, not a freshness guarantee: it is
237
+ // origin's default branch as of the best-effort fetch above.
238
+ const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
239
+ const resolvedBase = base
240
+ || (refExists(gitFn, repoDir, `refs/remotes/origin/${defaultBranch}`)
241
+ ? `origin/${defaultBranch}`
242
+ : defaultBranch);
243
+
244
+ let args;
245
+ if (refExists(gitFn, repoDir, branchRef)) {
246
+ args = ['worktree', 'add', path, branch];
247
+ } else if (refExists(gitFn, repoDir, `refs/remotes/origin/${branch}`)) {
248
+ args = ['worktree', 'add', '--track', '-b', branch, path, `origin/${branch}`];
249
+ } else {
250
+ args = ['worktree', 'add', '--no-track', '-b', branch, path, resolvedBase];
251
+ }
252
+ const res = run(gitFn, repoDir, args);
253
+ if (res.status !== 0) {
254
+ throw new Error(`createTaskWorktree: git worktree add failed: ${String(res.stderr || '').trim()}`);
255
+ }
256
+ if (isWorkspaceRepo(repo)) ensureWorkspaceExcluded(gitFn, repoDir);
257
+ else ensureExcluded(repoDir);
258
+ return { repo, branch, path, created: true };
259
+ }
260
+
261
+ /**
262
+ * Remove the task worktree for {branch} in {repo}. The branch is kept by
263
+ * default — re-creating the task after a failed PR needs it. With
264
+ * deleteBranch: true it is deleted too, with -D because a squash merge is
265
+ * never an ancestor; the forge's deleteBranch removed only the REMOTE
266
+ * branch, so post-merge teardown passes this to clean the local clone.
267
+ * deleteBranch always refuses the repo's default branch — the branch
268
+ * checked out at the launcher root is protected by the worktree check in
269
+ * deleteLocalBranch, but a detached-HEAD launcher would not be, and no
270
+ * repo's default branch is ever a task branch to clean up. A missing
271
+ * worktree is a no-op so cleanup can run unconditionally.
272
+ */
273
+ function removeTaskWorktree(root, { repo, branch, force = false, deleteBranch = false, gitFn = spawnSync } = {}) {
274
+ if (!repo) throw new Error('removeTaskWorktree: repo is required');
275
+ if (!branch) throw new Error('removeTaskWorktree: branch is required');
276
+ assertBranchName(gitFn, branch);
277
+ const rootDir = resolve(root);
278
+ const repoDir = repoDirFor(rootDir, repo);
279
+ const path = taskWorktreePath(rootDir, repo, branch);
280
+ if (deleteBranch && branch === defaultBranchFor(rootDir, repo, gitFn)) {
281
+ throw new Error(`removeTaskWorktree: ${branch} is the default branch of ${isWorkspaceRepo(repo) ? 'the workspace repo' : `repo "${repo}"`}; refusing to delete it`);
282
+ }
283
+ if (!existsSync(path)) {
284
+ // A hand-deleted worktree directory leaves a stale record — prune it so
285
+ // a later create is clean, and still honor deleteBranch: a retry after
286
+ // a partial teardown must not leak the branch.
287
+ run(gitFn, repoDir, ['worktree', 'prune']);
288
+ if (deleteBranch) {
289
+ return { repo, branch, path, removed: false, branchDeleted: deleteLocalBranch(gitFn, repoDir, branch) };
290
+ }
291
+ return { repo, branch, path, removed: false };
292
+ }
293
+
294
+ // The slug is lossy — feature/y and feature-y share one. Resolve through
295
+ // git's registry so the worktree at this path must really be {branch}'s;
296
+ // otherwise refuse rather than remove someone else's tree.
297
+ const entry = listWorktrees(gitFn, repoDir).find((w) => samePath(w.path, path));
298
+ if (!entry) throw new Error(`removeTaskWorktree: ${path} exists but is not a git worktree`);
299
+ if (entry.branch !== `refs/heads/${branch}`) {
300
+ throw new Error(`removeTaskWorktree: ${path} is on ${entry.branch ?? 'a detached HEAD'}, not ${branch} (slug collision)`);
301
+ }
302
+
303
+ // The worktree holds the only copy of uncommitted work until the branch
304
+ // is pushed. git refuses too, but without saying what the user should do.
305
+ const status = run(gitFn, path, ['status', '--porcelain']);
306
+ if (status.status === 0 && String(status.stdout).trim() !== '' && !force) {
307
+ throw new Error(`removeTaskWorktree: ${path} has uncommitted changes; re-run with force to discard them`);
308
+ }
309
+
310
+ const res = run(gitFn, repoDir, ['worktree', 'remove', ...(force ? ['--force'] : []), path]);
311
+ if (res.status !== 0) {
312
+ throw new Error(`removeTaskWorktree: git worktree remove failed: ${String(res.stderr || '').trim()}`);
313
+ }
314
+ run(gitFn, repoDir, ['worktree', 'prune']);
315
+ if (deleteBranch && !deleteLocalBranch(gitFn, repoDir, branch)) {
316
+ throw new Error(`removeTaskWorktree: git branch -D failed for ${branch}`);
317
+ }
318
+ return deleteBranch
319
+ ? { repo, branch, path, removed: true, branchDeleted: true }
320
+ : { repo, branch, path, removed: true };
321
+ }
322
+
323
+ // relative() output is "inside" when it is a plain descent — not '', '..',
324
+ // a '..'-prefixed climb, or a cross-volume absolute path (Windows drives).
325
+ function isDescent(rel) {
326
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
327
+ }
328
+
329
+ function currentBranch(gitFn, path) {
330
+ try {
331
+ const res = gitFn('git', ['-C', path, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' });
332
+ if (!res || res.status !== 0) return null;
333
+ const name = String(res.stdout).trim();
334
+ return name === 'HEAD' ? null : name; // "HEAD" means detached
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ // The session check, in two steps. Primary: cwd under this root's own
341
+ // sessions dir at {sessionsDir}/{name}/workspace. Fallback: the chat may
342
+ // be running inside a session worktree with that worktree itself passed
343
+ // as --root, so the sessions dir is not under the root at all — the real
344
+ // path still carries the layout, so recognize the junction wherever it
345
+ // appears in cwd's own segments (deepest match wins).
346
+ function detectSession(cwdReal, rootReal) {
347
+ const sessionsDir = sessionsDirFor(rootReal);
348
+ const rel = relative(sessionsDir, cwdReal);
349
+ if (isDescent(rel)) {
350
+ const parts = rel.split(sep);
351
+ if (parts.length >= 2 && parts[1] === 'workspace') {
352
+ return { model: 'session', sessionName: parts[0], workspaceDir: join(sessionsDir, parts[0], 'workspace') };
353
+ }
354
+ }
355
+ const segs = cwdReal.split(sep);
356
+ const name = basename(sessionsDir);
357
+ for (let i = segs.length - 3; i >= 0; i -= 1) {
358
+ if (segs[i] === name && segs[i + 2] === 'workspace') {
359
+ return { model: 'session', sessionName: segs[i + 1], workspaceDir: segs.slice(0, i + 3).join(sep) };
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+
365
+ // The chat record's open tasks, when a chat name is known. With a branch,
366
+ // only that branch's entries match (the worktree case); without one, any
367
+ // open task counts (the at-launcher case — cwd says nothing there).
368
+ function matchingTasks(rootDir, chat, branch) {
369
+ if (!chat) return null;
370
+ const rec = readRecord(rootDir, chat);
371
+ if (!rec || !Array.isArray(rec.tasks) || rec.tasks.length === 0) return null;
372
+ const tasks = branch === null ? rec.tasks : rec.tasks.filter((t) => t.branch === branch);
373
+ return tasks.length > 0 ? tasks : null;
374
+ }
375
+
376
+ // One task worktree's detect payload, or null when {path} does not really
377
+ // hold a task: a stale plain directory under .claude/worktrees/ would
378
+ // otherwise detect through cwd and resolve to the repo around it — for the
379
+ // workspace layout, to the launcher itself, whose default branch
380
+ // /complete-work would then rebase and push. So require git to confirm the
381
+ // path is a worktree root, and never report the repo's default branch.
382
+ function taskWorktreeInfo(gitFn, rootReal, repo, path, chat) {
383
+ try {
384
+ const res = gitFn('git', ['-C', path, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
385
+ if (!res || res.status !== 0) return null;
386
+ if (!samePath(String(res.stdout).trim(), path)) return null;
387
+ const branch = currentBranch(gitFn, path);
388
+ if (branch && branch === defaultBranchFor(rootReal, repo, gitFn)) return null;
389
+ const out = { model: 'task', source: 'worktree', repo, branch, path };
390
+ const tasks = matchingTasks(rootReal, chat, branch);
391
+ if (tasks) out.tasks = tasks;
392
+ return out;
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Tell a skill which lifecycle the current directory is under, in order:
400
+ * session (cwd in the old model's tree), task (cwd in a task worktree,
401
+ * enriched with the chat's matching tasks when {chat} is given), then —
402
+ * because task chats run at the launcher, where cwd is just the root —
403
+ * task again if the named chat's record has open tasks. Else none.
404
+ */
405
+ function detectWorkModel(cwd, root, { chat = null, gitFn = spawnSync } = {}) {
406
+ const rootReal = realPath(resolve(root));
407
+ const cwdReal = realPath(resolve(cwd));
408
+
409
+ const session = detectSession(cwdReal, rootReal);
410
+ if (session) return session;
411
+
412
+ // The workspace repo's own task worktrees: {root}/.claude/worktrees/{slug}.
413
+ // {root}/.claude or {root}/.claude/worktrees themselves are not inside a
414
+ // worktree and fall through.
415
+ const relRoot = relative(rootReal, cwdReal);
416
+ if (isDescent(relRoot)) {
417
+ const rootParts = relRoot.split(sep);
418
+ if (rootParts.length >= 3 && rootParts[0] === '.claude' && rootParts[1] === 'worktrees') {
419
+ const hit = taskWorktreeInfo(gitFn, rootReal, WORKSPACE_REPO, join(rootReal, WORKTREES_DIR, rootParts[2]), chat);
420
+ if (hit) return hit;
421
+ }
422
+ }
423
+
424
+ const reposDir = join(rootReal, 'repos');
425
+ const relRepo = relative(reposDir, cwdReal);
426
+ if (isDescent(relRepo)) {
427
+ const parts = relRepo.split(sep);
428
+ if (parts.length >= 4 && parts[1] === '.claude' && parts[2] === 'worktrees') {
429
+ const hit = taskWorktreeInfo(gitFn, rootReal, parts[0], join(reposDir, parts[0], WORKTREES_DIR, parts[3]), chat);
430
+ if (hit) return hit;
431
+ }
432
+ }
433
+
434
+ const tasks = matchingTasks(rootReal, chat, null);
435
+ if (tasks) return { model: 'task', source: 'chat-record', tasks };
436
+
437
+ return { model: 'none' };
438
+ }
439
+
440
+ const MODE_FLAGS = new Set(['--create', '--remove', '--detect']);
441
+ const VALUE_FLAGS = new Map([
442
+ ['--root', 'root'],
443
+ ['--repo', 'repo'],
444
+ ['--branch', 'branch'],
445
+ ['--base', 'base'],
446
+ ['--cwd', 'cwd'],
447
+ ['--chat', 'chat'],
448
+ ]);
449
+
450
+ // A repo name becomes a path segment under repos/ — one segment only, no
451
+ // separators, dot segments, or absolute paths. "." is the one exception:
452
+ // it addresses the workspace repo itself (gh:146); ".." and every other
453
+ // dots-only name stay rejected.
454
+ function isRepoSegment(repo) {
455
+ if (typeof repo !== 'string' || repo === '' || isAbsolute(repo)) return false;
456
+ const segs = repo.split(/[\\/]/);
457
+ if (segs.length !== 1) return false;
458
+ return segs[0] === WORKSPACE_REPO || !/^\.+$/.test(segs[0]);
459
+ }
460
+
461
+ function parseArgs(argv) {
462
+ const args = { root: '.', mode: null, repo: null, branch: null, base: null, cwd: null, chat: null, force: false, deleteBranch: false };
463
+ const rest = argv.slice(2);
464
+ for (let i = 0; i < rest.length; i += 1) {
465
+ const a = rest[i];
466
+ if (MODE_FLAGS.has(a)) {
467
+ if (args.mode) throw new Error(`only one of --create, --remove, --detect may be given (already have --${args.mode})`);
468
+ args.mode = a.slice(2);
469
+ continue;
470
+ }
471
+ if (a === '--force') { args.force = true; continue; }
472
+ if (a === '--delete-branch') { args.deleteBranch = true; continue; }
473
+ const key = VALUE_FLAGS.get(a);
474
+ if (key) {
475
+ const v = rest[i + 1];
476
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value`);
477
+ args[key] = v;
478
+ i += 1;
479
+ continue;
480
+ }
481
+ throw new Error(`unknown argument: ${a}`);
482
+ }
483
+ if (!args.mode) throw new Error('one of --create, --remove, --detect is required');
484
+ if (args.mode === 'create' && (!args.repo || !args.branch)) {
485
+ throw new Error('--create requires --repo and --branch');
486
+ }
487
+ if (args.mode === 'remove' && (!args.repo || !args.branch)) {
488
+ throw new Error('--remove requires --repo and --branch');
489
+ }
490
+ if (args.deleteBranch && args.mode !== 'remove') {
491
+ throw new Error('--delete-branch is only valid with --remove');
492
+ }
493
+ if (args.repo != null && !isRepoSegment(args.repo)) {
494
+ throw new Error(`--repo must be a single path segment, got: ${args.repo}`);
495
+ }
496
+ return args;
497
+ }
498
+
499
+ function main() {
500
+ const args = parseArgs(process.argv);
501
+ let out;
502
+ if (args.mode === 'create') {
503
+ out = createTaskWorktree(args.root, { repo: args.repo, branch: args.branch, base: args.base });
504
+ } else if (args.mode === 'remove') {
505
+ out = removeTaskWorktree(args.root, { repo: args.repo, branch: args.branch, force: args.force, deleteBranch: args.deleteBranch });
506
+ } else {
507
+ out = detectWorkModel(args.cwd || process.cwd(), args.root, { chat: args.chat });
508
+ }
509
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
510
+ }
511
+
512
+ if (isMainModule(import.meta.url)) {
513
+ try {
514
+ main();
515
+ } catch (err) {
516
+ process.stderr.write(`task-worktree: ${err.message}\n`);
517
+ process.exit(2);
518
+ }
519
+ }
520
+
521
+ export {
522
+ slugForBranch, taskWorktreePath, createTaskWorktree, removeTaskWorktree,
523
+ detectWorkModel, parseArgs, defaultBranchFor, WORKSPACE_REPO,
524
+ WORKTREES_DIR, EXCLUDE_LINE,
525
+ };