@ulysses-ai/create-workspace 0.23.1-beta.0 → 0.23.3-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/upgrade.mjs +127 -41
- package/lib/upgrade.test.mjs +138 -14
- package/package.json +1 -1
- package/template/_claude/rules/workspace-structure.md +2 -1
- package/template/_claude/scripts/forges/gitlab.mjs +6 -1
- package/template/_claude/scripts/migrate-sessions.mjs +212 -34
- package/template/_claude/scripts/template-merge.mjs +255 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +17 -3
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
- package/template/_claude/skills/workspace-update/SKILL.md +13 -5
package/lib/upgrade.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/upgrade.mjs
|
|
2
2
|
import {
|
|
3
3
|
existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync, mkdtempSync, renameSync,
|
|
4
|
+
readdirSync, statSync,
|
|
4
5
|
} from 'fs';
|
|
5
6
|
import { join } from 'path';
|
|
6
7
|
import { tmpdir } from 'os';
|
|
@@ -85,35 +86,103 @@ function installWorkspaceUpdateSkill(targetDir, payloadDir, { copySkill = cpSync
|
|
|
85
86
|
return true;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// Where --upgrade stages the template CONTENT of the version being upgraded
|
|
90
|
+
// FROM, inside the payload and under live names (the paths the payload
|
|
91
|
+
// itself installs). template/_claude/scripts/template-merge.mjs reads it as
|
|
92
|
+
// the common ancestor of a `differs` file's three-way merge — keep the name
|
|
93
|
+
// in sync with that script's TEMPLATE_BASE_DIR.
|
|
94
|
+
const TEMPLATE_BASE_DIR = '.template-base';
|
|
95
|
+
|
|
96
|
+
function countFiles(dir) {
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
99
|
+
if (entry.isDirectory()) n += countFiles(join(dir, entry.name));
|
|
100
|
+
else if (entry.isFile()) n++;
|
|
101
|
+
}
|
|
102
|
+
return n;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Copy the verbatim-installed template roots (.claude/, .mcp.json,
|
|
106
|
+
// .claudeignore — via the layout pairs of the extracted tarball) into the
|
|
107
|
+
// payload at .template-base/. With this staged, /workspace-update merges
|
|
108
|
+
// each `differs` file three-way (local vs base vs template) instead of
|
|
109
|
+
// asking apply-or-keep on it (gh:193). Best effort: a failure mid-copy
|
|
110
|
+
// removes the half-written base — a partial tree must never be trusted as
|
|
111
|
+
// anyone's ancestor — and warns rather than failing the upgrade.
|
|
112
|
+
function stageTemplateBase(payloadDir, extracted, pairs, fromVersion, { copyBase = cpSync } = {}) {
|
|
113
|
+
const baseDir = join(payloadDir, TEMPLATE_BASE_DIR);
|
|
114
|
+
let staged = 0;
|
|
115
|
+
try {
|
|
116
|
+
for (const [sourceName, installedName] of pairs) {
|
|
117
|
+
const src = join(extracted, sourceName);
|
|
118
|
+
if (!existsSync(src)) continue;
|
|
119
|
+
copyBase(src, join(baseDir, installedName), { recursive: true });
|
|
120
|
+
staged += statSync(src).isFile() ? 1 : countFiles(src);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
124
|
+
console.error(` Warning: could not stage the merge base (${err.message}) — locally modified files will be decided per file.`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (staged === 0) {
|
|
128
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
129
|
+
console.error(' Warning: no template files to stage as a merge base — locally modified files will be decided per file.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.log(` Staged the v${fromVersion} template as a merge base (${staged} files) — .workspace-update/${TEMPLATE_BASE_DIR}/`);
|
|
133
|
+
}
|
|
134
|
+
|
|
88
135
|
// A workspace upgraded from a pre-baseline template version has no
|
|
89
136
|
// .claude/.template-baseline.json, so its first /workspace-update reports
|
|
90
137
|
// every template change as a local edit — dozens of per-file questions
|
|
91
138
|
// with a handful of real edits among them. The template tree of the
|
|
92
139
|
// INSTALLED version still exists in exactly one place: its published npm
|
|
93
|
-
// tarball. Fetch it,
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// launcher
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
140
|
+
// tarball. Fetch it once, best effort, and stage from it INSIDE THE
|
|
141
|
+
// PAYLOAD, not the launcher: with a remote, /workspace-update classifies
|
|
142
|
+
// inside a task worktree that can only see what travels with the payload,
|
|
143
|
+
// and an untracked launcher baseline would dirty the launcher against the
|
|
144
|
+
// incoming PR. Two things ride on the one fetch:
|
|
145
|
+
// - a baseline of what that version shipped (the same normalisation
|
|
146
|
+
// template-baseline.mjs applies everywhere), marked "reconstructed":
|
|
147
|
+
// true — classify-update.mjs falls back to the staged file
|
|
148
|
+
// automatically (gh:186); and
|
|
149
|
+
// - the file CONTENT itself at .template-base/, the common ancestor
|
|
150
|
+
// template-merge.mjs three-way-merges `differs` files against (gh:193).
|
|
151
|
+
// Any failure — unknown version, offline, unparsable tarball, an IO error
|
|
152
|
+
// mid-staging — warns and falls back to the ask-per-file behaviour; the
|
|
153
|
+
// upgrade itself never fails.
|
|
154
|
+
async function stageInstalledTemplate(payloadDir, root, fromVersion, {
|
|
155
|
+
fetchTarball = fetchPackageTarball, buildBase = buildBaseline, copyBase = cpSync,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const hasBaseline = readBaseline(root) !== null;
|
|
158
|
+
if (hasBaseline) {
|
|
159
|
+
console.log(' Template baseline already present — leaving it in place.');
|
|
160
|
+
}
|
|
161
|
+
const warnNothingStaged = (reason) => {
|
|
162
|
+
if (hasBaseline) {
|
|
163
|
+
console.error(` Warning: ${reason} — no merge base staged for locally modified files.`);
|
|
164
|
+
console.error(' They will be decided per file, without a three-way merge.');
|
|
165
|
+
} else {
|
|
166
|
+
console.error(` Warning: ${reason} — no template baseline written.`);
|
|
167
|
+
console.error(' The first /workspace-update will ask about every changed file individually.');
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
104
171
|
if (typeof fromVersion !== 'string' || fromVersion === 'unknown' || fromVersion === '') {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return false;
|
|
172
|
+
warnNothingStaged('workspace.json records no templateVersion');
|
|
173
|
+
return;
|
|
108
174
|
}
|
|
175
|
+
// Progress, on stderr: the fetch blocks up to 15 s (45 s through the
|
|
176
|
+
// npm-pack fallback) before its timeout — an offline operator should
|
|
177
|
+
// know what the wait is for.
|
|
178
|
+
console.error(` Fetching the v${fromVersion} template as the merge base…`);
|
|
109
179
|
let tarball = null;
|
|
110
180
|
try {
|
|
111
181
|
tarball = await fetchTarball(fromVersion);
|
|
112
182
|
} catch { /* a failing fetch is the same as no fetch */ }
|
|
113
183
|
if (tarball === null) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return false;
|
|
184
|
+
warnNothingStaged(`could not fetch the v${fromVersion} package tarball from npm`);
|
|
185
|
+
return;
|
|
117
186
|
}
|
|
118
187
|
|
|
119
188
|
const tmp = mkdtempSync(join(tmpdir(), 'create-workspace-baseline-'));
|
|
@@ -121,27 +190,45 @@ async function reconstructBaseline(payloadDir, fromVersion, { fetchTarball = fet
|
|
|
121
190
|
// npm tarballs root every file under package/; the template itself
|
|
122
191
|
// ships under both layouts across versions: _claude/ and _mcp.json
|
|
123
192
|
// (v0.19.0 on, same as this package) and the older live-named .claude/
|
|
124
|
-
// and .mcp.json.
|
|
125
|
-
// through
|
|
193
|
+
// and .mcp.json. Both staging jobs map either onto the installed names
|
|
194
|
+
// through the same pairs.
|
|
126
195
|
const extracted = join(tmp, 'template');
|
|
127
|
-
let
|
|
196
|
+
let pairs = null;
|
|
128
197
|
try {
|
|
129
198
|
extractTarballEntries(tarball, extracted, { prefix: 'package/template/' });
|
|
130
|
-
const
|
|
131
|
-
|
|
199
|
+
const candidate = existsSync(join(extracted, '_claude')) ? INERT_PAIRS : LIVE_PAIRS;
|
|
200
|
+
if (candidate.some(([src]) => existsSync(join(extracted, src)))) pairs = candidate;
|
|
132
201
|
} catch { /* unreadable tarball or unhashable tree — the fallback below */ }
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return false;
|
|
202
|
+
if (pairs === null) {
|
|
203
|
+
warnNothingStaged(`the v${fromVersion} package tarball carries no readable template files`);
|
|
204
|
+
return;
|
|
137
205
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
206
|
+
|
|
207
|
+
if (!hasBaseline) {
|
|
208
|
+
// Hashing and writing the baseline is as best-effort as everything
|
|
209
|
+
// downstream of the fetch: a failure warns and behaves like the
|
|
210
|
+
// no-baseline path, never blocking the merge base staged below.
|
|
211
|
+
try {
|
|
212
|
+
const baseline = buildBase(extracted, { pairs, version: fromVersion });
|
|
213
|
+
if (Object.keys(baseline.files).length === 0) {
|
|
214
|
+
// The tree extracted fine but nothing in it is baselinable — say
|
|
215
|
+
// so, and still stage the merge base below.
|
|
216
|
+
console.error(` Warning: the v${fromVersion} template files are not baselinable — no template baseline written.`);
|
|
217
|
+
console.error(' The first /workspace-update will ask about every changed file individually.');
|
|
218
|
+
} else {
|
|
219
|
+
baseline.reconstructed = true;
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(payloadDir, RECONSTRUCTED_BASELINE_NAME),
|
|
222
|
+
JSON.stringify(baseline, null, 2) + '\n',
|
|
223
|
+
);
|
|
224
|
+
console.log(` Reconstructed template baseline for v${fromVersion} from the npm tarball (${Object.keys(baseline.files).length} files) — staged in the payload as ${RECONSTRUCTED_BASELINE_NAME}`);
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error(` Warning: could not stage a template baseline for v${fromVersion} (${err.message}) — no template baseline written.`);
|
|
228
|
+
console.error(' The first /workspace-update will ask about every changed file individually.');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
stageTemplateBase(payloadDir, extracted, pairs, fromVersion, { copyBase });
|
|
145
232
|
} finally {
|
|
146
233
|
rmSync(tmp, { recursive: true, force: true });
|
|
147
234
|
}
|
|
@@ -172,18 +259,17 @@ export async function upgradeWorkspace(targetDir, opts = {}) {
|
|
|
172
259
|
|
|
173
260
|
const fromVersion = config.workspace?.templateVersion || 'unknown';
|
|
174
261
|
|
|
175
|
-
// Stage the payload first:
|
|
262
|
+
// Stage the payload first: the reconstructed baseline and the merge base
|
|
263
|
+
// are staged INSIDE it.
|
|
176
264
|
const { payloadDir, toVersion } = stagePayload(targetDir, { action: 'upgrade', fromVersion });
|
|
177
265
|
|
|
178
266
|
// A workspace older than the baseline's introduction has no baseline —
|
|
179
267
|
// and a corrupt one is as good as none (readBaseline parses it). In
|
|
180
|
-
// either case
|
|
181
|
-
// so the update can tell template changes from
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
await reconstructBaseline(payloadDir, fromVersion, opts);
|
|
186
|
-
}
|
|
268
|
+
// either case stage what the installed version shipped into the payload:
|
|
269
|
+
// a reconstructed baseline so the update can tell template changes from
|
|
270
|
+
// local edits (gh:186), and the merge base so it can merge locally edited
|
|
271
|
+
// files three-way instead of asking apply-or-keep (gh:193).
|
|
272
|
+
await stageInstalledTemplate(payloadDir, targetDir, fromVersion, opts);
|
|
187
273
|
|
|
188
274
|
// Bootstrap the current /workspace-update skill — the workspace runs its
|
|
189
275
|
// installed copy next, and the old one does not know this payload's flow.
|
package/lib/upgrade.test.mjs
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// from v0.19.0, but a workspace upgraded from an older template may still
|
|
7
7
|
// carry a tracked payload), the workspace-update skill bootstrap (--upgrade
|
|
8
8
|
// installs the payload's copy so the workspace never runs an outdated
|
|
9
|
-
// flow),
|
|
9
|
+
// flow), baseline reconstruction for pre-baseline workspaces (gh:186), and
|
|
10
|
+
// the .template-base/ merge-base staging every upgrade attempts (gh:193)
|
|
10
11
|
// — the tarball fetch is injected so the suite never touches the network.
|
|
11
12
|
import { upgradeWorkspace } from './upgrade.mjs';
|
|
12
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
|
|
13
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'fs';
|
|
13
14
|
import { join } from 'path';
|
|
14
15
|
import { tmpdir } from 'os';
|
|
15
16
|
import { execSync, spawnSync } from 'child_process';
|
|
@@ -92,6 +93,9 @@ function makeTarball(entries) {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
96
|
+
// The fixtures are ASCII with LF endings, so a plain sha256 of the text
|
|
97
|
+
// equals template-baseline's CRLF-normalized hashBytes.
|
|
98
|
+
const hashFile = (p) => sha(readFileSync(p, 'utf8'));
|
|
95
99
|
|
|
96
100
|
// A v0.18-era package: every path rooted under package/ (npm's layout),
|
|
97
101
|
// template/ still carried the live names.
|
|
@@ -119,23 +123,27 @@ const mustNotFetch = async () => { throw new Error('fetchTarball must not be cal
|
|
|
119
123
|
|
|
120
124
|
console.log('# upgrade');
|
|
121
125
|
|
|
122
|
-
// 1. Clean workspace with a baseline: payload staged, no
|
|
123
|
-
// warning,
|
|
124
|
-
//
|
|
126
|
+
// 1. Clean workspace with a baseline, offline: payload staged, no
|
|
127
|
+
// tracked-payload warning, the baseline left in place, no reconstruction,
|
|
128
|
+
// and no merge base — the failure warns but never fails the upgrade.
|
|
125
129
|
{
|
|
126
130
|
const root = buildWorkspace({ baseline: true });
|
|
127
131
|
try {
|
|
128
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball:
|
|
132
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
129
133
|
const stderr = out.error.join('\n');
|
|
130
134
|
check('no tracked-payload warning on a clean workspace',
|
|
131
135
|
!stderr.includes('git rm -r --cached'));
|
|
132
136
|
check('payload staged', existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
133
137
|
check('freshly staged payload is not tracked', lsFilesPayload(root).length === 0);
|
|
134
138
|
check('staging reported', out.log.join('\n').includes('Staged template payload'));
|
|
135
|
-
check('
|
|
139
|
+
check('existing baseline named as left in place',
|
|
136
140
|
out.log.join('\n').includes('Template baseline already present'));
|
|
137
141
|
check('no reconstructed baseline staged when one exists',
|
|
138
142
|
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
143
|
+
check('offline warns about the missing merge base',
|
|
144
|
+
stderr.includes('no merge base staged for locally modified files'));
|
|
145
|
+
check('no template base staged offline',
|
|
146
|
+
!existsSync(join(root, '.workspace-update', '.template-base')));
|
|
139
147
|
} finally {
|
|
140
148
|
rmSync(root, { recursive: true, force: true });
|
|
141
149
|
}
|
|
@@ -192,7 +200,7 @@ console.log('# upgrade');
|
|
|
192
200
|
writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
|
|
193
201
|
writeFileSync(join(oldSkill, 'OLD-EXTRA.md'), 'stale\n');
|
|
194
202
|
|
|
195
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball:
|
|
203
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
196
204
|
const newSkill = readFileSync(join(oldSkill, 'SKILL.md'), 'utf8');
|
|
197
205
|
check('installed skill is the payload copy, not the old one',
|
|
198
206
|
newSkill === readFileSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md'), 'utf8')
|
|
@@ -257,7 +265,7 @@ console.log('# upgrade');
|
|
|
257
265
|
mkdirSync(oldSkill, { recursive: true });
|
|
258
266
|
writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
|
|
259
267
|
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
260
|
-
fetchTarball:
|
|
268
|
+
fetchTarball: noTarball,
|
|
261
269
|
copySkill: () => { throw new Error('disk full'); },
|
|
262
270
|
}));
|
|
263
271
|
check('a failing copy keeps the installed skill',
|
|
@@ -272,7 +280,8 @@ console.log('# upgrade');
|
|
|
272
280
|
|
|
273
281
|
// 5. No baseline + old live-name tarball: reconstruction stages a baseline
|
|
274
282
|
// for the INSTALLED version INSIDE THE PAYLOAD — never the launcher —
|
|
275
|
-
// marked reconstructed (gh:186)
|
|
283
|
+
// marked reconstructed (gh:186), and the same fetch stages the template
|
|
284
|
+
// content as the merge base under live names (gh:193).
|
|
276
285
|
{
|
|
277
286
|
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
278
287
|
try {
|
|
@@ -295,13 +304,31 @@ console.log('# upgrade');
|
|
|
295
304
|
check('reconstruction reported', out.log.join('\n').includes('Reconstructed template baseline'));
|
|
296
305
|
check('reconstruction names its staged location',
|
|
297
306
|
out.log.join('\n').includes('.template-baseline.reconstructed.json'));
|
|
307
|
+
check('the fetch is announced on stderr before it can stall',
|
|
308
|
+
out.error.join('\n').includes('Fetching the v0.15.0 template as the merge base'));
|
|
309
|
+
|
|
310
|
+
const base = join(root, '.workspace-update', '.template-base');
|
|
311
|
+
check('merge base staged in the payload',
|
|
312
|
+
existsSync(join(base, '.claude', 'hooks', 'session-start.mjs')));
|
|
313
|
+
check('merge base carries the tarball content',
|
|
314
|
+
readFileSync(join(base, '.claude', 'hooks', 'session-start.mjs'), 'utf8') === '// hooks v0.15\n');
|
|
315
|
+
check('merge base uses live names only',
|
|
316
|
+
!existsSync(join(base, '_claude')) && !existsSync(join(base, '_mcp.json')));
|
|
317
|
+
check('merge base keeps non-verbatim files out',
|
|
318
|
+
!existsSync(join(base, 'CLAUDE.md.tmpl')));
|
|
319
|
+
check('base staging reported',
|
|
320
|
+
out.log.join('\n').includes('.template-base'));
|
|
321
|
+
// The invariant template-merge.mjs relies on: what the baseline
|
|
322
|
+
// records is exactly what the base holds.
|
|
323
|
+
check('staged base hashes to the reconstructed baseline entry',
|
|
324
|
+
hashFile(join(base, '.claude', 'hooks', 'session-start.mjs')) === baseline.files['.claude/hooks/session-start.mjs']);
|
|
298
325
|
} finally {
|
|
299
326
|
rmSync(root, { recursive: true, force: true });
|
|
300
327
|
}
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
// 6. No baseline + new inert-name tarball: same reconstruction through the
|
|
304
|
-
// _claude/_mcp.json layout,
|
|
331
|
+
// _claude/_mcp.json layout, and the merge base lands under live names.
|
|
305
332
|
{
|
|
306
333
|
const root = buildWorkspace({ templateVersion: '0.19.0' });
|
|
307
334
|
try {
|
|
@@ -317,6 +344,13 @@ console.log('# upgrade');
|
|
|
317
344
|
check('baseline marked reconstructed', baseline.reconstructed === true);
|
|
318
345
|
check('launcher still has no baseline of its own',
|
|
319
346
|
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
347
|
+
const base = join(root, '.workspace-update', '.template-base');
|
|
348
|
+
check('inert layout stages the base under live names',
|
|
349
|
+
readFileSync(join(base, '.claude', 'hooks', 'session-start.mjs'), 'utf8') === '// hooks v0.19\n');
|
|
350
|
+
check('_mcp.json staged into the base as .mcp.json',
|
|
351
|
+
readFileSync(join(base, '.mcp.json'), 'utf8') === '{"mcpServers":{}}\n');
|
|
352
|
+
check('no inert names inside the base',
|
|
353
|
+
!existsSync(join(base, '_claude')) && !existsSync(join(base, '_mcp.json')));
|
|
320
354
|
} finally {
|
|
321
355
|
rmSync(root, { recursive: true, force: true });
|
|
322
356
|
}
|
|
@@ -337,6 +371,8 @@ console.log('# upgrade');
|
|
|
337
371
|
!existsSync(join(root, '.claude', '.template-baseline.json')));
|
|
338
372
|
check('no reconstructed baseline staged on fetch failure',
|
|
339
373
|
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
374
|
+
check('no template base staged on fetch failure',
|
|
375
|
+
!existsSync(join(root, '.workspace-update', '.template-base')));
|
|
340
376
|
check('upgrade continues after fetch failure',
|
|
341
377
|
existsSync(join(root, '.workspace-update', '.manifest.json'))
|
|
342
378
|
&& existsSync(join(root, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
@@ -377,13 +413,14 @@ console.log('# upgrade');
|
|
|
377
413
|
}
|
|
378
414
|
}
|
|
379
415
|
|
|
380
|
-
// 10. An existing baseline is left alone: no
|
|
381
|
-
//
|
|
416
|
+
// 10. An existing baseline is left alone: no rewrite, nothing reconstructed
|
|
417
|
+
// alongside it. The fetch still happens (the merge base needs the
|
|
418
|
+
// content) but its result never touches the baseline.
|
|
382
419
|
{
|
|
383
420
|
const root = buildWorkspace({ baseline: true });
|
|
384
421
|
try {
|
|
385
422
|
const before = readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8');
|
|
386
|
-
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball:
|
|
423
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
387
424
|
check('existing baseline untouched',
|
|
388
425
|
readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === before);
|
|
389
426
|
check('reconstruction not reported', !out.log.join('\n').includes('Reconstructed'));
|
|
@@ -416,6 +453,93 @@ console.log('# upgrade');
|
|
|
416
453
|
}
|
|
417
454
|
}
|
|
418
455
|
|
|
456
|
+
// 12. A workspace WITH a baseline still gets the merge base: the fetch now
|
|
457
|
+
// serves the base staging alone, and no reconstruction happens (gh:193).
|
|
458
|
+
{
|
|
459
|
+
const root = buildWorkspace({ baseline: true, templateVersion: '0.15.0' });
|
|
460
|
+
try {
|
|
461
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
462
|
+
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
463
|
+
}));
|
|
464
|
+
const base = join(root, '.workspace-update', '.template-base');
|
|
465
|
+
check('merge base staged beside an existing baseline',
|
|
466
|
+
readFileSync(join(base, '.claude', 'hooks', 'session-start.mjs'), 'utf8') === '// hooks v0.15\n');
|
|
467
|
+
check('no reconstruction next to an existing baseline',
|
|
468
|
+
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json'))
|
|
469
|
+
&& !out.log.join('\n').includes('Reconstructed'));
|
|
470
|
+
check('existing baseline named as left in place',
|
|
471
|
+
out.log.join('\n').includes('Template baseline already present'));
|
|
472
|
+
} finally {
|
|
473
|
+
rmSync(root, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 13. The offline skip with a baseline present: the upgrade completes, only
|
|
478
|
+
// the merge base is missing, and the warning says exactly that — not the
|
|
479
|
+
// pre-baseline fallback wording.
|
|
480
|
+
{
|
|
481
|
+
const root = buildWorkspace({ baseline: true, templateVersion: '0.15.0' });
|
|
482
|
+
try {
|
|
483
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
|
|
484
|
+
const stderr = out.error.join('\n');
|
|
485
|
+
check('offline with baseline warns about the merge base only',
|
|
486
|
+
stderr.includes('no merge base staged') && !stderr.includes('ask about every changed file'));
|
|
487
|
+
check('no template base staged', !existsSync(join(root, '.workspace-update', '.template-base')));
|
|
488
|
+
check('the upgrade still completes',
|
|
489
|
+
existsSync(join(root, '.workspace-update', '.manifest.json'))
|
|
490
|
+
&& existsSync(join(root, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
491
|
+
} finally {
|
|
492
|
+
rmSync(root, { recursive: true, force: true });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 14. Staging is best-effort end to end: a failing baseline build warns and
|
|
497
|
+
// still stages the merge base; a base copy that fails partway removes
|
|
498
|
+
// the partial base — never trusted as an ancestor — and the upgrade
|
|
499
|
+
// itself never fails (gh:193).
|
|
500
|
+
{
|
|
501
|
+
const root = buildWorkspace({ templateVersion: '0.15.0' });
|
|
502
|
+
try {
|
|
503
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
504
|
+
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
505
|
+
buildBase: () => { throw new Error('EACCES: permission denied'); },
|
|
506
|
+
}));
|
|
507
|
+
const stderr = out.error.join('\n');
|
|
508
|
+
check('baseline build failure warns', stderr.includes('no template baseline written'));
|
|
509
|
+
check('no reconstructed baseline on a build failure',
|
|
510
|
+
!existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
|
|
511
|
+
check('the merge base still stages after a baseline failure',
|
|
512
|
+
existsSync(join(root, '.workspace-update', '.template-base', '.claude', 'hooks', 'session-start.mjs')));
|
|
513
|
+
} finally {
|
|
514
|
+
rmSync(root, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 15. A base copy failing MID-WAY leaves a partial .template-base on disk
|
|
519
|
+
// for a moment — it must be removed, not left where template-merge
|
|
520
|
+
// could trust it.
|
|
521
|
+
{
|
|
522
|
+
const root = buildWorkspace({ baseline: true, templateVersion: '0.15.0' });
|
|
523
|
+
try {
|
|
524
|
+
let calls = 0;
|
|
525
|
+
const { out } = await captureConsole(() => upgradeWorkspace(root, {
|
|
526
|
+
fetchTarball: async () => OLD_LAYOUT_TARBALL,
|
|
527
|
+
copyBase: (src, dest, opts) => {
|
|
528
|
+
if (calls++ > 0) throw new Error('disk full');
|
|
529
|
+
cpSync(src, dest, opts); // the first root lands, the rest fail
|
|
530
|
+
},
|
|
531
|
+
}));
|
|
532
|
+
check('a mid-copy failure warns about the merge base',
|
|
533
|
+
out.error.join('\n').includes('could not stage the merge base'));
|
|
534
|
+
check('the partial base is removed',
|
|
535
|
+
!existsSync(join(root, '.workspace-update', '.template-base')));
|
|
536
|
+
check('the upgrade completes after a base-copy failure',
|
|
537
|
+
existsSync(join(root, '.workspace-update', '.manifest.json')));
|
|
538
|
+
} finally {
|
|
539
|
+
rmSync(root, { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
419
543
|
if (failed > 0) {
|
|
420
544
|
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
421
545
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -11,7 +11,8 @@ All paths are relative to the workspace root. Two work lifecycles coexist, selec
|
|
|
11
11
|
| `.claude/worktrees/{slug}/` | Task worktree for the workspace repo (`--repo .`) — Claude Code's native worktree location; the two converge | No |
|
|
12
12
|
| `work-sessions/{name}/workspace/…` | Session lifecycle: workspace worktree, nested project worktrees at `workspace/repos/{repo}/`, `session.md` + artifacts (`design/plan/goal/research/crossref-*.md`) on top | Session branch |
|
|
13
13
|
| `workspace-context/` | Team knowledge: `shared/` (ephemerals), `shared/locked/` (canonical truths), `team-member/{user}/` (per-user) | Yes |
|
|
14
|
-
| `workspace-context/index.md`, `canonical.md
|
|
14
|
+
| `workspace-context/index.md`, `canonical.md` | Auto-generated catalogs (`canonical.md` = verbatim `shared/locked/`; `.indexignore` excludes paths) — regenerate with `build-workspace-context.mjs`, never hand-edit | Yes |
|
|
15
|
+
| `workspace-context/team-member/{user}/index.md` | The per-user slice of that catalog — gitignored: generated per machine, so a tracked copy would conflict across users; the personal files beside it stay tracked | No |
|
|
15
16
|
| `workspace-scratchpad/` | Machine-local, regenerable: session log, hook debug output, chat records `chats/{chat}.json`, chat drawers `chats/{chat}/` (task-lifecycle designs, plans, braindumps, research in progress) | No |
|
|
16
17
|
| `CLAUDE.md`, `CLAUDE.local.md`, `.claude/` | Launcher prompt (imports `canonical.md` + `index.md`); per-user prompt; rules, agents, skills, hooks, scripts | All but `CLAUDE.local.md`, `settings.local.json`, `.claude/.active-session.json`, and `.claude/worktrees/` |
|
|
17
18
|
|
|
@@ -123,7 +123,10 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
123
123
|
// workspace MR and closes the issue ahead of CI ever publishing).
|
|
124
124
|
// --auto-merge=false makes glab merge now or fail, and the post-merge
|
|
125
125
|
// view verifies the merge actually happened — glab exiting 0 is not
|
|
126
|
-
// proof on its own.
|
|
126
|
+
// proof on its own. A project (or a whole group) may also require the
|
|
127
|
+
// merge to name the MR's current head SHA, so the pre-merge view's SHA
|
|
128
|
+
// rides along as --sha; when the view reports none the merge proceeds
|
|
129
|
+
// without it rather than failing — permissive projects still merge.
|
|
127
130
|
async function prMerge({ id, strategy = 'merge', deleteBranch = false, repo }) {
|
|
128
131
|
if (!id) throw new Error('prMerge: id is required');
|
|
129
132
|
const { number, repo: parsedRepo } = parseMrId(id, repoFor(repo));
|
|
@@ -132,6 +135,7 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
132
135
|
throw new MergeRejected(id, 'MR is a draft — mark it ready before merging');
|
|
133
136
|
}
|
|
134
137
|
const args = ['mr', 'merge', String(number), '--repo', parsedRepo, '--yes', '--auto-merge=false'];
|
|
138
|
+
if (before.headSha) args.push('--sha', before.headSha);
|
|
135
139
|
switch (strategy) {
|
|
136
140
|
case 'merge': break; // plain merge is glab's default
|
|
137
141
|
case 'squash': args.push('--squash'); break;
|
|
@@ -183,6 +187,7 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
183
187
|
mergeStateStatus: raw.detailed_merge_status ?? raw.merge_status ?? null,
|
|
184
188
|
reviewDecision: '',
|
|
185
189
|
headRefName: raw.source_branch,
|
|
190
|
+
headSha: raw.sha ?? null,
|
|
186
191
|
baseRefName: raw.target_branch,
|
|
187
192
|
isDraft: !!raw.draft,
|
|
188
193
|
mergedAt: raw.merged_at,
|