@ulysses-ai/create-workspace 0.23.0-beta.0 → 0.23.2-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 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, hash what that version shipped (the same
94
- // normalisation template-baseline.mjs applies everywhere), and stage a
95
- // baseline for it marked "reconstructed": true INSIDE THE PAYLOAD, not the
96
- // launcher: with a remote, /workspace-update classifies inside a task
97
- // worktree that can only see what travels with the payload, and an
98
- // untracked launcher baseline would dirty the launcher against the incoming
99
- // PR. classify-update.mjs falls back to the staged file automatically.
100
- // Any failure — unknown version, offline, unparsable tarball — warns and
101
- // falls back to the ask-per-file behaviour; the upgrade itself never fails.
102
- async function reconstructBaseline(payloadDir, fromVersion, { fetchTarball = fetchPackageTarball } = {}) {
103
- const fallbackNote = ' The first /workspace-update will ask about every changed file individually.';
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
- console.error(' Warning: workspace.json records no templateVersion — cannot reconstruct a template baseline.');
106
- console.error(fallbackNote);
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
- console.error(` Warning: could not fetch the v${fromVersion} package tarball from npm — no template baseline written.`);
115
- console.error(fallbackNote);
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. buildBaseline maps either onto the installed names
125
- // through its pairs.
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 baseline = null;
196
+ let pairs = null;
128
197
  try {
129
198
  extractTarballEntries(tarball, extracted, { prefix: 'package/template/' });
130
- const pairs = existsSync(join(extracted, '_claude')) ? INERT_PAIRS : LIVE_PAIRS;
131
- baseline = buildBaseline(extracted, { pairs, version: fromVersion });
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 (baseline === null || Object.keys(baseline.files).length === 0) {
134
- console.error(` Warning: the v${fromVersion} package tarball carries no readable template files — no template baseline written.`);
135
- console.error(fallbackNote);
136
- return false;
202
+ if (pairs === null) {
203
+ warnNothingStaged(`the v${fromVersion} package tarball carries no readable template files`);
204
+ return;
137
205
  }
138
- baseline.reconstructed = true;
139
- writeFileSync(
140
- join(payloadDir, RECONSTRUCTED_BASELINE_NAME),
141
- JSON.stringify(baseline, null, 2) + '\n',
142
- );
143
- 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}`);
144
- return true;
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: a reconstructed baseline is staged INSIDE it.
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 reconstruct one for the installed version into the payload,
181
- // so the update can tell template changes from local edits (gh:186).
182
- if (readBaseline(targetDir) !== null) {
183
- console.log(' Template baseline already present — leaving it in place.');
184
- } else {
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.
@@ -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), and baseline reconstruction for pre-baseline workspaces (gh:186)
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 tracked-payload
123
- // warning, no fetch, no reconstruction staged, and the freshly staged
124
- // payload is not tracked.
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: mustNotFetch }));
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('skipped reconstruction says why',
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: mustNotFetch }));
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: mustNotFetch,
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, staged in the payload the same way.
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 fetch, no rewrite, nothing
381
- // staged in the payload.
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: mustNotFetch }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.23.0-beta.0",
3
+ "version": "0.23.2-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -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`, `team-member/{user}/index.md` | Auto-generated catalogs (`canonical.md` = verbatim `shared/locked/`; `.indexignore` excludes paths) — regenerate with `build-workspace-context.mjs`, never hand-edit | Yes |
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