@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/template/.claude/hooks/_utils.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +161 -64
- package/template/.claude/hooks/session-start.mjs +35 -1
- package/template/.claude/hooks/subagent-start.mjs +89 -22
- package/template/.claude/lib/session-frontmatter.mjs +28 -0
- package/template/.claude/rules/coherent-revisions.md +1 -1
- package/template/.claude/rules/forge-operations.md +37 -93
- package/template/.claude/rules/git-conventions.md +16 -11
- package/template/.claude/rules/goal-driven-work.md +8 -416
- package/template/.claude/rules/honest-pushback.md +37 -37
- package/template/.claude/rules/memory-guidance.md +43 -90
- package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
- package/template/.claude/rules/work-item-tracking.md +30 -72
- package/template/.claude/rules/workspace-structure.md +36 -94
- package/template/.claude/scripts/build-workspace-context.mjs +61 -16
- package/template/.claude/scripts/chat-record.mjs +282 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +257 -68
- package/template/.claude/scripts/context-footprint.mjs +282 -0
- package/template/.claude/scripts/forges/github.mjs +45 -0
- package/template/.claude/scripts/forges/gitlab.mjs +3 -2
- package/template/.claude/scripts/forges/interface.mjs +12 -0
- package/template/.claude/scripts/generate-claude-local.mjs +21 -2
- package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/.claude/scripts/task-worktree.mjs +525 -0
- package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/.claude/skills/braindump/SKILL.md +11 -4
- package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
- package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +229 -219
- package/template/.claude/skills/context-placement/SKILL.md +199 -0
- package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
- package/template/.claude/skills/handoff/SKILL.md +11 -4
- package/template/.claude/skills/maintenance/SKILL.md +7 -0
- package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/.claude/skills/pause-work/SKILL.md +9 -1
- package/template/.claude/skills/release/SKILL.md +44 -108
- package/template/.claude/skills/start-work/SKILL.md +89 -7
- package/template/.claude/skills/workspace-init/SKILL.md +3 -1
- package/template/.claude/skills/workspace-update/SKILL.md +4 -0
- package/template/CLAUDE.md.tmpl +19 -2
- package/template/_gitignore +9 -0
- 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(
|
|
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(
|
|
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
|
+
};
|