@ulysses-ai/create-workspace 0.22.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.
@@ -3,14 +3,20 @@
3
3
  // can batch the safe cases and ask only where a decision is needed.
4
4
  //
5
5
  // Usage:
6
- // node classify-update.mjs [--root <dir>] [--payload <dir>]
6
+ // node classify-update.mjs [--root <dir>] [--payload <dir>] [--baseline <file>]
7
7
  // node classify-update.mjs --root <dir> --payload <dir> --write-baseline
8
8
  // node classify-update.mjs --root <dir> --payload <dir> --merge-claude-md
9
9
  //
10
- // --root workspace root; defaults to the current working directory (never
11
- // derived from this script's location — the upgrade payload runs
12
- // this file from <workspace>/.workspace-update/.claude/scripts/)
13
- // --payload the staged payload; defaults to <root>/.workspace-update
10
+ // --root workspace root; defaults to the current working directory (never
11
+ // derived from this script's location — the upgrade payload runs
12
+ // this file from <workspace>/.workspace-update/.claude/scripts/)
13
+ // --payload the staged payload; defaults to <root>/.workspace-update
14
+ // --baseline the baseline to classify against; defaults to
15
+ // <root>/.claude/.template-baseline.json, falling back to
16
+ // <payload>/.template-baseline.reconstructed.json (what --upgrade
17
+ // reconstructs for pre-baseline workspaces) when the root has none.
18
+ // Pass it explicitly in the worktree flow, where <root> is the
19
+ // worktree and the launcher's baseline may not be reachable.
14
20
  //
15
21
  // The default mode prints JSON with these lists:
16
22
  // new — no installed counterpart and no baseline entry; safe to
@@ -24,6 +30,23 @@
24
30
  // edit AND a template change — the one case that needs a
25
31
  // per-file decision (or the workspace predates baselines and
26
32
  // has no entry to compare).
33
+ // config — .mcp.json and .claude/settings.json: JSON the workspace
34
+ // owns jointly with the template (its own MCP servers and
35
+ // settings live beside template keys). Never classified by
36
+ // content and never batch-copied — instead each entry carries
37
+ // a key-level diff (`added` keys the template ships, keys
38
+ // `workspaceOnly`, keys `changed` in both, nested paths joined
39
+ // with '/'), and /workspace-update merges key by key: add
40
+ // template keys, keep workspace-only keys, ask on conflicting
41
+ // keys. Array-valued keys (hooks event lists,
42
+ // permissions.allow/deny) diff by ELEMENT instead of whole:
43
+ // each `arrays` entry is { path, added, workspaceOnly } with
44
+ // the element lists, and the skill merges arrays as a union —
45
+ // the workspace's elements kept, the template's new ones
46
+ // appended — so only true scalar conflicts ask. Entries flag
47
+ // `notInstalled` (no workspace file — ask once whether to
48
+ // install the payload's copy) or `unparseable` (broken JSON on
49
+ // either side — ask, never merge blind).
27
50
  // localOnly — installed file differs from the payload, but the payload
28
51
  // equals the baseline: the template hasn't touched the file
29
52
  // since the last update, so the difference is purely local.
@@ -38,6 +61,8 @@
38
61
  // active rule stays (gh:180)
39
62
  // removed — installed file with no payload counterpart: the template
40
63
  // stopped shipping it. Excludes what the workspace owns:
64
+ // the config files above (the template dropping one hands it
65
+ // to the workspace, it never deletes user content),
41
66
  // *.test.mjs (see staleTests), anything gitignored
42
67
  // (machine-local), paths under .claude/worktrees/, and entries
43
68
  // of workspace.json → workspace.localFiles (array of
@@ -48,21 +73,25 @@
48
73
  // checkout and are never updated by /workspace-update; the
49
74
  // skill offers to remove them (tests live in the template repo)
50
75
  //
51
- // Plus `hasBaseline`: whether .claude/.template-baseline.json exists. Without
52
- // it (workspaces older than the baseline's introduction) template changes
53
- // cannot be told from local edits, so they land in `differs` — the first
54
- // update after v0.21 asks per file; once it writes the baseline, later updates
55
- // won't.
76
+ // Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
77
+ // (which file it came from) and `baselineReconstructed`. The default
78
+ // resolution is <root>/.claude/.template-baseline.json, then the payload's
79
+ // .template-baseline.reconstructed.json (both unparseable-as-absent); without
80
+ // either, template changes cannot be told from local edits, so they land in
81
+ // `differs` — the first update asks per file; once it writes the baseline,
82
+ // later updates won't.
56
83
  //
57
84
  // Content comparisons hash with CRLF normalized to LF on both sides (binary
58
85
  // files hash byte-exact), so a git autocrlf checkout that stores CRLF where
59
86
  // the payload ships LF classifies as identical rather than locally modified.
60
87
  //
61
- // Only verbatim-installed files are classified: everything under .claude/,
62
- // plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
63
- // install with {{project-name}} substitution), _gitignore (merged line-by-line
64
- // into the workspace's .gitignore), and .manifest.json (payload metadata) are
65
- // handled by their own steps in /workspace-update and are excluded here.
88
+ // Only verbatim-installed files are classified: everything under .claude/
89
+ // except .claude/settings.json, plus .mcp.json and .claudeignore — the two
90
+ // JSON configs route to `config` instead of the content lists. The payload's
91
+ // templates (*.tmpl, which install with {{project-name}} substitution),
92
+ // _gitignore (merged line-by-line into the workspace's .gitignore), and
93
+ // .manifest.json (payload metadata) are handled by their own steps in
94
+ // /workspace-update and are excluded here.
66
95
  //
67
96
  // The other two modes are /workspace-update bookends:
68
97
  // --write-baseline write .claude/.template-baseline.json recording the
@@ -89,7 +118,13 @@ import {
89
118
  import { basename, join, resolve } from 'node:path';
90
119
  import { fileURLToPath } from 'node:url';
91
120
  import { gitIgnoredPaths } from './build-workspace-context.mjs';
92
- import { BASELINE_PATH, hashBytes, readBaseline, writeBaseline } from './template-baseline.mjs';
121
+ import {
122
+ BASELINE_PATH,
123
+ RECONSTRUCTED_BASELINE_NAME,
124
+ hashBytes,
125
+ readBaselineFile,
126
+ writeBaseline,
127
+ } from './template-baseline.mjs';
93
128
 
94
129
  function isMainModule(metaUrl) {
95
130
  if (!process.argv[1]) return false;
@@ -99,11 +134,12 @@ function isMainModule(metaUrl) {
99
134
  }
100
135
 
101
136
  function parseArgs(argv) {
102
- const args = { root: process.cwd(), payload: null, writeBaseline: false, mergeClaudeMd: false };
137
+ const args = { root: process.cwd(), payload: null, baseline: null, writeBaseline: false, mergeClaudeMd: false };
103
138
  for (let i = 2; i < argv.length; i++) {
104
139
  const a = argv[i];
105
140
  if (a === '--root') args.root = argv[++i];
106
141
  else if (a === '--payload') args.payload = argv[++i];
142
+ else if (a === '--baseline') args.baseline = argv[++i];
107
143
  else if (a === '--write-baseline') args.writeBaseline = true;
108
144
  else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
109
145
  else throw new Error(`Unknown arg: ${a}`);
@@ -115,6 +151,104 @@ function parseArgs(argv) {
115
151
  // Everything else in the payload is a template or metadata handled elsewhere.
116
152
  const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
117
153
 
154
+ // JSON configs the workspace owns jointly with the template: its own MCP
155
+ // servers sit inside .mcp.json's mcpServers, its own settings beside the
156
+ // template's keys in .claude/settings.json. Content classification would
157
+ // file every one of them as `differs` the moment the workspace adds
158
+ // anything, and a batch copy would wipe the workspace's entries — so they
159
+ // are reported in `config` with a key-level diff and merged key by key,
160
+ // never compared by bytes and never copied wholesale (gh:186).
161
+ const CONFIG_PATHS = new Set(['.mcp.json', '.claude/settings.json']);
162
+
163
+ function isPlainObject(value) {
164
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
165
+ }
166
+
167
+ /**
168
+ * Key-level diff between the payload's config object and the workspace's.
169
+ * Paths join keys with '/' (mcpServers/playwright) and stop at two
170
+ * segments: these configs are maps of named units — mcpServers/{server},
171
+ * permissions/{allow} — and a unit's own internals (a server's args vs
172
+ * command) merge as one decision, not as separate asks. Arrays the key
173
+ * carries on both sides diff by ELEMENT (a union merge needs no decision),
174
+ * and any other non-object value compares by JSON value and reports at its
175
+ * unit's path.
176
+ */
177
+ const CONFIG_DIFF_DEPTH = 2;
178
+
179
+ function arrayElementDiff(payloadArr, workspaceArr) {
180
+ const wsSet = new Set(workspaceArr.map((e) => JSON.stringify(e)));
181
+ const plSet = new Set(payloadArr.map((e) => JSON.stringify(e)));
182
+ return {
183
+ added: payloadArr.filter((e) => !wsSet.has(JSON.stringify(e))),
184
+ workspaceOnly: workspaceArr.filter((e) => !plSet.has(JSON.stringify(e))),
185
+ };
186
+ }
187
+
188
+ function configKeyDiff(payloadObj, workspaceObj, prefix = '') {
189
+ const added = [];
190
+ const workspaceOnly = [];
191
+ const changed = [];
192
+ const arrays = [];
193
+ const keys = new Set([...Object.keys(payloadObj), ...Object.keys(workspaceObj)]);
194
+ for (const key of [...keys].sort()) {
195
+ const path = prefix ? `${prefix}/${key}` : key;
196
+ const inPayload = Object.prototype.hasOwnProperty.call(payloadObj, key);
197
+ const inWorkspace = Object.prototype.hasOwnProperty.call(workspaceObj, key);
198
+ if (inPayload && !inWorkspace) { added.push(path); continue; }
199
+ if (!inPayload && inWorkspace) { workspaceOnly.push(path); continue; }
200
+ const pv = payloadObj[key];
201
+ const wv = workspaceObj[key];
202
+ if (
203
+ prefix.split('/').filter(Boolean).length + 1 < CONFIG_DIFF_DEPTH
204
+ && isPlainObject(pv) && isPlainObject(wv)
205
+ ) {
206
+ const sub = configKeyDiff(pv, wv, path);
207
+ added.push(...sub.added);
208
+ workspaceOnly.push(...sub.workspaceOnly);
209
+ changed.push(...sub.changed);
210
+ arrays.push(...sub.arrays);
211
+ } else if (Array.isArray(pv) && Array.isArray(wv)) {
212
+ // An array both sides hold is a set the workspace extends: element
213
+ // lists let the skill union-merge instead of choosing one side whole.
214
+ const diff = arrayElementDiff(pv, wv);
215
+ if (diff.added.length > 0 || diff.workspaceOnly.length > 0) {
216
+ arrays.push({ path, ...diff });
217
+ }
218
+ } else if (JSON.stringify(pv) !== JSON.stringify(wv)) {
219
+ changed.push(path);
220
+ }
221
+ }
222
+ return { added, workspaceOnly, changed, arrays };
223
+ }
224
+
225
+ /**
226
+ * One `config` entry: the key-level diff for a payload-shipped config file
227
+ * against the workspace's copy, or a flag when no diff is possible —
228
+ * `notInstalled` (no workspace file; the skill asks once whether to install
229
+ * the payload's copy) and `unparseable` (broken JSON on either side; the
230
+ * skill asks rather than merging blind).
231
+ */
232
+ function configEntry(absRoot, absPayload, rel) {
233
+ let payloadJson;
234
+ try {
235
+ payloadJson = JSON.parse(readFileSync(join(absPayload, rel), 'utf8'));
236
+ } catch {
237
+ return { path: rel, unparseable: true };
238
+ }
239
+ if (!isPlainObject(payloadJson)) return { path: rel, unparseable: true };
240
+ const installed = join(absRoot, rel);
241
+ if (!existsSync(installed)) return { path: rel, notInstalled: true };
242
+ let workspaceJson;
243
+ try {
244
+ workspaceJson = JSON.parse(readFileSync(installed, 'utf8'));
245
+ } catch {
246
+ return { path: rel, unparseable: true };
247
+ }
248
+ if (!isPlainObject(workspaceJson)) return { path: rel, unparseable: true };
249
+ return { path: rel, ...configKeyDiff(payloadJson, workspaceJson) };
250
+ }
251
+
118
252
  function isClassified(payloadRelPath) {
119
253
  const first = payloadRelPath.split('/')[0];
120
254
  return VERBATIM_ROOTS.includes(first);
@@ -196,7 +330,31 @@ function isOwnedByWorkspace(rel, localFiles) {
196
330
  return localFiles.some((pattern) => globMatches(pattern, claudeRel));
197
331
  }
198
332
 
199
- export function classifyUpdate({ root, payload }) {
333
+ /**
334
+ * Which baseline the classification runs against. An explicit --baseline
335
+ * wins; otherwise the workspace's own <root>/.claude/.template-baseline.json
336
+ * is tried first, then the payload's .template-baseline.reconstructed.json
337
+ * (staged by --upgrade for workspaces that predate baselines). The fallback
338
+ * matters in the worktree flow: <root> is the task worktree, which cannot
339
+ * see launcher-only files, while the payload travels there by absolute path.
340
+ * A file that exists but does not parse counts as absent — a corrupt
341
+ * baseline must not block the reconstructed one (gh:186).
342
+ */
343
+ export function resolveBaseline({ root, payload, baseline = null }) {
344
+ const candidates = baseline !== null
345
+ ? [{ path: resolve(baseline), label: baseline }]
346
+ : [
347
+ { path: join(resolve(root), BASELINE_PATH), label: BASELINE_PATH },
348
+ { path: join(resolve(payload), RECONSTRUCTED_BASELINE_NAME), label: `.workspace-update/${RECONSTRUCTED_BASELINE_NAME}` },
349
+ ];
350
+ for (const candidate of candidates) {
351
+ const parsed = readBaselineFile(candidate.path);
352
+ if (parsed !== null) return { baseline: parsed, source: candidate.label };
353
+ }
354
+ return { baseline: null, source: null };
355
+ }
356
+
357
+ export function classifyUpdate({ root, payload, baseline: baselineArg = null }) {
200
358
  const absRoot = resolve(root);
201
359
  const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
202
360
  if (!existsSync(absPayload)) {
@@ -205,21 +363,30 @@ export function classifyUpdate({ root, payload }) {
205
363
 
206
364
  const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
207
365
  const payloadSet = new Set(payloadFiles);
208
- const baseline = readBaseline(absRoot);
366
+ const { baseline, source } = resolveBaseline({ root: absRoot, payload: absPayload, baseline: baselineArg });
209
367
 
210
368
  const result = {
211
369
  new: [],
212
370
  identical: [],
213
371
  updated: [],
214
372
  differs: [],
373
+ config: [],
215
374
  localOnly: [],
216
375
  deletedLocally: [],
217
376
  activated: [],
218
377
  removed: [],
219
378
  staleTests: [],
220
379
  hasBaseline: baseline !== null,
380
+ baselineSource: source,
381
+ baselineReconstructed: baseline !== null && baseline.reconstructed === true,
221
382
  };
222
383
  for (const rel of payloadFiles) {
384
+ // Jointly-owned JSON configs never compare by content — the config
385
+ // list carries a key-level diff for the skill to merge instead.
386
+ if (CONFIG_PATHS.has(rel)) {
387
+ result.config.push(configEntry(absRoot, absPayload, rel));
388
+ continue;
389
+ }
223
390
  // A .skip rule whose active counterpart is installed was deliberately
224
391
  // activated by this workspace: report it as activated, not new.
225
392
  if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
@@ -270,6 +437,9 @@ export function classifyUpdate({ root, payload }) {
270
437
  const gitignored = gitIgnoredPaths(absRoot, installedFiles);
271
438
  for (const rel of installedFiles) {
272
439
  if (skipSet.has(rel)) continue;
440
+ // A config file the payload dropped stays with the workspace: it holds
441
+ // user content the template never deletes.
442
+ if (CONFIG_PATHS.has(rel)) continue;
273
443
  // An active rule whose .skip twin is in the payload is an activated rule,
274
444
  // not a removed one.
275
445
  if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
@@ -417,7 +587,17 @@ function resolvePayload(args) {
417
587
  }
418
588
 
419
589
  function writeBaselineMode(args) {
420
- const baseline = writeBaseline(args.root, resolvePayload(args));
590
+ const absPayload = resolvePayload(args);
591
+ // The previous baseline decides which declined updates keep their old
592
+ // entry — resolve it exactly as classification does, so the worktree flow
593
+ // (no baseline of its own yet) carries over from the payload's
594
+ // reconstructed one instead of starting from nothing.
595
+ const { baseline: previous } = resolveBaseline({
596
+ root: args.root,
597
+ payload: absPayload,
598
+ baseline: args.baseline,
599
+ });
600
+ const baseline = writeBaseline(args.root, absPayload, { previous });
421
601
  process.stdout.write(JSON.stringify({
422
602
  written: true,
423
603
  path: BASELINE_PATH,
@@ -453,7 +633,7 @@ function main() {
453
633
  } else if (args.mergeClaudeMd) {
454
634
  mergeClaudeMdMode(args);
455
635
  } else {
456
- const result = classifyUpdate({ root: args.root, payload: args.payload });
636
+ const result = classifyUpdate({ root: args.root, payload: args.payload, baseline: args.baseline });
457
637
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
458
638
  }
459
639
  }
@@ -4,7 +4,10 @@
4
4
  // Teardown order is MANDATORY:
5
5
  // 1. Remove each project worktree from its project repo
6
6
  // 2. Remove the workspace worktree from the workspace repo
7
- // 3. Prune each project repo (belt-and-suspenders)
7
+ // 3. Prune each project repo (belt-and-suspenders — and only when every
8
+ // prunable record in the repo belongs to this session; a repo also
9
+ // holding an unrelated external worktree is never pruned, so its
10
+ // record survives gh:187)
8
11
  // 4. Delete all local branches
9
12
  // 5. Remove the whole work-sessions/{name}/ folder
10
13
  //
@@ -83,6 +86,25 @@ function isRepoSegment(name) {
83
86
  return !/^\.+$/.test(segs[0]);
84
87
  }
85
88
 
89
+ // `worktree list --porcelain` stanzas reduced to what prune scoping needs:
90
+ // each stanza's worktree path and whether git marked it prunable. Paths
91
+ // are kept exactly as git printed them (realOf is applied by callers, so
92
+ // a record whose directory is gone survives the comparison).
93
+ function worktreeStanzas(porcelain) {
94
+ const stanzas = [];
95
+ let cur = null;
96
+ for (const line of String(porcelain).split(/\r?\n/)) {
97
+ if (line.startsWith('worktree ')) {
98
+ if (cur) stanzas.push(cur);
99
+ cur = { path: line.slice(9), prunable: false };
100
+ } else if (cur && line.startsWith('prunable')) {
101
+ cur.prunable = true;
102
+ }
103
+ }
104
+ if (cur) stanzas.push(cur);
105
+ return stanzas;
106
+ }
107
+
86
108
  const args = process.argv.slice(2);
87
109
  const getArg = (name) => {
88
110
  const idx = args.indexOf(`--${name}`);
@@ -335,9 +357,42 @@ if (existsSync(wsWorktree)) {
335
357
  }
336
358
 
337
359
  // === Step 3: Prune each project repo to mop up orphans ===
360
+ //
361
+ // `git worktree prune` has no path filter: one invocation drops EVERY
362
+ // prunable record the repo holds, including records of worktrees that
363
+ // have nothing to do with this workspace — a scratch checkout in tmp, a
364
+ // directory on another drive (gh:187). Those belong to no session, so
365
+ // prune runs only when every prunable record in the repo sits under this
366
+ // session's folder; otherwise it is skipped and the records are named in
367
+ // the output, left registered for whoever owns them.
368
+ const sessionFolderReal = realOf(sessionFolder);
369
+ const sessionFolderAbs = resolve(sessionFolder);
370
+ const underSessionFolder = (recordedPath) => {
371
+ const normalized = realOf(recordedPath);
372
+ return [sessionFolderReal, sessionFolderAbs].some(
373
+ (base) => normalized === base || normalized.startsWith(base + sep)
374
+ || recordedPath === base || recordedPath.startsWith(base + sep),
375
+ );
376
+ };
338
377
  for (const repo of repos) {
339
378
  const repoDir = join(reposDir, repo);
340
379
  if (!existsSync(repoDir)) continue;
380
+ const listRes = git(repoDir, ['worktree', 'list', '--porcelain']);
381
+ if (!listRes.ok) {
382
+ errors.push(`Could not list worktrees in ${repo}: ${listRes.err || listRes.out}`);
383
+ continue;
384
+ }
385
+ const foreign = worktreeStanzas(listRes.out)
386
+ .filter((s) => s.prunable && !underSessionFolder(s.path))
387
+ .map((s) => s.path);
388
+ if (foreign.length > 0) {
389
+ skipped.push({
390
+ step: 'prune',
391
+ repo,
392
+ reason: `prune skipped: ${repo} has prunable worktree records outside this session (${foreign.join(', ')}) — a blanket prune would drop them; they were left registered for their owner`,
393
+ });
394
+ continue;
395
+ }
341
396
  const res = git(repoDir, ['worktree', 'prune']);
342
397
  if (!res.ok) {
343
398
  // Prune is a safety net, but if it fails on a repo we touched, surface
@@ -407,8 +462,14 @@ for (const repo of repos) {
407
462
  continue;
408
463
  }
409
464
  const wtList = listRes.out;
410
- if (wtList.includes('prunable')) {
411
- errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
465
+ // Only this session's own prunable records are a leftover gh:119 orphan;
466
+ // a prunable record elsewhere in the repo is someone else's worktree and
467
+ // stays exactly as found (step 3 refused to prune it for that reason).
468
+ const leftover = worktreeStanzas(wtList)
469
+ .filter((s) => s.prunable && underSessionFolder(s.path))
470
+ .map((s) => s.path);
471
+ if (leftover.length > 0) {
472
+ errors.push(`Prunable worktree record(s) remain in ${repo} after cleanup (${leftover.join(', ')}) — the gh:119 symptom; inspect them, then run git -C ${repoDir} worktree prune yourself`);
412
473
  }
413
474
  if (wtList.includes(wsPath)) {
414
475
  errors.push(`${repo} still has a worktree record referencing the session path`);