@ulysses-ai/create-workspace 0.21.0-beta.0 → 0.23.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/lib/init.mjs +9 -0
  2. package/lib/init.test.mjs +75 -0
  3. package/lib/payload.mjs +170 -2
  4. package/lib/payload.test.mjs +158 -3
  5. package/lib/scaffold.mjs +8 -0
  6. package/lib/scaffold.test.mjs +20 -0
  7. package/lib/upgrade.mjs +148 -6
  8. package/lib/upgrade.test.mjs +319 -15
  9. package/package.json +1 -1
  10. package/template/_claude/rules/forge-operations.md +27 -6
  11. package/template/_claude/scripts/chat-record.mjs +51 -4
  12. package/template/_claude/scripts/classify-update.mjs +474 -38
  13. package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
  14. package/template/_claude/scripts/forges/gitlab.mjs +450 -18
  15. package/template/_claude/scripts/forges/interface.mjs +39 -6
  16. package/template/_claude/scripts/maintenance-audit.mjs +0 -0
  17. package/template/_claude/scripts/merge-mode.mjs +96 -12
  18. package/template/_claude/scripts/migrate-sessions.mjs +232 -24
  19. package/template/_claude/scripts/task-pr.mjs +52 -13
  20. package/template/_claude/scripts/task-worktree.mjs +79 -10
  21. package/template/_claude/scripts/template-baseline.mjs +239 -0
  22. package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
  23. package/template/_claude/scripts/trackers/interface.mjs +3 -0
  24. package/template/_claude/skills/complete-work/SKILL.md +7 -4
  25. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
  26. package/template/_claude/skills/release/SKILL.md +24 -8
  27. package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
  28. package/template/_claude/skills/start-work/SKILL.md +15 -2
  29. package/template/_claude/skills/workspace-init/SKILL.md +6 -0
  30. package/template/_claude/skills/workspace-update/SKILL.md +63 -27
@@ -3,34 +3,110 @@
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
+ // 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
- // --root workspace root; defaults to the current working directory (never
9
- // derived from this script's location — the upgrade payload runs
10
- // this file from <workspace>/.workspace-update/.claude/scripts/)
11
- // --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.
12
20
  //
13
- // Prints JSON with five lists:
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
17
- // activated — the payload ships rules/{name}.md.skip while the workspace
18
- // deliberately keeps {name}.md active; nothing to install, the
19
- // active rule stays (gh:180)
20
- // removed — installed file with no payload counterpart: the template
21
- // stopped shipping it. Excludes what the workspace owns:
22
- // *.test.mjs (the npm tarball does not ship tests, dev-checkout
23
- // installs do — every test file would otherwise read as
24
- // removed), anything gitignored (machine-local), paths under
25
- // .claude/worktrees/, and entries of workspace.json →
26
- // workspace.localFiles (array of .claude/-relative paths or
27
- // globs for files this workspace owns) (gh:180)
21
+ // The default mode prints JSON with these lists:
22
+ // new — no installed counterpart and no baseline entry; safe to
23
+ // batch-apply after one confirm
24
+ // identical — installed file already equals the payload
25
+ // updated — installed file equals the BASELINE (what the template last
26
+ // shipped here) but not the payload: a pure template change the
27
+ // user never touched. Batched with `new` behind one confirm.
28
+ // differs — installed file matches neither the payload nor the baseline
29
+ // while the payload also differs from the baseline: a local
30
+ // edit AND a template change — the one case that needs a
31
+ // per-file decision (or the workspace predates baselines and
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).
50
+ // localOnly — installed file differs from the payload, but the payload
51
+ // equals the baseline: the template hasn't touched the file
52
+ // since the last update, so the difference is purely local.
53
+ // Listed for information only — never asked about, never
54
+ // applied.
55
+ // deletedLocally — the baseline records the file and the payload still
56
+ // ships it, but it is missing from the workspace: deleted
57
+ // locally (or never installed at /workspace-init). The skill
58
+ // asks once whether to restore the list.
59
+ // activated — the payload ships rules/{name}.md.skip while the workspace
60
+ // deliberately keeps {name}.md active; nothing to install, the
61
+ // active rule stays (gh:180)
62
+ // removed — installed file with no payload counterpart: the template
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),
66
+ // *.test.mjs (see staleTests), anything gitignored
67
+ // (machine-local), paths under .claude/worktrees/, and entries
68
+ // of workspace.json → workspace.localFiles (array of
69
+ // .claude/-relative paths or globs for files this workspace
70
+ // owns) (gh:180)
71
+ // staleTests — *.test.mjs files under .claude/ with no payload counterpart.
72
+ // The npm tarball ships no tests, so these came from a dev
73
+ // checkout and are never updated by /workspace-update; the
74
+ // skill offers to remove them (tests live in the template repo)
28
75
  //
29
- // Only verbatim-installed files are classified: everything under .claude/,
30
- // plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
31
- // install with {{project-name}} substitution), _gitignore (merged line-by-line
32
- // into the workspace's .gitignore), and .manifest.json (payload metadata) are
33
- // handled by their own steps in /workspace-update and are excluded here.
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.
83
+ //
84
+ // Content comparisons hash with CRLF normalized to LF on both sides (binary
85
+ // files hash byte-exact), so a git autocrlf checkout that stores CRLF where
86
+ // the payload ships LF classifies as identical rather than locally modified.
87
+ //
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.
95
+ //
96
+ // The other two modes are /workspace-update bookends:
97
+ // --write-baseline write .claude/.template-baseline.json recording the
98
+ // hash of every verbatim payload file — what the template
99
+ // now ships. Run at the END of an update, after all
100
+ // per-file decisions. Entries record the PAYLOAD hash —
101
+ // except unapplied updates (workspace still holds the old
102
+ // baseline content), which keep the old entry so they
103
+ // present as `updated` again next time; see
104
+ // template-baseline.mjs. Throws rather than writing an
105
+ // empty baseline.
106
+ // --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
107
+ // merged in: template lines updated, the workspace's own
108
+ // lines (custom skill entries, sections) kept. The skill
109
+ // shows the diff against the current file before writing.
34
110
 
35
111
  import {
36
112
  existsSync,
@@ -39,9 +115,16 @@ import {
39
115
  statSync,
40
116
  realpathSync,
41
117
  } from 'node:fs';
42
- import { join, resolve } from 'node:path';
118
+ import { basename, join, resolve } from 'node:path';
43
119
  import { fileURLToPath } from 'node:url';
44
120
  import { gitIgnoredPaths } from './build-workspace-context.mjs';
121
+ import {
122
+ BASELINE_PATH,
123
+ RECONSTRUCTED_BASELINE_NAME,
124
+ hashBytes,
125
+ readBaselineFile,
126
+ writeBaseline,
127
+ } from './template-baseline.mjs';
45
128
 
46
129
  function isMainModule(metaUrl) {
47
130
  if (!process.argv[1]) return false;
@@ -51,11 +134,14 @@ function isMainModule(metaUrl) {
51
134
  }
52
135
 
53
136
  function parseArgs(argv) {
54
- const args = { root: process.cwd(), payload: null };
137
+ const args = { root: process.cwd(), payload: null, baseline: null, writeBaseline: false, mergeClaudeMd: false };
55
138
  for (let i = 2; i < argv.length; i++) {
56
139
  const a = argv[i];
57
140
  if (a === '--root') args.root = argv[++i];
58
141
  else if (a === '--payload') args.payload = argv[++i];
142
+ else if (a === '--baseline') args.baseline = argv[++i];
143
+ else if (a === '--write-baseline') args.writeBaseline = true;
144
+ else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
59
145
  else throw new Error(`Unknown arg: ${a}`);
60
146
  }
61
147
  return args;
@@ -65,6 +151,104 @@ function parseArgs(argv) {
65
151
  // Everything else in the payload is a template or metadata handled elsewhere.
66
152
  const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
67
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
+
68
252
  function isClassified(payloadRelPath) {
69
253
  const first = payloadRelPath.split('/')[0];
70
254
  return VERBATIM_ROOTS.includes(first);
@@ -136,15 +320,41 @@ function globMatches(pattern, rel) {
136
320
  }
137
321
 
138
322
  function isOwnedByWorkspace(rel, localFiles) {
139
- if (rel.endsWith('.test.mjs')) return true;
140
- // The template's own .gitignore declares these machine-local.
141
- if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json') return true;
323
+ // The template's own .gitignore declares these machine-local; the baseline
324
+ // is per-workspace state the template never ships.
325
+ if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json' || rel === BASELINE_PATH) {
326
+ return true;
327
+ }
142
328
  if (!rel.startsWith('.claude/')) return false;
143
329
  const claudeRel = rel.slice('.claude/'.length);
144
330
  return localFiles.some((pattern) => globMatches(pattern, claudeRel));
145
331
  }
146
332
 
147
- 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 }) {
148
358
  const absRoot = resolve(root);
149
359
  const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
150
360
  if (!existsSync(absPayload)) {
@@ -153,9 +363,30 @@ export function classifyUpdate({ root, payload }) {
153
363
 
154
364
  const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
155
365
  const payloadSet = new Set(payloadFiles);
366
+ const { baseline, source } = resolveBaseline({ root: absRoot, payload: absPayload, baseline: baselineArg });
156
367
 
157
- const result = { new: [], identical: [], differs: [], activated: [], removed: [] };
368
+ const result = {
369
+ new: [],
370
+ identical: [],
371
+ updated: [],
372
+ differs: [],
373
+ config: [],
374
+ localOnly: [],
375
+ deletedLocally: [],
376
+ activated: [],
377
+ removed: [],
378
+ staleTests: [],
379
+ hasBaseline: baseline !== null,
380
+ baselineSource: source,
381
+ baselineReconstructed: baseline !== null && baseline.reconstructed === true,
382
+ };
158
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
+ }
159
390
  // A .skip rule whose active counterpart is installed was deliberately
160
391
  // activated by this workspace: report it as activated, not new.
161
392
  if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
@@ -167,14 +398,34 @@ export function classifyUpdate({ root, payload }) {
167
398
  }
168
399
  const installed = join(absRoot, rel);
169
400
  if (!existsSync(installed)) {
170
- result.new.push(rel);
401
+ // A file the baseline records and the payload still ships, yet missing
402
+ // from the workspace: deleted locally (or declined at install time) —
403
+ // not new, the template has carried it all along.
404
+ if (baseline && typeof baseline.files[rel] === 'string') {
405
+ result.deletedLocally.push(rel);
406
+ } else {
407
+ result.new.push(rel);
408
+ }
171
409
  continue;
172
410
  }
173
- const payloadBytes = readFileSync(join(absPayload, rel));
174
- const installedBytes = readFileSync(installed);
175
- if (Buffer.compare(payloadBytes, installedBytes) === 0) {
411
+ const wsHash = hashBytes(readFileSync(installed));
412
+ const payloadHash = hashBytes(readFileSync(join(absPayload, rel)));
413
+ if (wsHash === payloadHash) {
176
414
  result.identical.push(rel);
415
+ continue;
416
+ }
417
+ const baseHash = baseline ? baseline.files[rel] : undefined;
418
+ if (baseHash !== undefined && wsHash === baseHash) {
419
+ // Workspace still holds exactly what the template last shipped here —
420
+ // the difference is the template's own change since then.
421
+ result.updated.push(rel);
422
+ } else if (baseHash !== undefined && payloadHash === baseHash) {
423
+ // The payload is unchanged since the baseline; the workspace's
424
+ // difference is purely local. Informational — nothing to apply.
425
+ result.localOnly.push(rel);
177
426
  } else {
427
+ // A local edit on top of a template change (or no baseline entry to
428
+ // compare) — the one case that needs a per-file decision.
178
429
  result.differs.push(rel);
179
430
  }
180
431
  }
@@ -186,20 +437,205 @@ export function classifyUpdate({ root, payload }) {
186
437
  const gitignored = gitIgnoredPaths(absRoot, installedFiles);
187
438
  for (const rel of installedFiles) {
188
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;
189
443
  // An active rule whose .skip twin is in the payload is an activated rule,
190
444
  // not a removed one.
191
445
  if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
192
446
  if (gitignored.has(rel)) continue;
447
+ // Test files never come from the npm tarball; the payload not carrying one
448
+ // means the template's test suite moved on without this copy.
449
+ if (rel.endsWith('.test.mjs')) {
450
+ result.staleTests.push(rel);
451
+ continue;
452
+ }
193
453
  if (isOwnedByWorkspace(rel, localFiles)) continue;
194
454
  result.removed.push(rel);
195
455
  }
196
456
  return result;
197
457
  }
198
458
 
459
+ // ---------- CLAUDE.md merge ----------
460
+
461
+ /**
462
+ * Split markdown into blocks: the preamble (heading null) plus one block per
463
+ * `## ` heading. Deeper headings belong to their enclosing section, and `## `
464
+ * lines inside fenced code blocks (``` or ~~~) stay content of their section.
465
+ */
466
+ function splitBlocks(text) {
467
+ const blocks = [];
468
+ let cur = { heading: null, lines: [] };
469
+ let fenced = false;
470
+ for (const line of text.split(/\r?\n/)) {
471
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
472
+ if (!fenced && /^##\s/.test(line)) {
473
+ blocks.push(cur);
474
+ cur = { heading: line.trim(), lines: [] };
475
+ } else {
476
+ cur.lines.push(line);
477
+ }
478
+ }
479
+ blocks.push(cur);
480
+ return blocks;
481
+ }
482
+
483
+ /**
484
+ * A heading's merge key. Identical headings match; beyond that, any
485
+ * `## Workspace:` heading matches any other — the intro heading carries the
486
+ * workspace name, which differs the moment a workspace is renamed (or the
487
+ * fallback directory name was used), and treating them as two sections
488
+ * duplicated the template's intro alongside the renamed original.
489
+ */
490
+ function headingKey(heading) {
491
+ if (heading !== null && heading.startsWith('## Workspace:')) return '## Workspace:';
492
+ return heading;
493
+ }
494
+
495
+ /**
496
+ * A list entry's merge key: the name of its first backticked `/command`
497
+ * token (`- \`/start-work [handoff|blank]\` — …` → start-work). Two entries
498
+ * with the same name are the same skill, so the template's reworded line
499
+ * replaces the workspace's instead of duplicating it.
500
+ */
501
+ function entryKey(line) {
502
+ const m = line.match(/^\s*[-*]\s+`\/([a-z0-9][a-z0-9-]*)[^`]*`/);
503
+ return m ? m[1] : null;
504
+ }
505
+
506
+ function trimTrailingBlanks(lines) {
507
+ let end = lines.length;
508
+ while (end > 0 && lines[end - 1].trim() === '') end--;
509
+ return lines.slice(0, end);
510
+ }
511
+
512
+ function trimLeadingBlanks(lines) {
513
+ let start = 0;
514
+ while (start < lines.length && lines[start].trim() === '') start++;
515
+ return lines.slice(start);
516
+ }
517
+
518
+ /**
519
+ * One section's bodies merged: the template's new lines, then the workspace's
520
+ * lines that the template no longer carries (matched by entry name for list
521
+ * entries, by trimmed text otherwise).
522
+ */
523
+ function mergeBody(curLines, nxtLines) {
524
+ const nxtKeys = new Set(nxtLines.map(entryKey).filter(Boolean));
525
+ const nxtTrimmed = new Set(nxtLines.map((l) => l.trim()).filter(Boolean));
526
+ const kept = [];
527
+ for (const line of curLines) {
528
+ const key = entryKey(line);
529
+ if (key !== null && nxtKeys.has(key)) continue; // template owns this entry — its line updates ours
530
+ const t = line.trim();
531
+ if (t !== '' && nxtTrimmed.has(t)) continue; // unchanged line, already present
532
+ kept.push(line);
533
+ }
534
+ const body = trimTrailingBlanks(nxtLines);
535
+ return kept.length === 0 ? body : [...body, ...trimLeadingBlanks(trimTrailingBlanks(kept))];
536
+ }
537
+
538
+ function renderBlocks(blocks, eol) {
539
+ const parts = [];
540
+ for (const b of blocks) {
541
+ const body = trimTrailingBlanks(b.lines);
542
+ if (b.heading === null) {
543
+ if (body.length > 0) parts.push(body.join(eol));
544
+ } else {
545
+ parts.push([b.heading, ...body].join(eol));
546
+ }
547
+ }
548
+ return parts.join(eol + eol) + eol;
549
+ }
550
+
551
+ /**
552
+ * Merge an updated template CLAUDE.md (`nextText`, already {{project-name}}-
553
+ * substituted) into the workspace's current one. Template-owned lines take the
554
+ * template's new versions; lines the template doesn't have — the workspace's
555
+ * own skill entries, custom bullets, whole sections — are kept. Sections are
556
+ * matched by heading (`## Workspace:` headings match regardless of name): the
557
+ * result follows the workspace's section order, new template sections are
558
+ * appended at the end, and kept lines land at the end of their section. The
559
+ * output keeps the current file's line endings — CRLF in, CRLF out.
560
+ */
561
+ export function mergeClaudeMd(currentText, nextText) {
562
+ const eol = currentText != null && currentText.includes('\r\n') ? '\r\n' : '\n';
563
+ const nxtBlocks = splitBlocks(nextText);
564
+ if (currentText == null || currentText.trim() === '') return renderBlocks(nxtBlocks, eol);
565
+ const nxtByHeading = new Map(nxtBlocks.map((b) => [headingKey(b.heading), b]));
566
+ const used = new Set();
567
+ const out = [];
568
+ for (const cur of splitBlocks(currentText)) {
569
+ const nxt = nxtByHeading.get(headingKey(cur.heading));
570
+ if (nxt) {
571
+ used.add(nxt);
572
+ out.push({ heading: nxt.heading, lines: mergeBody(cur.lines, nxt.lines) });
573
+ } else {
574
+ out.push(cur); // a section the template doesn't have — the workspace's own
575
+ }
576
+ }
577
+ for (const nxt of nxtBlocks) {
578
+ if (!used.has(nxt)) out.push({ heading: nxt.heading, lines: trimTrailingBlanks(nxt.lines) });
579
+ }
580
+ return renderBlocks(out, eol);
581
+ }
582
+
583
+ // ---------- CLI modes ----------
584
+
585
+ function resolvePayload(args) {
586
+ return resolve(args.payload ?? join(resolve(args.root), '.workspace-update'));
587
+ }
588
+
589
+ function writeBaselineMode(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 });
601
+ process.stdout.write(JSON.stringify({
602
+ written: true,
603
+ path: BASELINE_PATH,
604
+ templateVersion: baseline.templateVersion,
605
+ files: Object.keys(baseline.files).length,
606
+ }, null, 2) + '\n');
607
+ }
608
+
609
+ function mergeClaudeMdMode(args) {
610
+ const absRoot = resolve(args.root);
611
+ const absPayload = resolvePayload(args);
612
+ const tmplPath = join(absPayload, 'CLAUDE.md.tmpl');
613
+ if (!existsSync(tmplPath)) {
614
+ throw new Error(`No CLAUDE.md.tmpl in ${absPayload} — nothing to merge`);
615
+ }
616
+ // The workspace name for {{project-name}} substitution: workspace.json is
617
+ // the source of truth; the directory name is the fallback.
618
+ let name = basename(absRoot);
619
+ try {
620
+ const config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
621
+ if (typeof config?.workspace?.name === 'string' && config.workspace.name) name = config.workspace.name;
622
+ } catch { /* no workspace.json — keep the directory name */ }
623
+ const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
624
+ const claudeMdPath = join(absRoot, 'CLAUDE.md');
625
+ const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
626
+ process.stdout.write(mergeClaudeMd(current, next));
627
+ }
628
+
199
629
  function main() {
200
630
  const args = parseArgs(process.argv);
201
- const result = classifyUpdate({ root: args.root, payload: args.payload });
202
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
631
+ if (args.writeBaseline) {
632
+ writeBaselineMode(args);
633
+ } else if (args.mergeClaudeMd) {
634
+ mergeClaudeMdMode(args);
635
+ } else {
636
+ const result = classifyUpdate({ root: args.root, payload: args.payload, baseline: args.baseline });
637
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
638
+ }
203
639
  }
204
640
 
205
641
  if (isMainModule(import.meta.url)) {