@ulysses-ai/create-workspace 0.21.0-beta.0 → 0.23.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/lib/init.mjs +9 -0
- package/lib/init.test.mjs +75 -0
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +474 -38
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/maintenance-audit.mjs +0 -0
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +239 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +63 -27
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// GitLab Issues adapter. Wraps the `glab` CLI via an injectable spawnFn
|
|
2
|
+
// (mirrors trackers/github-issues.mjs). Issue IDs are opaque strings of the
|
|
3
|
+
// form "gl:N" — adapters outside this file don't need to parse them;
|
|
4
|
+
// routing is handled by interface.mjs.
|
|
5
|
+
//
|
|
6
|
+
// The repo is a `group/sub/project` path (any depth); a self-managed
|
|
7
|
+
// instance is selected with `host` in the tracker config (GITLAB_HOST for
|
|
8
|
+
// glab, --hostname for `glab api`), defaulting to gitlab.com.
|
|
9
|
+
|
|
10
|
+
import '../../lib/require-node.mjs';
|
|
11
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
12
|
+
import { AlreadyAssignedError } from './interface.mjs';
|
|
13
|
+
|
|
14
|
+
const GITLAB_DEFAULT_HOST = 'gitlab.com';
|
|
15
|
+
|
|
16
|
+
const STANDARD_LABELS = [
|
|
17
|
+
{ name: 'bug', color: 'd73a4a' },
|
|
18
|
+
{ name: 'feat', color: 'a2eeef' },
|
|
19
|
+
{ name: 'chore', color: 'cfd3d7' },
|
|
20
|
+
{ name: 'P1', color: 'b60205' },
|
|
21
|
+
{ name: 'P2', color: 'fbca04' },
|
|
22
|
+
{ name: 'P3', color: '0e8a16' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
26
|
+
const repo = resolveRepo(config, spawnFn);
|
|
27
|
+
const host = typeof config?.host === 'string' && config.host ? config.host : GITLAB_DEFAULT_HOST;
|
|
28
|
+
let loginCache = null;
|
|
29
|
+
|
|
30
|
+
function glab(args, { input } = {}) {
|
|
31
|
+
// GITLAB_HOST always names the resolved instance — always, because an
|
|
32
|
+
// exported foreign value must not leak into a gitlab.com run — and
|
|
33
|
+
// aims the -R/--repo slugs at it.
|
|
34
|
+
const env = { ...process.env, GITLAB_HOST: host };
|
|
35
|
+
const result = spawnFn('glab', args, {
|
|
36
|
+
input,
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
env,
|
|
39
|
+
stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
throw new Error(`glab ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
|
|
43
|
+
}
|
|
44
|
+
return result.stdout || '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `glab api` resolves its host from the CWD's git remote, not from
|
|
48
|
+
// GITLAB_HOST, so the resolved host is always passed explicitly.
|
|
49
|
+
function apiArgs(path) {
|
|
50
|
+
return ['api', path, '--hostname', host];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Paged walk over a `glab api` list endpoint — 100 at a time until a
|
|
54
|
+
// short page. The list subcommands fetch a single page and cannot
|
|
55
|
+
// express the server-side filters some of these calls need, so `glab
|
|
56
|
+
// api` does both jobs.
|
|
57
|
+
function apiListAll(path, params = []) {
|
|
58
|
+
const found = [];
|
|
59
|
+
for (let page = 1; ; page += 1) {
|
|
60
|
+
const query = [...params, 'per_page=100', `page=${page}`].join('&');
|
|
61
|
+
const batch = JSON.parse(glab(apiArgs(`${path}?${query}`)));
|
|
62
|
+
if (!Array.isArray(batch)) {
|
|
63
|
+
throw new Error(`glab api ${path} returned a non-list: ${JSON.stringify(batch).slice(0, 80)}`);
|
|
64
|
+
}
|
|
65
|
+
found.push(...batch);
|
|
66
|
+
if (batch.length < 100) break;
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function currentUser() {
|
|
72
|
+
if (loginCache) return loginCache;
|
|
73
|
+
loginCache = JSON.parse(glab(apiArgs('user'))).username;
|
|
74
|
+
return loginCache;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalize(raw) {
|
|
78
|
+
return {
|
|
79
|
+
id: `gl:${raw.iid}`,
|
|
80
|
+
number: raw.iid,
|
|
81
|
+
title: raw.title,
|
|
82
|
+
body: raw.description || '',
|
|
83
|
+
state: (raw.state || '').toLowerCase() === 'closed' ? 'closed' : 'open',
|
|
84
|
+
assignees: (raw.assignees || []).map((a) => a.username),
|
|
85
|
+
labels: (raw.labels || []).map((l) => (typeof l === 'string' ? l : l.name)),
|
|
86
|
+
milestone: raw.milestone?.title ?? null,
|
|
87
|
+
url: raw.web_url,
|
|
88
|
+
createdAt: raw.created_at,
|
|
89
|
+
updatedAt: raw.updated_at,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseIssueNumber(issueId) {
|
|
94
|
+
const m = issueId.match(/^gl:(\d+)$/);
|
|
95
|
+
if (!m) throw new Error(`Not a GitLab issue ID: ${issueId}`);
|
|
96
|
+
return parseInt(m[1], 10);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function listAssignedToMe() {
|
|
100
|
+
const me = currentUser();
|
|
101
|
+
// `glab issue list` defaults to open issues; JSON output is -O here,
|
|
102
|
+
// -F on the single-entity commands.
|
|
103
|
+
const stdout = glab(['issue', 'list', '--repo', repo, '--assignee', me, '-O', 'json', '--per-page', '100']);
|
|
104
|
+
return JSON.parse(stdout).map(normalize);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listUnassigned() {
|
|
108
|
+
// The API's server-side `assignee_id=None` filter, paged to the end —
|
|
109
|
+
// a client-side filter over one `issue list` page saw only the first
|
|
110
|
+
// hundred open issues.
|
|
111
|
+
return apiListAll(`projects/${encodeURIComponent(repo)}/issues`, ['state=opened', 'assignee_id=None'])
|
|
112
|
+
.map(normalize);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getIssue(issueId) {
|
|
116
|
+
const num = parseIssueNumber(issueId);
|
|
117
|
+
const stdout = glab(['issue', 'view', String(num), '--repo', repo, '-F', 'json']);
|
|
118
|
+
return normalize(JSON.parse(stdout));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function claim(issueId) {
|
|
122
|
+
const me = currentUser();
|
|
123
|
+
const issue = await getIssue(issueId);
|
|
124
|
+
const others = issue.assignees.filter((a) => a !== me);
|
|
125
|
+
if (others.length > 0) {
|
|
126
|
+
throw new AlreadyAssignedError(issueId, others);
|
|
127
|
+
}
|
|
128
|
+
if (!issue.assignees.includes(me)) {
|
|
129
|
+
// --assignee replaces the assignee list; with no other assignees
|
|
130
|
+
// (checked above) that is exactly "assign me".
|
|
131
|
+
glab(['issue', 'update', String(issue.number), '--repo', repo, '--assignee', me]);
|
|
132
|
+
return getIssue(issueId);
|
|
133
|
+
}
|
|
134
|
+
return issue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function createIssue({ title, body = '', labels = [], milestone = null }) {
|
|
138
|
+
const args = ['issue', 'create', '--repo', repo, '--title', title, '--description', body, '--yes'];
|
|
139
|
+
if (labels.length > 0) args.push('--label', labels.join(','));
|
|
140
|
+
if (milestone) args.push('--milestone', milestone);
|
|
141
|
+
const stdout = glab(args);
|
|
142
|
+
const m = stdout.match(/\/-\/issues\/(\d+)/);
|
|
143
|
+
if (!m) throw new Error(`Could not parse issue number from: ${stdout.trim()}`);
|
|
144
|
+
return getIssue(`gl:${m[1]}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function comment(issueId, body) {
|
|
148
|
+
const num = parseIssueNumber(issueId);
|
|
149
|
+
// glab has no --body-file: the message travels as an argv element.
|
|
150
|
+
glab(['issue', 'note', String(num), '--repo', repo, '--message', body]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A closing reference for MR bodies: `#N` closes an issue in the MR's own
|
|
154
|
+
// project, `group/sub/project#N` reaches one living elsewhere — the same
|
|
155
|
+
// shapes github-issues renders, with GitLab's full-path reference for the
|
|
156
|
+
// cross-project case. No `fromRepo` means the MR is in this adapter's own
|
|
157
|
+
// project.
|
|
158
|
+
function issueRef(issueId, { fromRepo } = {}) {
|
|
159
|
+
const num = parseIssueNumber(issueId);
|
|
160
|
+
return fromRepo && fromRepo !== repo ? `${repo}#${num}` : `#${num}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The canonical URL for an issue — what a cross-forge reference needs
|
|
164
|
+
// (a `group/sub#N` reference cannot resolve on a GitHub PR), minted from
|
|
165
|
+
// the same host/repo every other URL here is built from.
|
|
166
|
+
function issueUrl(issueId) {
|
|
167
|
+
const num = parseIssueNumber(issueId);
|
|
168
|
+
return `https://${host}/${repo}/-/issues/${num}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function closeIssue(issueId, { comment: commentBody } = {}) {
|
|
172
|
+
const num = parseIssueNumber(issueId);
|
|
173
|
+
if (commentBody) {
|
|
174
|
+
glab(['issue', 'note', String(num), '--repo', repo, '--message', commentBody]);
|
|
175
|
+
}
|
|
176
|
+
glab(['issue', 'close', String(num), '--repo', repo]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function ensureLabels() {
|
|
180
|
+
// No --force equivalent: labels that exist are left as they are, so
|
|
181
|
+
// this stays idempotent without rewriting a team's chosen colors.
|
|
182
|
+
const stdout = glab(['label', 'list', '--repo', repo, '-F', 'json', '--per-page', '100']);
|
|
183
|
+
const existing = new Set(JSON.parse(stdout).map((l) => l.name));
|
|
184
|
+
for (const { name, color } of STANDARD_LABELS) {
|
|
185
|
+
if (existing.has(name)) continue;
|
|
186
|
+
glab(['label', 'create', '--repo', repo, '--name', name, '--color', `#${color}`]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `glab milestone create` prints human text (it has no JSON output flag),
|
|
191
|
+
// and `milestone list` fetches a single page — so listing goes through
|
|
192
|
+
// the API paged to the end, and a create is verified by re-listing
|
|
193
|
+
// rather than by parsing what glab printed.
|
|
194
|
+
async function ensureMilestone({ title, description = '', dueOn = null } = {}) {
|
|
195
|
+
if (!title) throw new Error('ensureMilestone: title is required');
|
|
196
|
+
const findByTitle = () => apiListAll(`projects/${encodeURIComponent(repo)}/milestones`)
|
|
197
|
+
.find((m) => m.title === title);
|
|
198
|
+
const existing = findByTitle();
|
|
199
|
+
if (existing) return normalizeMilestone(existing);
|
|
200
|
+
const args = ['milestone', 'create', '--repo', repo, '--title', title];
|
|
201
|
+
if (description) args.push('--description', description);
|
|
202
|
+
if (dueOn) args.push('--due-date', dueOn);
|
|
203
|
+
glab(args);
|
|
204
|
+
const created = findByTitle();
|
|
205
|
+
if (!created) {
|
|
206
|
+
throw new Error(`milestone "${title}" was not in the milestone list after glab milestone create`);
|
|
207
|
+
}
|
|
208
|
+
return normalizeMilestone(created);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
listAssignedToMe,
|
|
213
|
+
listUnassigned,
|
|
214
|
+
getIssue,
|
|
215
|
+
claim,
|
|
216
|
+
createIssue,
|
|
217
|
+
comment,
|
|
218
|
+
closeIssue,
|
|
219
|
+
issueRef,
|
|
220
|
+
issueUrl,
|
|
221
|
+
ensureLabels,
|
|
222
|
+
ensureMilestone,
|
|
223
|
+
get identity() { return `gitlab-issues:${repo}`; },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeMilestone(raw) {
|
|
228
|
+
return {
|
|
229
|
+
number: raw.id,
|
|
230
|
+
title: raw.title,
|
|
231
|
+
description: raw.description || '',
|
|
232
|
+
state: raw.state === 'closed' ? 'closed' : 'open', // GitLab says "active"
|
|
233
|
+
dueOn: raw.due_date || null,
|
|
234
|
+
url: raw.web_url,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Like github-issues.mjs's resolveRepo, kept local so the adapter stands
|
|
239
|
+
// alone: gitlab.com plus the configured self-managed host, scp-style and
|
|
240
|
+
// scheme URLs (an explicit non-default port stays in the host, so `host`
|
|
241
|
+
// can carry it — gitlab.example.com:8443), nested groups at any depth,
|
|
242
|
+
// optional .git suffix.
|
|
243
|
+
const DEFAULT_PORTS = { https: 443, http: 80, ssh: 22, git: 9418 };
|
|
244
|
+
const SCHEME_URL_RE = /^(?<scheme>https?|ssh|git):\/\/(?:[^@/]+@)?(?<host>[^:/]+)(?::(?<port>\d+))?\/(?<path>.+)$/;
|
|
245
|
+
|
|
246
|
+
function resolveRepo(config, spawnFn) {
|
|
247
|
+
if (config?.repo && config.repo !== 'auto') return config.repo;
|
|
248
|
+
const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
|
249
|
+
if (result.status !== 0) {
|
|
250
|
+
throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
|
|
251
|
+
}
|
|
252
|
+
const hosts = [GITLAB_DEFAULT_HOST, ...(config?.host ? [String(config.host).toLowerCase()] : [])];
|
|
253
|
+
const s = result.stdout.trim().replace(/\/+$/, '');
|
|
254
|
+
let host = null;
|
|
255
|
+
let path = null;
|
|
256
|
+
const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/); // scp-style: git@host:path
|
|
257
|
+
if (scp) {
|
|
258
|
+
host = scp[1].toLowerCase();
|
|
259
|
+
path = scp[2];
|
|
260
|
+
} else {
|
|
261
|
+
const m = s.match(SCHEME_URL_RE);
|
|
262
|
+
if (m) {
|
|
263
|
+
const { scheme, host: h, port } = m.groups;
|
|
264
|
+
// A non-default port stays part of the host so it can match a
|
|
265
|
+
// configured host that carries one; a default port drops, so
|
|
266
|
+
// github.com:443-style URLs still resolve.
|
|
267
|
+
host = (port && Number(port) !== DEFAULT_PORTS[scheme] ? `${h}:${port}` : h).toLowerCase();
|
|
268
|
+
path = m.groups.path;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (host && path && hosts.includes(host)) {
|
|
272
|
+
const segments = path.replace(/\.git$/, '').split('/').filter(Boolean);
|
|
273
|
+
if (segments.length >= 2) return segments.join('/');
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Cannot parse GitLab remote: ${result.stdout.trim()}`);
|
|
276
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import '../../lib/require-node.mjs';
|
|
13
13
|
import { createGithubAdapter } from './github-issues.mjs';
|
|
14
|
+
import { createGitlabAdapter } from './gitlab-issues.mjs';
|
|
14
15
|
|
|
15
16
|
export class AlreadyAssignedError extends Error {
|
|
16
17
|
constructor(issueId, assignees) {
|
|
@@ -28,6 +29,8 @@ export function createTracker(config, options = {}) {
|
|
|
28
29
|
switch (config.type) {
|
|
29
30
|
case 'github-issues':
|
|
30
31
|
return createGithubAdapter(config, options);
|
|
32
|
+
case 'gitlab-issues':
|
|
33
|
+
return createGitlabAdapter(config, options);
|
|
31
34
|
default:
|
|
32
35
|
throw new Error(`Unknown tracker type: ${config.type}`);
|
|
33
36
|
}
|
|
@@ -13,15 +13,16 @@ Finalize the active work session. Handles all project repos (code changes, PRs)
|
|
|
13
13
|
|
|
14
14
|
Read the active-session pointer from `.claude/.active-session.json` in the current worktree. If it is present, this chat runs inside a session worktree: continue with this flow unchanged.
|
|
15
15
|
|
|
16
|
-
If no pointer is present, run work-model detection — this covers
|
|
16
|
+
If no pointer is present, run work-model detection — this covers lane chats, which run at the workspace root (the launcher) rather than inside a worktree, for both lifecycles:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}" --detect --chat "{chat}"
|
|
19
|
+
node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}" --detect --chat "{chat}" [--session-id "{id}"]
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- `{launcher-root}` is the absolute path on the `Workspace root:` line the SessionStart hook injects. If that line is absent, derive it from git: run `git rev-parse --git-common-dir` (when it prints a relative path, resolve it against the cwd) and take its parent directory. That derivation lands on the source clone `…/repos/{repo}` when run from inside a **project** task worktree — there the launcher is two levels up; from inside a `.` worktree (`.claude/worktrees/{slug}`) the parent already is the launcher.
|
|
23
|
-
- `{chat}` is the name from the `Chat record:` line the SessionStart hook injects. If that line is absent, run `node .claude/scripts/chat-record.mjs --whoami --root "{launcher-root}"` first — compaction can drop the hook line, and this recovers the name by matching the chat's session id against the records. When that too prints nothing (exit 1), omit `--chat` — detection then relies on cwd alone.
|
|
24
|
-
- `model: session` → continue with this flow (read the session tracker as below), taking `{session-name}` from the detect result's `sessionName`.
|
|
23
|
+
- `{chat}` is the name from the `Chat record:` line the SessionStart hook injects. If that line is absent, run `node .claude/scripts/chat-record.mjs --whoami --root "{launcher-root}"` first — compaction can drop the hook line, and this recovers the name by matching the chat's session id against the records. When that too prints nothing (exit 1), omit `--chat` — detection then relies on cwd and the session id alone. Detection also matches this chat's session id against each `work-sessions/*/workspace/session.md` `chatSessions` list, so a lane chat that drove an old-model session by path is findable from the launcher. Pass `--session-id` when the id is known from somewhere else; otherwise it resolves from the chat record's `sessionId`, then `$CLAUDE_CODE_SESSION_ID`, in that order.
|
|
24
|
+
- `model: session` → continue with this flow (read the session tracker as below), taking `{session-name}` from the detect result's `sessionName`. When detection ran at the launcher the result instead carries `source: "chat-sessions"` and a `sessions` list — present the entries (each `{ name, branch, workItem, status }`) and ask which to finish; `{session-name}` is the chosen entry's `name`.
|
|
25
|
+
- `model: mixed` → this chat has open tasks AND sessions its id is registered in. List both — each session as `{name} ({status}, {branch})`, each task group as `{branch}` (grouping by branch as below) — and ask which to finish. A session continues this session flow with that `{session-name}`; a task goes to **Task completion (session model v2)**.
|
|
25
26
|
- `model: task` → go to **Task completion (session model v2)**. The result's `tasks` come from the chat record; if several are open, ask the user which one to complete — group by branch, a multi-repo task is several entries sharing a branch.
|
|
26
27
|
- `model: none` → "No active work session. Nothing to complete."
|
|
27
28
|
|
|
@@ -376,6 +377,8 @@ Reached from Step 1 when detection says `model: task`. The state is the branch,
|
|
|
376
377
|
|
|
377
378
|
If several tasks are open, ask the user which one to complete — group by branch; a multi-repo task is several entries sharing a branch — and complete one branch at a time.
|
|
378
379
|
|
|
380
|
+
Notes meant for another chat are not drawer notes. The drawer is per-chat and machine-local — no other chat can read it, and this machine is its only copy — so nothing in it survives a handoff. Anything the next chat (or the operator, from another machine) needs to continue this task — decisions, gotchas, where work stopped — goes on the linked issue as a comment through the tracker adapter (`await tracker.comment("{workItem}", body)`), where every chat can read it.
|
|
381
|
+
|
|
379
382
|
0. **Pre-flight: the worktrees must be clean.** For each `{worktree}` of the chosen task, `git -C "{worktree}" status --porcelain` must be empty. If it is not, stop here — before the rebase — and ask the user to commit or discard: rebasing over uncommitted work silently invalidates it.
|
|
380
383
|
|
|
381
384
|
1. **Rebase each task worktree onto its base** — `origin/{defaultBranch}` for a `forge` repo (`git -C "{worktree}" fetch origin`, then `git -C "{worktree}" rebase "origin/{defaultBranch}"`), the local `{defaultBranch}` for a `local` repo (`git -C "{worktree}" rebase "{defaultBranch}"`; no fetch — there is no remote to fetch from). Freshness first: the PR in step 3 must describe the branch as it will merge. If conflicts arise, STOP and present them — do not auto-resolve.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: migrate-sessions
|
|
3
|
-
description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them, and switch workspace.json to the task model. Runs only inside the current workspace; never deletes anything.
|
|
3
|
+
description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script never deletes anything.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Migrate Sessions
|
|
@@ -19,12 +19,18 @@ node .claude/scripts/migrate-sessions.mjs --inventory
|
|
|
19
19
|
|
|
20
20
|
Read-only. Present the stderr table plus each session's proposal with its reasons and warnings. Say plainly that the proposals are proposals — evidence and a starting point, not decisions. Pay particular attention to the per-remote state shown per worktree (`same`, `ahead +N`, `behind -N`, `diverged +N/-M`, `not-fetched`, `unknown`) and to `unbacked` warnings: they change what Finish and Archive mean for that session. Each worktree also lists every configured remote with its exact URL — read those before any backup decision: `origin:none` means only that the remote holds no copy of this branch, never that the repo has no remote, and an `origin` that is really a third-party upstream shows its URL right there. Entries shown as `foreign` (symlinked) are never acted on — surface them for manual reconciliation.
|
|
21
21
|
|
|
22
|
+
Three more kinds of evidence change what you propose:
|
|
23
|
+
|
|
24
|
+
- **Fetch age** — every worktree line ends `fetch:…` with the age of its repo's last fetch, and anything over a day draws a `stale-fetch` warning: the commits-ahead and content counts ride on tracking refs frozen at that fetch. Tell the operator to fetch first, or re-run with `--inventory --fetch`, which fetches each touched source clone before inspecting it. A fetch is read-only with respect to the workspace's own state — it moves no local branch and touches no worktree, only refs/remotes/* and the object store — and a repo with no origin or an unreachable one is recorded as skipped or failed, never fatal.
|
|
25
|
+
- **Open chats** — `chat open?` on a session (with a `chat-open` warning) means its tracker records a chat session with no `ended:` — a chat may still be working in it. Confirm with the operator before proposing Archive.
|
|
26
|
+
- **External worktrees** — a trailing section lists worktrees the workspace's repos register outside the workspace root (a scratch checkout in tmp, a directory elsewhere). They belong to no session: nothing in this migration prunes, moves, or removes them, and you never should either — including by hand, because `git worktree prune` has no path filter and would drop their records.
|
|
27
|
+
|
|
22
28
|
## 2. Decide per session, with the operator — one at a time
|
|
23
29
|
|
|
24
30
|
For each session, lay out its evidence and ask the operator which way to go. Never infer the decision from the proposal. The options:
|
|
25
31
|
|
|
26
|
-
- **Finish** (typical for MERGEABLE) — resume
|
|
27
|
-
- **Archive** (typical for ABANDONED,
|
|
32
|
+
- **Finish** (typical for MERGEABLE, and the default offer for READY_TO_COMPLETE — a session `/complete-work` stopped partway through: tracker already stripped, worktrees still live, branch often already pushed) — resume it with `/start-work` (its walk lists sessions by their tracker, so a stripped one will not appear — name it and re-create a minimal tracker from the inventory's branch/repos before continuing), then run `/complete-work`; its own merge confirmation applies there. But if the inventory shows a **diverged** remote for that session, say so *before* the operator chooses Finish: `/complete-work`'s plain push will be rejected, and pushing the rewritten history needs `--force-with-lease` — which you run only on the operator's explicit yes naming the branch. Never force silently.
|
|
33
|
+
- **Archive** (typical for ABANDONED, an ORPHAN_SHELL that still holds files, or a MERGEABLE the operator gives up on) — take it out of the active lifecycle without destroying anything. When the inventory shows `chat open?`, confirm with the operator that the chat is really done before offering Archive — a live chat's uncommitted work would ride into the archive unseen. Three steps, in this order, each its own decision:
|
|
28
34
|
1. **Clear uncommitted work.** `--archive` refuses when any of the session's worktrees has uncommitted or untracked changes — an edited `session.md` counts — and names them, because edits buried uncommitted in an archive are invisible to every later merge or PR. Offer the operator: commit them to the session branch first (`git -C {worktree} add -A`, then `git -C {worktree} commit -m "…"` — per dirty worktree), discard them explicitly (`git -C {worktree} restore …` / `git -C {worktree} clean …`), or, on an explicit yes, re-run with `--allow-uncommitted` to archive them mid-edit. Do this before the backup: the backup tags committed tips only, so committing first brings those edits under the backup, while anything archived with `--allow-uncommitted` is NOT in it.
|
|
29
35
|
2. **Offer a backup.** Archiving keeps everything on this machine; a backup adds an off-machine copy of the session's commits, and it is what makes a later deletion safe. It covers the committed tips as they stand after step 1. Plain `--backup` creates `drain/{session}/…` tags locally and pushes nothing — for a repo whose only remote is one the operator does not own, that local tag IS the backup. Pushing is a separate, explicitly allowed step:
|
|
30
36
|
```bash
|
|
@@ -32,6 +38,10 @@ For each session, lay out its evidence and ask the operator which way to go. Nev
|
|
|
32
38
|
```
|
|
33
39
|
With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining is fine — the archive still keeps everything locally.
|
|
34
40
|
3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}`. The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted. The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, or when it holds a submodule checkout (its link cannot be repaired) — surface the reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move).
|
|
41
|
+
- **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The command re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
|
|
42
|
+
```bash
|
|
43
|
+
node -e "const fs=require('fs');const p=process.argv[1];const empty=d=>fs.readdirSync(d,{withFileTypes:true}).every(e=>e.isDirectory()&&empty(d+'/'+e.name));if(!empty(p)){console.error(p+' is not empty — left alone');process.exit(1)}fs.rmSync(p,{recursive:true});console.log('removed empty shell '+p)" work-sessions/{name}
|
|
44
|
+
```
|
|
35
45
|
- **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle.
|
|
36
46
|
|
|
37
47
|
Unbacked commits (present on no remote) are safe in an archive — they are only ever at risk when someone deletes one. Say so when you archive such a session, and offer the backup.
|
|
@@ -48,7 +58,13 @@ The switch procedure:
|
|
|
48
58
|
```bash
|
|
49
59
|
node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model
|
|
50
60
|
```
|
|
51
|
-
3. Land the change.
|
|
61
|
+
3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`, then create, ask before merging, and pull the launcher after the merge:
|
|
62
|
+
```bash
|
|
63
|
+
node .claude/scripts/task-pr.mjs --create --root . --branch chore/enable-task-model \
|
|
64
|
+
--repo . --body-file .=workspace-scratchpad/switch-pr.md --out workspace-scratchpad/switch-prs.json
|
|
65
|
+
node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
|
|
66
|
+
```
|
|
67
|
+
Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree, fast-forward the launcher's default branch, remove the worktree:
|
|
52
68
|
```bash
|
|
53
69
|
git -C .claude/worktrees/chore-enable-task-model add workspace.json
|
|
54
70
|
git -C .claude/worktrees/chore-enable-task-model commit -m "chore: switch to the task lifecycle"
|
|
@@ -23,19 +23,22 @@ Release notes come from the forge. GitHub's generated notes (merged PR titles) a
|
|
|
23
23
|
Ask which repo to release — read `repos` from `workspace.json` and default to the entry with `"primary": true`. If no version was given, ask the bump kind (patch/minor/major) after showing the merged PRs since the last tag, so the operator can judge the impact:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
git -C repos/{repo} describe --tags --abbrev=0
|
|
26
|
+
git -C repos/{repo} describe --tags --abbrev=0 # v{previous}
|
|
27
|
+
git -C repos/{repo} log -1 --format=%cI v{previous} # the previous tag's commit time
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
Then call the forge adapter's `prList` with a merged-after search bounded by that tag's
|
|
30
|
+
Then call the forge adapter's `prList` with `state: 'merged'`, `base:` the repo's default branch, and a merged-after search bounded by that tag's commit time — the full `%cI` timestamp (e.g. `merged:>2026-05-01T10:00:00Z`), never a bare date, which would sweep in every PR merged later the same day. `base` matters because PRs merged into other branches are not this release. If the returned list carries `truncated: true`, older PRs may sit beyond the fetched page — say so and re-query with a higher `limit` before judging the bump. Pre-v1.0 breaking changes are a minor bump.
|
|
30
31
|
|
|
31
|
-
**Step 2: Preflight the tag**
|
|
32
|
+
**Step 2: Preflight the tag and the forge**
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
Two checks before anything is pushed. First the tag: if `v{version}` already exists on origin, stop and ask — reuse it, investigate with `forge.releaseView`, or pick another version. Never force-push a tag.
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
37
|
git -C repos/{repo} ls-remote --exit-code origin refs/tags/v{version}
|
|
37
38
|
```
|
|
38
39
|
|
|
40
|
+
Second the release path. Build the repo's forge with `forgeConfigForRepo(root, '{repo}', ws)` from `.claude/scripts/merge-mode.mjs` — it picks the adapter from the repo's own origin, so a GitLab repo gets the gitlab adapter whatever `workspace.forge` says — and decide now who creates the release (Step 5): the repo's CI, or this skill. If the flow needs something the forge cannot do, stop here — past this point the tag is pushed, and a publish that cannot run is much harder to unwind.
|
|
41
|
+
|
|
39
42
|
**Step 3: Bump on a branch**
|
|
40
43
|
|
|
41
44
|
Create a task worktree for the release branch:
|
|
@@ -48,7 +51,7 @@ If the repo has a `package.json` with a `version`, set it to `{version}` (edit t
|
|
|
48
51
|
|
|
49
52
|
**Step 4: Merge**
|
|
50
53
|
|
|
51
|
-
Push the branch and open a PR through the
|
|
54
|
+
Push the branch and open a PR through the same per-repo forge (`forgeConfigForRepo` from Step 2) with head `release/v{version}`. Ask `Merge? [Y/n]`, then merge (squash, delete branch).
|
|
52
55
|
|
|
53
56
|
**Step 5: Tag and publish**
|
|
54
57
|
|
|
@@ -60,15 +63,28 @@ git -C repos/{repo} tag v{version}
|
|
|
60
63
|
git -C repos/{repo} push origin v{version}
|
|
61
64
|
```
|
|
62
65
|
|
|
63
|
-
Who creates the release depends on the repo
|
|
66
|
+
Who creates the release depends on the repo, and the CI check is per-forge:
|
|
67
|
+
|
|
68
|
+
- **GitHub**: `.github/workflows/publish.yml` that itself creates the release (it contains `gh release create`, `softprops/action-gh-release`, or `actions/create-release`) owns the release — do not call `releaseCreate`; racing it duplicates the release or fails. Instead find and watch its run with `workflowRunFind` / `workflowRunWatch` — retry the find up to 5 times with 3 s backoff (the run may not be registered the moment the tag lands); a failed run is reported to the operator, not thrown.
|
|
69
|
+
- **GitLab**: `.gitlab-ci.yml` with a `release:` keyword block (a release-cli job) owns the release the same way — do not call `releaseCreate`; find and watch the tag's pipeline with `workflowRunFind({ workflow: 'ci.yml', branch: 'v{version}' })` / `workflowRunWatch` (the workflow name is ignored — one pipeline per ref).
|
|
70
|
+
|
|
71
|
+
Either way, confirm the release exists with `forge.releaseView({ tag: 'v{version}', repo })`.
|
|
64
72
|
|
|
65
|
-
Otherwise the skill creates the release itself:
|
|
73
|
+
Otherwise the skill creates the release itself — on GitHub the forge generates the notes; on GitLab it cannot (`NOT_SUPPORTED`), so the notes come from the same query Step 1 ran:
|
|
66
74
|
|
|
67
75
|
```js
|
|
76
|
+
// GitHub
|
|
68
77
|
await forge.releaseCreate({ tag: 'v{version}', repo, generateNotes: true });
|
|
78
|
+
// GitLab — notes come from the skill, not the forge. tagTime is the %cI of
|
|
79
|
+
// v{previous}; base excludes MRs merged into other branches.
|
|
80
|
+
const merged = await forge.prList({ state: 'merged', base: '{default branch}',
|
|
81
|
+
search: `merged:>${tagTime}`, repo });
|
|
82
|
+
if (merged.truncated) warn('older MRs may be missing — raise limit and re-query');
|
|
83
|
+
await forge.releaseCreate({ tag: 'v{version}', repo, generateNotes: false,
|
|
84
|
+
notes: merged.map((p) => `- ${p.title}`).join('\n') });
|
|
69
85
|
```
|
|
70
86
|
|
|
71
|
-
If a `publish.yml` without release creation exists, still find and watch its run the same way.
|
|
87
|
+
If a `publish.yml` (or a publish job) without release creation exists, still find and watch its run the same way.
|
|
72
88
|
|
|
73
89
|
**Step 6: Tear down and report**
|
|
74
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup-tracker
|
|
3
|
-
description: Configure an issue tracker for this workspace — writes workspace.json → tracker block and initializes labels. GitHub Issues
|
|
3
|
+
description: Configure an issue tracker for this workspace — writes workspace.json → tracker block and initializes labels. GitHub Issues and GitLab Issues are the shipped backends; others can be added by dropping an adapter at .claude/scripts/trackers/{type}.mjs. Runnable during /workspace-init or standalone.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Setup Tracker
|
|
@@ -10,7 +10,7 @@ Wire this workspace up to an external issue tracker. The tracker becomes the sou
|
|
|
10
10
|
## Prerequisites
|
|
11
11
|
|
|
12
12
|
- `.claude/rules/work-item-tracking.md` should be active. If missing, warn but continue.
|
|
13
|
-
- The adapter for the chosen backend must exist at `.claude/scripts/trackers/{type}.mjs`. The template ships `github-issues.mjs`.
|
|
13
|
+
- The adapter for the chosen backend must exist at `.claude/scripts/trackers/{type}.mjs`. The template ships `github-issues.mjs` and `gitlab-issues.mjs`.
|
|
14
14
|
|
|
15
15
|
## Flow
|
|
16
16
|
|
|
@@ -20,15 +20,16 @@ Read `workspace.json` → `workspace.tracker`. If already configured: "Tracker i
|
|
|
20
20
|
|
|
21
21
|
### Step 2: Pick a backend
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Offer the backend that matches the workspace's own remote first — run `git -C {workspace-root} remote get-url origin` and suggest GitLab Issues when the host is `gitlab.com` or the workspace's configured GitLab `host`, GitHub Issues otherwise. Then ask: "Which issue tracker?"
|
|
24
24
|
1. GitHub Issues (shipped)
|
|
25
|
-
2.
|
|
26
|
-
3.
|
|
27
|
-
4.
|
|
25
|
+
2. GitLab Issues (shipped)
|
|
26
|
+
3. Linear — not yet supported
|
|
27
|
+
4. Jira — not yet supported
|
|
28
|
+
5. None — skip
|
|
28
29
|
|
|
29
|
-
For (
|
|
30
|
+
For (3) and (4): tell the user the adapter isn't shipped and exit. To add one, write a module at `.claude/scripts/trackers/{type}.mjs` that implements the contract in `.claude/scripts/trackers/interface.mjs`.
|
|
30
31
|
|
|
31
|
-
For (
|
|
32
|
+
For (5): exit — no changes.
|
|
32
33
|
|
|
33
34
|
### Step 3: GitHub Issues configuration
|
|
34
35
|
|
|
@@ -96,14 +97,47 @@ For (4): exit — no changes.
|
|
|
96
97
|
```
|
|
97
98
|
Expected: empty list (no tickets yet) or the existing ones if the repo already had issues.
|
|
98
99
|
|
|
99
|
-
### Step 4:
|
|
100
|
+
### Step 4: GitLab Issues configuration
|
|
101
|
+
|
|
102
|
+
1. **Verify `glab` auth.** Run `glab auth status`. For a self-managed instance, walk through `glab auth login --hostname {host}`. Do not proceed until authenticated.
|
|
103
|
+
|
|
104
|
+
2. **Resolve the target project.** Default to the workspace's own git remote (`git -C {workspace-root} remote get-url origin`, parsed as above). Ask: "Use `{group/sub/project}` for issues, or a different project?" Accept any nested `group/sub/project` path.
|
|
105
|
+
|
|
106
|
+
GitLab projects have issues enabled by default, so there is no equivalent of the GitHub `hasIssuesEnabled` check.
|
|
107
|
+
|
|
108
|
+
3. **Write `workspace.json`:**
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"workspace": {
|
|
112
|
+
"tracker": {
|
|
113
|
+
"type": "gitlab-issues",
|
|
114
|
+
"repo": "{group/sub/project}",
|
|
115
|
+
"host": "{host}"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
Include `"host"` only for a self-managed instance — omit it for gitlab.com. Preserve all other fields. Commit:
|
|
121
|
+
```bash
|
|
122
|
+
git add workspace.json
|
|
123
|
+
git commit -m "chore: configure gitlab-issues tracker on {group/sub/project}"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
4. **Initialize labels and (optionally) milestones** exactly as in Step 3 — the one-liners go through `createTracker`, so the same commands work against GitLab.
|
|
127
|
+
|
|
128
|
+
5. **Verify:**
|
|
129
|
+
```bash
|
|
130
|
+
glab issue list --repo {group/sub/project} --per-page 5
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Step 5: Report
|
|
100
134
|
|
|
101
135
|
```
|
|
102
136
|
Tracker configured:
|
|
103
|
-
Type: github-issues
|
|
137
|
+
Type: {github-issues | gitlab-issues}
|
|
104
138
|
Repo: {slug}
|
|
105
139
|
Labels: bug, feat, chore, P1, P2, P3
|
|
106
|
-
Milestones: {list or "(none — add via
|
|
140
|
+
Milestones: {list or "(none — add via the forge UI)"}
|
|
107
141
|
|
|
108
142
|
Next: run /start-work to pick or create an issue and begin.
|
|
109
143
|
```
|
|
@@ -113,5 +147,5 @@ Next: run /start-work to pick or create an issue and begin.
|
|
|
113
147
|
- The workspace repo is the default target — no separate `workspace-{project}` repo needed.
|
|
114
148
|
- Issues track everything across all project repos in the workspace. Cross-repo work items live in one place.
|
|
115
149
|
- One-way integration: the tracker is the source of truth. Skills read and write via the adapter; nothing else reflects tracker state locally.
|
|
116
|
-
- If
|
|
150
|
+
- If CLI auth later expires, skill flows will surface `gh`/`glab` errors — re-run `gh auth login` (or `glab auth login --hostname {host}`) and try again.
|
|
117
151
|
- Adding a new backend: write an adapter module at `.claude/scripts/trackers/{type}.mjs` implementing the interface in `interface.mjs`, then add a case in the `createTracker` switch statement.
|
|
@@ -21,7 +21,20 @@ New work as a task: one tracker issue, one branch, one worktree per repo the wor
|
|
|
21
21
|
|
|
22
22
|
If `workspace.tracker` is absent, say tracking is off and skip step 1 — but still ask for the type (`bug` / `feat` / `chore`) and a one-line description, because the type picks the branch prefix — then continue with steps 2–6. What that costs: without a tracker there is no `workItem` and no issue to close at completion — the task is still recorded on the chat record (with no work item, keyed by repo + branch), so `/complete-work` finds it from the launcher like any other task.
|
|
23
23
|
|
|
24
|
-
1. **Identify or create the tracker issue and claim it.** If the invocation's arguments already name an issue — `gh:N`, `#N`, or an issue URL — normalize it to the adapter's id (`#42` and a `.../issues/42` URL both mean `gh:42`)
|
|
24
|
+
1. **Identify or create the tracker issue and claim it.** If the invocation's arguments already name an issue — `gh:N`, `#N`, or an issue URL — normalize it to the adapter's id (`#42` and a `.../issues/42` URL both mean `gh:42`) and fetch it with `tracker.getIssue(id)`. Then check whether the issue already has a task before claiming anything — another chat may have started it (gh:188):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node .claude/scripts/chat-record.mjs --root . --owner "{workItem}"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Exit 1 means no chat owns a task for the issue: claim it when it is not yet assigned to you (with the same `ALREADY_ASSIGNED` handling as the fallback pick below) and skip the candidate list entirely. Exit 0 prints `{ chat, branch, repos }` — a task exists. First make sure its worktrees are there: run step 4's `--create` for each of `repos`; over an existing branch or worktree `--create` REUSES what it finds (`created: false`) — it never fails or duplicates. Then ask the operator which way to take it:
|
|
31
|
+
- **Adopt into this chat** — record the task with step 5's `--add-task`, and remove it from the old chat's record (`--remove-task --chat "{owner.chat}"` with the same selectors) only when the operator says that chat is done with it. While both chats still work the task, both records legitimately list it — the branch and worktrees are shared, not duplicated.
|
|
32
|
+
- **Leave it with the owner** — message the owning chat via SendMessage using the name `--owner` printed (the record name doubles as the session-registry name SendMessage reaches a chat by) and stop here.
|
|
33
|
+
- When `--owner` names this chat, it is a resume of your own task: reuse the worktrees as above and continue.
|
|
34
|
+
|
|
35
|
+
Notes another chat will need to continue the task never go in this chat's drawer — the drawer is per-chat and machine-local. Put them on the issue as a comment (`await tracker.comment("{workItem}", body)`), where any chat can read them.
|
|
36
|
+
|
|
37
|
+
When the invocation's arguments name no issue, list the candidates — the same adapter calls as Flow: Blank steps 3–6 (an issue picked from the list gets the same `--owner` check once its id is known):
|
|
25
38
|
|
|
26
39
|
```javascript
|
|
27
40
|
import { createTracker } from './.claude/scripts/trackers/interface.mjs';
|
|
@@ -91,7 +104,7 @@ If `workspace.tracker` is absent, say tracking is off and skip step 1 — but st
|
|
|
91
104
|
- Workspace: `work-sessions/{name}/workspace/`
|
|
92
105
|
- For each repo in `repos:` frontmatter: `work-sessions/{name}/workspace/repos/{repo}/`
|
|
93
106
|
- If any are missing, recreate from the branch
|
|
94
|
-
3. The session-start hook automatically registers each chat in the session tracker's `chatSessions` frontmatter when Claude opens in a worktree. Verify the current chat is registered —
|
|
107
|
+
3. The session-start hook automatically registers each chat in the session tracker's `chatSessions` frontmatter when Claude opens in a worktree. A lane chat resuming from the launcher is never opened there, so the hook cannot register it. Verify the current chat is registered — from either starting point — and when it is not, append an entry using the invocation shown under "Create work session" below (the helper is an importable library, not a CLI). Take the chat's UUID from this chat's record: `node .claude/scripts/chat-record.mjs --root . --read "{chat}"` prints it as `sessionId` (the `Chat record:` hook line names `{chat}`). The id matters — it is what `/complete-work` later matches this session by from the launcher.
|
|
95
108
|
|
|
96
109
|
Each `chatSessions` entry has this shape:
|
|
97
110
|
```yaml
|
|
@@ -126,6 +126,12 @@ Also install these top-level files from the payload:
|
|
|
126
126
|
|
|
127
127
|
**Per-repo CLAUDE.md stubs:** For each repo in `workspace.json`, check if `repos/{repo}/CLAUDE.md` exists. If not, ask "Scaffold a CLAUDE.md for {repo}? [Y/n]". If yes, write a blank stub from `.workspace-update/repo-claude.md.tmpl`, substituting `{{repo-name}}` with the repo name. The stub body is comment text only — no workspace-specific content — and its `## Commands` section is where you will add repo-specific test, lint, and build commands.
|
|
128
128
|
|
|
129
|
+
**Write the template baseline** now that the components are installed, so future `/workspace-update` runs can tell template changes from local edits (three-way classification):
|
|
130
|
+
```bash
|
|
131
|
+
node .claude/scripts/classify-update.mjs --root . --write-baseline --payload .workspace-update
|
|
132
|
+
```
|
|
133
|
+
It hashes the payload's verbatim files — run it before Step 15 deletes the payload.
|
|
134
|
+
|
|
129
135
|
**Commit:** `git commit -m "feat: install template components from payload"`
|
|
130
136
|
|
|
131
137
|
### Step 6: Activate optional rules
|