@ulysses-ai/create-workspace 0.20.0-beta.0 → 0.22.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.
@@ -4,22 +4,80 @@
4
4
  //
5
5
  // Usage:
6
6
  // node classify-update.mjs [--root <dir>] [--payload <dir>]
7
+ // node classify-update.mjs --root <dir> --payload <dir> --write-baseline
8
+ // node classify-update.mjs --root <dir> --payload <dir> --merge-claude-md
7
9
  //
8
10
  // --root workspace root; defaults to the current working directory (never
9
11
  // derived from this script's location — the upgrade payload runs
10
12
  // this file from <workspace>/.workspace-update/.claude/scripts/)
11
13
  // --payload the staged payload; defaults to <root>/.workspace-update
12
14
  //
13
- // Prints JSON: { "new": [...], "identical": [...], "differs": [...] }
14
- // new — no installed counterpart; safe to batch-apply after one confirm
15
- // identical — installed file already equals the payload byte-for-byte
16
- // differs — installed file differs; needs a per-file decision
15
+ // The default mode prints JSON with these lists:
16
+ // new — no installed counterpart and no baseline entry; safe to
17
+ // batch-apply after one confirm
18
+ // identical — installed file already equals the payload
19
+ // updated — installed file equals the BASELINE (what the template last
20
+ // shipped here) but not the payload: a pure template change the
21
+ // user never touched. Batched with `new` behind one confirm.
22
+ // differs — installed file matches neither the payload nor the baseline
23
+ // while the payload also differs from the baseline: a local
24
+ // edit AND a template change — the one case that needs a
25
+ // per-file decision (or the workspace predates baselines and
26
+ // has no entry to compare).
27
+ // localOnly — installed file differs from the payload, but the payload
28
+ // equals the baseline: the template hasn't touched the file
29
+ // since the last update, so the difference is purely local.
30
+ // Listed for information only — never asked about, never
31
+ // applied.
32
+ // deletedLocally — the baseline records the file and the payload still
33
+ // ships it, but it is missing from the workspace: deleted
34
+ // locally (or never installed at /workspace-init). The skill
35
+ // asks once whether to restore the list.
36
+ // activated — the payload ships rules/{name}.md.skip while the workspace
37
+ // deliberately keeps {name}.md active; nothing to install, the
38
+ // active rule stays (gh:180)
39
+ // removed — installed file with no payload counterpart: the template
40
+ // stopped shipping it. Excludes what the workspace owns:
41
+ // *.test.mjs (see staleTests), anything gitignored
42
+ // (machine-local), paths under .claude/worktrees/, and entries
43
+ // of workspace.json → workspace.localFiles (array of
44
+ // .claude/-relative paths or globs for files this workspace
45
+ // owns) (gh:180)
46
+ // staleTests — *.test.mjs files under .claude/ with no payload counterpart.
47
+ // The npm tarball ships no tests, so these came from a dev
48
+ // checkout and are never updated by /workspace-update; the
49
+ // skill offers to remove them (tests live in the template repo)
50
+ //
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.
56
+ //
57
+ // Content comparisons hash with CRLF normalized to LF on both sides (binary
58
+ // files hash byte-exact), so a git autocrlf checkout that stores CRLF where
59
+ // the payload ships LF classifies as identical rather than locally modified.
17
60
  //
18
61
  // Only verbatim-installed files are classified: everything under .claude/,
19
62
  // plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
20
63
  // install with {{project-name}} substitution), _gitignore (merged line-by-line
21
64
  // into the workspace's .gitignore), and .manifest.json (payload metadata) are
22
65
  // handled by their own steps in /workspace-update and are excluded here.
66
+ //
67
+ // The other two modes are /workspace-update bookends:
68
+ // --write-baseline write .claude/.template-baseline.json recording the
69
+ // hash of every verbatim payload file — what the template
70
+ // now ships. Run at the END of an update, after all
71
+ // per-file decisions. Entries record the PAYLOAD hash —
72
+ // except unapplied updates (workspace still holds the old
73
+ // baseline content), which keep the old entry so they
74
+ // present as `updated` again next time; see
75
+ // template-baseline.mjs. Throws rather than writing an
76
+ // empty baseline.
77
+ // --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
78
+ // merged in: template lines updated, the workspace's own
79
+ // lines (custom skill entries, sections) kept. The skill
80
+ // shows the diff against the current file before writing.
23
81
 
24
82
  import {
25
83
  existsSync,
@@ -28,8 +86,10 @@ import {
28
86
  statSync,
29
87
  realpathSync,
30
88
  } from 'node:fs';
31
- import { join, resolve } from 'node:path';
89
+ import { basename, join, resolve } from 'node:path';
32
90
  import { fileURLToPath } from 'node:url';
91
+ import { gitIgnoredPaths } from './build-workspace-context.mjs';
92
+ import { BASELINE_PATH, hashBytes, readBaseline, writeBaseline } from './template-baseline.mjs';
33
93
 
34
94
  function isMainModule(metaUrl) {
35
95
  if (!process.argv[1]) return false;
@@ -39,11 +99,13 @@ function isMainModule(metaUrl) {
39
99
  }
40
100
 
41
101
  function parseArgs(argv) {
42
- const args = { root: process.cwd(), payload: null };
102
+ const args = { root: process.cwd(), payload: null, writeBaseline: false, mergeClaudeMd: false };
43
103
  for (let i = 2; i < argv.length; i++) {
44
104
  const a = argv[i];
45
105
  if (a === '--root') args.root = argv[++i];
46
106
  else if (a === '--payload') args.payload = argv[++i];
107
+ else if (a === '--write-baseline') args.writeBaseline = true;
108
+ else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
47
109
  else throw new Error(`Unknown arg: ${a}`);
48
110
  }
49
111
  return args;
@@ -58,7 +120,12 @@ function isClassified(payloadRelPath) {
58
120
  return VERBATIM_ROOTS.includes(first);
59
121
  }
60
122
 
61
- function* walkFiles(dir, prefix = '') {
123
+ // Directories never walked when looking for removed files. .claude/worktrees/
124
+ // holds entire nested worktrees — walking them is slow and every file inside
125
+ // is unmanaged by the template.
126
+ const SKIPPED_DIRS = new Set(['worktrees']);
127
+
128
+ function* walkFiles(dir, prefix = '', skipDirs = null) {
62
129
  let entries;
63
130
  try {
64
131
  entries = readdirSync(dir).sort();
@@ -66,15 +133,69 @@ function* walkFiles(dir, prefix = '') {
66
133
  return;
67
134
  }
68
135
  for (const name of entries) {
136
+ if (skipDirs && skipDirs.has(name)) continue;
69
137
  const rel = prefix ? `${prefix}/${name}` : name;
70
138
  const full = join(dir, name);
71
139
  let st;
72
140
  try { st = statSync(full); } catch { continue; }
73
- if (st.isDirectory()) yield* walkFiles(full, rel);
141
+ if (st.isDirectory()) yield* walkFiles(full, rel, skipDirs);
74
142
  else if (st.isFile()) yield rel;
75
143
  }
76
144
  }
77
145
 
146
+ // Installed files under the verbatim-managed roots: the .claude/ tree (minus
147
+ // skipped directories) plus the two standalone files. Nothing else in the
148
+ // workspace root is walked — repos/ and work-sessions/ hold entire worktrees
149
+ // the template never manages.
150
+ function* walkInstalledFiles(absRoot) {
151
+ yield* walkFiles(join(absRoot, '.claude'), '.claude', SKIPPED_DIRS);
152
+ for (const name of ['.mcp.json', '.claudeignore']) {
153
+ if (existsSync(join(absRoot, name))) yield name;
154
+ }
155
+ }
156
+
157
+ /** workspace.json → workspace.localFiles, normalized to .claude/-relative globs. */
158
+ function readLocalFiles(absRoot) {
159
+ const configPath = join(absRoot, 'workspace.json');
160
+ try {
161
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
162
+ const entries = config?.workspace?.localFiles;
163
+ if (!Array.isArray(entries)) return [];
164
+ return entries
165
+ .filter((e) => typeof e === 'string' && e.length > 0)
166
+ .map((e) => e.replace(/^(\.claude\/)+/, ''));
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Match `rel` (a .claude/-relative posix path) against a localFiles entry —
174
+ * an exact path or a glob where `**` spans separators and `*` does not.
175
+ * No glob library: the shapes localFiles needs are these two stars.
176
+ */
177
+ function globMatches(pattern, rel) {
178
+ if (pattern === rel) return true;
179
+ if (!pattern.includes('*')) return false;
180
+ const re = new RegExp(
181
+ `^${pattern.split('**').map(
182
+ (part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'),
183
+ ).join('.*')}$`,
184
+ );
185
+ return re.test(rel);
186
+ }
187
+
188
+ function isOwnedByWorkspace(rel, localFiles) {
189
+ // The template's own .gitignore declares these machine-local; the baseline
190
+ // is per-workspace state the template never ships.
191
+ if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json' || rel === BASELINE_PATH) {
192
+ return true;
193
+ }
194
+ if (!rel.startsWith('.claude/')) return false;
195
+ const claudeRel = rel.slice('.claude/'.length);
196
+ return localFiles.some((pattern) => globMatches(pattern, claudeRel));
197
+ }
198
+
78
199
  export function classifyUpdate({ root, payload }) {
79
200
  const absRoot = resolve(root);
80
201
  const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
@@ -82,29 +203,259 @@ export function classifyUpdate({ root, payload }) {
82
203
  throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
83
204
  }
84
205
 
85
- const result = { new: [], identical: [], differs: [] };
86
- for (const rel of walkFiles(absPayload)) {
87
- if (!isClassified(rel)) continue;
206
+ const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
207
+ const payloadSet = new Set(payloadFiles);
208
+ const baseline = readBaseline(absRoot);
209
+
210
+ const result = {
211
+ new: [],
212
+ identical: [],
213
+ updated: [],
214
+ differs: [],
215
+ localOnly: [],
216
+ deletedLocally: [],
217
+ activated: [],
218
+ removed: [],
219
+ staleTests: [],
220
+ hasBaseline: baseline !== null,
221
+ };
222
+ for (const rel of payloadFiles) {
223
+ // A .skip rule whose active counterpart is installed was deliberately
224
+ // activated by this workspace: report it as activated, not new.
225
+ if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
226
+ const active = rel.replace(/\.skip$/, '');
227
+ if (existsSync(join(absRoot, active)) && !existsSync(join(absRoot, rel))) {
228
+ result.activated.push({ skip: rel, active });
229
+ continue;
230
+ }
231
+ }
88
232
  const installed = join(absRoot, rel);
89
233
  if (!existsSync(installed)) {
90
- result.new.push(rel);
234
+ // A file the baseline records and the payload still ships, yet missing
235
+ // from the workspace: deleted locally (or declined at install time) —
236
+ // not new, the template has carried it all along.
237
+ if (baseline && typeof baseline.files[rel] === 'string') {
238
+ result.deletedLocally.push(rel);
239
+ } else {
240
+ result.new.push(rel);
241
+ }
91
242
  continue;
92
243
  }
93
- const payloadBytes = readFileSync(join(absPayload, rel));
94
- const installedBytes = readFileSync(installed);
95
- if (Buffer.compare(payloadBytes, installedBytes) === 0) {
244
+ const wsHash = hashBytes(readFileSync(installed));
245
+ const payloadHash = hashBytes(readFileSync(join(absPayload, rel)));
246
+ if (wsHash === payloadHash) {
96
247
  result.identical.push(rel);
248
+ continue;
249
+ }
250
+ const baseHash = baseline ? baseline.files[rel] : undefined;
251
+ if (baseHash !== undefined && wsHash === baseHash) {
252
+ // Workspace still holds exactly what the template last shipped here —
253
+ // the difference is the template's own change since then.
254
+ result.updated.push(rel);
255
+ } else if (baseHash !== undefined && payloadHash === baseHash) {
256
+ // The payload is unchanged since the baseline; the workspace's
257
+ // difference is purely local. Informational — nothing to apply.
258
+ result.localOnly.push(rel);
97
259
  } else {
260
+ // A local edit on top of a template change (or no baseline entry to
261
+ // compare) — the one case that needs a per-file decision.
98
262
  result.differs.push(rel);
99
263
  }
100
264
  }
265
+
266
+ // Removed: installed verbatim-managed files with no payload counterpart.
267
+ const skipSet = new Set(payloadFiles);
268
+ const localFiles = readLocalFiles(absRoot);
269
+ const installedFiles = [...walkInstalledFiles(absRoot)];
270
+ const gitignored = gitIgnoredPaths(absRoot, installedFiles);
271
+ for (const rel of installedFiles) {
272
+ if (skipSet.has(rel)) continue;
273
+ // An active rule whose .skip twin is in the payload is an activated rule,
274
+ // not a removed one.
275
+ if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
276
+ if (gitignored.has(rel)) continue;
277
+ // Test files never come from the npm tarball; the payload not carrying one
278
+ // means the template's test suite moved on without this copy.
279
+ if (rel.endsWith('.test.mjs')) {
280
+ result.staleTests.push(rel);
281
+ continue;
282
+ }
283
+ if (isOwnedByWorkspace(rel, localFiles)) continue;
284
+ result.removed.push(rel);
285
+ }
101
286
  return result;
102
287
  }
103
288
 
289
+ // ---------- CLAUDE.md merge ----------
290
+
291
+ /**
292
+ * Split markdown into blocks: the preamble (heading null) plus one block per
293
+ * `## ` heading. Deeper headings belong to their enclosing section, and `## `
294
+ * lines inside fenced code blocks (``` or ~~~) stay content of their section.
295
+ */
296
+ function splitBlocks(text) {
297
+ const blocks = [];
298
+ let cur = { heading: null, lines: [] };
299
+ let fenced = false;
300
+ for (const line of text.split(/\r?\n/)) {
301
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
302
+ if (!fenced && /^##\s/.test(line)) {
303
+ blocks.push(cur);
304
+ cur = { heading: line.trim(), lines: [] };
305
+ } else {
306
+ cur.lines.push(line);
307
+ }
308
+ }
309
+ blocks.push(cur);
310
+ return blocks;
311
+ }
312
+
313
+ /**
314
+ * A heading's merge key. Identical headings match; beyond that, any
315
+ * `## Workspace:` heading matches any other — the intro heading carries the
316
+ * workspace name, which differs the moment a workspace is renamed (or the
317
+ * fallback directory name was used), and treating them as two sections
318
+ * duplicated the template's intro alongside the renamed original.
319
+ */
320
+ function headingKey(heading) {
321
+ if (heading !== null && heading.startsWith('## Workspace:')) return '## Workspace:';
322
+ return heading;
323
+ }
324
+
325
+ /**
326
+ * A list entry's merge key: the name of its first backticked `/command`
327
+ * token (`- \`/start-work [handoff|blank]\` — …` → start-work). Two entries
328
+ * with the same name are the same skill, so the template's reworded line
329
+ * replaces the workspace's instead of duplicating it.
330
+ */
331
+ function entryKey(line) {
332
+ const m = line.match(/^\s*[-*]\s+`\/([a-z0-9][a-z0-9-]*)[^`]*`/);
333
+ return m ? m[1] : null;
334
+ }
335
+
336
+ function trimTrailingBlanks(lines) {
337
+ let end = lines.length;
338
+ while (end > 0 && lines[end - 1].trim() === '') end--;
339
+ return lines.slice(0, end);
340
+ }
341
+
342
+ function trimLeadingBlanks(lines) {
343
+ let start = 0;
344
+ while (start < lines.length && lines[start].trim() === '') start++;
345
+ return lines.slice(start);
346
+ }
347
+
348
+ /**
349
+ * One section's bodies merged: the template's new lines, then the workspace's
350
+ * lines that the template no longer carries (matched by entry name for list
351
+ * entries, by trimmed text otherwise).
352
+ */
353
+ function mergeBody(curLines, nxtLines) {
354
+ const nxtKeys = new Set(nxtLines.map(entryKey).filter(Boolean));
355
+ const nxtTrimmed = new Set(nxtLines.map((l) => l.trim()).filter(Boolean));
356
+ const kept = [];
357
+ for (const line of curLines) {
358
+ const key = entryKey(line);
359
+ if (key !== null && nxtKeys.has(key)) continue; // template owns this entry — its line updates ours
360
+ const t = line.trim();
361
+ if (t !== '' && nxtTrimmed.has(t)) continue; // unchanged line, already present
362
+ kept.push(line);
363
+ }
364
+ const body = trimTrailingBlanks(nxtLines);
365
+ return kept.length === 0 ? body : [...body, ...trimLeadingBlanks(trimTrailingBlanks(kept))];
366
+ }
367
+
368
+ function renderBlocks(blocks, eol) {
369
+ const parts = [];
370
+ for (const b of blocks) {
371
+ const body = trimTrailingBlanks(b.lines);
372
+ if (b.heading === null) {
373
+ if (body.length > 0) parts.push(body.join(eol));
374
+ } else {
375
+ parts.push([b.heading, ...body].join(eol));
376
+ }
377
+ }
378
+ return parts.join(eol + eol) + eol;
379
+ }
380
+
381
+ /**
382
+ * Merge an updated template CLAUDE.md (`nextText`, already {{project-name}}-
383
+ * substituted) into the workspace's current one. Template-owned lines take the
384
+ * template's new versions; lines the template doesn't have — the workspace's
385
+ * own skill entries, custom bullets, whole sections — are kept. Sections are
386
+ * matched by heading (`## Workspace:` headings match regardless of name): the
387
+ * result follows the workspace's section order, new template sections are
388
+ * appended at the end, and kept lines land at the end of their section. The
389
+ * output keeps the current file's line endings — CRLF in, CRLF out.
390
+ */
391
+ export function mergeClaudeMd(currentText, nextText) {
392
+ const eol = currentText != null && currentText.includes('\r\n') ? '\r\n' : '\n';
393
+ const nxtBlocks = splitBlocks(nextText);
394
+ if (currentText == null || currentText.trim() === '') return renderBlocks(nxtBlocks, eol);
395
+ const nxtByHeading = new Map(nxtBlocks.map((b) => [headingKey(b.heading), b]));
396
+ const used = new Set();
397
+ const out = [];
398
+ for (const cur of splitBlocks(currentText)) {
399
+ const nxt = nxtByHeading.get(headingKey(cur.heading));
400
+ if (nxt) {
401
+ used.add(nxt);
402
+ out.push({ heading: nxt.heading, lines: mergeBody(cur.lines, nxt.lines) });
403
+ } else {
404
+ out.push(cur); // a section the template doesn't have — the workspace's own
405
+ }
406
+ }
407
+ for (const nxt of nxtBlocks) {
408
+ if (!used.has(nxt)) out.push({ heading: nxt.heading, lines: trimTrailingBlanks(nxt.lines) });
409
+ }
410
+ return renderBlocks(out, eol);
411
+ }
412
+
413
+ // ---------- CLI modes ----------
414
+
415
+ function resolvePayload(args) {
416
+ return resolve(args.payload ?? join(resolve(args.root), '.workspace-update'));
417
+ }
418
+
419
+ function writeBaselineMode(args) {
420
+ const baseline = writeBaseline(args.root, resolvePayload(args));
421
+ process.stdout.write(JSON.stringify({
422
+ written: true,
423
+ path: BASELINE_PATH,
424
+ templateVersion: baseline.templateVersion,
425
+ files: Object.keys(baseline.files).length,
426
+ }, null, 2) + '\n');
427
+ }
428
+
429
+ function mergeClaudeMdMode(args) {
430
+ const absRoot = resolve(args.root);
431
+ const absPayload = resolvePayload(args);
432
+ const tmplPath = join(absPayload, 'CLAUDE.md.tmpl');
433
+ if (!existsSync(tmplPath)) {
434
+ throw new Error(`No CLAUDE.md.tmpl in ${absPayload} — nothing to merge`);
435
+ }
436
+ // The workspace name for {{project-name}} substitution: workspace.json is
437
+ // the source of truth; the directory name is the fallback.
438
+ let name = basename(absRoot);
439
+ try {
440
+ const config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
441
+ if (typeof config?.workspace?.name === 'string' && config.workspace.name) name = config.workspace.name;
442
+ } catch { /* no workspace.json — keep the directory name */ }
443
+ const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
444
+ const claudeMdPath = join(absRoot, 'CLAUDE.md');
445
+ const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
446
+ process.stdout.write(mergeClaudeMd(current, next));
447
+ }
448
+
104
449
  function main() {
105
450
  const args = parseArgs(process.argv);
106
- const result = classifyUpdate({ root: args.root, payload: args.payload });
107
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
451
+ if (args.writeBaseline) {
452
+ writeBaselineMode(args);
453
+ } else if (args.mergeClaudeMd) {
454
+ mergeClaudeMdMode(args);
455
+ } else {
456
+ const result = classifyUpdate({ root: args.root, payload: args.payload });
457
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
458
+ }
108
459
  }
109
460
 
110
461
  if (isMainModule(import.meta.url)) {